From c4bb8dfa64ea0013a90f2ad3b611340ebfa214df Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 17 Jul 2025 17:23:49 -0400 Subject: [PATCH 01/75] Allow non-interactive unlock --- src/password_manager/manager.py | 26 ++++-- src/tests/test_noninteractive_init_unlock.py | 84 ++++++++++++++++++++ 2 files changed, 103 insertions(+), 7 deletions(-) create mode 100644 src/tests/test_noninteractive_init_unlock.py diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index c3c2205..c84b592 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -117,7 +117,9 @@ class PasswordManager: verification, ensuring the integrity and confidentiality of the stored password database. """ - def __init__(self, fingerprint: Optional[str] = None) -> None: + def __init__( + self, fingerprint: Optional[str] = None, *, password: Optional[str] = None + ) -> None: """Initialize the PasswordManager. Parameters @@ -161,7 +163,7 @@ class PasswordManager: if fingerprint: # Load the specified profile without prompting - self.select_fingerprint(fingerprint) + self.select_fingerprint(fingerprint, password=password) else: # Ensure a parent seed is set up before accessing the fingerprint directory self.setup_parent_seed() @@ -187,6 +189,11 @@ class PasswordManager: ) ) + @staticmethod + def get_password_prompt() -> str: + """Return the standard prompt for requesting a master password.""" + return "Enter your master password: " + @property def parent_seed(self) -> Optional[str]: """Return the decrypted parent seed if set.""" @@ -269,12 +276,15 @@ class PasswordManager: self.config_manager = None self.locked = True - def unlock_vault(self) -> None: - """Prompt for password and reinitialize managers.""" + def unlock_vault(self, password: Optional[str] = None) -> None: + """Unlock the vault using ``password`` without prompting if provided.""" start = time.perf_counter() if not self.fingerprint_dir: raise ValueError("Fingerprint directory not set") - self.setup_encryption_manager(self.fingerprint_dir) + if password is None: + self.setup_encryption_manager(self.fingerprint_dir) + else: + self.setup_encryption_manager(self.fingerprint_dir, password) self.initialize_bip85() self.initialize_managers() self.locked = False @@ -394,7 +404,9 @@ class PasswordManager: print(colored(f"Error: Failed to add new seed profile: {e}", "red")) sys.exit(1) - def select_fingerprint(self, fingerprint: str) -> None: + def select_fingerprint( + self, fingerprint: str, *, password: Optional[str] = None + ) -> None: if self.fingerprint_manager.select_fingerprint(fingerprint): self.current_fingerprint = fingerprint # Add this line self.fingerprint_dir = ( @@ -409,7 +421,7 @@ class PasswordManager: ) sys.exit(1) # Setup the encryption manager and load parent seed - self.setup_encryption_manager(self.fingerprint_dir) + self.setup_encryption_manager(self.fingerprint_dir, password) # Initialize BIP85 and other managers self.initialize_bip85() self.initialize_managers() diff --git a/src/tests/test_noninteractive_init_unlock.py b/src/tests/test_noninteractive_init_unlock.py new file mode 100644 index 0000000..d239fb4 --- /dev/null +++ b/src/tests/test_noninteractive_init_unlock.py @@ -0,0 +1,84 @@ +import importlib +import bcrypt +from pathlib import Path +from tempfile import TemporaryDirectory + +import constants +import password_manager.manager as manager_module +from utils.fingerprint_manager import FingerprintManager +from password_manager.config_manager import ConfigManager +from tests.helpers import TEST_SEED, TEST_PASSWORD, create_vault + + +def test_init_with_password(monkeypatch): + with TemporaryDirectory() as tmpdir: + tmp = Path(tmpdir) + monkeypatch.setattr(Path, "home", lambda: tmp) + importlib.reload(constants) + importlib.reload(manager_module) + + fm = FingerprintManager(constants.APP_DIR) + fp = fm.add_fingerprint(TEST_SEED) + dir_path = constants.APP_DIR / fp + vault, _enc = create_vault(dir_path, TEST_SEED, TEST_PASSWORD) + cfg = ConfigManager(vault, dir_path) + cfg.set_password_hash( + bcrypt.hashpw(TEST_PASSWORD.encode(), bcrypt.gensalt()).decode() + ) + cfg.set_kdf_iterations(100_000) + + called = {} + + def fake_setup(self, path, pw=None, **_): + called["password"] = pw + return True + + monkeypatch.setattr( + manager_module.PasswordManager, "initialize_bip85", lambda self: None + ) + monkeypatch.setattr( + manager_module.PasswordManager, "initialize_managers", lambda self: None + ) + monkeypatch.setattr( + manager_module.PasswordManager, "setup_encryption_manager", fake_setup + ) + + pm = manager_module.PasswordManager(fingerprint=fp, password=TEST_PASSWORD) + assert called["password"] == TEST_PASSWORD + + +def test_unlock_with_password(monkeypatch): + with TemporaryDirectory() as tmpdir: + tmp = Path(tmpdir) + monkeypatch.setattr(Path, "home", lambda: tmp) + importlib.reload(constants) + importlib.reload(manager_module) + + fm = FingerprintManager(constants.APP_DIR) + fp = fm.add_fingerprint(TEST_SEED) + dir_path = constants.APP_DIR / fp + vault, _enc = create_vault(dir_path, TEST_SEED, TEST_PASSWORD) + cfg = ConfigManager(vault, dir_path) + cfg.set_password_hash( + bcrypt.hashpw(TEST_PASSWORD.encode(), bcrypt.gensalt()).decode() + ) + + pm = manager_module.PasswordManager.__new__(manager_module.PasswordManager) + pm.fingerprint_dir = dir_path + pm.config_manager = cfg + pm.locked = True + called = {} + + def fake_setup(path, pw=None): + called["password"] = pw + + monkeypatch.setattr( + manager_module.PasswordManager, "initialize_bip85", lambda self: None + ) + monkeypatch.setattr( + manager_module.PasswordManager, "initialize_managers", lambda self: None + ) + pm.setup_encryption_manager = fake_setup + + pm.unlock_vault(TEST_PASSWORD) + assert called["password"] == TEST_PASSWORD From c23b2e4913676875b4200ff3b8b1d2adf4c626b8 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 17 Jul 2025 19:21:10 -0400 Subject: [PATCH 02/75] Refactor password manager modules --- scripts/generate_test_profile.py | 10 ++-- scripts/update_checksum.py | 2 +- src/main.py | 4 +- src/nostr/client.py | 8 ++-- src/seedpass/api.py | 4 +- src/seedpass/cli.py | 4 +- .../core}/__init__.py | 2 +- .../core}/backup.py | 4 +- .../core}/config_manager.py | 4 +- .../core}/encryption.py | 2 +- .../core}/entry_management.py | 22 ++++----- .../core}/entry_types.py | 2 +- .../core}/manager.py | 28 +++++------ .../core}/migrations.py | 0 .../core}/password_generation.py | 4 +- .../core}/portable_backup.py | 6 +-- .../core}/seedqr.py | 0 .../core}/totp.py | 0 .../core}/vault.py | 0 src/tests/helpers.py | 4 +- src/tests/test_add_tags_from_retrieve.py | 8 ++-- src/tests/test_additional_backup.py | 6 +-- src/tests/test_archive_from_retrieve.py | 8 ++-- src/tests/test_archive_nonpassword.py | 6 +-- src/tests/test_archive_restore.py | 8 ++-- src/tests/test_background_relay_check.py | 2 +- src/tests/test_background_sync_always.py | 10 ++-- src/tests/test_backup_interval.py | 4 +- src/tests/test_backup_restore.py | 4 +- src/tests/test_bip85_vectors.py | 2 +- src/tests/test_cli_doc_examples.py | 2 +- src/tests/test_cli_export_import.py | 6 +-- src/tests/test_cli_subcommands.py | 2 +- src/tests/test_concurrency_stress.py | 8 ++-- src/tests/test_config_manager.py | 4 +- src/tests/test_custom_fields_display.py | 8 ++-- src/tests/test_default_encryption_mode.py | 2 +- src/tests/test_edit_tags_from_retrieve.py | 8 ++-- src/tests/test_encryption_checksum.py | 2 +- src/tests/test_encryption_files.py | 2 +- src/tests/test_entries_empty.py | 8 ++-- src/tests/test_entry_add.py | 8 ++-- .../test_entry_management_checksum_path.py | 8 ++-- src/tests/test_export_totp_codes.py | 14 +++--- src/tests/test_fingerprint_encryption.py | 2 +- src/tests/test_full_sync_roundtrip.py | 8 ++-- src/tests/test_full_sync_roundtrip_new.py | 8 ++-- src/tests/test_fuzz_key_derivation.py | 2 +- src/tests/test_index_cache.py | 6 +-- src/tests/test_index_import_export.py | 4 +- src/tests/test_kdf_modes.py | 12 ++--- src/tests/test_key_value_entry.py | 6 +-- src/tests/test_last_used_fingerprint.py | 4 +- src/tests/test_list_entries_sort_filter.py | 8 ++-- src/tests/test_managed_account.py | 10 ++-- src/tests/test_managed_account_entry.py | 12 ++--- src/tests/test_manager_add_totp.py | 8 ++-- src/tests/test_manager_checksum_backup.py | 16 +++---- .../test_manager_current_notification.py | 6 +-- src/tests/test_manager_display_totp_codes.py | 12 ++--- src/tests/test_manager_edit_totp.py | 12 ++--- src/tests/test_manager_list_entries.py | 46 +++++++++---------- src/tests/test_manager_retrieve_totp.py | 12 ++--- src/tests/test_manager_search_display.py | 12 ++--- src/tests/test_manager_seed_setup.py | 6 +-- .../test_manager_warning_notifications.py | 12 ++--- src/tests/test_manager_workflow.py | 12 ++--- src/tests/test_migrations.py | 2 +- src/tests/test_modify_totp_entry.py | 6 +-- src/tests/test_multiple_fingerprint_prompt.py | 2 +- src/tests/test_noninteractive_init_unlock.py | 4 +- src/tests/test_nostr_backup.py | 8 ++-- src/tests/test_nostr_client.py | 2 +- src/tests/test_nostr_contract.py | 2 +- src/tests/test_nostr_dummy_client.py | 6 +-- src/tests/test_nostr_entry.py | 8 ++-- src/tests/test_nostr_index_size.py | 10 ++-- src/tests/test_nostr_qr.py | 14 +++--- src/tests/test_nostr_real.py | 2 +- src/tests/test_nostr_snapshot.py | 2 +- src/tests/test_offline_mode_behavior.py | 2 +- src/tests/test_parent_seed_backup.py | 12 ++--- src/tests/test_password_change.py | 18 ++++---- src/tests/test_password_generation_policy.py | 2 +- src/tests/test_password_helpers.py | 2 +- src/tests/test_password_length_constraints.py | 2 +- src/tests/test_password_properties.py | 4 +- .../test_password_unlock_after_change.py | 22 ++++----- src/tests/test_pgp_entry.py | 6 +-- src/tests/test_portable_backup.py | 10 ++-- src/tests/test_profile_cleanup.py | 4 +- src/tests/test_profile_init_integration.py | 4 +- src/tests/test_profile_management.py | 12 ++--- src/tests/test_profiles.py | 6 +-- src/tests/test_publish_json_result.py | 2 +- .../test_retrieve_pause_sensitive_entries.py | 12 ++--- src/tests/test_search_entries.py | 6 +-- src/tests/test_secret_mode.py | 20 ++++---- src/tests/test_seed_entry.py | 8 ++-- src/tests/test_seed_generation.py | 4 +- src/tests/test_seed_import.py | 4 +- src/tests/test_seed_migration.py | 2 +- src/tests/test_seedqr_encoding.py | 2 +- src/tests/test_settings_menu.py | 4 +- src/tests/test_ssh_entry.py | 8 ++-- src/tests/test_ssh_entry_valid.py | 8 ++-- src/tests/test_tag_persistence.py | 6 +-- src/tests/test_totp.py | 4 +- src/tests/test_totp_entry.py | 10 ++-- src/tests/test_totp_uri.py | 2 +- src/tests/test_typer_cli.py | 2 +- src/tests/test_unlock_sync.py | 4 +- src/tests/test_v2_prefix_fallback.py | 6 +-- src/tests/test_vault_initialization.py | 10 ++-- src/tests/test_verbose_timing.py | 8 ++-- 115 files changed, 388 insertions(+), 404 deletions(-) rename src/{password_manager => seedpass/core}/__init__.py (95%) rename src/{password_manager => seedpass/core}/backup.py (98%) rename src/{password_manager => seedpass/core}/config_manager.py (99%) rename src/{password_manager => seedpass/core}/encryption.py (99%) rename src/{password_manager => seedpass/core}/entry_management.py (98%) rename src/{password_manager => seedpass/core}/entry_types.py (91%) rename src/{password_manager => seedpass/core}/manager.py (99%) rename src/{password_manager => seedpass/core}/migrations.py (100%) rename src/{password_manager => seedpass/core}/password_generation.py (99%) rename src/{password_manager => seedpass/core}/portable_backup.py (96%) rename src/{password_manager => seedpass/core}/seedqr.py (100%) rename src/{password_manager => seedpass/core}/totp.py (100%) rename src/{password_manager => seedpass/core}/vault.py (100%) diff --git a/scripts/generate_test_profile.py b/scripts/generate_test_profile.py index 2632e9a..00e7aa0 100644 --- a/scripts/generate_test_profile.py +++ b/scripts/generate_test_profile.py @@ -38,11 +38,11 @@ consts.SCRIPT_CHECKSUM_FILE = consts.APP_DIR / "seedpass_script_checksum.txt" from constants import APP_DIR, initialize_app from utils.key_derivation import derive_key_from_password, derive_index_key -from password_manager.encryption import EncryptionManager -from password_manager.vault import Vault -from password_manager.config_manager import ConfigManager -from password_manager.backup import BackupManager -from password_manager.entry_management import EntryManager +from seedpass.core.encryption import EncryptionManager +from seedpass.core.vault import Vault +from seedpass.core.config_manager import ConfigManager +from seedpass.core.backup import BackupManager +from seedpass.core.entry_management import EntryManager from nostr.client import NostrClient from utils.fingerprint import generate_fingerprint from utils.fingerprint_manager import FingerprintManager diff --git a/scripts/update_checksum.py b/scripts/update_checksum.py index 537c415..eb52041 100644 --- a/scripts/update_checksum.py +++ b/scripts/update_checksum.py @@ -14,7 +14,7 @@ from constants import SCRIPT_CHECKSUM_FILE, initialize_app def main() -> None: """Calculate checksum for the main script and write it to SCRIPT_CHECKSUM_FILE.""" initialize_app() - script_path = SRC_DIR / "password_manager" / "manager.py" + script_path = SRC_DIR / "seedpass/core" / "manager.py" if not update_checksum_file(str(script_path), str(SCRIPT_CHECKSUM_FILE)): raise SystemExit(f"Failed to update checksum for {script_path}") print(f"Updated checksum written to {SCRIPT_CHECKSUM_FILE}") diff --git a/src/main.py b/src/main.py index 61da7f2..3dea1f7 100644 --- a/src/main.py +++ b/src/main.py @@ -20,9 +20,9 @@ from termcolor import colored from utils.color_scheme import color_text import traceback -from password_manager.manager import PasswordManager +from seedpass.core.manager import PasswordManager from nostr.client import NostrClient -from password_manager.entry_types import EntryType +from seedpass.core.entry_types import EntryType from constants import INACTIVITY_TIMEOUT, initialize_app from utils.password_prompt import PasswordPromptError from utils import ( diff --git a/src/nostr/client.py b/src/nostr/client.py index ea2d817..16fcc94 100644 --- a/src/nostr/client.py +++ b/src/nostr/client.py @@ -26,12 +26,12 @@ from nostr_sdk import EventId, Timestamp from .key_manager import KeyManager as SeedPassKeyManager from .backup_models import Manifest, ChunkMeta, KIND_MANIFEST, KIND_SNAPSHOT_CHUNK -from password_manager.encryption import EncryptionManager +from seedpass.core.encryption import EncryptionManager from constants import MAX_RETRIES, RETRY_DELAY from utils.file_lock import exclusive_lock if TYPE_CHECKING: # pragma: no cover - imported for type hints - from password_manager.config_manager import ConfigManager + from seedpass.core.config_manager import ConfigManager # Backwards compatibility for tests that patch these symbols KeyManager = SeedPassKeyManager @@ -295,8 +295,8 @@ class NostrClient: if retries is None or delay is None: if self.config_manager is None: - from password_manager.config_manager import ConfigManager - from password_manager.vault import Vault + from seedpass.core.config_manager import ConfigManager + from seedpass.core.vault import Vault cfg_mgr = ConfigManager( Vault(self.encryption_manager, self.fingerprint_dir), diff --git a/src/seedpass/api.py b/src/seedpass/api.py index fdc748c..2ac4d1b 100644 --- a/src/seedpass/api.py +++ b/src/seedpass/api.py @@ -14,8 +14,8 @@ import asyncio import sys from fastapi.middleware.cors import CORSMiddleware -from password_manager.manager import PasswordManager -from password_manager.entry_types import EntryType +from seedpass.core.manager import PasswordManager +from seedpass.core.entry_types import EntryType app = FastAPI() diff --git a/src/seedpass/cli.py b/src/seedpass/cli.py index 2ffb0e6..fa0b658 100644 --- a/src/seedpass/cli.py +++ b/src/seedpass/cli.py @@ -4,8 +4,8 @@ import json import typer -from password_manager.manager import PasswordManager -from password_manager.entry_types import EntryType +from seedpass.core.manager import PasswordManager +from seedpass.core.entry_types import EntryType import uvicorn from . import api as api_module diff --git a/src/password_manager/__init__.py b/src/seedpass/core/__init__.py similarity index 95% rename from src/password_manager/__init__.py rename to src/seedpass/core/__init__.py index fd7cf15..00d933c 100644 --- a/src/password_manager/__init__.py +++ b/src/seedpass/core/__init__.py @@ -1,4 +1,4 @@ -# password_manager/__init__.py +# seedpass.core/__init__.py """Expose password manager components with lazy imports.""" diff --git a/src/password_manager/backup.py b/src/seedpass/core/backup.py similarity index 98% rename from src/password_manager/backup.py rename to src/seedpass/core/backup.py index 10da249..3431051 100644 --- a/src/password_manager/backup.py +++ b/src/seedpass/core/backup.py @@ -1,4 +1,4 @@ -# password_manager/backup.py +# seedpass.core/backup.py """ Backup Manager Module @@ -19,7 +19,7 @@ import traceback from pathlib import Path from termcolor import colored -from password_manager.config_manager import ConfigManager +from .config_manager import ConfigManager from utils.file_lock import exclusive_lock from constants import APP_DIR diff --git a/src/password_manager/config_manager.py b/src/seedpass/core/config_manager.py similarity index 99% rename from src/password_manager/config_manager.py rename to src/seedpass/core/config_manager.py index eb0e0cf..f0312ac 100644 --- a/src/password_manager/config_manager.py +++ b/src/seedpass/core/config_manager.py @@ -10,7 +10,7 @@ from utils.seed_prompt import masked_input import bcrypt -from password_manager.vault import Vault +from .vault import Vault from nostr.client import DEFAULT_RELAYS as DEFAULT_NOSTR_RELAYS from constants import INACTIVITY_TIMEOUT @@ -251,7 +251,7 @@ class ConfigManager: # Password policy settings def get_password_policy(self) -> "PasswordPolicy": """Return the password complexity policy.""" - from password_manager.password_generation import PasswordPolicy + from .password_generation import PasswordPolicy cfg = self.load_config(require_pin=False) return PasswordPolicy( diff --git a/src/password_manager/encryption.py b/src/seedpass/core/encryption.py similarity index 99% rename from src/password_manager/encryption.py rename to src/seedpass/core/encryption.py index ae21416..d063387 100644 --- a/src/password_manager/encryption.py +++ b/src/seedpass/core/encryption.py @@ -1,4 +1,4 @@ -# /src/password_manager/encryption.py +# /src/seedpass.core/encryption.py import logging import traceback diff --git a/src/password_manager/entry_management.py b/src/seedpass/core/entry_management.py similarity index 98% rename from src/password_manager/entry_management.py rename to src/seedpass/core/entry_management.py index b779580..7a9673e 100644 --- a/src/password_manager/entry_management.py +++ b/src/seedpass/core/entry_management.py @@ -1,4 +1,4 @@ -# password_manager/entry_management.py +# seedpass.core/entry_management.py """ Entry Management Module @@ -31,14 +31,14 @@ from typing import Optional, Tuple, Dict, Any, List from pathlib import Path from termcolor import colored -from password_manager.migrations import LATEST_VERSION -from password_manager.entry_types import EntryType -from password_manager.totp import TotpManager +from .migrations import LATEST_VERSION +from .entry_types import EntryType +from .totp import TotpManager from utils.fingerprint import generate_fingerprint from utils.checksum import canonical_json_dumps -from password_manager.vault import Vault -from password_manager.backup import BackupManager +from .vault import Vault +from .backup import BackupManager # Instantiate the logger @@ -312,7 +312,7 @@ class EntryManager: if not entry or (etype != EntryType.SSH.value and kind != EntryType.SSH.value): raise ValueError("Entry is not an SSH key entry") - from password_manager.password_generation import derive_ssh_key_pair + from .password_generation import derive_ssh_key_pair key_index = int(entry.get("index", index)) return derive_ssh_key_pair(parent_seed, key_index) @@ -360,7 +360,7 @@ class EntryManager: if not entry or (etype != EntryType.PGP.value and kind != EntryType.PGP.value): raise ValueError("Entry is not a PGP key entry") - from password_manager.password_generation import derive_pgp_key + from .password_generation import derive_pgp_key from local_bip85.bip85 import BIP85 from bip_utils import Bip39SeedGenerator @@ -501,7 +501,7 @@ class EntryManager: ): raise ValueError("Entry is not a seed entry") - from password_manager.password_generation import derive_seed_phrase + from .password_generation import derive_seed_phrase from local_bip85.bip85 import BIP85 from bip_utils import Bip39SeedGenerator @@ -530,7 +530,7 @@ class EntryManager: if index is None: index = self.get_next_index() - from password_manager.password_generation import derive_seed_phrase + from .password_generation import derive_seed_phrase from local_bip85.bip85 import BIP85 from bip_utils import Bip39SeedGenerator @@ -576,7 +576,7 @@ class EntryManager: ): raise ValueError("Entry is not a managed account entry") - from password_manager.password_generation import derive_seed_phrase + from .password_generation import derive_seed_phrase from local_bip85.bip85 import BIP85 from bip_utils import Bip39SeedGenerator diff --git a/src/password_manager/entry_types.py b/src/seedpass/core/entry_types.py similarity index 91% rename from src/password_manager/entry_types.py rename to src/seedpass/core/entry_types.py index da5bd15..a11643a 100644 --- a/src/password_manager/entry_types.py +++ b/src/seedpass/core/entry_types.py @@ -1,4 +1,4 @@ -# password_manager/entry_types.py +# seedpass.core/entry_types.py """Enumerations for entry types used by SeedPass.""" from enum import Enum diff --git a/src/password_manager/manager.py b/src/seedpass/core/manager.py similarity index 99% rename from src/password_manager/manager.py rename to src/seedpass/core/manager.py index c84b592..50d865b 100644 --- a/src/password_manager/manager.py +++ b/src/seedpass/core/manager.py @@ -1,4 +1,4 @@ -# password_manager/manager.py +# seedpass.core/manager.py """ Password Manager Module @@ -25,14 +25,14 @@ from termcolor import colored from utils.color_scheme import color_text from utils.input_utils import timed_input -from password_manager.encryption import EncryptionManager -from password_manager.entry_management import EntryManager -from password_manager.password_generation import PasswordGenerator -from password_manager.backup import BackupManager -from password_manager.vault import Vault -from password_manager.portable_backup import export_backup, import_backup -from password_manager.totp import TotpManager -from password_manager.entry_types import EntryType +from .encryption import EncryptionManager +from .entry_management import EntryManager +from .password_generation import PasswordGenerator +from .backup import BackupManager +from .vault import Vault +from .portable_backup import export_backup, import_backup +from .totp import TotpManager +from .entry_types import EntryType from utils.key_derivation import ( derive_key_from_parent_seed, derive_key_from_password, @@ -64,7 +64,7 @@ from utils.terminal_utils import ( ) from utils.fingerprint import generate_fingerprint from constants import MIN_HEALTHY_RELAYS -from password_manager.migrations import LATEST_VERSION +from .migrations import LATEST_VERSION from constants import ( APP_DIR, @@ -94,7 +94,7 @@ from utils.fingerprint_manager import FingerprintManager # Import NostrClient from nostr.client import NostrClient, DEFAULT_RELAYS -from password_manager.config_manager import ConfigManager +from .config_manager import ConfigManager # Instantiate the logger logger = logging.getLogger(__name__) @@ -1579,7 +1579,7 @@ class PasswordManager: print(colored("Seed Phrase:", "cyan")) print(color_text(phrase, "deterministic")) if confirm_action("Show Compact Seed QR? (Y/N): "): - from password_manager.seedqr import encode_seedqr + from .seedqr import encode_seedqr TotpManager.print_qr_code(encode_seedqr(phrase)) try: @@ -1841,7 +1841,7 @@ class PasswordManager: else: print(color_text(seed, "deterministic")) if confirm_action("Show Compact Seed QR? (Y/N): "): - from password_manager.seedqr import encode_seedqr + from .seedqr import encode_seedqr TotpManager.print_qr_code(encode_seedqr(seed)) try: @@ -2075,7 +2075,7 @@ class PasswordManager: ) print(color_text(seed, "deterministic")) - from password_manager.seedqr import encode_seedqr + from .seedqr import encode_seedqr TotpManager.print_qr_code(encode_seedqr(seed)) pause() diff --git a/src/password_manager/migrations.py b/src/seedpass/core/migrations.py similarity index 100% rename from src/password_manager/migrations.py rename to src/seedpass/core/migrations.py diff --git a/src/password_manager/password_generation.py b/src/seedpass/core/password_generation.py similarity index 99% rename from src/password_manager/password_generation.py rename to src/seedpass/core/password_generation.py index b61523f..a70ab0b 100644 --- a/src/password_manager/password_generation.py +++ b/src/seedpass/core/password_generation.py @@ -1,4 +1,4 @@ -# password_manager/password_generation.py +# seedpass.core/password_generation.py """ Password Generation Module @@ -43,7 +43,7 @@ except ModuleNotFoundError: # pragma: no cover - fallback for removed module from local_bip85.bip85 import BIP85 from constants import DEFAULT_PASSWORD_LENGTH, MIN_PASSWORD_LENGTH, MAX_PASSWORD_LENGTH -from password_manager.encryption import EncryptionManager +from .encryption import EncryptionManager # Instantiate the logger logger = logging.getLogger(__name__) diff --git a/src/password_manager/portable_backup.py b/src/seedpass/core/portable_backup.py similarity index 96% rename from src/password_manager/portable_backup.py rename to src/seedpass/core/portable_backup.py index 8731818..a76879b 100644 --- a/src/password_manager/portable_backup.py +++ b/src/seedpass/core/portable_backup.py @@ -12,14 +12,14 @@ import asyncio from enum import Enum from pathlib import Path -from password_manager.vault import Vault -from password_manager.backup import BackupManager +from .vault import Vault +from .backup import BackupManager from nostr.client import NostrClient from utils.key_derivation import ( derive_index_key, EncryptionMode, ) -from password_manager.encryption import EncryptionManager +from .encryption import EncryptionManager from utils.checksum import json_checksum, canonical_json_dumps logger = logging.getLogger(__name__) diff --git a/src/password_manager/seedqr.py b/src/seedpass/core/seedqr.py similarity index 100% rename from src/password_manager/seedqr.py rename to src/seedpass/core/seedqr.py diff --git a/src/password_manager/totp.py b/src/seedpass/core/totp.py similarity index 100% rename from src/password_manager/totp.py rename to src/seedpass/core/totp.py diff --git a/src/password_manager/vault.py b/src/seedpass/core/vault.py similarity index 100% rename from src/password_manager/vault.py rename to src/seedpass/core/vault.py diff --git a/src/tests/helpers.py b/src/tests/helpers.py index 8157bc3..c36fa65 100644 --- a/src/tests/helpers.py +++ b/src/tests/helpers.py @@ -5,8 +5,8 @@ from pathlib import Path sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.vault import Vault -from password_manager.encryption import EncryptionManager +from seedpass.core.vault import Vault +from seedpass.core.encryption import EncryptionManager from utils.key_derivation import ( derive_index_key, derive_key_from_password, diff --git a/src/tests/test_add_tags_from_retrieve.py b/src/tests/test_add_tags_from_retrieve.py index fac5866..d9ed835 100644 --- a/src/tests/test_add_tags_from_retrieve.py +++ b/src/tests/test_add_tags_from_retrieve.py @@ -7,10 +7,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.manager import PasswordManager, EncryptionMode -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.manager import PasswordManager, EncryptionMode +from seedpass.core.config_manager import ConfigManager class FakePasswordGenerator: diff --git a/src/tests/test_additional_backup.py b/src/tests/test_additional_backup.py index 5597394..ee7c9dc 100644 --- a/src/tests/test_additional_backup.py +++ b/src/tests/test_additional_backup.py @@ -4,9 +4,9 @@ from tempfile import TemporaryDirectory from helpers import create_vault, TEST_SEED, TEST_PASSWORD -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager def test_entry_manager_additional_backup(monkeypatch): diff --git a/src/tests/test_archive_from_retrieve.py b/src/tests/test_archive_from_retrieve.py index bc094da..65844ba 100644 --- a/src/tests/test_archive_from_retrieve.py +++ b/src/tests/test_archive_from_retrieve.py @@ -8,10 +8,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.manager import PasswordManager, EncryptionMode -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.manager import PasswordManager, EncryptionMode +from seedpass.core.config_manager import ConfigManager class FakePasswordGenerator: diff --git a/src/tests/test_archive_nonpassword.py b/src/tests/test_archive_nonpassword.py index 6296813..3a1e6b8 100644 --- a/src/tests/test_archive_nonpassword.py +++ b/src/tests/test_archive_nonpassword.py @@ -6,9 +6,9 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager def setup_entry_mgr(tmp_path: Path) -> EntryManager: diff --git a/src/tests/test_archive_restore.py b/src/tests/test_archive_restore.py index 8332fd5..a906274 100644 --- a/src/tests/test_archive_restore.py +++ b/src/tests/test_archive_restore.py @@ -10,10 +10,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.config_manager import ConfigManager -from password_manager.manager import PasswordManager, EncryptionMode +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager +from seedpass.core.manager import PasswordManager, EncryptionMode def setup_entry_mgr(tmp_path: Path) -> EntryManager: diff --git a/src/tests/test_background_relay_check.py b/src/tests/test_background_relay_check.py index d537c70..ecc0c5b 100644 --- a/src/tests/test_background_relay_check.py +++ b/src/tests/test_background_relay_check.py @@ -6,7 +6,7 @@ import sys sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.manager import PasswordManager +from seedpass.core.manager import PasswordManager from constants import MIN_HEALTHY_RELAYS diff --git a/src/tests/test_background_sync_always.py b/src/tests/test_background_sync_always.py index 84faa32..e94a899 100644 --- a/src/tests/test_background_sync_always.py +++ b/src/tests/test_background_sync_always.py @@ -4,8 +4,8 @@ from pathlib import Path sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.manager import PasswordManager -import password_manager.manager as manager_module +from seedpass.core.manager import PasswordManager +import seedpass.core.manager as manager_module def test_switch_fingerprint_triggers_bg_sync(monkeypatch, tmp_path): @@ -23,16 +23,14 @@ def test_switch_fingerprint_triggers_bg_sync(monkeypatch, tmp_path): monkeypatch.setattr("builtins.input", lambda *_a, **_k: "1") monkeypatch.setattr( - "password_manager.manager.prompt_existing_password", lambda *_a, **_k: "pw" + "seedpass.core.manager.prompt_existing_password", lambda *_a, **_k: "pw" ) monkeypatch.setattr( PasswordManager, "setup_encryption_manager", lambda *a, **k: True ) monkeypatch.setattr(PasswordManager, "initialize_bip85", lambda *a, **k: None) monkeypatch.setattr(PasswordManager, "initialize_managers", lambda *a, **k: None) - monkeypatch.setattr( - "password_manager.manager.NostrClient", lambda *a, **kw: object() - ) + monkeypatch.setattr("seedpass.core.manager.NostrClient", lambda *a, **kw: object()) calls = {"count": 0} diff --git a/src/tests/test_backup_interval.py b/src/tests/test_backup_interval.py index f7ce39a..baec9b4 100644 --- a/src/tests/test_backup_interval.py +++ b/src/tests/test_backup_interval.py @@ -4,8 +4,8 @@ from tempfile import TemporaryDirectory from helpers import create_vault, TEST_SEED, TEST_PASSWORD -from password_manager.backup import BackupManager -from password_manager.config_manager import ConfigManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager def test_backup_interval(monkeypatch): diff --git a/src/tests/test_backup_restore.py b/src/tests/test_backup_restore.py index fdbc221..d22d6a4 100644 --- a/src/tests/test_backup_restore.py +++ b/src/tests/test_backup_restore.py @@ -8,8 +8,8 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.backup import BackupManager -from password_manager.config_manager import ConfigManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager def test_backup_restore_workflow(monkeypatch): diff --git a/src/tests/test_bip85_vectors.py b/src/tests/test_bip85_vectors.py index 8d62aa3..68938b0 100644 --- a/src/tests/test_bip85_vectors.py +++ b/src/tests/test_bip85_vectors.py @@ -5,7 +5,7 @@ import pytest sys.path.append(str(Path(__file__).resolve().parents[1])) from local_bip85.bip85 import BIP85, Bip85Error -from password_manager.password_generation import ( +from seedpass.core.password_generation import ( derive_ssh_key, derive_seed_phrase, ) diff --git a/src/tests/test_cli_doc_examples.py b/src/tests/test_cli_doc_examples.py index 9937926..ecd208e 100644 --- a/src/tests/test_cli_doc_examples.py +++ b/src/tests/test_cli_doc_examples.py @@ -8,7 +8,7 @@ sys.path.append(str(Path(__file__).resolve().parents[1] / "src")) from typer.testing import CliRunner from seedpass import cli -from password_manager.entry_types import EntryType +from seedpass.core.entry_types import EntryType class DummyPM: diff --git a/src/tests/test_cli_export_import.py b/src/tests/test_cli_export_import.py index 5e268b7..e01b38d 100644 --- a/src/tests/test_cli_export_import.py +++ b/src/tests/test_cli_export_import.py @@ -6,9 +6,9 @@ import sys sys.path.append(str(Path(__file__).resolve().parents[1])) import main -from password_manager.portable_backup import export_backup, import_backup -from password_manager.config_manager import ConfigManager -from password_manager.backup import BackupManager +from seedpass.core.portable_backup import export_backup, import_backup +from seedpass.core.config_manager import ConfigManager +from seedpass.core.backup import BackupManager from helpers import create_vault, TEST_SEED diff --git a/src/tests/test_cli_subcommands.py b/src/tests/test_cli_subcommands.py index 56e437f..751ac74 100644 --- a/src/tests/test_cli_subcommands.py +++ b/src/tests/test_cli_subcommands.py @@ -5,7 +5,7 @@ from pathlib import Path sys.path.append(str(Path(__file__).resolve().parents[1])) import main -from password_manager.entry_types import EntryType +from seedpass.core.entry_types import EntryType def make_pm(search_results, entry=None, totp_code="123456"): diff --git a/src/tests/test_concurrency_stress.py b/src/tests/test_concurrency_stress.py index da79dd4..2c145c4 100644 --- a/src/tests/test_concurrency_stress.py +++ b/src/tests/test_concurrency_stress.py @@ -6,10 +6,10 @@ from helpers import TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.encryption import EncryptionManager -from password_manager.vault import Vault -from password_manager.backup import BackupManager -from password_manager.config_manager import ConfigManager +from seedpass.core.encryption import EncryptionManager +from seedpass.core.vault import Vault +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager from utils.key_derivation import derive_index_key, derive_key_from_password diff --git a/src/tests/test_config_manager.py b/src/tests/test_config_manager.py index d26e465..c6ee18e 100644 --- a/src/tests/test_config_manager.py +++ b/src/tests/test_config_manager.py @@ -7,8 +7,8 @@ import sys sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.config_manager import ConfigManager -from password_manager.vault import Vault +from seedpass.core.config_manager import ConfigManager +from seedpass.core.vault import Vault from nostr.client import DEFAULT_RELAYS from constants import INACTIVITY_TIMEOUT diff --git a/src/tests/test_custom_fields_display.py b/src/tests/test_custom_fields_display.py index 04e2d45..b2fc943 100644 --- a/src/tests/test_custom_fields_display.py +++ b/src/tests/test_custom_fields_display.py @@ -7,10 +7,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.manager import PasswordManager, EncryptionMode -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.manager import PasswordManager, EncryptionMode +from seedpass.core.config_manager import ConfigManager def test_retrieve_entry_shows_custom_fields(monkeypatch, capsys): diff --git a/src/tests/test_default_encryption_mode.py b/src/tests/test_default_encryption_mode.py index dd0108f..251eeb2 100644 --- a/src/tests/test_default_encryption_mode.py +++ b/src/tests/test_default_encryption_mode.py @@ -6,7 +6,7 @@ sys.path.append(str(Path(__file__).resolve().parents[1])) from types import SimpleNamespace from pathlib import Path -from password_manager.manager import PasswordManager +from seedpass.core.manager import PasswordManager from utils.key_derivation import EncryptionMode diff --git a/src/tests/test_edit_tags_from_retrieve.py b/src/tests/test_edit_tags_from_retrieve.py index 143da53..ab93657 100644 --- a/src/tests/test_edit_tags_from_retrieve.py +++ b/src/tests/test_edit_tags_from_retrieve.py @@ -7,10 +7,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.manager import PasswordManager, EncryptionMode -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.manager import PasswordManager, EncryptionMode +from seedpass.core.config_manager import ConfigManager class FakePasswordGenerator: diff --git a/src/tests/test_encryption_checksum.py b/src/tests/test_encryption_checksum.py index 33d76fc..c95f82d 100644 --- a/src/tests/test_encryption_checksum.py +++ b/src/tests/test_encryption_checksum.py @@ -8,7 +8,7 @@ import base64 sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.encryption import EncryptionManager +from seedpass.core.encryption import EncryptionManager from utils.checksum import verify_and_update_checksum diff --git a/src/tests/test_encryption_files.py b/src/tests/test_encryption_files.py index 0332f6f..04fb511 100644 --- a/src/tests/test_encryption_files.py +++ b/src/tests/test_encryption_files.py @@ -8,7 +8,7 @@ import base64 sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.encryption import EncryptionManager +from seedpass.core.encryption import EncryptionManager def test_json_save_and_load_round_trip(): diff --git a/src/tests/test_entries_empty.py b/src/tests/test_entries_empty.py index f9700a5..0f05cb4 100644 --- a/src/tests/test_entries_empty.py +++ b/src/tests/test_entries_empty.py @@ -5,10 +5,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.vault import Vault -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.vault import Vault +from seedpass.core.config_manager import ConfigManager def test_list_entries_empty(): diff --git a/src/tests/test_entry_add.py b/src/tests/test_entry_add.py index 1714da5..07344bb 100644 --- a/src/tests/test_entry_add.py +++ b/src/tests/test_entry_add.py @@ -8,10 +8,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.vault import Vault -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.vault import Vault +from seedpass.core.config_manager import ConfigManager def test_add_and_retrieve_entry(): diff --git a/src/tests/test_entry_management_checksum_path.py b/src/tests/test_entry_management_checksum_path.py index 7f75b65..4d456b1 100644 --- a/src/tests/test_entry_management_checksum_path.py +++ b/src/tests/test_entry_management_checksum_path.py @@ -5,10 +5,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.vault import Vault -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.vault import Vault +from seedpass.core.config_manager import ConfigManager def test_update_checksum_writes_to_expected_path(): diff --git a/src/tests/test_export_totp_codes.py b/src/tests/test_export_totp_codes.py index 2f474da..f588e9e 100644 --- a/src/tests/test_export_totp_codes.py +++ b/src/tests/test_export_totp_codes.py @@ -8,11 +8,11 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.manager import PasswordManager, EncryptionMode -from password_manager.config_manager import ConfigManager -from password_manager.totp import TotpManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.manager import PasswordManager, EncryptionMode +from seedpass.core.config_manager import ConfigManager +from seedpass.core.totp import TotpManager class FakeNostrClient: @@ -42,9 +42,7 @@ def test_handle_export_totp_codes(monkeypatch, tmp_path): export_path = tmp_path / "out.json" monkeypatch.setattr("builtins.input", lambda *a, **k: str(export_path)) - monkeypatch.setattr( - "password_manager.manager.confirm_action", lambda *_a, **_k: False - ) + monkeypatch.setattr("seedpass.core.manager.confirm_action", lambda *_a, **_k: False) pm.handle_export_totp_codes() diff --git a/src/tests/test_fingerprint_encryption.py b/src/tests/test_fingerprint_encryption.py index a306c1f..9cc14d7 100644 --- a/src/tests/test_fingerprint_encryption.py +++ b/src/tests/test_fingerprint_encryption.py @@ -9,7 +9,7 @@ import base64 sys.path.append(str(Path(__file__).resolve().parents[1])) from utils.fingerprint import generate_fingerprint -from password_manager.encryption import EncryptionManager +from seedpass.core.encryption import EncryptionManager def test_generate_fingerprint_deterministic(): diff --git a/src/tests/test_full_sync_roundtrip.py b/src/tests/test_full_sync_roundtrip.py index 64f110e..cdcde6a 100644 --- a/src/tests/test_full_sync_roundtrip.py +++ b/src/tests/test_full_sync_roundtrip.py @@ -4,10 +4,10 @@ from tempfile import TemporaryDirectory from helpers import create_vault, dummy_nostr_client -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.config_manager import ConfigManager -from password_manager.manager import PasswordManager, EncryptionMode +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager +from seedpass.core.manager import PasswordManager, EncryptionMode def _init_pm(dir_path: Path, client) -> PasswordManager: diff --git a/src/tests/test_full_sync_roundtrip_new.py b/src/tests/test_full_sync_roundtrip_new.py index 64f110e..cdcde6a 100644 --- a/src/tests/test_full_sync_roundtrip_new.py +++ b/src/tests/test_full_sync_roundtrip_new.py @@ -4,10 +4,10 @@ from tempfile import TemporaryDirectory from helpers import create_vault, dummy_nostr_client -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.config_manager import ConfigManager -from password_manager.manager import PasswordManager, EncryptionMode +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager +from seedpass.core.manager import PasswordManager, EncryptionMode def _init_pm(dir_path: Path, client) -> PasswordManager: diff --git a/src/tests/test_fuzz_key_derivation.py b/src/tests/test_fuzz_key_derivation.py index 45a35b6..89e26c8 100644 --- a/src/tests/test_fuzz_key_derivation.py +++ b/src/tests/test_fuzz_key_derivation.py @@ -9,7 +9,7 @@ from utils.key_derivation import ( derive_key_from_password_argon2, derive_index_key, ) -from password_manager.encryption import EncryptionManager +from seedpass.core.encryption import EncryptionManager cfg_values = st.one_of( diff --git a/src/tests/test_index_cache.py b/src/tests/test_index_cache.py index e4a054b..2ee8eac 100644 --- a/src/tests/test_index_cache.py +++ b/src/tests/test_index_cache.py @@ -3,9 +3,9 @@ from tempfile import TemporaryDirectory from unittest.mock import patch from helpers import create_vault, TEST_SEED, TEST_PASSWORD -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager def test_index_caching(): diff --git a/src/tests/test_index_import_export.py b/src/tests/test_index_import_export.py index 04e3194..a9ee75a 100644 --- a/src/tests/test_index_import_export.py +++ b/src/tests/test_index_import_export.py @@ -7,8 +7,8 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.encryption import EncryptionManager -from password_manager.vault import Vault +from seedpass.core.encryption import EncryptionManager +from seedpass.core.vault import Vault from utils.key_derivation import derive_index_key, derive_key_from_password SEED = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" diff --git a/src/tests/test_kdf_modes.py b/src/tests/test_kdf_modes.py index ab453de..177d050 100644 --- a/src/tests/test_kdf_modes.py +++ b/src/tests/test_kdf_modes.py @@ -8,10 +8,10 @@ from utils.key_derivation import ( derive_key_from_password_argon2, derive_index_key, ) -from password_manager.encryption import EncryptionManager -from password_manager.vault import Vault -from password_manager.config_manager import ConfigManager -from password_manager.manager import PasswordManager, EncryptionMode +from seedpass.core.encryption import EncryptionManager +from seedpass.core.vault import Vault +from seedpass.core.config_manager import ConfigManager +from seedpass.core.manager import PasswordManager, EncryptionMode TEST_SEED = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" TEST_PASSWORD = "pw" @@ -59,12 +59,12 @@ def test_setup_encryption_manager_kdf_modes(monkeypatch): cfg = _setup_profile(path, mode) pm = _make_pm(path, cfg) monkeypatch.setattr( - "password_manager.manager.prompt_existing_password", + "seedpass.core.manager.prompt_existing_password", lambda *_: TEST_PASSWORD, ) if mode == "argon2": monkeypatch.setattr( - "password_manager.manager.derive_key_from_password_argon2", + "seedpass.core.manager.derive_key_from_password_argon2", lambda pw: derive_key_from_password_argon2(pw, **argon_kwargs), ) monkeypatch.setattr(PasswordManager, "initialize_bip85", lambda self: None) diff --git a/src/tests/test_key_value_entry.py b/src/tests/test_key_value_entry.py index 895dfbf..86a4629 100644 --- a/src/tests/test_key_value_entry.py +++ b/src/tests/test_key_value_entry.py @@ -6,9 +6,9 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager def setup_entry_mgr(tmp_path: Path) -> EntryManager: diff --git a/src/tests/test_last_used_fingerprint.py b/src/tests/test_last_used_fingerprint.py index b097c4a..1e294f9 100644 --- a/src/tests/test_last_used_fingerprint.py +++ b/src/tests/test_last_used_fingerprint.py @@ -3,9 +3,9 @@ from pathlib import Path from tempfile import TemporaryDirectory import constants -import password_manager.manager as manager_module +import seedpass.core.manager as manager_module from utils.fingerprint_manager import FingerprintManager -from password_manager.manager import EncryptionMode +from seedpass.core.manager import EncryptionMode from helpers import TEST_SEED diff --git a/src/tests/test_list_entries_sort_filter.py b/src/tests/test_list_entries_sort_filter.py index f56d3ef..68d693f 100644 --- a/src/tests/test_list_entries_sort_filter.py +++ b/src/tests/test_list_entries_sort_filter.py @@ -6,10 +6,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.config_manager import ConfigManager -from password_manager.entry_types import EntryType +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager +from seedpass.core.entry_types import EntryType def setup_entry_manager(tmp_path: Path) -> EntryManager: diff --git a/src/tests/test_managed_account.py b/src/tests/test_managed_account.py index b62d814..a6d18cb 100644 --- a/src/tests/test_managed_account.py +++ b/src/tests/test_managed_account.py @@ -4,14 +4,14 @@ from tempfile import TemporaryDirectory from helpers import create_vault, TEST_SEED, TEST_PASSWORD from utils.fingerprint import generate_fingerprint -import password_manager.manager as manager_module -from password_manager.manager import EncryptionMode +import seedpass.core.manager as manager_module +from seedpass.core.manager import EncryptionMode sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager def setup_entry_manager(tmp_path: Path) -> EntryManager: diff --git a/src/tests/test_managed_account_entry.py b/src/tests/test_managed_account_entry.py index d9d6cef..abeadac 100644 --- a/src/tests/test_managed_account_entry.py +++ b/src/tests/test_managed_account_entry.py @@ -4,15 +4,15 @@ from tempfile import TemporaryDirectory from helpers import create_vault, TEST_SEED, TEST_PASSWORD from utils.fingerprint import generate_fingerprint -import password_manager.manager as manager_module -from password_manager.manager import EncryptionMode +import seedpass.core.manager as manager_module +from seedpass.core.manager import EncryptionMode sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.config_manager import ConfigManager -from password_manager.password_generation import derive_seed_phrase +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager +from seedpass.core.password_generation import derive_seed_phrase from local_bip85.bip85 import BIP85 from bip_utils import Bip39SeedGenerator diff --git a/src/tests/test_manager_add_totp.py b/src/tests/test_manager_add_totp.py index 3dd140a..27ed550 100644 --- a/src/tests/test_manager_add_totp.py +++ b/src/tests/test_manager_add_totp.py @@ -7,10 +7,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.manager import PasswordManager, EncryptionMode -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.manager import PasswordManager, EncryptionMode +from seedpass.core.config_manager import ConfigManager class FakeNostrClient: diff --git a/src/tests/test_manager_checksum_backup.py b/src/tests/test_manager_checksum_backup.py index cfba90c..5eeb632 100644 --- a/src/tests/test_manager_checksum_backup.py +++ b/src/tests/test_manager_checksum_backup.py @@ -3,7 +3,7 @@ from pathlib import Path sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.manager import PasswordManager, EncryptionMode +from seedpass.core.manager import PasswordManager, EncryptionMode import queue @@ -29,8 +29,8 @@ def test_handle_verify_checksum_success(monkeypatch, tmp_path, capsys): pm = _make_pm() chk_file = tmp_path / "chk.txt" chk_file.write_text("abc") - monkeypatch.setattr("password_manager.manager.SCRIPT_CHECKSUM_FILE", chk_file) - monkeypatch.setattr("password_manager.manager.calculate_checksum", lambda _: "abc") + monkeypatch.setattr("seedpass.core.manager.SCRIPT_CHECKSUM_FILE", chk_file) + monkeypatch.setattr("seedpass.core.manager.calculate_checksum", lambda _: "abc") pm.handle_verify_checksum() out = capsys.readouterr().out assert "Checksum verification passed." in out @@ -40,8 +40,8 @@ def test_handle_verify_checksum_failure(monkeypatch, tmp_path, capsys): pm = _make_pm() chk_file = tmp_path / "chk.txt" chk_file.write_text("xyz") - monkeypatch.setattr("password_manager.manager.SCRIPT_CHECKSUM_FILE", chk_file) - monkeypatch.setattr("password_manager.manager.calculate_checksum", lambda _: "abc") + monkeypatch.setattr("seedpass.core.manager.SCRIPT_CHECKSUM_FILE", chk_file) + monkeypatch.setattr("seedpass.core.manager.calculate_checksum", lambda _: "abc") pm.handle_verify_checksum() out = capsys.readouterr().out assert "Checksum verification failed" in out @@ -50,13 +50,13 @@ def test_handle_verify_checksum_failure(monkeypatch, tmp_path, capsys): def test_handle_verify_checksum_missing(monkeypatch, tmp_path, capsys): pm = _make_pm() chk_file = tmp_path / "chk.txt" - monkeypatch.setattr("password_manager.manager.SCRIPT_CHECKSUM_FILE", chk_file) - monkeypatch.setattr("password_manager.manager.calculate_checksum", lambda _: "abc") + monkeypatch.setattr("seedpass.core.manager.SCRIPT_CHECKSUM_FILE", chk_file) + monkeypatch.setattr("seedpass.core.manager.calculate_checksum", lambda _: "abc") def raise_missing(*_args, **_kwargs): raise FileNotFoundError - monkeypatch.setattr("password_manager.manager.verify_checksum", raise_missing) + monkeypatch.setattr("seedpass.core.manager.verify_checksum", raise_missing) pm.handle_verify_checksum() note = pm.notifications.get_nowait() assert note.level == "WARNING" diff --git a/src/tests/test_manager_current_notification.py b/src/tests/test_manager_current_notification.py index ab94d9e..a9d341c 100644 --- a/src/tests/test_manager_current_notification.py +++ b/src/tests/test_manager_current_notification.py @@ -5,7 +5,7 @@ import sys sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.manager import PasswordManager, Notification +from seedpass.core.manager import PasswordManager, Notification from constants import NOTIFICATION_DURATION @@ -20,7 +20,7 @@ def _make_pm(): def test_notify_sets_current(monkeypatch): pm = _make_pm() current = {"val": 100.0} - monkeypatch.setattr("password_manager.manager.time.time", lambda: current["val"]) + monkeypatch.setattr("seedpass.core.manager.time.time", lambda: current["val"]) pm.notify("hello") note = pm._current_notification assert hasattr(note, "message") @@ -32,7 +32,7 @@ def test_notify_sets_current(monkeypatch): def test_get_current_notification_ttl(monkeypatch): pm = _make_pm() now = {"val": 0.0} - monkeypatch.setattr("password_manager.manager.time.time", lambda: now["val"]) + monkeypatch.setattr("seedpass.core.manager.time.time", lambda: now["val"]) pm.notify("note1") assert pm.get_current_notification().message == "note1" diff --git a/src/tests/test_manager_display_totp_codes.py b/src/tests/test_manager_display_totp_codes.py index 649bcd2..783e985 100644 --- a/src/tests/test_manager_display_totp_codes.py +++ b/src/tests/test_manager_display_totp_codes.py @@ -6,10 +6,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.manager import PasswordManager, EncryptionMode -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.manager import PasswordManager, EncryptionMode +from seedpass.core.config_manager import ConfigManager class FakeNostrClient: @@ -50,7 +50,7 @@ def test_handle_display_totp_codes(monkeypatch, capsys): # interrupt the loop after first iteration monkeypatch.setattr( - "password_manager.manager.timed_input", + "seedpass.core.manager.timed_input", lambda *a, **k: (_ for _ in ()).throw(KeyboardInterrupt()), ) @@ -91,7 +91,7 @@ def test_display_totp_codes_excludes_archived(monkeypatch, capsys): ) monkeypatch.setattr( - "password_manager.manager.timed_input", + "seedpass.core.manager.timed_input", lambda *a, **k: (_ for _ in ()).throw(KeyboardInterrupt()), ) diff --git a/src/tests/test_manager_edit_totp.py b/src/tests/test_manager_edit_totp.py index 53e43d4..8d2bbbe 100644 --- a/src/tests/test_manager_edit_totp.py +++ b/src/tests/test_manager_edit_totp.py @@ -6,10 +6,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.manager import PasswordManager, EncryptionMode -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.manager import PasswordManager, EncryptionMode +from seedpass.core.config_manager import ConfigManager class FakeNostrClient: @@ -49,8 +49,8 @@ def test_edit_totp_period_from_retrieve(monkeypatch): monkeypatch.setattr( pm.entry_manager, "get_totp_time_remaining", lambda *a, **k: 1 ) - monkeypatch.setattr("password_manager.manager.time.sleep", lambda *a, **k: None) - monkeypatch.setattr("password_manager.manager.timed_input", lambda *a, **k: "b") + monkeypatch.setattr("seedpass.core.manager.time.sleep", lambda *a, **k: None) + monkeypatch.setattr("seedpass.core.manager.timed_input", lambda *a, **k: "b") pm.handle_retrieve_entry() entry = entry_mgr.retrieve_entry(0) diff --git a/src/tests/test_manager_list_entries.py b/src/tests/test_manager_list_entries.py index 444d420..6e39282 100644 --- a/src/tests/test_manager_list_entries.py +++ b/src/tests/test_manager_list_entries.py @@ -10,11 +10,11 @@ import sys sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.manager import PasswordManager, EncryptionMode -from password_manager.entry_types import EntryType -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.manager import PasswordManager, EncryptionMode +from seedpass.core.entry_types import EntryType +from seedpass.core.config_manager import ConfigManager def test_handle_list_entries(monkeypatch, capsys): @@ -79,9 +79,9 @@ def test_list_entries_show_details(monkeypatch, capsys): monkeypatch.setattr( pm.entry_manager, "get_totp_time_remaining", lambda *a, **k: 1 ) - monkeypatch.setattr("password_manager.manager.time.sleep", lambda *a, **k: None) + monkeypatch.setattr("seedpass.core.manager.time.sleep", lambda *a, **k: None) monkeypatch.setattr( - "password_manager.manager.timed_input", + "seedpass.core.manager.timed_input", lambda *a, **k: "b", ) @@ -119,7 +119,7 @@ def test_show_entry_details_by_index(monkeypatch): header_calls = [] monkeypatch.setattr( - "password_manager.manager.clear_header_with_notification", + "seedpass.core.manager.clear_header_with_notification", lambda *a, **k: header_calls.append(True), ) @@ -134,9 +134,9 @@ def test_show_entry_details_by_index(monkeypatch): "_entry_actions_menu", lambda *a, **k: call_order.append("actions"), ) - monkeypatch.setattr("password_manager.manager.pause", lambda *a, **k: None) + monkeypatch.setattr("seedpass.core.manager.pause", lambda *a, **k: None) monkeypatch.setattr( - "password_manager.manager.confirm_action", lambda *a, **k: False + "seedpass.core.manager.confirm_action", lambda *a, **k: False ) pm.password_generator = SimpleNamespace(generate_password=lambda l, i: "pw123") monkeypatch.setattr(pm, "notify", lambda *a, **k: None) @@ -168,16 +168,14 @@ def _setup_manager(tmp_path): def _detail_common(monkeypatch, pm): monkeypatch.setattr( - "password_manager.manager.clear_header_with_notification", + "seedpass.core.manager.clear_header_with_notification", lambda *a, **k: None, ) - monkeypatch.setattr("password_manager.manager.pause", lambda *a, **k: None) + monkeypatch.setattr("seedpass.core.manager.pause", lambda *a, **k: None) monkeypatch.setattr("builtins.input", lambda *a, **k: "") - monkeypatch.setattr( - "password_manager.manager.confirm_action", lambda *a, **k: False - ) - monkeypatch.setattr("password_manager.manager.timed_input", lambda *a, **k: "b") - monkeypatch.setattr("password_manager.manager.time.sleep", lambda *a, **k: None) + monkeypatch.setattr("seedpass.core.manager.confirm_action", lambda *a, **k: False) + monkeypatch.setattr("seedpass.core.manager.timed_input", lambda *a, **k: "b") + monkeypatch.setattr("seedpass.core.manager.time.sleep", lambda *a, **k: None) monkeypatch.setattr(pm, "notify", lambda *a, **k: None) pm.password_generator = SimpleNamespace(generate_password=lambda l, i: "pw123") called = [] @@ -300,21 +298,21 @@ def test_show_entry_details_sensitive(monkeypatch, capsys, entry_type): pm.password_generator = SimpleNamespace(generate_password=lambda l, i: "pw123") monkeypatch.setattr( - "password_manager.manager.confirm_action", lambda *a, **k: True + "seedpass.core.manager.confirm_action", lambda *a, **k: True ) monkeypatch.setattr( - "password_manager.manager.copy_to_clipboard", lambda *a, **k: None + "seedpass.core.manager.copy_to_clipboard", lambda *a, **k: None ) - monkeypatch.setattr("password_manager.manager.timed_input", lambda *a, **k: "b") - monkeypatch.setattr("password_manager.manager.time.sleep", lambda *a, **k: None) + monkeypatch.setattr("seedpass.core.manager.timed_input", lambda *a, **k: "b") + monkeypatch.setattr("seedpass.core.manager.time.sleep", lambda *a, **k: None) monkeypatch.setattr( - "password_manager.manager.TotpManager.print_qr_code", lambda *a, **k: None + "seedpass.core.manager.TotpManager.print_qr_code", lambda *a, **k: None ) monkeypatch.setattr( - "password_manager.manager.clear_header_with_notification", + "seedpass.core.manager.clear_header_with_notification", lambda *a, **k: None, ) - monkeypatch.setattr("password_manager.manager.pause", lambda *a, **k: None) + monkeypatch.setattr("seedpass.core.manager.pause", lambda *a, **k: None) input_val = "r" if entry_type == "managed_account" else "" monkeypatch.setattr("builtins.input", lambda *a, **k: input_val) diff --git a/src/tests/test_manager_retrieve_totp.py b/src/tests/test_manager_retrieve_totp.py index 2d300ad..8d01c28 100644 --- a/src/tests/test_manager_retrieve_totp.py +++ b/src/tests/test_manager_retrieve_totp.py @@ -6,10 +6,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.manager import PasswordManager, EncryptionMode, TotpManager -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.manager import PasswordManager, EncryptionMode, TotpManager +from seedpass.core.config_manager import ConfigManager class FakeNostrClient: @@ -49,9 +49,9 @@ def test_handle_retrieve_totp_entry(monkeypatch, capsys): monkeypatch.setattr( pm.entry_manager, "get_totp_time_remaining", lambda *a, **k: 1 ) - monkeypatch.setattr("password_manager.manager.time.sleep", lambda *a, **k: None) + monkeypatch.setattr("seedpass.core.manager.time.sleep", lambda *a, **k: None) monkeypatch.setattr( - "password_manager.manager.timed_input", + "seedpass.core.manager.timed_input", lambda *a, **k: "b", ) diff --git a/src/tests/test_manager_search_display.py b/src/tests/test_manager_search_display.py index 5116ae2..6781f0b 100644 --- a/src/tests/test_manager_search_display.py +++ b/src/tests/test_manager_search_display.py @@ -7,10 +7,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.manager import PasswordManager, EncryptionMode -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.manager import PasswordManager, EncryptionMode +from seedpass.core.config_manager import ConfigManager def test_search_entries_prompt_for_details(monkeypatch, capsys): @@ -38,8 +38,8 @@ def test_search_entries_prompt_for_details(monkeypatch, capsys): monkeypatch.setattr( pm.entry_manager, "get_totp_time_remaining", lambda *a, **k: 1 ) - monkeypatch.setattr("password_manager.manager.time.sleep", lambda *a, **k: None) - monkeypatch.setattr("password_manager.manager.timed_input", lambda *a, **k: "b") + monkeypatch.setattr("seedpass.core.manager.time.sleep", lambda *a, **k: None) + monkeypatch.setattr("seedpass.core.manager.timed_input", lambda *a, **k: "b") inputs = iter(["Example", "0"]) monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs)) diff --git a/src/tests/test_manager_seed_setup.py b/src/tests/test_manager_seed_setup.py index ea6baf9..3c1a245 100644 --- a/src/tests/test_manager_seed_setup.py +++ b/src/tests/test_manager_seed_setup.py @@ -1,6 +1,6 @@ import builtins from mnemonic import Mnemonic -from password_manager.manager import PasswordManager +from seedpass.core.manager import PasswordManager from utils import seed_prompt @@ -28,7 +28,7 @@ def test_setup_existing_seed_words(monkeypatch): words = phrase.split() word_iter = iter(words) monkeypatch.setattr( - "password_manager.manager.masked_input", + "seedpass.core.manager.masked_input", lambda *_: next(word_iter), ) # Ensure prompt_seed_words uses the patched function @@ -52,7 +52,7 @@ def test_setup_existing_seed_paste(monkeypatch): called["prompt"] = prompt return phrase - monkeypatch.setattr("password_manager.manager.masked_input", fake_masked_input) + monkeypatch.setattr("seedpass.core.manager.masked_input", fake_masked_input) monkeypatch.setattr( builtins, "input", diff --git a/src/tests/test_manager_warning_notifications.py b/src/tests/test_manager_warning_notifications.py index 55ad85c..1c55db6 100644 --- a/src/tests/test_manager_warning_notifications.py +++ b/src/tests/test_manager_warning_notifications.py @@ -5,11 +5,11 @@ import sys sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.manager import PasswordManager, EncryptionMode -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager +from seedpass.core.manager import PasswordManager, EncryptionMode +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager from helpers import create_vault, TEST_SEED, TEST_PASSWORD -from password_manager.config_manager import ConfigManager +from seedpass.core.config_manager import ConfigManager def _make_pm(tmp_path: Path) -> PasswordManager: @@ -34,9 +34,9 @@ def _make_pm(tmp_path: Path) -> PasswordManager: def test_handle_search_entries_no_query(monkeypatch, tmp_path): pm = _make_pm(tmp_path) monkeypatch.setattr( - "password_manager.manager.clear_header_with_notification", lambda *a, **k: None + "seedpass.core.manager.clear_header_with_notification", lambda *a, **k: None ) - monkeypatch.setattr("password_manager.manager.pause", lambda: None) + monkeypatch.setattr("seedpass.core.manager.pause", lambda: None) monkeypatch.setattr("builtins.input", lambda *_: "") pm.handle_search_entries() diff --git a/src/tests/test_manager_workflow.py b/src/tests/test_manager_workflow.py index a5046b2..99bea0a 100644 --- a/src/tests/test_manager_workflow.py +++ b/src/tests/test_manager_workflow.py @@ -5,11 +5,11 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.vault import Vault -from password_manager.backup import BackupManager -from password_manager.manager import PasswordManager, EncryptionMode -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.vault import Vault +from seedpass.core.backup import BackupManager +from seedpass.core.manager import PasswordManager, EncryptionMode +from seedpass.core.config_manager import ConfigManager class FakePasswordGenerator: @@ -34,7 +34,7 @@ def test_manager_workflow(monkeypatch): backup_mgr = BackupManager(tmp_path, cfg_mgr) entry_mgr = EntryManager(vault, backup_mgr) - monkeypatch.setattr("password_manager.manager.NostrClient", FakeNostrClient) + monkeypatch.setattr("seedpass.core.manager.NostrClient", FakeNostrClient) pm = PasswordManager.__new__(PasswordManager) pm.encryption_mode = EncryptionMode.SEED_ONLY diff --git a/src/tests/test_migrations.py b/src/tests/test_migrations.py index 2371203..7f361ad 100644 --- a/src/tests/test_migrations.py +++ b/src/tests/test_migrations.py @@ -5,7 +5,7 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.migrations import LATEST_VERSION +from seedpass.core.migrations import LATEST_VERSION def setup(tmp_path: Path): diff --git a/src/tests/test_modify_totp_entry.py b/src/tests/test_modify_totp_entry.py index 8e038d6..262f95b 100644 --- a/src/tests/test_modify_totp_entry.py +++ b/src/tests/test_modify_totp_entry.py @@ -1,9 +1,9 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD import pytest -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager def test_modify_totp_entry_period_digits_and_archive(tmp_path): diff --git a/src/tests/test_multiple_fingerprint_prompt.py b/src/tests/test_multiple_fingerprint_prompt.py index f065ac6..c36dfaf 100644 --- a/src/tests/test_multiple_fingerprint_prompt.py +++ b/src/tests/test_multiple_fingerprint_prompt.py @@ -3,7 +3,7 @@ from pathlib import Path from tempfile import TemporaryDirectory import constants -import password_manager.manager as manager_module +import seedpass.core.manager as manager_module from utils.fingerprint_manager import FingerprintManager from helpers import TEST_SEED diff --git a/src/tests/test_noninteractive_init_unlock.py b/src/tests/test_noninteractive_init_unlock.py index d239fb4..b696905 100644 --- a/src/tests/test_noninteractive_init_unlock.py +++ b/src/tests/test_noninteractive_init_unlock.py @@ -4,9 +4,9 @@ from pathlib import Path from tempfile import TemporaryDirectory import constants -import password_manager.manager as manager_module +import seedpass.core.manager as manager_module from utils.fingerprint_manager import FingerprintManager -from password_manager.config_manager import ConfigManager +from seedpass.core.config_manager import ConfigManager from tests.helpers import TEST_SEED, TEST_PASSWORD, create_vault diff --git a/src/tests/test_nostr_backup.py b/src/tests/test_nostr_backup.py index b4ca998..f2f56c3 100644 --- a/src/tests/test_nostr_backup.py +++ b/src/tests/test_nostr_backup.py @@ -7,10 +7,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.vault import Vault -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.vault import Vault +from seedpass.core.config_manager import ConfigManager from nostr.client import NostrClient diff --git a/src/tests/test_nostr_client.py b/src/tests/test_nostr_client.py index c3a6e9a..508eb79 100644 --- a/src/tests/test_nostr_client.py +++ b/src/tests/test_nostr_client.py @@ -9,7 +9,7 @@ import base64 sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.encryption import EncryptionManager +from seedpass.core.encryption import EncryptionManager from nostr.client import NostrClient import nostr.client as nostr_client diff --git a/src/tests/test_nostr_contract.py b/src/tests/test_nostr_contract.py index 5501be0..be56e58 100644 --- a/src/tests/test_nostr_contract.py +++ b/src/tests/test_nostr_contract.py @@ -8,7 +8,7 @@ import base64 sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.encryption import EncryptionManager +from seedpass.core.encryption import EncryptionManager from nostr.client import NostrClient, Manifest diff --git a/src/tests/test_nostr_dummy_client.py b/src/tests/test_nostr_dummy_client.py index 5284a1e..db35d7e 100644 --- a/src/tests/test_nostr_dummy_client.py +++ b/src/tests/test_nostr_dummy_client.py @@ -3,9 +3,9 @@ import gzip import math from helpers import create_vault, dummy_nostr_client -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager from nostr.client import prepare_snapshot from nostr.backup_models import KIND_SNAPSHOT_CHUNK diff --git a/src/tests/test_nostr_entry.py b/src/tests/test_nostr_entry.py index c049850..b8e1edb 100644 --- a/src/tests/test_nostr_entry.py +++ b/src/tests/test_nostr_entry.py @@ -8,10 +8,10 @@ from nostr.coincurve_keys import Keys sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.vault import Vault -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.vault import Vault +from seedpass.core.config_manager import ConfigManager def test_nostr_key_determinism(): diff --git a/src/tests/test_nostr_index_size.py b/src/tests/test_nostr_index_size.py index 4277a76..a7598a2 100644 --- a/src/tests/test_nostr_index_size.py +++ b/src/tests/test_nostr_index_size.py @@ -15,11 +15,11 @@ import os sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.encryption import EncryptionManager -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.vault import Vault -from password_manager.config_manager import ConfigManager +from seedpass.core.encryption import EncryptionManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.vault import Vault +from seedpass.core.config_manager import ConfigManager from nostr.client import NostrClient, Kind, KindStandard diff --git a/src/tests/test_nostr_qr.py b/src/tests/test_nostr_qr.py index 0ad7fe3..1d032aa 100644 --- a/src/tests/test_nostr_qr.py +++ b/src/tests/test_nostr_qr.py @@ -6,10 +6,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.manager import PasswordManager, EncryptionMode, TotpManager -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.manager import PasswordManager, EncryptionMode, TotpManager +from seedpass.core.config_manager import ConfigManager from utils.color_scheme import color_text @@ -49,7 +49,7 @@ def test_show_qr_for_nostr_keys(monkeypatch): monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs)) called = [] monkeypatch.setattr( - "password_manager.manager.TotpManager.print_qr_code", + "seedpass.core.manager.TotpManager.print_qr_code", lambda data: called.append(data), ) @@ -85,7 +85,7 @@ def test_show_private_key_qr(monkeypatch, capsys): monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs)) called = [] monkeypatch.setattr( - "password_manager.manager.TotpManager.print_qr_code", + "seedpass.core.manager.TotpManager.print_qr_code", lambda data: called.append(data), ) @@ -130,7 +130,7 @@ def test_qr_menu_case_insensitive(monkeypatch): monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs)) called = [] monkeypatch.setattr( - "password_manager.manager.TotpManager.print_qr_code", + "seedpass.core.manager.TotpManager.print_qr_code", lambda data: called.append(data), ) diff --git a/src/tests/test_nostr_real.py b/src/tests/test_nostr_real.py index 0226626..14c2e89 100644 --- a/src/tests/test_nostr_real.py +++ b/src/tests/test_nostr_real.py @@ -13,7 +13,7 @@ import base64 sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.encryption import EncryptionManager +from seedpass.core.encryption import EncryptionManager from nostr.client import NostrClient diff --git a/src/tests/test_nostr_snapshot.py b/src/tests/test_nostr_snapshot.py index b466f46..c1d642c 100644 --- a/src/tests/test_nostr_snapshot.py +++ b/src/tests/test_nostr_snapshot.py @@ -9,7 +9,7 @@ import asyncio from unittest.mock import patch from nostr import prepare_snapshot, NostrClient -from password_manager.encryption import EncryptionManager +from seedpass.core.encryption import EncryptionManager def test_prepare_snapshot_roundtrip(): diff --git a/src/tests/test_offline_mode_behavior.py b/src/tests/test_offline_mode_behavior.py index 0480207..df9d1ef 100644 --- a/src/tests/test_offline_mode_behavior.py +++ b/src/tests/test_offline_mode_behavior.py @@ -1,7 +1,7 @@ import time from types import SimpleNamespace -from password_manager.manager import PasswordManager +from seedpass.core.manager import PasswordManager def test_sync_vault_skips_network(monkeypatch): diff --git a/src/tests/test_parent_seed_backup.py b/src/tests/test_parent_seed_backup.py index ff379a6..1f70920 100644 --- a/src/tests/test_parent_seed_backup.py +++ b/src/tests/test_parent_seed_backup.py @@ -6,7 +6,7 @@ import queue sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.manager import PasswordManager, EncryptionMode +from seedpass.core.manager import PasswordManager, EncryptionMode from constants import DEFAULT_SEED_BACKUP_FILENAME @@ -25,11 +25,11 @@ def test_handle_backup_reveal_parent_seed_confirm(monkeypatch, tmp_path, capsys) pm = _make_pm(tmp_path) monkeypatch.setattr( - "password_manager.manager.prompt_existing_password", lambda *_: "pw" + "seedpass.core.manager.prompt_existing_password", lambda *_: "pw" ) confirms = iter([True, True]) monkeypatch.setattr( - "password_manager.manager.confirm_action", lambda *_a, **_k: next(confirms) + "seedpass.core.manager.confirm_action", lambda *_a, **_k: next(confirms) ) saved = [] @@ -51,11 +51,9 @@ def test_handle_backup_reveal_parent_seed_cancel(monkeypatch, tmp_path, capsys): pm = _make_pm(tmp_path) monkeypatch.setattr( - "password_manager.manager.prompt_existing_password", lambda *_: "pw" - ) - monkeypatch.setattr( - "password_manager.manager.confirm_action", lambda *_a, **_k: False + "seedpass.core.manager.prompt_existing_password", lambda *_: "pw" ) + monkeypatch.setattr("seedpass.core.manager.confirm_action", lambda *_a, **_k: False) saved = [] pm.encryption_manager = SimpleNamespace( encrypt_and_save_file=lambda data, path: saved.append((data, path)) diff --git a/src/tests/test_password_change.py b/src/tests/test_password_change.py index 7401559..1a82df0 100644 --- a/src/tests/test_password_change.py +++ b/src/tests/test_password_change.py @@ -8,11 +8,11 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.config_manager import ConfigManager -from password_manager.backup import BackupManager -from password_manager.vault import Vault -from password_manager.manager import PasswordManager, EncryptionMode +from seedpass.core.entry_management import EntryManager +from seedpass.core.config_manager import ConfigManager +from seedpass.core.backup import BackupManager +from seedpass.core.vault import Vault +from seedpass.core.manager import PasswordManager, EncryptionMode def test_change_password_triggers_nostr_backup(monkeypatch): @@ -37,13 +37,11 @@ def test_change_password_triggers_nostr_backup(monkeypatch): pm.verify_password = lambda pw: True monkeypatch.setattr( - "password_manager.manager.prompt_existing_password", lambda *_: "old" - ) - monkeypatch.setattr( - "password_manager.manager.prompt_for_password", lambda: "new" + "seedpass.core.manager.prompt_existing_password", lambda *_: "old" ) + monkeypatch.setattr("seedpass.core.manager.prompt_for_password", lambda: "new") - with patch("password_manager.manager.NostrClient") as MockClient: + with patch("seedpass.core.manager.NostrClient") as MockClient: mock_instance = MockClient.return_value mock_instance.publish_snapshot = AsyncMock(return_value=(None, "abcd")) pm.nostr_client = mock_instance diff --git a/src/tests/test_password_generation_policy.py b/src/tests/test_password_generation_policy.py index 5384075..a4df419 100644 --- a/src/tests/test_password_generation_policy.py +++ b/src/tests/test_password_generation_policy.py @@ -4,7 +4,7 @@ import sys sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.password_generation import PasswordGenerator, PasswordPolicy +from seedpass.core.password_generation import PasswordGenerator, PasswordPolicy class DummyEnc: diff --git a/src/tests/test_password_helpers.py b/src/tests/test_password_helpers.py index d6f661c..080e363 100644 --- a/src/tests/test_password_helpers.py +++ b/src/tests/test_password_helpers.py @@ -1,5 +1,5 @@ import string -from password_manager.password_generation import PasswordGenerator, PasswordPolicy +from seedpass.core.password_generation import PasswordGenerator, PasswordPolicy class DummyEnc: diff --git a/src/tests/test_password_length_constraints.py b/src/tests/test_password_length_constraints.py index eaa4941..a800f9f 100644 --- a/src/tests/test_password_length_constraints.py +++ b/src/tests/test_password_length_constraints.py @@ -4,7 +4,7 @@ import sys sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.password_generation import PasswordGenerator, PasswordPolicy +from seedpass.core.password_generation import PasswordGenerator, PasswordPolicy from constants import MIN_PASSWORD_LENGTH diff --git a/src/tests/test_password_properties.py b/src/tests/test_password_properties.py index 60fce89..0c5f2ba 100644 --- a/src/tests/test_password_properties.py +++ b/src/tests/test_password_properties.py @@ -5,8 +5,8 @@ from hypothesis import given, strategies as st, settings sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.password_generation import PasswordGenerator, PasswordPolicy -from password_manager.entry_types import EntryType +from seedpass.core.password_generation import PasswordGenerator, PasswordPolicy +from seedpass.core.entry_types import EntryType class DummyEnc: diff --git a/src/tests/test_password_unlock_after_change.py b/src/tests/test_password_unlock_after_change.py index 114b7f1..6f31fbd 100644 --- a/src/tests/test_password_unlock_after_change.py +++ b/src/tests/test_password_unlock_after_change.py @@ -7,12 +7,12 @@ import bcrypt sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.encryption import EncryptionManager -from password_manager.vault import Vault -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.config_manager import ConfigManager -from password_manager.manager import PasswordManager, EncryptionMode +from seedpass.core.encryption import EncryptionManager +from seedpass.core.vault import Vault +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager +from seedpass.core.manager import PasswordManager, EncryptionMode from utils.key_derivation import derive_index_key, derive_key_from_password SEED = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" @@ -61,13 +61,11 @@ def test_password_change_and_unlock(monkeypatch): ) monkeypatch.setattr( - "password_manager.manager.prompt_existing_password", lambda *_: old_pw + "seedpass.core.manager.prompt_existing_password", lambda *_: old_pw ) + monkeypatch.setattr("seedpass.core.manager.prompt_for_password", lambda: new_pw) monkeypatch.setattr( - "password_manager.manager.prompt_for_password", lambda: new_pw - ) - monkeypatch.setattr( - "password_manager.manager.NostrClient", + "seedpass.core.manager.NostrClient", lambda *a, **kw: SimpleNamespace( publish_snapshot=lambda *a, **k: (None, "abcd") ), @@ -77,7 +75,7 @@ def test_password_change_and_unlock(monkeypatch): pm.lock_vault() monkeypatch.setattr( - "password_manager.manager.prompt_existing_password", lambda *_: new_pw + "seedpass.core.manager.prompt_existing_password", lambda *_: new_pw ) monkeypatch.setattr(PasswordManager, "initialize_bip85", lambda self: None) monkeypatch.setattr(PasswordManager, "initialize_managers", lambda self: None) diff --git a/src/tests/test_pgp_entry.py b/src/tests/test_pgp_entry.py index 494d4d5..c1fd37f 100644 --- a/src/tests/test_pgp_entry.py +++ b/src/tests/test_pgp_entry.py @@ -6,9 +6,9 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager def test_pgp_key_determinism(): diff --git a/src/tests/test_portable_backup.py b/src/tests/test_portable_backup.py index dc8910b..7e1b0ff 100644 --- a/src/tests/test_portable_backup.py +++ b/src/tests/test_portable_backup.py @@ -9,11 +9,11 @@ import sys sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.encryption import EncryptionManager -from password_manager.vault import Vault -from password_manager.backup import BackupManager -from password_manager.config_manager import ConfigManager -from password_manager.portable_backup import export_backup, import_backup +from seedpass.core.encryption import EncryptionManager +from seedpass.core.vault import Vault +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager +from seedpass.core.portable_backup import export_backup, import_backup from utils.key_derivation import derive_index_key, derive_key_from_password diff --git a/src/tests/test_profile_cleanup.py b/src/tests/test_profile_cleanup.py index 1959489..d85653f 100644 --- a/src/tests/test_profile_cleanup.py +++ b/src/tests/test_profile_cleanup.py @@ -11,7 +11,7 @@ sys.path.append(str(Path(__file__).resolve().parents[1])) def setup_pm(tmp_path): import constants - import password_manager.manager as manager_module + import seedpass.core.manager as manager_module importlib.reload(constants) importlib.reload(manager_module) @@ -30,7 +30,7 @@ def test_generate_seed_cleanup_on_failure(monkeypatch): pm, const, mgr = setup_pm(tmp_path) - with patch("password_manager.manager.confirm_action", return_value=True): + with patch("seedpass.core.manager.confirm_action", return_value=True): monkeypatch.setattr( pm, "save_and_encrypt_seed", diff --git a/src/tests/test_profile_init_integration.py b/src/tests/test_profile_init_integration.py index 62c291a..484447e 100644 --- a/src/tests/test_profile_init_integration.py +++ b/src/tests/test_profile_init_integration.py @@ -3,7 +3,7 @@ import importlib.util from pathlib import Path from tempfile import TemporaryDirectory -from password_manager.manager import PasswordManager, EncryptionMode +from seedpass.core.manager import PasswordManager, EncryptionMode def load_script(): @@ -33,7 +33,7 @@ def test_initialize_profile_and_manager(monkeypatch): pm.current_fingerprint = fingerprint monkeypatch.setattr( - "password_manager.manager.prompt_existing_password", + "seedpass.core.manager.prompt_existing_password", lambda *_: gtp.DEFAULT_PASSWORD, ) monkeypatch.setattr(PasswordManager, "initialize_bip85", lambda self: None) diff --git a/src/tests/test_profile_management.py b/src/tests/test_profile_management.py index 7665b87..e5169a4 100644 --- a/src/tests/test_profile_management.py +++ b/src/tests/test_profile_management.py @@ -11,12 +11,12 @@ sys.path.append(str(Path(__file__).resolve().parents[1])) from utils.fingerprint_manager import FingerprintManager import constants -import password_manager.manager as manager_module -from password_manager.vault import Vault -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.manager import EncryptionMode -from password_manager.config_manager import ConfigManager +import seedpass.core.manager as manager_module +from seedpass.core.vault import Vault +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.manager import EncryptionMode +from seedpass.core.config_manager import ConfigManager def test_add_and_delete_entry(monkeypatch): diff --git a/src/tests/test_profiles.py b/src/tests/test_profiles.py index aec32c8..fbbf097 100644 --- a/src/tests/test_profiles.py +++ b/src/tests/test_profiles.py @@ -5,7 +5,7 @@ from tempfile import TemporaryDirectory sys.path.append(str(Path(__file__).resolve().parents[1])) from utils.fingerprint_manager import FingerprintManager -from password_manager.manager import PasswordManager, EncryptionMode +from seedpass.core.manager import PasswordManager, EncryptionMode from helpers import create_vault, dummy_nostr_client import gzip from nostr.backup_models import Manifest, ChunkMeta @@ -32,7 +32,7 @@ def test_add_and_switch_fingerprint(monkeypatch): monkeypatch.setattr("builtins.input", lambda *_args, **_kwargs: "1") monkeypatch.setattr( - "password_manager.manager.prompt_existing_password", + "seedpass.core.manager.prompt_existing_password", lambda *_a, **_k: "pass", ) monkeypatch.setattr( @@ -47,7 +47,7 @@ def test_add_and_switch_fingerprint(monkeypatch): PasswordManager, "sync_index_from_nostr_if_missing", lambda self: None ) monkeypatch.setattr( - "password_manager.manager.NostrClient", lambda *a, **kw: object() + "seedpass.core.manager.NostrClient", lambda *a, **kw: object() ) assert pm.handle_switch_fingerprint() diff --git a/src/tests/test_publish_json_result.py b/src/tests/test_publish_json_result.py index 0abc648..bc93cf1 100644 --- a/src/tests/test_publish_json_result.py +++ b/src/tests/test_publish_json_result.py @@ -9,7 +9,7 @@ import base64 sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.encryption import EncryptionManager +from seedpass.core.encryption import EncryptionManager from nostr.client import NostrClient, Manifest diff --git a/src/tests/test_retrieve_pause_sensitive_entries.py b/src/tests/test_retrieve_pause_sensitive_entries.py index dcb719b..09d3852 100644 --- a/src/tests/test_retrieve_pause_sensitive_entries.py +++ b/src/tests/test_retrieve_pause_sensitive_entries.py @@ -6,10 +6,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.manager import PasswordManager, EncryptionMode -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.manager import PasswordManager, EncryptionMode +from seedpass.core.config_manager import ConfigManager import pytest @@ -45,13 +45,13 @@ def test_pause_before_entry_actions(monkeypatch, adder, needs_confirm): pause_calls = [] monkeypatch.setattr( - "password_manager.manager.pause", lambda *a, **k: pause_calls.append(True) + "seedpass.core.manager.pause", lambda *a, **k: pause_calls.append(True) ) monkeypatch.setattr(pm, "_entry_actions_menu", lambda *a, **k: None) monkeypatch.setattr("builtins.input", lambda *a, **k: str(index)) if needs_confirm: monkeypatch.setattr( - "password_manager.manager.confirm_action", lambda *a, **k: True + "seedpass.core.manager.confirm_action", lambda *a, **k: True ) pm.handle_retrieve_entry() diff --git a/src/tests/test_search_entries.py b/src/tests/test_search_entries.py index 86e6f35..5e3f921 100644 --- a/src/tests/test_search_entries.py +++ b/src/tests/test_search_entries.py @@ -6,9 +6,9 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager def setup_entry_manager(tmp_path: Path) -> EntryManager: diff --git a/src/tests/test_secret_mode.py b/src/tests/test_secret_mode.py index 3a524d7..6c4339d 100644 --- a/src/tests/test_secret_mode.py +++ b/src/tests/test_secret_mode.py @@ -8,10 +8,10 @@ import sys sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.manager import PasswordManager, EncryptionMode -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.manager import PasswordManager, EncryptionMode +from seedpass.core.config_manager import ConfigManager def setup_pm(tmp_path): @@ -45,7 +45,7 @@ def test_password_retrieve_secret_mode(monkeypatch, capsys): monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs)) called = [] monkeypatch.setattr( - "password_manager.manager.copy_to_clipboard", + "seedpass.core.manager.copy_to_clipboard", lambda text, t: called.append((text, t)), ) @@ -67,12 +67,12 @@ def test_totp_display_secret_mode(monkeypatch, capsys): pm.entry_manager, "get_totp_time_remaining", lambda *a, **k: 30 ) monkeypatch.setattr( - "password_manager.manager.timed_input", + "seedpass.core.manager.timed_input", lambda *a, **k: (_ for _ in ()).throw(KeyboardInterrupt()), ) called = [] monkeypatch.setattr( - "password_manager.manager.copy_to_clipboard", + "seedpass.core.manager.copy_to_clipboard", lambda text, t: called.append((text, t)), ) @@ -94,7 +94,7 @@ def test_password_retrieve_no_secret_mode(monkeypatch, capsys): monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs)) called = [] monkeypatch.setattr( - "password_manager.manager.copy_to_clipboard", + "seedpass.core.manager.copy_to_clipboard", lambda *a, **k: called.append((a, k)), ) @@ -117,12 +117,12 @@ def test_totp_display_no_secret_mode(monkeypatch, capsys): pm.entry_manager, "get_totp_time_remaining", lambda *a, **k: 30 ) monkeypatch.setattr( - "password_manager.manager.timed_input", + "seedpass.core.manager.timed_input", lambda *a, **k: (_ for _ in ()).throw(KeyboardInterrupt()), ) called = [] monkeypatch.setattr( - "password_manager.manager.copy_to_clipboard", + "seedpass.core.manager.copy_to_clipboard", lambda *a, **k: called.append((a, k)), ) diff --git a/src/tests/test_seed_entry.py b/src/tests/test_seed_entry.py index d7d9d36..06665fe 100644 --- a/src/tests/test_seed_entry.py +++ b/src/tests/test_seed_entry.py @@ -7,10 +7,10 @@ from mnemonic import Mnemonic sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.config_manager import ConfigManager -from password_manager.password_generation import derive_seed_phrase +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager +from seedpass.core.password_generation import derive_seed_phrase from local_bip85.bip85 import BIP85 from bip_utils import Bip39SeedGenerator diff --git a/src/tests/test_seed_generation.py b/src/tests/test_seed_generation.py index 99f6e57..eb6b6ca 100644 --- a/src/tests/test_seed_generation.py +++ b/src/tests/test_seed_generation.py @@ -10,7 +10,7 @@ sys.path.append(str(Path(__file__).resolve().parents[1])) def setup_password_manager(): """Instantiate PasswordManager using a temporary APP_DIR without running __init__.""" import constants - import password_manager.manager as manager_module + import seedpass.core.manager as manager_module # Reload modules so constants use the mocked home directory importlib.reload(constants) @@ -34,7 +34,7 @@ def test_generate_bip85_and_new_seed(monkeypatch): mnemonic = pm.generate_bip85_seed() assert len(mnemonic.split()) == 12 - with patch("password_manager.manager.confirm_action", return_value=True): + with patch("seedpass.core.manager.confirm_action", return_value=True): fingerprint = pm.generate_new_seed() expected_dir = const.APP_DIR / fingerprint diff --git a/src/tests/test_seed_import.py b/src/tests/test_seed_import.py index eb6db7f..7cbbe6d 100644 --- a/src/tests/test_seed_import.py +++ b/src/tests/test_seed_import.py @@ -7,8 +7,8 @@ from mnemonic import Mnemonic sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.encryption import EncryptionManager -from password_manager.manager import PasswordManager, EncryptionMode +from seedpass.core.encryption import EncryptionManager +from seedpass.core.manager import PasswordManager, EncryptionMode def test_seed_encryption_round_trip(): diff --git a/src/tests/test_seed_migration.py b/src/tests/test_seed_migration.py index a273be3..845dfaa 100644 --- a/src/tests/test_seed_migration.py +++ b/src/tests/test_seed_migration.py @@ -7,7 +7,7 @@ from utils.key_derivation import derive_key_from_password sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.encryption import EncryptionManager +from seedpass.core.encryption import EncryptionManager def test_parent_seed_migrates_from_fernet(tmp_path: Path) -> None: diff --git a/src/tests/test_seedqr_encoding.py b/src/tests/test_seedqr_encoding.py index 7d0b5e7..b986ee0 100644 --- a/src/tests/test_seedqr_encoding.py +++ b/src/tests/test_seedqr_encoding.py @@ -3,7 +3,7 @@ from pathlib import Path sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.seedqr import encode_seedqr +from seedpass.core.seedqr import encode_seedqr def test_seedqr_standard_example(): diff --git a/src/tests/test_settings_menu.py b/src/tests/test_settings_menu.py index 6899822..d7699d0 100644 --- a/src/tests/test_settings_menu.py +++ b/src/tests/test_settings_menu.py @@ -11,8 +11,8 @@ sys.path.append(str(Path(__file__).resolve().parents[1])) import main from nostr.client import DEFAULT_RELAYS -from password_manager.config_manager import ConfigManager -from password_manager.vault import Vault +from seedpass.core.config_manager import ConfigManager +from seedpass.core.vault import Vault from utils.fingerprint_manager import FingerprintManager diff --git a/src/tests/test_ssh_entry.py b/src/tests/test_ssh_entry.py index f037437..b0d8a4d 100644 --- a/src/tests/test_ssh_entry.py +++ b/src/tests/test_ssh_entry.py @@ -6,10 +6,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.vault import Vault -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.vault import Vault +from seedpass.core.config_manager import ConfigManager def test_add_and_retrieve_ssh_key_pair(): diff --git a/src/tests/test_ssh_entry_valid.py b/src/tests/test_ssh_entry_valid.py index 9b7ecad..c74945d 100644 --- a/src/tests/test_ssh_entry_valid.py +++ b/src/tests/test_ssh_entry_valid.py @@ -6,10 +6,10 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.vault import Vault -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.vault import Vault +from seedpass.core.config_manager import ConfigManager from cryptography.hazmat.primitives import serialization diff --git a/src/tests/test_tag_persistence.py b/src/tests/test_tag_persistence.py index 487fb3c..3de796f 100644 --- a/src/tests/test_tag_persistence.py +++ b/src/tests/test_tag_persistence.py @@ -6,9 +6,9 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.config_manager import ConfigManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager def setup_entry_manager(tmp_path: Path) -> EntryManager: diff --git a/src/tests/test_totp.py b/src/tests/test_totp.py index ddaacd9..62ea473 100644 --- a/src/tests/test_totp.py +++ b/src/tests/test_totp.py @@ -7,7 +7,7 @@ from freezegun import freeze_time sys.path.append(str(Path(__file__).resolve().parents[1])) from helpers import TEST_SEED -from password_manager.totp import TotpManager +from seedpass.core.totp import TotpManager @freeze_time("1970-01-01 00:16:40") @@ -25,6 +25,6 @@ def test_time_remaining(): def test_print_progress_bar_terminates(monkeypatch): monkeypatch.setattr(TotpManager, "time_remaining", lambda period: 0) calls = [] - monkeypatch.setattr("password_manager.totp.time.sleep", lambda s: calls.append(s)) + monkeypatch.setattr("seedpass.core.totp.time.sleep", lambda s: calls.append(s)) TotpManager.print_progress_bar(period=30) assert calls == [] diff --git a/src/tests/test_totp_entry.py b/src/tests/test_totp_entry.py index 6eb0b12..eff4988 100644 --- a/src/tests/test_totp_entry.py +++ b/src/tests/test_totp_entry.py @@ -9,11 +9,11 @@ from helpers import create_vault, TEST_SEED, TEST_PASSWORD sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.entry_management import EntryManager -from password_manager.backup import BackupManager -from password_manager.vault import Vault -from password_manager.config_manager import ConfigManager -from password_manager.totp import TotpManager +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.vault import Vault +from seedpass.core.config_manager import ConfigManager +from seedpass.core.totp import TotpManager import pyotp diff --git a/src/tests/test_totp_uri.py b/src/tests/test_totp_uri.py index 26b8429..1970794 100644 --- a/src/tests/test_totp_uri.py +++ b/src/tests/test_totp_uri.py @@ -5,7 +5,7 @@ import pytest sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.totp import TotpManager +from seedpass.core.totp import TotpManager # Test parsing a normal otpauth URI with custom period and digits diff --git a/src/tests/test_typer_cli.py b/src/tests/test_typer_cli.py index 878fd0c..fc9a093 100644 --- a/src/tests/test_typer_cli.py +++ b/src/tests/test_typer_cli.py @@ -8,7 +8,7 @@ from typer.testing import CliRunner from seedpass.cli import app, PasswordManager from seedpass import cli -from password_manager.entry_types import EntryType +from seedpass.core.entry_types import EntryType runner = CliRunner() diff --git a/src/tests/test_unlock_sync.py b/src/tests/test_unlock_sync.py index d618974..dffd619 100644 --- a/src/tests/test_unlock_sync.py +++ b/src/tests/test_unlock_sync.py @@ -5,8 +5,8 @@ import sys sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.manager import PasswordManager -from password_manager import manager as manager_module +from seedpass.core.manager import PasswordManager +from seedpass.core import manager as manager_module def test_unlock_triggers_sync(monkeypatch, tmp_path): diff --git a/src/tests/test_v2_prefix_fallback.py b/src/tests/test_v2_prefix_fallback.py index 0d23cbf..6460409 100644 --- a/src/tests/test_v2_prefix_fallback.py +++ b/src/tests/test_v2_prefix_fallback.py @@ -7,7 +7,7 @@ from cryptography.fernet import InvalidToken from helpers import TEST_SEED from utils.key_derivation import derive_index_key -from password_manager.encryption import EncryptionManager +from seedpass.core.encryption import EncryptionManager def test_v2_prefix_fernet_fallback(tmp_path: Path, caplog) -> None: @@ -18,7 +18,7 @@ def test_v2_prefix_fernet_fallback(tmp_path: Path, caplog) -> None: token = manager.fernet.encrypt(original) payload = b"V2:" + token - caplog.set_level(logging.WARNING, logger="password_manager.encryption") + caplog.set_level(logging.WARNING, logger="seedpass.core.encryption") decrypted = manager.decrypt_data(payload) assert decrypted == original @@ -31,7 +31,7 @@ def test_aesgcm_payload_too_short(tmp_path: Path, caplog) -> None: payload = b"V2:" + os.urandom(12) + b"short" - caplog.set_level(logging.ERROR, logger="password_manager.encryption") + caplog.set_level(logging.ERROR, logger="seedpass.core.encryption") with pytest.raises(InvalidToken, match="AES-GCM payload too short"): manager.decrypt_data(payload) diff --git a/src/tests/test_vault_initialization.py b/src/tests/test_vault_initialization.py index 38e90c8..f3b0d57 100644 --- a/src/tests/test_vault_initialization.py +++ b/src/tests/test_vault_initialization.py @@ -5,8 +5,8 @@ from unittest.mock import patch sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.manager import PasswordManager, EncryptionMode -from password_manager.vault import Vault +from seedpass.core.manager import PasswordManager, EncryptionMode +from seedpass.core.vault import Vault VALID_SEED = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" @@ -20,11 +20,9 @@ def test_save_and_encrypt_seed_initializes_vault(monkeypatch): pm.config_manager = None pm.current_fingerprint = "fp" + monkeypatch.setattr("seedpass.core.manager.prompt_for_password", lambda: "pw") monkeypatch.setattr( - "password_manager.manager.prompt_for_password", lambda: "pw" - ) - monkeypatch.setattr( - "password_manager.manager.NostrClient", lambda *a, **kw: object() + "seedpass.core.manager.NostrClient", lambda *a, **kw: object() ) pm.save_and_encrypt_seed(VALID_SEED, tmp_path) diff --git a/src/tests/test_verbose_timing.py b/src/tests/test_verbose_timing.py index 79cd5ca..45c1b61 100644 --- a/src/tests/test_verbose_timing.py +++ b/src/tests/test_verbose_timing.py @@ -1,7 +1,7 @@ import asyncio import logging -from password_manager.manager import PasswordManager +from seedpass.core.manager import PasswordManager from helpers import dummy_nostr_client @@ -13,11 +13,9 @@ def test_unlock_vault_logs_time(monkeypatch, caplog, tmp_path): pm.initialize_managers = lambda: None pm.update_activity = lambda: None pm.verbose_timing = True - caplog.set_level(logging.INFO, logger="password_manager.manager") + caplog.set_level(logging.INFO, logger="seedpass.core.manager") times = iter([0.0, 1.0]) - monkeypatch.setattr( - "password_manager.manager.time.perf_counter", lambda: next(times) - ) + monkeypatch.setattr("seedpass.core.manager.time.perf_counter", lambda: next(times)) pm.unlock_vault() assert "Vault unlocked in 1.00 seconds" in caplog.text From 8bd9a756291d0d75904632f9bb7d776ac12ce2c0 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 17 Jul 2025 19:38:28 -0400 Subject: [PATCH 03/75] Parametrize password actions --- src/seedpass/api.py | 6 ++- src/seedpass/cli.py | 22 +++++++++- src/seedpass/core/manager.py | 41 +++++++++---------- src/tests/test_api.py | 10 +++-- src/tests/test_api_new_endpoints.py | 4 +- src/tests/test_cli_doc_examples.py | 2 +- src/tests/test_password_change.py | 7 +--- .../test_password_unlock_after_change.py | 4 +- src/tests/test_typer_cli.py | 8 ++-- src/tests/test_unlock_sync.py | 2 +- src/tests/test_verbose_timing.py | 4 +- 11 files changed, 64 insertions(+), 46 deletions(-) diff --git a/src/seedpass/api.py b/src/seedpass/api.py index 2ac4d1b..134575a 100644 --- a/src/seedpass/api.py +++ b/src/seedpass/api.py @@ -554,11 +554,13 @@ def backup_parent_seed( @app.post("/api/v1/change-password") -def change_password(authorization: str | None = Header(None)) -> dict[str, str]: +def change_password( + data: dict, authorization: str | None = Header(None) +) -> dict[str, str]: """Change the master password for the active profile.""" _check_token(authorization) assert _pm is not None - _pm.change_password() + _pm.change_password(data.get("old", ""), data.get("new", "")) return {"status": "ok"} diff --git a/src/seedpass/cli.py b/src/seedpass/cli.py index fa0b658..d01805f 100644 --- a/src/seedpass/cli.py +++ b/src/seedpass/cli.py @@ -384,7 +384,27 @@ def vault_import( def vault_change_password(ctx: typer.Context) -> None: """Change the master password used for encryption.""" pm = _get_pm(ctx) - pm.change_password() + old_pw = typer.prompt("Current password", hide_input=True) + new_pw = typer.prompt("New password", hide_input=True, confirmation_prompt=True) + try: + pm.change_password(old_pw, new_pw) + except Exception as exc: # pragma: no cover - pass through errors + typer.echo(f"Error: {exc}") + raise typer.Exit(code=1) + typer.echo("Password updated") + + +@vault_app.command("unlock") +def vault_unlock(ctx: typer.Context) -> None: + """Unlock the vault for the active profile.""" + pm = _get_pm(ctx) + password = typer.prompt("Master password", hide_input=True) + try: + duration = pm.unlock_vault(password) + except Exception as exc: # pragma: no cover - pass through errors + typer.echo(f"Error: {exc}") + raise typer.Exit(code=1) + typer.echo(f"Unlocked in {duration:.2f}s") @vault_app.command("lock") diff --git a/src/seedpass/core/manager.py b/src/seedpass/core/manager.py index 50d865b..5ef9089 100644 --- a/src/seedpass/core/manager.py +++ b/src/seedpass/core/manager.py @@ -276,28 +276,31 @@ class PasswordManager: self.config_manager = None self.locked = True - def unlock_vault(self, password: Optional[str] = None) -> None: - """Unlock the vault using ``password`` without prompting if provided.""" + def unlock_vault(self, password: str) -> float: + """Unlock the vault using the provided ``password``. + + Parameters + ---------- + password: + Master password for the active profile. + + Returns + ------- + float + Duration of the unlock process in seconds. + """ start = time.perf_counter() if not self.fingerprint_dir: raise ValueError("Fingerprint directory not set") - if password is None: - self.setup_encryption_manager(self.fingerprint_dir) - else: - self.setup_encryption_manager(self.fingerprint_dir, password) + self.setup_encryption_manager(self.fingerprint_dir, password) self.initialize_bip85() self.initialize_managers() self.locked = False self.update_activity() self.last_unlock_duration = time.perf_counter() - start - print( - colored( - f"Vault unlocked in {self.last_unlock_duration:.2f} seconds", - "yellow", - ) - ) if getattr(self, "verbose_timing", False): logger.info("Vault unlocked in %.2f seconds", self.last_unlock_duration) + return self.last_unlock_duration def initialize_fingerprint_manager(self): """ @@ -3884,15 +3887,11 @@ class PasswordManager: print(colored(f"Error: Failed to store hashed password: {e}", "red")) raise - def change_password(self) -> None: + def change_password(self, old_password: str, new_password: str) -> None: """Change the master password used for encryption.""" try: - current = prompt_existing_password("Enter your current master password: ") - if not self.verify_password(current): - print(colored("Incorrect password.", "red")) - return - - new_password = prompt_for_password() + if not self.verify_password(old_password): + raise ValueError("Incorrect password") # Load data with existing encryption manager index_data = self.vault.load_index() @@ -3927,8 +3926,6 @@ class PasswordManager: parent_seed=getattr(self, "parent_seed", None), ) - print(colored("Master password changed successfully.", "green")) - # Push a fresh backup to Nostr so the newly encrypted index is # stored remotely. Include a tag to mark the password change. try: @@ -3940,7 +3937,7 @@ class PasswordManager: ) except Exception as e: logging.error(f"Failed to change password: {e}", exc_info=True) - print(colored(f"Error: Failed to change password: {e}", "red")) + raise def get_profile_stats(self) -> dict: """Return various statistics about the current seed profile.""" diff --git a/src/tests/test_api.py b/src/tests/test_api.py index 67f1b47..1aa4c08 100644 --- a/src/tests/test_api.py +++ b/src/tests/test_api.py @@ -179,12 +179,16 @@ def test_change_password_route(client): cl, token = client called = {} - api._pm.change_password = lambda: called.setdefault("called", True) + api._pm.change_password = lambda o, n: called.setdefault("called", (o, n)) headers = {"Authorization": f"Bearer {token}", "Origin": "http://example.com"} - res = cl.post("/api/v1/change-password", headers=headers) + res = cl.post( + "/api/v1/change-password", + headers=headers, + json={"old": "old", "new": "new"}, + ) assert res.status_code == 200 assert res.json() == {"status": "ok"} - assert called.get("called") is True + assert called.get("called") == ("old", "new") assert res.headers.get("access-control-allow-origin") == "http://example.com" diff --git a/src/tests/test_api_new_endpoints.py b/src/tests/test_api_new_endpoints.py index dda8d0b..337a724 100644 --- a/src/tests/test_api_new_endpoints.py +++ b/src/tests/test_api_new_endpoints.py @@ -291,8 +291,8 @@ def test_vault_lock_endpoint(client): assert res.json() == {"status": "locked"} assert called.get("locked") is True assert api._pm.locked is True - api._pm.unlock_vault = lambda: setattr(api._pm, "locked", False) - api._pm.unlock_vault() + api._pm.unlock_vault = lambda pw: setattr(api._pm, "locked", False) + api._pm.unlock_vault("pw") assert api._pm.locked is False diff --git a/src/tests/test_cli_doc_examples.py b/src/tests/test_cli_doc_examples.py index ecd208e..f20a2f6 100644 --- a/src/tests/test_cli_doc_examples.py +++ b/src/tests/test_cli_doc_examples.py @@ -40,7 +40,7 @@ class DummyPM: self.handle_display_totp_codes = lambda: None self.handle_export_database = lambda path: None self.handle_import_database = lambda path: None - self.change_password = lambda: None + self.change_password = lambda *a, **kw: None self.lock_vault = lambda: None self.get_profile_stats = lambda: {"n": 1} self.handle_backup_reveal_parent_seed = lambda path=None: None diff --git a/src/tests/test_password_change.py b/src/tests/test_password_change.py index 1a82df0..efe001d 100644 --- a/src/tests/test_password_change.py +++ b/src/tests/test_password_change.py @@ -36,14 +36,9 @@ def test_change_password_triggers_nostr_backup(monkeypatch): pm.store_hashed_password = lambda pw: None pm.verify_password = lambda pw: True - monkeypatch.setattr( - "seedpass.core.manager.prompt_existing_password", lambda *_: "old" - ) - monkeypatch.setattr("seedpass.core.manager.prompt_for_password", lambda: "new") - with patch("seedpass.core.manager.NostrClient") as MockClient: mock_instance = MockClient.return_value mock_instance.publish_snapshot = AsyncMock(return_value=(None, "abcd")) pm.nostr_client = mock_instance - pm.change_password() + pm.change_password("old", "new") mock_instance.publish_snapshot.assert_called_once() diff --git a/src/tests/test_password_unlock_after_change.py b/src/tests/test_password_unlock_after_change.py index 6f31fbd..a0bda0a 100644 --- a/src/tests/test_password_unlock_after_change.py +++ b/src/tests/test_password_unlock_after_change.py @@ -71,7 +71,7 @@ def test_password_change_and_unlock(monkeypatch): ), ) - pm.change_password() + pm.change_password(old_pw, new_pw) pm.lock_vault() monkeypatch.setattr( @@ -81,7 +81,7 @@ def test_password_change_and_unlock(monkeypatch): monkeypatch.setattr(PasswordManager, "initialize_managers", lambda self: None) monkeypatch.setattr(PasswordManager, "sync_index_from_nostr", lambda self: None) - pm.unlock_vault() + pm.unlock_vault(new_pw) assert pm.parent_seed == SEED assert pm.verify_password(new_pw) diff --git a/src/tests/test_typer_cli.py b/src/tests/test_typer_cli.py index fc9a093..cce6e6e 100644 --- a/src/tests/test_typer_cli.py +++ b/src/tests/test_typer_cli.py @@ -126,14 +126,14 @@ def test_vault_import_triggers_sync(monkeypatch, tmp_path): def test_vault_change_password(monkeypatch): called = {} - def change_pw(): - called["called"] = True + def change_pw(old, new): + called["args"] = (old, new) pm = SimpleNamespace(change_password=change_pw, select_fingerprint=lambda fp: None) monkeypatch.setattr(cli, "PasswordManager", lambda: pm) - result = runner.invoke(app, ["vault", "change-password"]) + result = runner.invoke(app, ["vault", "change-password"], input="old\nnew\nnew\n") assert result.exit_code == 0 - assert called.get("called") is True + assert called.get("args") == ("old", "new") def test_vault_lock(monkeypatch): diff --git a/src/tests/test_unlock_sync.py b/src/tests/test_unlock_sync.py index dffd619..a2852c4 100644 --- a/src/tests/test_unlock_sync.py +++ b/src/tests/test_unlock_sync.py @@ -22,7 +22,7 @@ def test_unlock_triggers_sync(monkeypatch, tmp_path): monkeypatch.setattr(PasswordManager, "sync_index_from_nostr", fake_sync) - pm.unlock_vault() + pm.unlock_vault("pw") pm.start_background_sync() time.sleep(0.05) diff --git a/src/tests/test_verbose_timing.py b/src/tests/test_verbose_timing.py index 45c1b61..a623b25 100644 --- a/src/tests/test_verbose_timing.py +++ b/src/tests/test_verbose_timing.py @@ -8,7 +8,7 @@ from helpers import dummy_nostr_client def test_unlock_vault_logs_time(monkeypatch, caplog, tmp_path): pm = PasswordManager.__new__(PasswordManager) pm.fingerprint_dir = tmp_path - pm.setup_encryption_manager = lambda path: None + pm.setup_encryption_manager = lambda path, pw=None: None pm.initialize_bip85 = lambda: None pm.initialize_managers = lambda: None pm.update_activity = lambda: None @@ -16,7 +16,7 @@ def test_unlock_vault_logs_time(monkeypatch, caplog, tmp_path): caplog.set_level(logging.INFO, logger="seedpass.core.manager") times = iter([0.0, 1.0]) monkeypatch.setattr("seedpass.core.manager.time.perf_counter", lambda: next(times)) - pm.unlock_vault() + pm.unlock_vault("pw") assert "Vault unlocked in 1.00 seconds" in caplog.text From 72faee02b62e353964c847a3787110ec0cb4a6be Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 17 Jul 2025 19:54:54 -0400 Subject: [PATCH 04/75] Add service layer and update CLI --- src/seedpass/cli.py | 84 +++++++++++------ src/seedpass/core/api.py | 196 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 250 insertions(+), 30 deletions(-) create mode 100644 src/seedpass/core/api.py diff --git a/src/seedpass/cli.py b/src/seedpass/cli.py index d01805f..07da8e5 100644 --- a/src/seedpass/cli.py +++ b/src/seedpass/cli.py @@ -6,6 +6,18 @@ import typer from seedpass.core.manager import PasswordManager from seedpass.core.entry_types import EntryType +from seedpass.core.api import ( + VaultService, + ProfileService, + SyncService, + VaultExportRequest, + VaultImportRequest, + ChangePasswordRequest, + UnlockRequest, + BackupParentSeedRequest, + ProfileSwitchRequest, + ProfileRemoveRequest, +) import uvicorn from . import api as api_module @@ -52,6 +64,15 @@ def _get_pm(ctx: typer.Context) -> PasswordManager: return pm +def _get_services( + ctx: typer.Context, +) -> tuple[VaultService, ProfileService, SyncService]: + """Return service layer instances for the current context.""" + + pm = _get_pm(ctx) + return VaultService(pm), ProfileService(pm), SyncService(pm) + + @app.callback(invoke_without_command=True) def main(ctx: typer.Context, fingerprint: Optional[str] = fingerprint_option) -> None: """SeedPass CLI entry point. @@ -364,8 +385,8 @@ def vault_export( ctx: typer.Context, file: str = typer.Option(..., help="Output file") ) -> None: """Export the vault.""" - pm = _get_pm(ctx) - pm.handle_export_database(Path(file)) + vault_service, _profile, _sync = _get_services(ctx) + vault_service.export_vault(VaultExportRequest(path=Path(file))) typer.echo(str(file)) @@ -374,20 +395,21 @@ def vault_import( ctx: typer.Context, file: str = typer.Option(..., help="Input file") ) -> None: """Import a vault from an encrypted JSON file.""" - pm = _get_pm(ctx) - pm.handle_import_database(Path(file)) - pm.sync_vault() + vault_service, _profile, _sync = _get_services(ctx) + vault_service.import_vault(VaultImportRequest(path=Path(file))) typer.echo(str(file)) @vault_app.command("change-password") def vault_change_password(ctx: typer.Context) -> None: """Change the master password used for encryption.""" - pm = _get_pm(ctx) + vault_service, _profile, _sync = _get_services(ctx) old_pw = typer.prompt("Current password", hide_input=True) new_pw = typer.prompt("New password", hide_input=True, confirmation_prompt=True) try: - pm.change_password(old_pw, new_pw) + vault_service.change_password( + ChangePasswordRequest(old_password=old_pw, new_password=new_pw) + ) except Exception as exc: # pragma: no cover - pass through errors typer.echo(f"Error: {exc}") raise typer.Exit(code=1) @@ -397,29 +419,29 @@ def vault_change_password(ctx: typer.Context) -> None: @vault_app.command("unlock") def vault_unlock(ctx: typer.Context) -> None: """Unlock the vault for the active profile.""" - pm = _get_pm(ctx) + vault_service, _profile, _sync = _get_services(ctx) password = typer.prompt("Master password", hide_input=True) try: - duration = pm.unlock_vault(password) + resp = vault_service.unlock(UnlockRequest(password=password)) except Exception as exc: # pragma: no cover - pass through errors typer.echo(f"Error: {exc}") raise typer.Exit(code=1) - typer.echo(f"Unlocked in {duration:.2f}s") + typer.echo(f"Unlocked in {resp.duration:.2f}s") @vault_app.command("lock") def vault_lock(ctx: typer.Context) -> None: """Lock the vault and clear sensitive data from memory.""" - pm = _get_pm(ctx) - pm.lock_vault() + vault_service, _profile, _sync = _get_services(ctx) + vault_service.lock() typer.echo("locked") @vault_app.command("stats") def vault_stats(ctx: typer.Context) -> None: """Display statistics about the current seed profile.""" - pm = _get_pm(ctx) - stats = pm.get_profile_stats() + vault_service, _profile, _sync = _get_services(ctx) + stats = vault_service.stats() typer.echo(json.dumps(stats, indent=2)) @@ -431,21 +453,23 @@ def vault_reveal_parent_seed( ), ) -> None: """Display the parent seed and optionally write an encrypted backup file.""" - pm = _get_pm(ctx) - pm.handle_backup_reveal_parent_seed(Path(file) if file else None) + vault_service, _profile, _sync = _get_services(ctx) + vault_service.backup_parent_seed( + BackupParentSeedRequest(path=Path(file) if file else None) + ) @nostr_app.command("sync") def nostr_sync(ctx: typer.Context) -> None: """Sync with configured Nostr relays.""" - pm = _get_pm(ctx) - result = pm.sync_vault() - if result: + _vault, _profile, sync_service = _get_services(ctx) + model = sync_service.sync() + if model: typer.echo("Event IDs:") - typer.echo(f"- manifest: {result['manifest_id']}") - for cid in result["chunk_ids"]: + typer.echo(f"- manifest: {model.manifest_id}") + for cid in model.chunk_ids: typer.echo(f"- chunk: {cid}") - for did in result["delta_ids"]: + for did in model.delta_ids: typer.echo(f"- delta: {did}") else: typer.echo("Error: Failed to sync vault") @@ -606,30 +630,30 @@ def config_toggle_offline(ctx: typer.Context) -> None: @fingerprint_app.command("list") def fingerprint_list(ctx: typer.Context) -> None: """List available seed profiles.""" - pm = _get_pm(ctx) - for fp in pm.fingerprint_manager.list_fingerprints(): + _vault, profile_service, _sync = _get_services(ctx) + for fp in profile_service.list_profiles(): typer.echo(fp) @fingerprint_app.command("add") def fingerprint_add(ctx: typer.Context) -> None: """Create a new seed profile.""" - pm = _get_pm(ctx) - pm.add_new_fingerprint() + _vault, profile_service, _sync = _get_services(ctx) + profile_service.add_profile() @fingerprint_app.command("remove") def fingerprint_remove(ctx: typer.Context, fingerprint: str) -> None: """Remove a seed profile.""" - pm = _get_pm(ctx) - pm.fingerprint_manager.remove_fingerprint(fingerprint) + _vault, profile_service, _sync = _get_services(ctx) + profile_service.remove_profile(ProfileRemoveRequest(fingerprint=fingerprint)) @fingerprint_app.command("switch") def fingerprint_switch(ctx: typer.Context, fingerprint: str) -> None: """Switch to another seed profile.""" - pm = _get_pm(ctx) - pm.select_fingerprint(fingerprint) + _vault, profile_service, _sync = _get_services(ctx) + profile_service.switch_profile(ProfileSwitchRequest(fingerprint=fingerprint)) @util_app.command("generate-password") diff --git a/src/seedpass/core/api.py b/src/seedpass/core/api.py new file mode 100644 index 0000000..03609a8 --- /dev/null +++ b/src/seedpass/core/api.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +"""Service layer wrapping :class:`PasswordManager` operations. + +These services provide thread-safe methods for common operations used by the CLI +and API. Request and response payloads are represented using Pydantic models to +allow easy validation and documentation. +""" + +from pathlib import Path +from threading import Lock +from typing import List, Optional, Dict + +from pydantic import BaseModel + +from .manager import PasswordManager + + +class VaultExportRequest(BaseModel): + """Parameters required to export the vault.""" + + path: Path + + +class VaultExportResponse(BaseModel): + """Result of a vault export operation.""" + + path: Path + + +class VaultImportRequest(BaseModel): + """Parameters required to import a vault.""" + + path: Path + + +class ChangePasswordRequest(BaseModel): + """Payload for :meth:`VaultService.change_password`.""" + + old_password: str + new_password: str + + +class UnlockRequest(BaseModel): + """Payload for unlocking the vault.""" + + password: str + + +class UnlockResponse(BaseModel): + """Duration taken to unlock the vault.""" + + duration: float + + +class BackupParentSeedRequest(BaseModel): + """Optional path to write the encrypted seed backup.""" + + path: Optional[Path] = None + + +class ProfileSwitchRequest(BaseModel): + """Select a different seed profile.""" + + fingerprint: str + + +class ProfileRemoveRequest(BaseModel): + """Remove a seed profile.""" + + fingerprint: str + + +class SyncResponse(BaseModel): + """Information about uploaded events after syncing.""" + + manifest_id: str + chunk_ids: List[str] = [] + delta_ids: List[str] = [] + + +class VaultService: + """Thread-safe wrapper around vault operations.""" + + def __init__(self, manager: PasswordManager) -> None: + self._manager = manager + self._lock = Lock() + + def export_vault(self, req: VaultExportRequest) -> VaultExportResponse: + """Export the vault to ``req.path``.""" + + with self._lock: + self._manager.handle_export_database(req.path) + return VaultExportResponse(path=req.path) + + def import_vault(self, req: VaultImportRequest) -> None: + """Import the vault from ``req.path`` and sync.""" + + with self._lock: + self._manager.handle_import_database(req.path) + self._manager.sync_vault() + + def change_password(self, req: ChangePasswordRequest) -> None: + """Change the master password.""" + + with self._lock: + self._manager.change_password(req.old_password, req.new_password) + + def unlock(self, req: UnlockRequest) -> UnlockResponse: + """Unlock the vault and return the duration.""" + + with self._lock: + duration = self._manager.unlock_vault(req.password) + return UnlockResponse(duration=duration) + + def lock(self) -> None: + """Lock the vault and clear sensitive data.""" + + with self._lock: + self._manager.lock_vault() + + def backup_parent_seed(self, req: BackupParentSeedRequest) -> None: + """Backup and reveal the parent seed.""" + + with self._lock: + self._manager.handle_backup_reveal_parent_seed(req.path) + + def stats(self) -> Dict: + """Return statistics about the current profile.""" + + with self._lock: + return self._manager.get_profile_stats() + + +class ProfileService: + """Thread-safe wrapper around profile management operations.""" + + def __init__(self, manager: PasswordManager) -> None: + self._manager = manager + self._lock = Lock() + + def list_profiles(self) -> List[str]: + """List available seed profiles.""" + + with self._lock: + return list(self._manager.fingerprint_manager.list_fingerprints()) + + def add_profile(self) -> Optional[str]: + """Create a new seed profile and return its fingerprint if available.""" + + with self._lock: + self._manager.add_new_fingerprint() + return getattr( + self._manager.fingerprint_manager, "current_fingerprint", None + ) + + def remove_profile(self, req: ProfileRemoveRequest) -> None: + """Remove the specified seed profile.""" + + with self._lock: + self._manager.fingerprint_manager.remove_fingerprint(req.fingerprint) + + def switch_profile(self, req: ProfileSwitchRequest) -> None: + """Switch to ``req.fingerprint``.""" + + with self._lock: + self._manager.select_fingerprint(req.fingerprint) + + +class SyncService: + """Thread-safe wrapper around vault synchronization.""" + + def __init__(self, manager: PasswordManager) -> None: + self._manager = manager + self._lock = Lock() + + def sync(self) -> Optional[SyncResponse]: + """Publish the vault to Nostr and return event info.""" + + with self._lock: + result = self._manager.sync_vault() + if not result: + return None + return SyncResponse(**result) + + def start_background_sync(self) -> None: + """Begin background synchronization if possible.""" + + with self._lock: + self._manager.start_background_sync() + + def start_background_vault_sync(self, summary: Optional[str] = None) -> None: + """Publish the vault in a background thread.""" + + with self._lock: + self._manager.start_background_vault_sync(summary) From 6c54d4d8e31e97643e376824bec3f26c60309f42 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 17 Jul 2025 20:21:23 -0400 Subject: [PATCH 05/75] Convert vault sync to async --- src/seedpass/core/manager.py | 59 ++++++++++++++++++++++++------------ 1 file changed, 40 insertions(+), 19 deletions(-) diff --git a/src/seedpass/core/manager.py b/src/seedpass/core/manager.py index 5ef9089..e42c8f7 100644 --- a/src/seedpass/core/manager.py +++ b/src/seedpass/core/manager.py @@ -1101,18 +1101,18 @@ class PasswordManager: print(colored(f"Error: Failed to initialize managers: {e}", "red")) sys.exit(1) - def sync_index_from_nostr(self) -> None: + async def sync_index_from_nostr_async(self) -> None: """Always fetch the latest vault data from Nostr and update the local index.""" start = time.perf_counter() try: - result = asyncio.run(self.nostr_client.fetch_latest_snapshot()) + result = await self.nostr_client.fetch_latest_snapshot() if not result: return manifest, chunks = result encrypted = gzip.decompress(b"".join(chunks)) if manifest.delta_since: version = int(manifest.delta_since) - deltas = asyncio.run(self.nostr_client.fetch_deltas_since(version)) + deltas = await self.nostr_client.fetch_deltas_since(version) if deltas: encrypted = deltas[-1] current = self.vault.get_encrypted_index() @@ -1128,18 +1128,19 @@ class PasswordManager: duration = time.perf_counter() - start logger.info("sync_index_from_nostr completed in %.2f seconds", duration) + def sync_index_from_nostr(self) -> None: + asyncio.run(self.sync_index_from_nostr_async()) + def start_background_sync(self) -> None: """Launch a thread to synchronize the vault without blocking the UI.""" if getattr(self, "offline_mode", False): return - if ( - hasattr(self, "_sync_thread") - and self._sync_thread - and self._sync_thread.is_alive() + if getattr(self, "_sync_task", None) and not getattr( + self._sync_task, "done", True ): return - def _worker() -> None: + async def _worker() -> None: try: if hasattr(self, "nostr_client") and hasattr(self, "vault"): self.attempt_initial_sync() @@ -1148,8 +1149,12 @@ class PasswordManager: except Exception as exc: logger.warning(f"Background sync failed: {exc}") - self._sync_thread = threading.Thread(target=_worker, daemon=True) - self._sync_thread.start() + try: + loop = asyncio.get_running_loop() + except RuntimeError: + threading.Thread(target=lambda: asyncio.run(_worker()), daemon=True).start() + else: + self._sync_task = asyncio.create_task(_worker()) def start_background_relay_check(self) -> None: """Check relay health in a background thread.""" @@ -1185,13 +1190,18 @@ class PasswordManager: def _worker() -> None: try: - self.sync_vault(alt_summary=alt_summary) + asyncio.run(self.sync_vault_async(alt_summary=alt_summary)) except Exception as exc: logging.error(f"Background vault sync failed: {exc}", exc_info=True) - threading.Thread(target=_worker, daemon=True).start() + try: + loop = asyncio.get_running_loop() + except RuntimeError: + threading.Thread(target=_worker, daemon=True).start() + else: + asyncio.create_task(self.sync_vault_async(alt_summary=alt_summary)) - def attempt_initial_sync(self) -> bool: + async def attempt_initial_sync_async(self) -> bool: """Attempt to download the initial vault snapshot from Nostr. Returns ``True`` if the snapshot was successfully downloaded and the @@ -1205,13 +1215,13 @@ class PasswordManager: have_data = False start = time.perf_counter() try: - result = asyncio.run(self.nostr_client.fetch_latest_snapshot()) + result = await self.nostr_client.fetch_latest_snapshot() if result: manifest, chunks = result encrypted = gzip.decompress(b"".join(chunks)) if manifest.delta_since: version = int(manifest.delta_since) - deltas = asyncio.run(self.nostr_client.fetch_deltas_since(version)) + deltas = await self.nostr_client.fetch_deltas_since(version) if deltas: encrypted = deltas[-1] success = self.vault.decrypt_and_save_index_from_nostr( @@ -1229,17 +1239,23 @@ class PasswordManager: return have_data + def attempt_initial_sync(self) -> bool: + return asyncio.run(self.attempt_initial_sync_async()) + def sync_index_from_nostr_if_missing(self) -> None: """Retrieve the password database from Nostr if it doesn't exist locally. If no valid data is found or decryption fails, initialize a fresh local database and publish it to Nostr. """ - success = self.attempt_initial_sync() + asyncio.run(self.sync_index_from_nostr_if_missing_async()) + + async def sync_index_from_nostr_if_missing_async(self) -> None: + success = await self.attempt_initial_sync_async() if not success: self.vault.save_index({"schema_version": LATEST_VERSION, "entries": {}}) try: - self.sync_vault() + await self.sync_vault_async() except Exception as exc: # pragma: no cover - best effort logger.warning(f"Unable to publish fresh database: {exc}") @@ -3532,7 +3548,7 @@ class PasswordManager: # Re-raise the exception to inform the calling function of the failure raise - def sync_vault( + async def sync_vault_async( self, alt_summary: str | None = None ) -> dict[str, list[str] | str] | None: """Publish the current vault contents to Nostr and return event IDs.""" @@ -3547,7 +3563,7 @@ class PasswordManager: event_id = None if callable(pub_snap): if asyncio.iscoroutinefunction(pub_snap): - manifest, event_id = asyncio.run(pub_snap(encrypted)) + manifest, event_id = await pub_snap(encrypted) else: manifest, event_id = pub_snap(encrypted) else: @@ -3569,6 +3585,11 @@ class PasswordManager: logging.error(f"Failed to sync vault: {e}", exc_info=True) return None + def sync_vault( + self, alt_summary: str | None = None + ) -> dict[str, list[str] | str] | None: + return asyncio.run(self.sync_vault_async(alt_summary=alt_summary)) + def backup_database(self) -> None: """ Creates a backup of the encrypted JSON index file. From 5019c99c11320bd67d54433300b23050586ea481 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 17 Jul 2025 20:42:23 -0400 Subject: [PATCH 06/75] Refactor CLI to use service layer --- src/seedpass/cli.py | 197 +++++++++++------------ src/seedpass/core/api.py | 327 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 414 insertions(+), 110 deletions(-) diff --git a/src/seedpass/cli.py b/src/seedpass/cli.py index 07da8e5..309b3ac 100644 --- a/src/seedpass/cli.py +++ b/src/seedpass/cli.py @@ -10,6 +10,10 @@ from seedpass.core.api import ( VaultService, ProfileService, SyncService, + EntryService, + ConfigService, + UtilityService, + NostrService, VaultExportRequest, VaultImportRequest, ChangePasswordRequest, @@ -73,6 +77,26 @@ def _get_services( return VaultService(pm), ProfileService(pm), SyncService(pm) +def _get_entry_service(ctx: typer.Context) -> EntryService: + pm = _get_pm(ctx) + return EntryService(pm) + + +def _get_config_service(ctx: typer.Context) -> ConfigService: + pm = _get_pm(ctx) + return ConfigService(pm) + + +def _get_util_service(ctx: typer.Context) -> UtilityService: + pm = _get_pm(ctx) + return UtilityService(pm) + + +def _get_nostr_service(ctx: typer.Context) -> NostrService: + pm = _get_pm(ctx) + return NostrService(pm) + + @app.callback(invoke_without_command=True) def main(ctx: typer.Context, fingerprint: Optional[str] = fingerprint_option) -> None: """SeedPass CLI entry point. @@ -95,8 +119,8 @@ def entry_list( archived: bool = typer.Option(False, "--archived", help="Include archived"), ) -> None: """List entries in the vault.""" - pm = _get_pm(ctx) - entries = pm.entry_manager.list_entries( + service = _get_entry_service(ctx) + entries = service.list_entries( sort_by=sort, filter_kind=kind, include_archived=archived ) for idx, label, username, url, is_archived in entries: @@ -113,8 +137,8 @@ def entry_list( @entry_app.command("search") def entry_search(ctx: typer.Context, query: str) -> None: """Search entries.""" - pm = _get_pm(ctx) - results = pm.entry_manager.search_entries(query) + service = _get_entry_service(ctx) + results = service.search_entries(query) if not results: typer.echo("No matching entries found") return @@ -130,8 +154,8 @@ def entry_search(ctx: typer.Context, query: str) -> None: @entry_app.command("get") def entry_get(ctx: typer.Context, query: str) -> None: """Retrieve a single entry's secret.""" - pm = _get_pm(ctx) - matches = pm.entry_manager.search_entries(query) + service = _get_entry_service(ctx) + matches = service.search_entries(query) if len(matches) == 0: typer.echo("No matching entries found") raise typer.Exit(code=1) @@ -145,14 +169,14 @@ def entry_get(ctx: typer.Context, query: str) -> None: raise typer.Exit(code=1) index = matches[0][0] - entry = pm.entry_manager.retrieve_entry(index) + entry = service.retrieve_entry(index) etype = entry.get("type", entry.get("kind")) if etype == EntryType.PASSWORD.value: length = int(entry.get("length", 12)) - password = pm.password_generator.generate_password(length, index) + password = service.generate_password(length, index) typer.echo(password) elif etype == EntryType.TOTP.value: - code = pm.entry_manager.get_totp_code(index, pm.parent_seed) + code = service.get_totp_code(index) typer.echo(code) else: typer.echo("Unsupported entry type") @@ -168,10 +192,9 @@ def entry_add( url: Optional[str] = typer.Option(None, "--url"), ) -> None: """Add a new password entry and output its index.""" - pm = _get_pm(ctx) - index = pm.entry_manager.add_entry(label, length, username, url) + service = _get_entry_service(ctx) + index = service.add_entry(label, length, username, url) typer.echo(str(index)) - pm.sync_vault() @entry_app.command("add-totp") @@ -184,17 +207,15 @@ def entry_add_totp( digits: int = typer.Option(6, "--digits", help="Number of TOTP digits"), ) -> None: """Add a TOTP entry and output the otpauth URI.""" - pm = _get_pm(ctx) - uri = pm.entry_manager.add_totp( + service = _get_entry_service(ctx) + uri = service.add_totp( label, - pm.parent_seed, index=index, secret=secret, period=period, digits=digits, ) typer.echo(uri) - pm.sync_vault() @entry_app.command("add-ssh") @@ -205,15 +226,13 @@ def entry_add_ssh( notes: str = typer.Option("", "--notes", help="Entry notes"), ) -> None: """Add an SSH key entry and output its index.""" - pm = _get_pm(ctx) - idx = pm.entry_manager.add_ssh_key( + service = _get_entry_service(ctx) + idx = service.add_ssh_key( label, - pm.parent_seed, index=index, notes=notes, ) typer.echo(str(idx)) - pm.sync_vault() @entry_app.command("add-pgp") @@ -226,17 +245,15 @@ def entry_add_pgp( notes: str = typer.Option("", "--notes", help="Entry notes"), ) -> None: """Add a PGP key entry and output its index.""" - pm = _get_pm(ctx) - idx = pm.entry_manager.add_pgp_key( + service = _get_entry_service(ctx) + idx = service.add_pgp_key( label, - pm.parent_seed, index=index, key_type=key_type, user_id=user_id, notes=notes, ) typer.echo(str(idx)) - pm.sync_vault() @entry_app.command("add-nostr") @@ -247,14 +264,13 @@ def entry_add_nostr( notes: str = typer.Option("", "--notes", help="Entry notes"), ) -> None: """Add a Nostr key entry and output its index.""" - pm = _get_pm(ctx) - idx = pm.entry_manager.add_nostr_key( + service = _get_entry_service(ctx) + idx = service.add_nostr_key( label, index=index, notes=notes, ) typer.echo(str(idx)) - pm.sync_vault() @entry_app.command("add-seed") @@ -266,16 +282,14 @@ def entry_add_seed( notes: str = typer.Option("", "--notes", help="Entry notes"), ) -> None: """Add a derived seed phrase entry and output its index.""" - pm = _get_pm(ctx) - idx = pm.entry_manager.add_seed( + service = _get_entry_service(ctx) + idx = service.add_seed( label, - pm.parent_seed, index=index, - words_num=words, + words=words, notes=notes, ) typer.echo(str(idx)) - pm.sync_vault() @entry_app.command("add-key-value") @@ -286,10 +300,9 @@ def entry_add_key_value( notes: str = typer.Option("", "--notes", help="Entry notes"), ) -> None: """Add a key/value entry and output its index.""" - pm = _get_pm(ctx) - idx = pm.entry_manager.add_key_value(label, value, notes=notes) + service = _get_entry_service(ctx) + idx = service.add_key_value(label, value, notes=notes) typer.echo(str(idx)) - pm.sync_vault() @entry_app.command("add-managed-account") @@ -300,15 +313,13 @@ def entry_add_managed_account( notes: str = typer.Option("", "--notes", help="Entry notes"), ) -> None: """Add a managed account seed entry and output its index.""" - pm = _get_pm(ctx) - idx = pm.entry_manager.add_managed_account( + service = _get_entry_service(ctx) + idx = service.add_managed_account( label, - pm.parent_seed, index=index, notes=notes, ) typer.echo(str(idx)) - pm.sync_vault() @entry_app.command("modify") @@ -326,9 +337,9 @@ def entry_modify( value: Optional[str] = typer.Option(None, "--value", help="New value"), ) -> None: """Modify an existing entry.""" - pm = _get_pm(ctx) + service = _get_entry_service(ctx) try: - pm.entry_manager.modify_entry( + service.modify_entry( entry_id, username=username, url=url, @@ -341,32 +352,29 @@ def entry_modify( except ValueError as e: typer.echo(str(e)) raise typer.Exit(code=1) - pm.sync_vault() @entry_app.command("archive") def entry_archive(ctx: typer.Context, entry_id: int) -> None: """Archive an entry.""" - pm = _get_pm(ctx) - pm.entry_manager.archive_entry(entry_id) + service = _get_entry_service(ctx) + service.archive_entry(entry_id) typer.echo(str(entry_id)) - pm.sync_vault() @entry_app.command("unarchive") def entry_unarchive(ctx: typer.Context, entry_id: int) -> None: """Restore an archived entry.""" - pm = _get_pm(ctx) - pm.entry_manager.restore_entry(entry_id) + service = _get_entry_service(ctx) + service.restore_entry(entry_id) typer.echo(str(entry_id)) - pm.sync_vault() @entry_app.command("totp-codes") def entry_totp_codes(ctx: typer.Context) -> None: """Display all current TOTP codes.""" - pm = _get_pm(ctx) - pm.handle_display_totp_codes() + service = _get_entry_service(ctx) + service.display_totp_codes() @entry_app.command("export-totp") @@ -374,8 +382,8 @@ def entry_export_totp( ctx: typer.Context, file: str = typer.Option(..., help="Output file") ) -> None: """Export all TOTP secrets to a JSON file.""" - pm = _get_pm(ctx) - data = pm.entry_manager.export_totp_entries(pm.parent_seed) + service = _get_entry_service(ctx) + data = service.export_totp_entries() Path(file).write_text(json.dumps(data, indent=2)) typer.echo(str(file)) @@ -478,16 +486,16 @@ def nostr_sync(ctx: typer.Context) -> None: @nostr_app.command("get-pubkey") def nostr_get_pubkey(ctx: typer.Context) -> None: """Display the active profile's npub.""" - pm = _get_pm(ctx) - npub = pm.nostr_client.key_manager.get_npub() + service = _get_nostr_service(ctx) + npub = service.get_pubkey() typer.echo(npub) @config_app.command("get") def config_get(ctx: typer.Context, key: str) -> None: """Get a configuration value.""" - pm = _get_pm(ctx) - value = pm.config_manager.load_config(require_pin=False).get(key) + service = _get_config_service(ctx) + value = service.get(key) if value is None: typer.echo("Key not found") else: @@ -497,43 +505,18 @@ def config_get(ctx: typer.Context, key: str) -> None: @config_app.command("set") def config_set(ctx: typer.Context, key: str, value: str) -> None: """Set a configuration value.""" - pm = _get_pm(ctx) - cfg = pm.config_manager - - mapping = { - "inactivity_timeout": lambda v: cfg.set_inactivity_timeout(float(v)), - "secret_mode_enabled": lambda v: cfg.set_secret_mode_enabled( - v.lower() in ("1", "true", "yes", "y", "on") - ), - "clipboard_clear_delay": lambda v: cfg.set_clipboard_clear_delay(int(v)), - "additional_backup_path": lambda v: cfg.set_additional_backup_path(v or None), - "relays": lambda v: cfg.set_relays( - [r.strip() for r in v.split(",") if r.strip()], require_pin=False - ), - "kdf_iterations": lambda v: cfg.set_kdf_iterations(int(v)), - "kdf_mode": lambda v: cfg.set_kdf_mode(v), - "backup_interval": lambda v: cfg.set_backup_interval(float(v)), - "nostr_max_retries": lambda v: cfg.set_nostr_max_retries(int(v)), - "nostr_retry_delay": lambda v: cfg.set_nostr_retry_delay(float(v)), - "min_uppercase": lambda v: cfg.set_min_uppercase(int(v)), - "min_lowercase": lambda v: cfg.set_min_lowercase(int(v)), - "min_digits": lambda v: cfg.set_min_digits(int(v)), - "min_special": lambda v: cfg.set_min_special(int(v)), - "quick_unlock": lambda v: cfg.set_quick_unlock( - v.lower() in ("1", "true", "yes", "y", "on") - ), - "verbose_timing": lambda v: cfg.set_verbose_timing( - v.lower() in ("1", "true", "yes", "y", "on") - ), - } - - action = mapping.get(key) - if action is None: - typer.echo("Unknown key") - raise typer.Exit(code=1) + service = _get_config_service(ctx) try: - action(value) + val = ( + [r.strip() for r in value.split(",") if r.strip()] + if key == "relays" + else value + ) + service.set(key, val) + except KeyError: + typer.echo("Unknown key") + raise typer.Exit(code=1) except Exception as exc: # pragma: no cover - pass through errors typer.echo(f"Error: {exc}") raise typer.Exit(code=1) @@ -544,11 +527,10 @@ def config_set(ctx: typer.Context, key: str, value: str) -> None: @config_app.command("toggle-secret-mode") def config_toggle_secret_mode(ctx: typer.Context) -> None: """Interactively enable or disable secret mode.""" - pm = _get_pm(ctx) - cfg = pm.config_manager + service = _get_config_service(ctx) try: - enabled = cfg.get_secret_mode_enabled() - delay = cfg.get_clipboard_clear_delay() + enabled = service.get_secret_mode_enabled() + delay = service.get_clipboard_clear_delay() except Exception as exc: # pragma: no cover - pass through errors typer.echo(f"Error loading settings: {exc}") raise typer.Exit(code=1) @@ -580,10 +562,7 @@ def config_toggle_secret_mode(ctx: typer.Context) -> None: raise typer.Exit(code=1) try: - cfg.set_secret_mode_enabled(enabled) - cfg.set_clipboard_clear_delay(delay) - pm.secret_mode_enabled = enabled - pm.clipboard_clear_delay = delay + service.set_secret_mode(enabled, delay) except Exception as exc: # pragma: no cover - pass through errors typer.echo(f"Error: {exc}") raise typer.Exit(code=1) @@ -595,10 +574,9 @@ def config_toggle_secret_mode(ctx: typer.Context) -> None: @config_app.command("toggle-offline") def config_toggle_offline(ctx: typer.Context) -> None: """Enable or disable offline mode.""" - pm = _get_pm(ctx) - cfg = pm.config_manager + service = _get_config_service(ctx) try: - enabled = cfg.get_offline_mode() + enabled = service.get_offline_mode() except Exception as exc: # pragma: no cover - pass through errors typer.echo(f"Error loading settings: {exc}") raise typer.Exit(code=1) @@ -617,8 +595,7 @@ def config_toggle_offline(ctx: typer.Context) -> None: enabled = False try: - cfg.set_offline_mode(enabled) - pm.offline_mode = enabled + service.set_offline_mode(enabled) except Exception as exc: # pragma: no cover - pass through errors typer.echo(f"Error: {exc}") raise typer.Exit(code=1) @@ -659,23 +636,23 @@ def fingerprint_switch(ctx: typer.Context, fingerprint: str) -> None: @util_app.command("generate-password") def generate_password(ctx: typer.Context, length: int = 24) -> None: """Generate a strong password.""" - pm = _get_pm(ctx) - password = pm.password_generator.generate_password(length) + service = _get_util_service(ctx) + password = service.generate_password(length) typer.echo(password) @util_app.command("verify-checksum") def verify_checksum(ctx: typer.Context) -> None: """Verify the SeedPass script checksum.""" - pm = _get_pm(ctx) - pm.handle_verify_checksum() + service = _get_util_service(ctx) + service.verify_checksum() @util_app.command("update-checksum") def update_checksum(ctx: typer.Context) -> None: """Regenerate the script checksum file.""" - pm = _get_pm(ctx) - pm.handle_update_script_checksum() + service = _get_util_service(ctx) + service.update_checksum() @api_app.command("start") diff --git a/src/seedpass/core/api.py b/src/seedpass/core/api.py index 03609a8..9f6671c 100644 --- a/src/seedpass/core/api.py +++ b/src/seedpass/core/api.py @@ -194,3 +194,330 @@ class SyncService: with self._lock: self._manager.start_background_vault_sync(summary) + + +class EntryService: + """Thread-safe wrapper around entry operations.""" + + def __init__(self, manager: PasswordManager) -> None: + self._manager = manager + self._lock = Lock() + + def list_entries( + self, + sort_by: str = "index", + filter_kind: str | None = None, + include_archived: bool = False, + ): + with self._lock: + return self._manager.entry_manager.list_entries( + sort_by=sort_by, + filter_kind=filter_kind, + include_archived=include_archived, + ) + + def search_entries(self, query: str): + with self._lock: + return self._manager.entry_manager.search_entries(query) + + def retrieve_entry(self, entry_id: int): + with self._lock: + return self._manager.entry_manager.retrieve_entry(entry_id) + + def generate_password(self, length: int, index: int) -> str: + with self._lock: + return self._manager.password_generator.generate_password(length, index) + + def get_totp_code(self, entry_id: int) -> str: + with self._lock: + return self._manager.entry_manager.get_totp_code( + entry_id, self._manager.parent_seed + ) + + def add_entry( + self, + label: str, + length: int, + username: str | None = None, + url: str | None = None, + ) -> int: + with self._lock: + idx = self._manager.entry_manager.add_entry(label, length, username, url) + self._manager.sync_vault() + return idx + + def add_totp( + self, + label: str, + *, + index: int | None = None, + secret: str | None = None, + period: int = 30, + digits: int = 6, + ) -> str: + with self._lock: + uri = self._manager.entry_manager.add_totp( + label, + self._manager.parent_seed, + index=index, + secret=secret, + period=period, + digits=digits, + ) + self._manager.sync_vault() + return uri + + def add_ssh_key( + self, + label: str, + *, + index: int | None = None, + notes: str = "", + ) -> int: + with self._lock: + idx = self._manager.entry_manager.add_ssh_key( + label, + self._manager.parent_seed, + index=index, + notes=notes, + ) + self._manager.sync_vault() + return idx + + def add_pgp_key( + self, + label: str, + *, + index: int | None = None, + key_type: str = "ed25519", + user_id: str = "", + notes: str = "", + ) -> int: + with self._lock: + idx = self._manager.entry_manager.add_pgp_key( + label, + self._manager.parent_seed, + index=index, + key_type=key_type, + user_id=user_id, + notes=notes, + ) + self._manager.sync_vault() + return idx + + def add_nostr_key( + self, + label: str, + *, + index: int | None = None, + notes: str = "", + ) -> int: + with self._lock: + idx = self._manager.entry_manager.add_nostr_key( + label, + index=index, + notes=notes, + ) + self._manager.sync_vault() + return idx + + def add_seed( + self, + label: str, + *, + index: int | None = None, + words: int = 24, + notes: str = "", + ) -> int: + with self._lock: + idx = self._manager.entry_manager.add_seed( + label, + self._manager.parent_seed, + index=index, + words_num=words, + notes=notes, + ) + self._manager.sync_vault() + return idx + + def add_key_value(self, label: str, value: str, *, notes: str = "") -> int: + with self._lock: + idx = self._manager.entry_manager.add_key_value(label, value, notes=notes) + self._manager.sync_vault() + return idx + + def add_managed_account( + self, + label: str, + *, + index: int | None = None, + notes: str = "", + ) -> int: + with self._lock: + idx = self._manager.entry_manager.add_managed_account( + label, + self._manager.parent_seed, + index=index, + notes=notes, + ) + self._manager.sync_vault() + return idx + + def modify_entry( + self, + entry_id: int, + *, + username: str | None = None, + url: str | None = None, + notes: str | None = None, + label: str | None = None, + period: int | None = None, + digits: int | None = None, + value: str | None = None, + ) -> None: + with self._lock: + self._manager.entry_manager.modify_entry( + entry_id, + username=username, + url=url, + notes=notes, + label=label, + period=period, + digits=digits, + value=value, + ) + self._manager.sync_vault() + + def archive_entry(self, entry_id: int) -> None: + with self._lock: + self._manager.entry_manager.archive_entry(entry_id) + self._manager.sync_vault() + + def restore_entry(self, entry_id: int) -> None: + with self._lock: + self._manager.entry_manager.restore_entry(entry_id) + self._manager.sync_vault() + + def export_totp_entries(self) -> dict: + with self._lock: + return self._manager.entry_manager.export_totp_entries( + self._manager.parent_seed + ) + + def display_totp_codes(self) -> None: + with self._lock: + self._manager.handle_display_totp_codes() + + +class ConfigService: + """Thread-safe wrapper around configuration access.""" + + def __init__(self, manager: PasswordManager) -> None: + self._manager = manager + self._lock = Lock() + + def get(self, key: str): + with self._lock: + return self._manager.config_manager.load_config(require_pin=False).get(key) + + def set(self, key: str, value: str) -> None: + cfg = self._manager.config_manager + mapping = { + "inactivity_timeout": ("set_inactivity_timeout", float), + "secret_mode_enabled": ( + "set_secret_mode_enabled", + lambda v: v.lower() in ("1", "true", "yes", "y", "on"), + ), + "clipboard_clear_delay": ("set_clipboard_clear_delay", int), + "additional_backup_path": ( + "set_additional_backup_path", + lambda v: v or None, + ), + "relays": ("set_relays", lambda v: (v, {"require_pin": False})), + "kdf_iterations": ("set_kdf_iterations", int), + "kdf_mode": ("set_kdf_mode", lambda v: v), + "backup_interval": ("set_backup_interval", float), + "nostr_max_retries": ("set_nostr_max_retries", int), + "nostr_retry_delay": ("set_nostr_retry_delay", float), + "min_uppercase": ("set_min_uppercase", int), + "min_lowercase": ("set_min_lowercase", int), + "min_digits": ("set_min_digits", int), + "min_special": ("set_min_special", int), + "quick_unlock": ( + "set_quick_unlock", + lambda v: v.lower() in ("1", "true", "yes", "y", "on"), + ), + } + entry = mapping.get(key) + if entry is None: + raise KeyError(key) + method_name, conv = entry + with self._lock: + result = conv(value) + if ( + isinstance(result, tuple) + and len(result) == 2 + and isinstance(result[1], dict) + ): + arg, kwargs = result + getattr(cfg, method_name)(arg, **kwargs) + else: + getattr(cfg, method_name)(result) + + def get_secret_mode_enabled(self) -> bool: + with self._lock: + return self._manager.config_manager.get_secret_mode_enabled() + + def get_clipboard_clear_delay(self) -> int: + with self._lock: + return self._manager.config_manager.get_clipboard_clear_delay() + + def set_secret_mode(self, enabled: bool, delay: int) -> None: + with self._lock: + cfg = self._manager.config_manager + cfg.set_secret_mode_enabled(enabled) + cfg.set_clipboard_clear_delay(delay) + self._manager.secret_mode_enabled = enabled + self._manager.clipboard_clear_delay = delay + + def get_offline_mode(self) -> bool: + with self._lock: + return self._manager.config_manager.get_offline_mode() + + def set_offline_mode(self, enabled: bool) -> None: + with self._lock: + cfg = self._manager.config_manager + cfg.set_offline_mode(enabled) + self._manager.offline_mode = enabled + + +class UtilityService: + """Miscellaneous helper operations.""" + + def __init__(self, manager: PasswordManager) -> None: + self._manager = manager + self._lock = Lock() + + def generate_password(self, length: int) -> str: + with self._lock: + return self._manager.password_generator.generate_password(length) + + def verify_checksum(self) -> None: + with self._lock: + self._manager.handle_verify_checksum() + + def update_checksum(self) -> None: + with self._lock: + self._manager.handle_update_script_checksum() + + +class NostrService: + """Nostr related helper methods.""" + + def __init__(self, manager: PasswordManager) -> None: + self._manager = manager + self._lock = Lock() + + def get_pubkey(self) -> str: + with self._lock: + return self._manager.nostr_client.key_manager.get_npub() From 6c22e28512151be5f36e8cfb0a2a85f98320eb8f Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 17 Jul 2025 21:09:12 -0400 Subject: [PATCH 07/75] Add basic Toga GUI --- src/requirements.txt | 1 + src/runtime_requirements.txt | 1 + src/seedpass_gui/__init__.py | 11 ++ src/seedpass_gui/app.py | 199 +++++++++++++++++++++++++++++++++++ 4 files changed, 212 insertions(+) create mode 100644 src/seedpass_gui/__init__.py create mode 100644 src/seedpass_gui/app.py diff --git a/src/requirements.txt b/src/requirements.txt index f3951b0..c0e094c 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -32,3 +32,4 @@ requests>=2.32 python-multipart orjson argon2-cffi +toga-core>=0.5.2 diff --git a/src/runtime_requirements.txt b/src/runtime_requirements.txt index 38cf46e..1bde15f 100644 --- a/src/runtime_requirements.txt +++ b/src/runtime_requirements.txt @@ -27,3 +27,4 @@ requests>=2.32 python-multipart orjson argon2-cffi +toga-core>=0.5.2 diff --git a/src/seedpass_gui/__init__.py b/src/seedpass_gui/__init__.py new file mode 100644 index 0000000..f473abc --- /dev/null +++ b/src/seedpass_gui/__init__.py @@ -0,0 +1,11 @@ +"""Graphical user interface for SeedPass.""" + +from .app import SeedPassApp + + +def main() -> None: + """Launch the GUI application.""" + SeedPassApp().main_loop() + + +__all__ = ["SeedPassApp", "main"] diff --git a/src/seedpass_gui/app.py b/src/seedpass_gui/app.py new file mode 100644 index 0000000..1ef0e2d --- /dev/null +++ b/src/seedpass_gui/app.py @@ -0,0 +1,199 @@ +from __future__ import annotations + +import toga +from toga.style import Pack +from toga.style.pack import COLUMN, ROW + +from seedpass.core.manager import PasswordManager +from seedpass.core.api import ( + VaultService, + EntryService, + UnlockRequest, +) + + +class LockScreenWindow(toga.Window): + """Window prompting for the master password.""" + + def __init__( + self, app: SeedPassApp, vault: VaultService, entries: EntryService + ) -> None: + super().__init__("Unlock Vault") + self.app = app + self.vault = vault + self.entries = entries + + self.password_input = toga.PasswordInput(style=Pack(flex=1)) + self.message = toga.Label("", style=Pack(color="red")) + unlock_button = toga.Button( + "Unlock", on_press=self.handle_unlock, style=Pack(padding_top=10) + ) + + box = toga.Box(style=Pack(direction=COLUMN, padding=20)) + box.add(toga.Label("Master Password:")) + box.add(self.password_input) + box.add(unlock_button) + box.add(self.message) + self.content = box + + def handle_unlock(self, widget: toga.Widget) -> None: + password = self.password_input.value or "" + try: + self.vault.unlock(UnlockRequest(password=password)) + except Exception as exc: # pragma: no cover - GUI error handling + self.message.text = str(exc) + return + main = MainWindow(self.app, self.vault, self.entries) + self.app.main_window = main + main.show() + self.close() + + +class MainWindow(toga.Window): + """Main application window showing vault entries.""" + + def __init__( + self, app: SeedPassApp, vault: VaultService, entries: EntryService + ) -> None: + super().__init__("SeedPass") + self.app = app + self.vault = vault + self.entries = entries + + self.table = toga.Table( + headings=["ID", "Label", "Username", "URL"], style=Pack(flex=1) + ) + + add_button = toga.Button("Add", on_press=self.add_entry) + edit_button = toga.Button("Edit", on_press=self.edit_entry) + search_button = toga.Button("Search", on_press=self.search_entries) + + button_box = toga.Box(style=Pack(direction=ROW, padding_top=5)) + button_box.add(add_button) + button_box.add(edit_button) + button_box.add(search_button) + + box = toga.Box(style=Pack(direction=COLUMN, padding=10)) + box.add(self.table) + box.add(button_box) + self.content = box + + self.refresh_entries() + + def refresh_entries(self) -> None: + self.table.data = [] + for idx, label, username, url, _arch in self.entries.list_entries(): + self.table.data.append((idx, label, username or "", url or "")) + + # --- Button handlers ------------------------------------------------- + def add_entry(self, widget: toga.Widget) -> None: + dlg = EntryDialog(self, None) + dlg.show() + + def edit_entry(self, widget: toga.Widget) -> None: + if self.table.selection is None: + return + entry_id = int(self.table.selection[0]) + dlg = EntryDialog(self, entry_id) + dlg.show() + + def search_entries(self, widget: toga.Widget) -> None: + dlg = SearchDialog(self) + dlg.show() + + +class EntryDialog(toga.Window): + """Dialog for adding or editing an entry.""" + + def __init__(self, main: MainWindow, entry_id: int | None) -> None: + title = "Add Entry" if entry_id is None else "Edit Entry" + super().__init__(title) + self.main = main + self.entry_id = entry_id + + self.label_input = toga.TextInput(style=Pack(flex=1)) + self.username_input = toga.TextInput(style=Pack(flex=1)) + self.url_input = toga.TextInput(style=Pack(flex=1)) + self.length_input = toga.NumberInput( + min_value=8, max_value=128, style=Pack(width=80), value=16 + ) + + save_button = toga.Button( + "Save", on_press=self.save, style=Pack(padding_top=10) + ) + + box = toga.Box(style=Pack(direction=COLUMN, padding=20)) + box.add(toga.Label("Label")) + box.add(self.label_input) + box.add(toga.Label("Username")) + box.add(self.username_input) + box.add(toga.Label("URL")) + box.add(self.url_input) + box.add(toga.Label("Length")) + box.add(self.length_input) + box.add(save_button) + self.content = box + + if entry_id is not None: + entry = self.main.entries.retrieve_entry(entry_id) + if entry: + self.label_input.value = entry.get("label", "") + self.username_input.value = entry.get("username", "") or "" + self.url_input.value = entry.get("url", "") or "" + self.length_input.value = entry.get("length", 16) + + def save(self, widget: toga.Widget) -> None: + label = self.label_input.value or "" + username = self.username_input.value or None + url = self.url_input.value or None + length = int(self.length_input.value or 16) + + if self.entry_id is None: + self.main.entries.add_entry(label, length, username=username, url=url) + else: + self.main.entries.modify_entry( + self.entry_id, username=username, url=url, label=label + ) + self.main.refresh_entries() + self.close() + + +class SearchDialog(toga.Window): + """Dialog for searching entries.""" + + def __init__(self, main: MainWindow) -> None: + super().__init__("Search Entries") + self.main = main + self.query_input = toga.TextInput(style=Pack(flex=1)) + search_button = toga.Button( + "Search", on_press=self.do_search, style=Pack(padding_top=10) + ) + box = toga.Box(style=Pack(direction=COLUMN, padding=20)) + box.add(toga.Label("Query")) + box.add(self.query_input) + box.add(search_button) + self.content = box + + def do_search(self, widget: toga.Widget) -> None: + query = self.query_input.value or "" + results = self.main.entries.search_entries(query) + self.main.table.data = [] + for idx, label, username, url, _arch in results: + self.main.table.data.append((idx, label, username or "", url or "")) + self.close() + + +def build() -> SeedPassApp: + return SeedPassApp() + + +class SeedPassApp(toga.App): + def startup(self) -> None: # pragma: no cover - GUI bootstrap + pm = PasswordManager() + self.vault_service = VaultService(pm) + self.entry_service = EntryService(pm) + self.lock_window = LockScreenWindow( + self, self.vault_service, self.entry_service + ) + self.main_window = None + self.lock_window.show() From fac31cd99f81c47ef9cff1854aa05e849746b99b Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 17 Jul 2025 21:21:05 -0400 Subject: [PATCH 08/75] Add GUI runner and packaging docs --- .../01-getting-started/05-briefcase.md | 20 +++++++++++++++++++ pyproject.toml | 1 + src/run_gui.py | 4 ++++ src/seedpass_gui/app.py | 5 +++++ 4 files changed, 30 insertions(+) create mode 100644 docs/docs/content/01-getting-started/05-briefcase.md create mode 100644 src/run_gui.py diff --git a/docs/docs/content/01-getting-started/05-briefcase.md b/docs/docs/content/01-getting-started/05-briefcase.md new file mode 100644 index 0000000..c461f44 --- /dev/null +++ b/docs/docs/content/01-getting-started/05-briefcase.md @@ -0,0 +1,20 @@ +# Packaging the GUI with Briefcase + +This project uses [BeeWare's Briefcase](https://beeware.org) to generate +platform‑native installers. Once your development environment is set up, +package the GUI by running the following commands from the repository root: + +```bash +# Create the application scaffold for your platform +briefcase create + +# Compile dependencies and produce a distributable bundle +briefcase build + +# Run the packaged application +briefcase run +``` + +`briefcase create` only needs to be executed once per platform. After the +initial creation step you can repeatedly run `briefcase build` followed by +`briefcase run` to test your packaged application on Windows, macOS or Linux. diff --git a/pyproject.toml b/pyproject.toml index 4165dd2..866aa67 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,6 +4,7 @@ version = "0.1.0" [project.scripts] seedpass = "seedpass.cli:app" +seedpass-gui = "seedpass_gui.app:main" [tool.mypy] python_version = "3.11" diff --git a/src/run_gui.py b/src/run_gui.py new file mode 100644 index 0000000..9f757c7 --- /dev/null +++ b/src/run_gui.py @@ -0,0 +1,4 @@ +from seedpass_gui.app import main + +if __name__ == "__main__": + main() diff --git a/src/seedpass_gui/app.py b/src/seedpass_gui/app.py index 1ef0e2d..b3b84cb 100644 --- a/src/seedpass_gui/app.py +++ b/src/seedpass_gui/app.py @@ -197,3 +197,8 @@ class SeedPassApp(toga.App): ) self.main_window = None self.lock_window.show() + + +def main() -> None: # pragma: no cover - GUI bootstrap + """Run the BeeWare application.""" + SeedPassApp().main_loop() From cd0f9624aea24c72712986cb7515b97ed617af96 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 17 Jul 2025 21:33:06 -0400 Subject: [PATCH 09/75] Add service layer and CLI regression tests --- src/tests/test_cli_core_services.py | 69 ++++++++++++++++++++++++++++ src/tests/test_core_services.py | 71 +++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 src/tests/test_cli_core_services.py create mode 100644 src/tests/test_core_services.py diff --git a/src/tests/test_cli_core_services.py b/src/tests/test_cli_core_services.py new file mode 100644 index 0000000..45a6d9d --- /dev/null +++ b/src/tests/test_cli_core_services.py @@ -0,0 +1,69 @@ +from types import SimpleNamespace + +import typer +from typer.testing import CliRunner + +from seedpass import cli +from seedpass.cli import app + +runner = CliRunner() + + +def test_cli_vault_unlock(monkeypatch): + called = {} + + def unlock_vault(pw): + called["pw"] = pw + return 0.5 + + pm = SimpleNamespace(unlock_vault=unlock_vault, select_fingerprint=lambda fp: None) + monkeypatch.setattr(cli, "PasswordManager", lambda: pm) + monkeypatch.setattr(cli.typer, "prompt", lambda *a, **k: "pw") + result = runner.invoke(app, ["vault", "unlock"]) + assert result.exit_code == 0 + assert "Unlocked in" in result.stdout + assert called["pw"] == "pw" + + +def test_cli_entry_add_search_sync(monkeypatch): + calls = {} + + def add_entry(label, length, username=None, url=None): + calls["add"] = (label, length, username, url) + return 1 + + def search_entries(q): + calls["search"] = q + return [(1, "Label", None, None, False)] + + def sync_vault(): + calls["sync"] = True + return {"manifest_id": "m", "chunk_ids": [], "delta_ids": []} + + pm = SimpleNamespace( + entry_manager=SimpleNamespace( + add_entry=add_entry, search_entries=search_entries + ), + sync_vault=sync_vault, + select_fingerprint=lambda fp: None, + ) + monkeypatch.setattr(cli, "PasswordManager", lambda: pm) + + # entry add + result = runner.invoke(app, ["entry", "add", "Label"]) + assert result.exit_code == 0 + assert "1" in result.stdout + assert calls["add"] == ("Label", 12, None, None) + assert calls.get("sync") is True + + # entry search + result = runner.invoke(app, ["entry", "search", "lab"]) + assert result.exit_code == 0 + assert "Label" in result.stdout + assert calls["search"] == "lab" + + # nostr sync + result = runner.invoke(app, ["nostr", "sync"]) + assert result.exit_code == 0 + assert "manifest" in result.stdout.lower() + assert calls.get("sync") is True diff --git a/src/tests/test_core_services.py b/src/tests/test_core_services.py new file mode 100644 index 0000000..d71a859 --- /dev/null +++ b/src/tests/test_core_services.py @@ -0,0 +1,71 @@ +import types +from types import SimpleNamespace + +from seedpass.core.api import VaultService, EntryService, SyncService, UnlockRequest + + +def test_vault_service_unlock(): + called = {} + + def unlock_vault(pw: str) -> float: + called["pw"] = pw + return 0.42 + + pm = SimpleNamespace(unlock_vault=unlock_vault) + service = VaultService(pm) + resp = service.unlock(UnlockRequest(password="secret")) + assert called["pw"] == "secret" + assert resp.duration == 0.42 + + +def test_entry_service_add_entry_and_search(): + called = {} + + def add_entry(label, length, username=None, url=None): + called["add"] = (label, length, username, url) + return 5 + + def search_entries(q): + called["search"] = q + return [(5, "Example", username, url, False)] + + def sync_vault(): + called["sync"] = True + + username = "user" + url = "https://ex.com" + pm = SimpleNamespace( + entry_manager=SimpleNamespace( + add_entry=add_entry, search_entries=search_entries + ), + sync_vault=sync_vault, + ) + service = EntryService(pm) + idx = service.add_entry("Example", 12, username, url) + assert idx == 5 + assert called["add"] == ("Example", 12, username, url) + assert called.get("sync") is True + + results = service.search_entries("ex") + assert results == [(5, "Example", username, url, False)] + assert called["search"] == "ex" + + +def test_sync_service_sync(): + called = {} + + def sync_vault(): + called["sync"] = True + return { + "manifest_id": "m1", + "chunk_ids": ["c1"], + "delta_ids": ["d1"], + } + + pm = SimpleNamespace(sync_vault=sync_vault) + service = SyncService(pm) + resp = service.sync() + assert called["sync"] is True + assert resp.manifest_id == "m1" + assert resp.chunk_ids == ["c1"] + assert resp.delta_ids == ["d1"] From 47ea11b533257390b7454496438e7541ceb1cb6a Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 17 Jul 2025 21:50:18 -0400 Subject: [PATCH 10/75] docs: describe architecture --- README.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/README.md b/README.md index 7b39a4d..1c66e3d 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No ## Table of Contents - [Features](#features) +- [Architecture Overview](#architecture-overview) - [Prerequisites](#prerequisites) - [Installation](#installation) - [1. Clone the Repository](#1-clone-the-repository) @@ -65,9 +66,39 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No - **Relay Management:** List, add, remove or reset configured Nostr relays. - **Offline Mode:** Disable all Nostr communication for local-only operation. + A small on-screen notification area now shows queued messages for 10 seconds before fading. +## Architecture Overview + +SeedPass follows a layered design. The **`seedpass.core`** package exposes the +`PasswordManager` along with service classes (e.g. `VaultService` and +`EntryService`) that implement the main API used across interfaces. +The command line tool in **`seedpass.cli`** is a thin adapter built with Typer +that delegates operations to this API layer. + +The BeeWare desktop interface lives in **`seedpass_gui.app`** and can be +started with either `seedpass-gui` or `python -m seedpass_gui`. It reuses the +same service objects to unlock the vault, list entries and search through them. + +An optional browser extension can communicate with the FastAPI server exposed by +`seedpass.api` to manage entries from within the browser. + +```mermaid +graph TD + core["seedpass.core"] + cli["CLI"] + api["FastAPI server"] + gui["BeeWare GUI"] + ext["Browser Extension"] + + cli --> core + gui --> core + api --> core + ext --> api +``` + ## Prerequisites - **Python 3.8+** (3.11 or 3.12 recommended): Install Python from [python.org](https://www.python.org/downloads/) and be sure to check **"Add Python to PATH"** during setup. Using Python 3.13 is currently discouraged because some dependencies do not ship wheels for it yet, which can cause build failures on Windows unless you install the Visual C++ Build Tools. From 728c4be6e1dc508ede1225584756ac396f95eccd Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 17 Jul 2025 21:57:22 -0400 Subject: [PATCH 11/75] docs: add architecture diagram --- docs/docs/content/index.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/docs/content/index.md b/docs/docs/content/index.md index d417824..a4d072a 100644 --- a/docs/docs/content/index.md +++ b/docs/docs/content/index.md @@ -16,6 +16,22 @@ This software was not developed by an experienced security expert and should be ✔ Windows 10/11 • macOS 12+ • Any modern Linux SeedPass now uses the `portalocker` library for cross-platform file locking. No WSL or Cygwin required. +```mermaid +graph TD + core(seedpass.core) + cli(CLI/TUI) + gui(BeeWare GUI) + ext(Browser extension) + cli --> core + gui --> core + ext --> core +``` + +SeedPass uses a modular design with a single core library that handles all +security-critical logic. The current CLI/TUI adapter communicates with +`seedpass.core`, and future interfaces like a BeeWare GUI and a browser +extension can hook into the same layer. This architecture keeps the codebase +maintainable while enabling a consistent experience on multiple platforms. ## Table of Contents From 3ff98437509120cc3b2eb97ff920b933a1138010 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 17 Jul 2025 22:03:53 -0400 Subject: [PATCH 12/75] docs: explain BeeWare adapter --- .../01-getting-started/06-gui_adapter.md | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 docs/docs/content/01-getting-started/06-gui_adapter.md diff --git a/docs/docs/content/01-getting-started/06-gui_adapter.md b/docs/docs/content/01-getting-started/06-gui_adapter.md new file mode 100644 index 0000000..3d9c9b5 --- /dev/null +++ b/docs/docs/content/01-getting-started/06-gui_adapter.md @@ -0,0 +1,77 @@ +# BeeWare GUI Adapter + +SeedPass ships with a proof-of-concept graphical interface built using [BeeWare](https://beeware.org). The GUI interacts with the same core services as the CLI by instantiating wrappers around `PasswordManager`. + +## VaultService and EntryService + +`VaultService` provides thread-safe access to vault operations like exporting, importing, unlocking and locking the vault. `EntryService` exposes methods for listing, searching and modifying entries. Both classes live in `seedpass.core.api` and hold a `PasswordManager` instance protected by a `threading.Lock` to ensure safe concurrent access. + +```python +class VaultService: + """Thread-safe wrapper around vault operations.""" + def __init__(self, manager: PasswordManager) -> None: + self._manager = manager + self._lock = Lock() +``` + +```python +class EntryService: + """Thread-safe wrapper around entry operations.""" + def __init__(self, manager: PasswordManager) -> None: + self._manager = manager + self._lock = Lock() +``` + +## BeeWare Windows + +The GUI defines two main windows in `src/seedpass_gui/app.py`. `LockScreenWindow` prompts for the master password and then opens `MainWindow` to display the vault entries. + +```python +class LockScreenWindow(toga.Window): + """Window prompting for the master password.""" + def __init__(self, app: SeedPassApp, vault: VaultService, entries: EntryService) -> None: + super().__init__("Unlock Vault") + self.app = app + self.vault = vault + self.entries = entries + ... +``` + +```python +class MainWindow(toga.Window): + """Main application window showing vault entries.""" + def __init__(self, app: SeedPassApp, vault: VaultService, entries: EntryService) -> None: + super().__init__("SeedPass") + self.app = app + self.vault = vault + self.entries = entries + ... +``` + +Each window receives the service instances and calls methods such as `vault.unlock()` or `entries.add_entry()` when buttons are pressed. This keeps the UI thin while reusing the core logic. + +## Asynchronous Synchronization + +`PasswordManager` performs network synchronization with Nostr using `asyncio`. Methods like `start_background_vault_sync()` create a coroutine that calls `sync_vault_async()` in a background thread or task without blocking the UI. + +```python +async def sync_vault_async(self, alt_summary: str | None = None) -> dict[str, list[str] | str] | None: + """Publish the current vault contents to Nostr and return event IDs.""" + ... +``` + +```python +def start_background_vault_sync(self, alt_summary: str | None = None) -> None: + if getattr(self, "offline_mode", False): + return + def _worker() -> None: + asyncio.run(self.sync_vault_async(alt_summary=alt_summary)) + try: + loop = asyncio.get_running_loop() + except RuntimeError: + threading.Thread(target=_worker, daemon=True).start() + else: + asyncio.create_task(self.sync_vault_async(alt_summary=alt_summary)) +``` + +This approach ensures synchronization happens asynchronously whether the GUI is running inside or outside an existing event loop. From beb690ba725c4502cdd0c2742b7671f37c190fd5 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 17 Jul 2025 22:11:58 -0400 Subject: [PATCH 13/75] landing: add modular architecture diagram --- landing/index.html | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/landing/index.html b/landing/index.html index 2814e2b..678c5b9 100644 --- a/landing/index.html +++ b/landing/index.html @@ -84,6 +84,26 @@ flowchart TB

Architecture Overview

 ---
+config:
+  layout: fixed
+  theme: base
+  themeVariables:
+    primaryColor: '#e94a39'
+    primaryBorderColor: '#e94a39'
+    lineColor: '#e94a39'
+  look: classic
+---
+graph TD
+    core(seedpass.core)
+    cli(CLI/TUI)
+    gui(BeeWare GUI)
+    ext(Browser extension)
+    cli --> core
+    gui --> core
+    ext --> core
+                
+
+---
 config:
   layout: fixed
   theme: base

From df0279ac03fdab8b3aae2c2c541f92b30955394c Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Thu, 17 Jul 2025 22:21:09 -0400
Subject: [PATCH 14/75] docs: reference gui adapter and core services

---
 docs/docs/content/01-getting-started/02-api_reference.md | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/docs/docs/content/01-getting-started/02-api_reference.md b/docs/docs/content/01-getting-started/02-api_reference.md
index 5c23bf6..38ac3b6 100644
--- a/docs/docs/content/01-getting-started/02-api_reference.md
+++ b/docs/docs/content/01-getting-started/02-api_reference.md
@@ -2,6 +2,9 @@
 
 This guide covers how to start the SeedPass API, authenticate requests, and interact with the available endpoints.
 
+**Note:** All UI layers, including the CLI, BeeWare GUI, and future adapters, consume this REST API through service classes in `seedpass.core`. See [docs/gui_adapter.md](docs/gui_adapter.md) for more details on the GUI integration.
+
+
 ## Starting the API
 
 Run `seedpass api start` from your terminal. The command prints a one‑time token used for authentication:

From 474f2d134b95b045794c4908a189e9c79bb1c05c Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Thu, 17 Jul 2025 22:25:34 -0400
Subject: [PATCH 15/75] Add architecture documentation

---
 README.md            |  2 ++
 docs/ARCHITECTURE.md | 41 +++++++++++++++++++++++++++++++++++++++++
 2 files changed, 43 insertions(+)
 create mode 100644 docs/ARCHITECTURE.md

diff --git a/README.md b/README.md
index 1c66e3d..2cbf771 100644
--- a/README.md
+++ b/README.md
@@ -99,6 +99,8 @@ graph TD
     ext --> api
 ```
 
+See `docs/ARCHITECTURE.md` for details.
+
 ## Prerequisites
 
 - **Python 3.8+** (3.11 or 3.12 recommended): Install Python from [python.org](https://www.python.org/downloads/) and be sure to check **"Add Python to PATH"** during setup. Using Python 3.13 is currently discouraged because some dependencies do not ship wheels for it yet, which can cause build failures on Windows unless you install the Visual C++ Build Tools.  
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
new file mode 100644
index 0000000..f8416b8
--- /dev/null
+++ b/docs/ARCHITECTURE.md
@@ -0,0 +1,41 @@
+# SeedPass Architecture
+
+SeedPass follows a layered design that keeps the security-critical logic isolated in a reusable core package. Interfaces like the command line tool, REST API and graphical client act as thin adapters around this core.
+
+## Core Components
+
+- **`seedpass.core`** – houses all encryption, key derivation and vault management code.
+- **`VaultService`** and **`EntryService`** – thread-safe wrappers exposing the main API.
+- **`PasswordManager`** – orchestrates vault operations, migrations and Nostr sync.
+
+## Adapters
+
+- **CLI/TUI** – implemented in [`seedpass.cli`](src/seedpass/cli.py). The [Advanced CLI](docs/docs/content/01-getting-started/01-advanced_cli.md) guide details all commands.
+- **FastAPI server** – defined in [`seedpass.api`](src/seedpass/api.py). See the [API Reference](docs/docs/content/01-getting-started/02-api_reference.md) for endpoints.
+- **BeeWare GUI** – located in [`seedpass_gui`](src/seedpass_gui/app.py) and explained in the [GUI Adapter](docs/docs/content/01-getting-started/06-gui_adapter.md) page.
+
+## Planned Extensions
+
+SeedPass is built to support additional adapters. Planned or experimental options include:
+
+- A browser extension communicating with the API
+- Automation scripts using the CLI
+- Additional vault import/export helpers described in [JSON Entries](docs/docs/content/01-getting-started/03-json_entries.md)
+
+## Overview Diagram
+
+```mermaid
+graph TD
+    core["seedpass.core"]
+    cli["CLI / TUI"]
+    api["FastAPI server"]
+    gui["BeeWare GUI"]
+    ext["Browser extension"]
+
+    cli --> core
+    api --> core
+    gui --> core
+    ext --> api
+```
+
+All adapters depend on the same core, allowing features to evolve without duplicating logic across interfaces.

From d679d52b6614a46d1ab979c5a46c1641c27b1107 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Fri, 18 Jul 2025 08:25:07 -0400
Subject: [PATCH 16/75] Refactor manager to accept provided credentials

---
 src/seedpass/cli.py                      |  8 +++-
 src/seedpass/core/api.py                 |  8 +++-
 src/seedpass/core/manager.py             | 59 +++++++++++++++---------
 src/tests/test_background_sync_always.py |  5 +-
 src/tests/test_cli_doc_examples.py       |  4 +-
 src/tests/test_manager_seed_setup.py     | 28 ++++++++++-
 src/tests/test_parent_seed_backup.py     | 10 +---
 src/tests/test_profiles.py               |  6 +--
 src/tests/test_typer_cli.py              | 10 ++--
 9 files changed, 88 insertions(+), 50 deletions(-)

diff --git a/src/seedpass/cli.py b/src/seedpass/cli.py
index 309b3ac..81b1628 100644
--- a/src/seedpass/cli.py
+++ b/src/seedpass/cli.py
@@ -462,8 +462,9 @@ def vault_reveal_parent_seed(
 ) -> None:
     """Display the parent seed and optionally write an encrypted backup file."""
     vault_service, _profile, _sync = _get_services(ctx)
+    password = typer.prompt("Master password", hide_input=True)
     vault_service.backup_parent_seed(
-        BackupParentSeedRequest(path=Path(file) if file else None)
+        BackupParentSeedRequest(path=Path(file) if file else None, password=password)
     )
 
 
@@ -630,7 +631,10 @@ def fingerprint_remove(ctx: typer.Context, fingerprint: str) -> None:
 def fingerprint_switch(ctx: typer.Context, fingerprint: str) -> None:
     """Switch to another seed profile."""
     _vault, profile_service, _sync = _get_services(ctx)
-    profile_service.switch_profile(ProfileSwitchRequest(fingerprint=fingerprint))
+    password = typer.prompt("Master password", hide_input=True)
+    profile_service.switch_profile(
+        ProfileSwitchRequest(fingerprint=fingerprint, password=password)
+    )
 
 
 @util_app.command("generate-password")
diff --git a/src/seedpass/core/api.py b/src/seedpass/core/api.py
index 9f6671c..eeb76af 100644
--- a/src/seedpass/core/api.py
+++ b/src/seedpass/core/api.py
@@ -57,12 +57,14 @@ class BackupParentSeedRequest(BaseModel):
     """Optional path to write the encrypted seed backup."""
 
     path: Optional[Path] = None
+    password: Optional[str] = None
 
 
 class ProfileSwitchRequest(BaseModel):
     """Select a different seed profile."""
 
     fingerprint: str
+    password: Optional[str] = None
 
 
 class ProfileRemoveRequest(BaseModel):
@@ -123,7 +125,9 @@ class VaultService:
         """Backup and reveal the parent seed."""
 
         with self._lock:
-            self._manager.handle_backup_reveal_parent_seed(req.path)
+            self._manager.handle_backup_reveal_parent_seed(
+                req.path, password=req.password
+            )
 
     def stats(self) -> Dict:
         """Return statistics about the current profile."""
@@ -164,7 +168,7 @@ class ProfileService:
         """Switch to ``req.fingerprint``."""
 
         with self._lock:
-            self._manager.select_fingerprint(req.fingerprint)
+            self._manager.select_fingerprint(req.fingerprint, password=req.password)
 
 
 class SyncService:
diff --git a/src/seedpass/core/manager.py b/src/seedpass/core/manager.py
index e42c8f7..06ffa07 100644
--- a/src/seedpass/core/manager.py
+++ b/src/seedpass/core/manager.py
@@ -546,7 +546,7 @@ class PasswordManager:
             print(colored(f"Error: Failed to load parent seed: {e}", "red"))
             sys.exit(1)
 
-    def handle_switch_fingerprint(self) -> bool:
+    def handle_switch_fingerprint(self, *, password: Optional[str] = None) -> bool:
         """
         Handles switching to a different seed profile.
 
@@ -587,9 +587,10 @@ class PasswordManager:
                 return False  # Return False to indicate failure
 
             # Prompt for master password for the selected seed profile
-            password = prompt_existing_password(
-                "Enter the master password for the selected seed profile: "
-            )
+            if password is None:
+                password = prompt_existing_password(
+                    "Enter the master password for the selected seed profile: "
+                )
 
             # Set up the encryption manager with the new password and seed profile directory
             if not self.setup_encryption_manager(
@@ -676,14 +677,14 @@ class PasswordManager:
         self.update_activity()
         self.start_background_sync()
 
-    def handle_existing_seed(self) -> None:
+    def handle_existing_seed(self, *, password: Optional[str] = None) -> None:
         """
         Handles the scenario where an existing parent seed file is found.
         Prompts the user for the master password to decrypt the seed.
         """
         try:
-            # Prompt for password using masked input
-            password = prompt_existing_password("Enter your login password: ")
+            if password is None:
+                password = prompt_existing_password("Enter your login password: ")
 
             # Derive encryption key from password
             iterations = (
@@ -778,7 +779,11 @@ class PasswordManager:
             sys.exit(1)
 
     def setup_existing_seed(
-        self, method: Literal["paste", "words"] = "paste"
+        self,
+        method: Literal["paste", "words"] = "paste",
+        *,
+        seed: Optional[str] = None,
+        password: Optional[str] = None,
     ) -> Optional[str]:
         """Prompt for an existing BIP-85 seed and set it up.
 
@@ -794,7 +799,9 @@ class PasswordManager:
             The fingerprint if setup is successful, ``None`` otherwise.
         """
         try:
-            if method == "words":
+            if seed is not None:
+                parent_seed = seed
+            elif method == "words":
                 parent_seed = prompt_seed_words()
             else:
                 parent_seed = masked_input("Enter your 12-word BIP-85 seed: ").strip()
@@ -804,17 +811,21 @@ class PasswordManager:
                 print(colored("Error: Invalid BIP-85 seed phrase.", "red"))
                 sys.exit(1)
 
-            return self._finalize_existing_seed(parent_seed)
+            return self._finalize_existing_seed(parent_seed, password=password)
         except KeyboardInterrupt:
             logging.info("Operation cancelled by user.")
             self.notify("Operation cancelled by user.", level="WARNING")
             sys.exit(0)
 
-    def setup_existing_seed_word_by_word(self) -> Optional[str]:
+    def setup_existing_seed_word_by_word(
+        self, *, seed: Optional[str] = None, password: Optional[str] = None
+    ) -> Optional[str]:
         """Prompt for an existing seed one word at a time and set it up."""
-        return self.setup_existing_seed(method="words")
+        return self.setup_existing_seed(method="words", seed=seed, password=password)
 
-    def _finalize_existing_seed(self, parent_seed: str) -> Optional[str]:
+    def _finalize_existing_seed(
+        self, parent_seed: str, *, password: Optional[str] = None
+    ) -> Optional[str]:
         """Common logic for initializing an existing seed."""
         if self.validate_bip85_seed(parent_seed):
             fingerprint = self.fingerprint_manager.add_fingerprint(parent_seed)
@@ -842,7 +853,8 @@ class PasswordManager:
             logging.info(f"Current seed profile set to {fingerprint}")
 
             try:
-                password = prompt_for_password()
+                if password is None:
+                    password = prompt_for_password()
                 index_key = derive_index_key(parent_seed)
                 iterations = (
                     self.config_manager.get_kdf_iterations()
@@ -976,7 +988,9 @@ class PasswordManager:
             print(colored(f"Error: Failed to generate BIP-85 seed: {e}", "red"))
             sys.exit(1)
 
-    def save_and_encrypt_seed(self, seed: str, fingerprint_dir: Path) -> None:
+    def save_and_encrypt_seed(
+        self, seed: str, fingerprint_dir: Path, *, password: Optional[str] = None
+    ) -> None:
         """
         Saves and encrypts the parent seed.
 
@@ -988,8 +1002,8 @@ class PasswordManager:
             # Set self.fingerprint_dir
             self.fingerprint_dir = fingerprint_dir
 
-            # Prompt for password
-            password = prompt_for_password()
+            if password is None:
+                password = prompt_for_password()
 
             index_key = derive_index_key(seed)
             iterations = (
@@ -3732,7 +3746,9 @@ class PasswordManager:
             print(colored(f"Error: Failed to export 2FA codes: {e}", "red"))
             return None
 
-    def handle_backup_reveal_parent_seed(self, file: Path | None = None) -> None:
+    def handle_backup_reveal_parent_seed(
+        self, file: Path | None = None, *, password: Optional[str] = None
+    ) -> None:
         """Reveal the parent seed and optionally save an encrypted backup.
 
         Parameters
@@ -3762,9 +3778,10 @@ class PasswordManager:
             )
 
             # Verify user's identity with secure password verification
-            password = prompt_existing_password(
-                "Enter your master password to continue: "
-            )
+            if password is None:
+                password = prompt_existing_password(
+                    "Enter your master password to continue: "
+                )
             if not self.verify_password(password):
                 print(colored("Incorrect password. Operation aborted.", "red"))
                 return
diff --git a/src/tests/test_background_sync_always.py b/src/tests/test_background_sync_always.py
index e94a899..f266489 100644
--- a/src/tests/test_background_sync_always.py
+++ b/src/tests/test_background_sync_always.py
@@ -22,9 +22,6 @@ def test_switch_fingerprint_triggers_bg_sync(monkeypatch, tmp_path):
     pm.config_manager = SimpleNamespace(get_quick_unlock=lambda: False)
 
     monkeypatch.setattr("builtins.input", lambda *_a, **_k: "1")
-    monkeypatch.setattr(
-        "seedpass.core.manager.prompt_existing_password", lambda *_a, **_k: "pw"
-    )
     monkeypatch.setattr(
         PasswordManager, "setup_encryption_manager", lambda *a, **k: True
     )
@@ -39,7 +36,7 @@ def test_switch_fingerprint_triggers_bg_sync(monkeypatch, tmp_path):
 
     monkeypatch.setattr(PasswordManager, "start_background_sync", fake_bg)
 
-    assert pm.handle_switch_fingerprint()
+    assert pm.handle_switch_fingerprint(password="pw")
     assert calls["count"] == 1
 
 
diff --git a/src/tests/test_cli_doc_examples.py b/src/tests/test_cli_doc_examples.py
index f20a2f6..03f162e 100644
--- a/src/tests/test_cli_doc_examples.py
+++ b/src/tests/test_cli_doc_examples.py
@@ -43,7 +43,7 @@ class DummyPM:
         self.change_password = lambda *a, **kw: None
         self.lock_vault = lambda: None
         self.get_profile_stats = lambda: {"n": 1}
-        self.handle_backup_reveal_parent_seed = lambda path=None: None
+        self.handle_backup_reveal_parent_seed = lambda path=None, **_: None
         self.handle_verify_checksum = lambda: None
         self.handle_update_script_checksum = lambda: None
         self.add_new_fingerprint = lambda: None
@@ -76,7 +76,7 @@ class DummyPM:
         )
         self.secret_mode_enabled = True
         self.clipboard_clear_delay = 30
-        self.select_fingerprint = lambda fp: None
+        self.select_fingerprint = lambda fp, **_: None
 
 
 def load_doc_commands() -> list[str]:
diff --git a/src/tests/test_manager_seed_setup.py b/src/tests/test_manager_seed_setup.py
index 3c1a245..4759373 100644
--- a/src/tests/test_manager_seed_setup.py
+++ b/src/tests/test_manager_seed_setup.py
@@ -36,7 +36,7 @@ def test_setup_existing_seed_words(monkeypatch):
     monkeypatch.setattr(builtins, "input", lambda *_: "y")
 
     pm = PasswordManager.__new__(PasswordManager)
-    monkeypatch.setattr(pm, "_finalize_existing_seed", lambda seed: seed)
+    monkeypatch.setattr(pm, "_finalize_existing_seed", lambda seed, **_: seed)
 
     result = pm.setup_existing_seed(method="words")
     assert result == phrase
@@ -60,8 +60,32 @@ def test_setup_existing_seed_paste(monkeypatch):
     )
 
     pm = PasswordManager.__new__(PasswordManager)
-    monkeypatch.setattr(pm, "_finalize_existing_seed", lambda seed: seed)
+    monkeypatch.setattr(pm, "_finalize_existing_seed", lambda seed, **_: seed)
 
     result = pm.setup_existing_seed(method="paste")
     assert result == phrase
     assert called["prompt"].startswith("Enter your 12-word BIP-85 seed")
+
+
+def test_setup_existing_seed_with_args(monkeypatch):
+    m = Mnemonic("english")
+    phrase = m.generate(strength=128)
+
+    called = {}
+
+    monkeypatch.setattr(
+        "seedpass.core.manager.masked_input",
+        lambda *_: (_ for _ in ()).throw(RuntimeError("prompt")),
+    )
+
+    def finalize(seed, *, password=None):
+        called["seed"] = seed
+        called["pw"] = password
+        return "fp"
+
+    pm = PasswordManager.__new__(PasswordManager)
+    monkeypatch.setattr(pm, "_finalize_existing_seed", finalize)
+    result = pm.setup_existing_seed(method="paste", seed=phrase, password="pw")
+    assert result == "fp"
+    assert called["seed"] == phrase
+    assert called["pw"] == "pw"
diff --git a/src/tests/test_parent_seed_backup.py b/src/tests/test_parent_seed_backup.py
index 1f70920..a25fd4a 100644
--- a/src/tests/test_parent_seed_backup.py
+++ b/src/tests/test_parent_seed_backup.py
@@ -24,9 +24,6 @@ def _make_pm(tmp_path: Path) -> PasswordManager:
 def test_handle_backup_reveal_parent_seed_confirm(monkeypatch, tmp_path, capsys):
     pm = _make_pm(tmp_path)
 
-    monkeypatch.setattr(
-        "seedpass.core.manager.prompt_existing_password", lambda *_: "pw"
-    )
     confirms = iter([True, True])
     monkeypatch.setattr(
         "seedpass.core.manager.confirm_action", lambda *_a, **_k: next(confirms)
@@ -39,7 +36,7 @@ def test_handle_backup_reveal_parent_seed_confirm(monkeypatch, tmp_path, capsys)
     pm.encryption_manager = SimpleNamespace(encrypt_and_save_file=fake_save)
     monkeypatch.setattr(builtins, "input", lambda *_: "mybackup.enc")
 
-    pm.handle_backup_reveal_parent_seed()
+    pm.handle_backup_reveal_parent_seed(password="pw")
     out = capsys.readouterr().out
 
     assert "seed phrase" in out
@@ -50,16 +47,13 @@ def test_handle_backup_reveal_parent_seed_confirm(monkeypatch, tmp_path, capsys)
 def test_handle_backup_reveal_parent_seed_cancel(monkeypatch, tmp_path, capsys):
     pm = _make_pm(tmp_path)
 
-    monkeypatch.setattr(
-        "seedpass.core.manager.prompt_existing_password", lambda *_: "pw"
-    )
     monkeypatch.setattr("seedpass.core.manager.confirm_action", lambda *_a, **_k: False)
     saved = []
     pm.encryption_manager = SimpleNamespace(
         encrypt_and_save_file=lambda data, path: saved.append((data, path))
     )
 
-    pm.handle_backup_reveal_parent_seed()
+    pm.handle_backup_reveal_parent_seed(password="pw")
     out = capsys.readouterr().out
 
     assert "seed phrase" not in out
diff --git a/src/tests/test_profiles.py b/src/tests/test_profiles.py
index fbbf097..35e05db 100644
--- a/src/tests/test_profiles.py
+++ b/src/tests/test_profiles.py
@@ -31,10 +31,6 @@ def test_add_and_switch_fingerprint(monkeypatch):
         pm.current_fingerprint = None
 
         monkeypatch.setattr("builtins.input", lambda *_args, **_kwargs: "1")
-        monkeypatch.setattr(
-            "seedpass.core.manager.prompt_existing_password",
-            lambda *_a, **_k: "pass",
-        )
         monkeypatch.setattr(
             PasswordManager,
             "setup_encryption_manager",
@@ -50,7 +46,7 @@ def test_add_and_switch_fingerprint(monkeypatch):
             "seedpass.core.manager.NostrClient", lambda *a, **kw: object()
         )
 
-        assert pm.handle_switch_fingerprint()
+        assert pm.handle_switch_fingerprint(password="pass")
         assert pm.current_fingerprint == fingerprint
         assert fm.current_fingerprint == fingerprint
         assert pm.fingerprint_dir == expected_dir
diff --git a/src/tests/test_typer_cli.py b/src/tests/test_typer_cli.py
index cce6e6e..b0b2479 100644
--- a/src/tests/test_typer_cli.py
+++ b/src/tests/test_typer_cli.py
@@ -156,7 +156,7 @@ def test_vault_lock(monkeypatch):
 def test_vault_reveal_parent_seed(monkeypatch, tmp_path):
     called = {}
 
-    def reveal(path=None):
+    def reveal(path=None, **_):
         called["path"] = path
 
     pm = SimpleNamespace(
@@ -165,7 +165,9 @@ def test_vault_reveal_parent_seed(monkeypatch, tmp_path):
     monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
     out_path = tmp_path / "seed.enc"
     result = runner.invoke(
-        app, ["vault", "reveal-parent-seed", "--file", str(out_path)]
+        app,
+        ["vault", "reveal-parent-seed", "--file", str(out_path)],
+        input="pw\n",
     )
     assert result.exit_code == 0
     assert called["path"] == out_path
@@ -231,14 +233,14 @@ def test_fingerprint_remove(monkeypatch):
 def test_fingerprint_switch(monkeypatch):
     called = {}
 
-    def switch(fp):
+    def switch(fp, **_):
         called["fp"] = fp
 
     pm = SimpleNamespace(
         select_fingerprint=switch, fingerprint_manager=SimpleNamespace()
     )
     monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
-    result = runner.invoke(app, ["fingerprint", "switch", "def"])
+    result = runner.invoke(app, ["fingerprint", "switch", "def"], input="pw\n")
     assert result.exit_code == 0
     assert called.get("fp") == "def"
 

From d93f47e3eee7d3d6dd58d10ae8d5ce6f82bea518 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Fri, 18 Jul 2025 08:46:34 -0400
Subject: [PATCH 17/75] Add basic GUI windows and headless tests

---
 src/seedpass_gui/__init__.py   |  4 +-
 src/seedpass_gui/app.py        | 23 +++++++-----
 src/tests/test_gui_headless.py | 69 ++++++++++++++++++++++++++++++++++
 3 files changed, 85 insertions(+), 11 deletions(-)
 create mode 100644 src/tests/test_gui_headless.py

diff --git a/src/seedpass_gui/__init__.py b/src/seedpass_gui/__init__.py
index f473abc..4ef96bc 100644
--- a/src/seedpass_gui/__init__.py
+++ b/src/seedpass_gui/__init__.py
@@ -1,11 +1,11 @@
 """Graphical user interface for SeedPass."""
 
-from .app import SeedPassApp
+from .app import SeedPassApp, build
 
 
 def main() -> None:
     """Launch the GUI application."""
-    SeedPassApp().main_loop()
+    build().main_loop()
 
 
 __all__ = ["SeedPassApp", "main"]
diff --git a/src/seedpass_gui/app.py b/src/seedpass_gui/app.py
index b3b84cb..f1578f1 100644
--- a/src/seedpass_gui/app.py
+++ b/src/seedpass_gui/app.py
@@ -16,10 +16,12 @@ class LockScreenWindow(toga.Window):
     """Window prompting for the master password."""
 
     def __init__(
-        self, app: SeedPassApp, vault: VaultService, entries: EntryService
+        self, controller: SeedPassApp, vault: VaultService, entries: EntryService
     ) -> None:
         super().__init__("Unlock Vault")
-        self.app = app
+        # Store a reference to the SeedPass application instance separately from
+        # the ``toga`` ``Window.app`` attribute to avoid conflicts.
+        self.controller = controller
         self.vault = vault
         self.entries = entries
 
@@ -43,8 +45,8 @@ class LockScreenWindow(toga.Window):
         except Exception as exc:  # pragma: no cover - GUI error handling
             self.message.text = str(exc)
             return
-        main = MainWindow(self.app, self.vault, self.entries)
-        self.app.main_window = main
+        main = MainWindow(self.controller, self.vault, self.entries)
+        self.controller.main_window = main
         main.show()
         self.close()
 
@@ -53,10 +55,12 @@ class MainWindow(toga.Window):
     """Main application window showing vault entries."""
 
     def __init__(
-        self, app: SeedPassApp, vault: VaultService, entries: EntryService
+        self, controller: SeedPassApp, vault: VaultService, entries: EntryService
     ) -> None:
         super().__init__("SeedPass")
-        self.app = app
+        # ``Window.app`` is reserved for the Toga ``App`` instance. Store the
+        # SeedPass application reference separately.
+        self.controller = controller
         self.vault = vault
         self.entries = entries
 
@@ -115,7 +119,7 @@ class EntryDialog(toga.Window):
         self.username_input = toga.TextInput(style=Pack(flex=1))
         self.url_input = toga.TextInput(style=Pack(flex=1))
         self.length_input = toga.NumberInput(
-            min_value=8, max_value=128, style=Pack(width=80), value=16
+            min=8, max=128, style=Pack(width=80), value=16
         )
 
         save_button = toga.Button(
@@ -184,7 +188,8 @@ class SearchDialog(toga.Window):
 
 
 def build() -> SeedPassApp:
-    return SeedPassApp()
+    """Return a configured :class:`SeedPassApp` instance."""
+    return SeedPassApp(formal_name="SeedPass", app_id="org.seedpass.gui")
 
 
 class SeedPassApp(toga.App):
@@ -201,4 +206,4 @@ class SeedPassApp(toga.App):
 
 def main() -> None:  # pragma: no cover - GUI bootstrap
     """Run the BeeWare application."""
-    SeedPassApp().main_loop()
+    build().main_loop()
diff --git a/src/tests/test_gui_headless.py b/src/tests/test_gui_headless.py
new file mode 100644
index 0000000..67b5557
--- /dev/null
+++ b/src/tests/test_gui_headless.py
@@ -0,0 +1,69 @@
+import os
+from types import SimpleNamespace
+
+import toga
+
+from seedpass_gui.app import LockScreenWindow, MainWindow, EntryDialog
+
+
+class FakeVault:
+    def __init__(self):
+        self.called = False
+
+    def unlock(self, request):
+        self.called = True
+
+
+class FakeEntries:
+    def __init__(self):
+        self.added = []
+        self.modified = []
+
+    def list_entries(self):
+        return []
+
+    def search_entries(self, query):
+        return []
+
+    def add_entry(self, label, length, username=None, url=None):
+        self.added.append((label, length, username, url))
+        return 1
+
+    def modify_entry(self, entry_id, username=None, url=None, label=None):
+        self.modified.append((entry_id, username, url, label))
+
+
+def setup_module(module):
+    os.environ["TOGA_BACKEND"] = "toga_dummy"
+    import asyncio
+
+    asyncio.set_event_loop(asyncio.new_event_loop())
+
+
+def test_unlock_creates_main_window():
+    app = toga.App("Test", "org.example")
+    controller = SimpleNamespace(main_window=None)
+    vault = FakeVault()
+    entries = FakeEntries()
+
+    win = LockScreenWindow(controller, vault, entries)
+    win.password_input.value = "pw"
+    win.handle_unlock(None)
+
+    assert vault.called
+    assert isinstance(controller.main_window, MainWindow)
+
+
+def test_entrydialog_add_calls_service():
+    toga.App("Test2", "org.example2")
+    entries = FakeEntries()
+    main = SimpleNamespace(entries=entries, refresh_entries=lambda: None)
+
+    dlg = EntryDialog(main, None)
+    dlg.label_input.value = "L"
+    dlg.username_input.value = "u"
+    dlg.url_input.value = "x"
+    dlg.length_input.value = 12
+    dlg.save(None)
+
+    assert entries.added == [("L", 12, "u", "x")]

From 2874bf0f824b2493ca5e43b44dbe329ca387222d Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Fri, 18 Jul 2025 08:59:41 -0400
Subject: [PATCH 18/75] Install headless GUI dependencies

---
 src/requirements.txt | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/src/requirements.txt b/src/requirements.txt
index c0e094c..0c335d9 100644
--- a/src/requirements.txt
+++ b/src/requirements.txt
@@ -33,3 +33,5 @@ python-multipart
 orjson
 argon2-cffi
 toga-core>=0.5.2
+pillow
+toga-dummy>=0.5.2  # for headless GUI tests

From dcda452da8f02ac3eff3b239db66b162049440bb Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Fri, 18 Jul 2025 09:48:13 -0400
Subject: [PATCH 19/75] Add GUI getting started docs

---
 README.md                                     | 31 +++++++++++++++++++
 .../01-getting-started/06-gui_adapter.md      | 30 ++++++++++++++++++
 2 files changed, 61 insertions(+)

diff --git a/README.md b/README.md
index 2cbf771..a72ce57 100644
--- a/README.md
+++ b/README.md
@@ -229,6 +229,37 @@ seedpass list --filter totp
 
 For additional command examples, see [docs/advanced_cli.md](docs/advanced_cli.md). Details on the REST API can be found in [docs/api_reference.md](docs/api_reference.md).
 
+### Getting Started with the GUI
+
+SeedPass also ships with a simple BeeWare desktop interface. Launch it from
+your virtual environment using:
+
+```bash
+python -m seedpass_gui
+```
+
+If installed globally, you can run the `seedpass-gui` entry point instead:
+
+```bash
+seedpass-gui
+```
+
+The GUI works with the same vault and configuration files as the CLI.
+
+```mermaid
+graph TD
+    core["seedpass.core"]
+    cli["CLI"]
+    api["FastAPI server"]
+    gui["BeeWare GUI"]
+    ext["Browser Extension"]
+
+    cli --> core
+    gui --> core
+    api --> core
+    ext --> api
+```
+
 ### Vault JSON Layout
 
 The encrypted index file `seedpass_entries_db.json.enc` begins with `schema_version` `2` and stores an `entries` map keyed by entry numbers.
diff --git a/docs/docs/content/01-getting-started/06-gui_adapter.md b/docs/docs/content/01-getting-started/06-gui_adapter.md
index 3d9c9b5..c45aef5 100644
--- a/docs/docs/content/01-getting-started/06-gui_adapter.md
+++ b/docs/docs/content/01-getting-started/06-gui_adapter.md
@@ -2,6 +2,36 @@
 
 SeedPass ships with a proof-of-concept graphical interface built using [BeeWare](https://beeware.org). The GUI interacts with the same core services as the CLI by instantiating wrappers around `PasswordManager`.
 
+## Getting Started with the GUI
+
+After installing the project dependencies, launch the desktop interface with:
+
+```bash
+python -m seedpass_gui
+```
+
+If you installed the package globally, you can use the provided entry point:
+
+```bash
+seedpass-gui
+```
+
+The GUI shares the same encrypted vault and configuration as the command line tool.
+
+```mermaid
+graph TD
+    core["seedpass.core"]
+    cli["CLI"]
+    api["FastAPI server"]
+    gui["BeeWare GUI"]
+    ext["Browser Extension"]
+
+    cli --> core
+    gui --> core
+    api --> core
+    ext --> api
+```
+
 ## VaultService and EntryService
 
 `VaultService` provides thread-safe access to vault operations like exporting, importing, unlocking and locking the vault. `EntryService` exposes methods for listing, searching and modifying entries. Both classes live in `seedpass.core.api` and hold a `PasswordManager` instance protected by a `threading.Lock` to ensure safe concurrent access.

From 37c78f608a12712545528de1f567b104eef464f0 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Fri, 18 Jul 2025 10:39:52 -0400
Subject: [PATCH 20/75] Add module entry point

---
 src/seedpass_gui/__main__.py | 4 ++++
 1 file changed, 4 insertions(+)
 create mode 100644 src/seedpass_gui/__main__.py

diff --git a/src/seedpass_gui/__main__.py b/src/seedpass_gui/__main__.py
new file mode 100644
index 0000000..37c2a2d
--- /dev/null
+++ b/src/seedpass_gui/__main__.py
@@ -0,0 +1,4 @@
+from .app import main
+
+if __name__ == "__main__":
+    main()

From 7ef7246361ee9ebbbc3c586c2f11230ad97eeaea Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Fri, 18 Jul 2025 10:57:00 -0400
Subject: [PATCH 21/75] Add GUI command

---
 src/seedpass/cli.py         |  8 ++++++++
 src/tests/test_typer_cli.py | 16 ++++++++++++++++
 2 files changed, 24 insertions(+)

diff --git a/src/seedpass/cli.py b/src/seedpass/cli.py
index 81b1628..1a6c80a 100644
--- a/src/seedpass/cli.py
+++ b/src/seedpass/cli.py
@@ -682,5 +682,13 @@ def api_stop(ctx: typer.Context, host: str = "127.0.0.1", port: int = 8000) -> N
         typer.echo(f"Failed to stop server: {exc}")
 
 
+@app.command()
+def gui() -> None:
+    """Launch the BeeWare GUI."""
+    from seedpass_gui.app import main
+
+    main()
+
+
 if __name__ == "__main__":
     app()
diff --git a/src/tests/test_typer_cli.py b/src/tests/test_typer_cli.py
index b0b2479..e8e6725 100644
--- a/src/tests/test_typer_cli.py
+++ b/src/tests/test_typer_cli.py
@@ -532,3 +532,19 @@ def test_tui_forward_fingerprint(monkeypatch):
     result = runner.invoke(app, ["--fingerprint", "abc"])
     assert result.exit_code == 0
     assert called.get("fp") == "abc"
+
+
+def test_gui_command(monkeypatch):
+    called = {}
+
+    def fake_main():
+        called["called"] = True
+
+    monkeypatch.setitem(
+        sys.modules,
+        "seedpass_gui.app",
+        SimpleNamespace(main=fake_main),
+    )
+    result = runner.invoke(app, ["gui"])
+    assert result.exit_code == 0
+    assert called.get("called") is True

From 66dfb9d20541649ce1dd90b04517d5cfe33592ff Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Fri, 18 Jul 2025 11:11:13 -0400
Subject: [PATCH 22/75] Add Briefcase config and update docs

---
 .../01-getting-started/05-briefcase.md        | 15 ++++++--
 pyproject.toml                                | 35 +++++++++++++++++++
 2 files changed, 47 insertions(+), 3 deletions(-)

diff --git a/docs/docs/content/01-getting-started/05-briefcase.md b/docs/docs/content/01-getting-started/05-briefcase.md
index c461f44..e7b82aa 100644
--- a/docs/docs/content/01-getting-started/05-briefcase.md
+++ b/docs/docs/content/01-getting-started/05-briefcase.md
@@ -15,6 +15,15 @@ briefcase build
 briefcase run
 ```
 
-`briefcase create` only needs to be executed once per platform. After the
-initial creation step you can repeatedly run `briefcase build` followed by
-`briefcase run` to test your packaged application on Windows, macOS or Linux.
+## Command Overview
+
+- **`briefcase create`** — generates the project scaffold for your
+  operating system. Run this once per platform.
+- **`briefcase build`** — compiles dependencies and produces the
+  distributable bundle.
+- **`briefcase run`** — launches the packaged application so you can test
+  it locally.
+
+After the initial creation step you can repeatedly run `briefcase build`
+followed by `briefcase run` to test your packaged application on Windows,
+macOS or Linux.
diff --git a/pyproject.toml b/pyproject.toml
index 866aa67..8158005 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -10,3 +10,38 @@ seedpass-gui = "seedpass_gui.app:main"
 python_version = "3.11"
 strict = true
 mypy_path = "src"
+
+[tool.briefcase.app.seedpass-gui]
+formal-name = "SeedPass"
+description = "Deterministic password manager with a BeeWare GUI"
+sources = ["src"]
+requires = [
+    "toga-core>=0.5.2",
+    "colorama>=0.4.6",
+    "termcolor>=1.1.0",
+    "cryptography>=40.0.2",
+    "bip-utils>=2.5.0",
+    "bech32==1.2.0",
+    "coincurve>=18.0.0",
+    "mnemonic",
+    "aiohttp>=3.12.14",
+    "bcrypt",
+    "portalocker>=2.8",
+    "nostr-sdk>=0.42.1",
+    "websocket-client==1.7.0",
+    "websockets>=15.0.0",
+    "tomli",
+    "pgpy==0.6.0",
+    "pyotp>=2.8.0",
+    "pyperclip",
+    "qrcode>=8.2",
+    "typer>=0.12.3",
+    "fastapi>=0.116.0",
+    "uvicorn>=0.35.0",
+    "httpx>=0.28.1",
+    "requests>=2.32",
+    "python-multipart",
+    "orjson",
+    "argon2-cffi",
+]
+icon = "logo/png/SeedPass-Logo-24.png"

From 726e88de199723df332af341a8dce43b993ea438 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Fri, 18 Jul 2025 11:30:39 -0400
Subject: [PATCH 23/75] Remove unused run_gui script

---
 src/run_gui.py | 4 ----
 1 file changed, 4 deletions(-)
 delete mode 100644 src/run_gui.py

diff --git a/src/run_gui.py b/src/run_gui.py
deleted file mode 100644
index 9f757c7..0000000
--- a/src/run_gui.py
+++ /dev/null
@@ -1,4 +0,0 @@
-from seedpass_gui.app import main
-
-if __name__ == "__main__":
-    main()

From edea2d3a6f9d7dc00c0d5c65d4fb8584a61f9365 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Fri, 18 Jul 2025 11:45:22 -0400
Subject: [PATCH 24/75] docs: expand GUI start options

---
 README.md                                          | 14 ++++++++------
 .../content/01-getting-started/06-gui_adapter.md   | 11 +++++------
 2 files changed, 13 insertions(+), 12 deletions(-)

diff --git a/README.md b/README.md
index a72ce57..8e62cbb 100644
--- a/README.md
+++ b/README.md
@@ -232,15 +232,11 @@ For additional command examples, see [docs/advanced_cli.md](docs/advanced_cli.md
 ### Getting Started with the GUI
 
 SeedPass also ships with a simple BeeWare desktop interface. Launch it from
-your virtual environment using:
+your virtual environment using any of the following commands:
 
 ```bash
+seedpass gui
 python -m seedpass_gui
-```
-
-If installed globally, you can run the `seedpass-gui` entry point instead:
-
-```bash
 seedpass-gui
 ```
 
@@ -601,6 +597,12 @@ scripts/vendor_dependencies.sh
 pyinstaller SeedPass.spec
 ```
 
+You can also produce packaged installers for the GUI with BeeWare's Briefcase:
+
+```bash
+briefcase build
+```
+
 The standalone executable will appear in the `dist/` directory. This process works on Windows, macOS and Linux but you must build on each platform for a native binary.
 
 ## Security Considerations
diff --git a/docs/docs/content/01-getting-started/06-gui_adapter.md b/docs/docs/content/01-getting-started/06-gui_adapter.md
index c45aef5..e930307 100644
--- a/docs/docs/content/01-getting-started/06-gui_adapter.md
+++ b/docs/docs/content/01-getting-started/06-gui_adapter.md
@@ -4,20 +4,19 @@ SeedPass ships with a proof-of-concept graphical interface built using [BeeWare]
 
 ## Getting Started with the GUI
 
-After installing the project dependencies, launch the desktop interface with:
+After installing the project dependencies, launch the desktop interface with one
+of the following commands:
 
 ```bash
+seedpass gui
 python -m seedpass_gui
-```
-
-If you installed the package globally, you can use the provided entry point:
-
-```bash
 seedpass-gui
 ```
 
 The GUI shares the same encrypted vault and configuration as the command line tool.
 
+To generate a packaged binary, run `briefcase build` (after the initial `briefcase create`).
+
 ```mermaid
 graph TD
     core["seedpass.core"]

From a83001a799c0383e14fdea5f9711a2c066556785 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Fri, 18 Jul 2025 12:38:09 -0400
Subject: [PATCH 25/75] Allow unlock without explicit password

---
 src/seedpass/core/manager.py | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/src/seedpass/core/manager.py b/src/seedpass/core/manager.py
index 06ffa07..e14d55b 100644
--- a/src/seedpass/core/manager.py
+++ b/src/seedpass/core/manager.py
@@ -276,7 +276,7 @@ class PasswordManager:
         self.config_manager = None
         self.locked = True
 
-    def unlock_vault(self, password: str) -> float:
+    def unlock_vault(self, password: Optional[str] = None) -> float:
         """Unlock the vault using the provided ``password``.
 
         Parameters
@@ -292,6 +292,8 @@ class PasswordManager:
         start = time.perf_counter()
         if not self.fingerprint_dir:
             raise ValueError("Fingerprint directory not set")
+        if password is None:
+            password = prompt_existing_password(self.get_password_prompt())
         self.setup_encryption_manager(self.fingerprint_dir, password)
         self.initialize_bip85()
         self.initialize_managers()

From 4491dd35df1863e9f49ee2d6e5ea046d27dfad85 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Fri, 18 Jul 2025 13:01:31 -0400
Subject: [PATCH 26/75] Add build-system section

---
 pyproject.toml | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/pyproject.toml b/pyproject.toml
index 8158005..880a5c7 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -2,6 +2,10 @@
 name = "seedpass"
 version = "0.1.0"
 
+[build-system]
+requires = ["setuptools>=61", "wheel"]
+build-backend = "setuptools.build_meta"
+
 [project.scripts]
 seedpass = "seedpass.cli:app"
 seedpass-gui = "seedpass_gui.app:main"

From d89fa7f707539b09ad21040c2fc0f99b71f4e5c4 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Fri, 18 Jul 2025 13:31:42 -0400
Subject: [PATCH 27/75] Add test for handle_add_password

---
 src/tests/test_manager_add_password.py | 81 ++++++++++++++++++++++++++
 1 file changed, 81 insertions(+)
 create mode 100644 src/tests/test_manager_add_password.py

diff --git a/src/tests/test_manager_add_password.py b/src/tests/test_manager_add_password.py
new file mode 100644
index 0000000..641bc5d
--- /dev/null
+++ b/src/tests/test_manager_add_password.py
@@ -0,0 +1,81 @@
+import sys
+from pathlib import Path
+from tempfile import TemporaryDirectory
+from types import SimpleNamespace
+
+import pytest
+
+from helpers import create_vault, TEST_SEED, TEST_PASSWORD, dummy_nostr_client
+
+sys.path.append(str(Path(__file__).resolve().parents[1]))
+
+from seedpass.core.entry_management import EntryManager
+from seedpass.core.backup import BackupManager
+from seedpass.core.manager import PasswordManager, EncryptionMode
+from seedpass.core.config_manager import ConfigManager
+from constants import DEFAULT_PASSWORD_LENGTH
+
+
+class FakePasswordGenerator:
+    def generate_password(self, length: int, index: int) -> str:  # noqa: D401
+        return f"pw-{index}-{length}"
+
+
+def test_handle_add_password(monkeypatch, dummy_nostr_client, capsys):
+    client, _relay = dummy_nostr_client
+    with TemporaryDirectory() as tmpdir:
+        tmp_path = Path(tmpdir)
+        vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
+        cfg_mgr = ConfigManager(vault, tmp_path)
+        backup_mgr = BackupManager(tmp_path, cfg_mgr)
+        entry_mgr = EntryManager(vault, backup_mgr)
+
+        pm = PasswordManager.__new__(PasswordManager)
+        pm.encryption_mode = EncryptionMode.SEED_ONLY
+        pm.encryption_manager = enc_mgr
+        pm.vault = vault
+        pm.entry_manager = entry_mgr
+        pm.backup_manager = backup_mgr
+        pm.password_generator = FakePasswordGenerator()
+        pm.parent_seed = TEST_SEED
+        pm.nostr_client = client
+        pm.fingerprint_dir = tmp_path
+        pm.secret_mode_enabled = False
+        pm.is_dirty = False
+
+        inputs = iter(
+            [
+                "Example",  # label
+                "",  # username
+                "",  # url
+                "",  # notes
+                "",  # tags
+                "n",  # add custom field
+                "",  # length (default)
+            ]
+        )
+        monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs))
+        monkeypatch.setattr("seedpass.core.manager.pause", lambda *a, **k: None)
+        monkeypatch.setattr(pm, "start_background_vault_sync", lambda *a, **k: None)
+
+        pm.handle_add_password()
+        out = capsys.readouterr().out
+
+        entries = entry_mgr.list_entries(verbose=False)
+        assert entries == [(0, "Example", "", "", False)]
+
+        entry = entry_mgr.retrieve_entry(0)
+        assert entry == {
+            "label": "Example",
+            "length": DEFAULT_PASSWORD_LENGTH,
+            "username": "",
+            "url": "",
+            "archived": False,
+            "type": "password",
+            "kind": "password",
+            "notes": "",
+            "custom_fields": [],
+            "tags": [],
+        }
+
+        assert f"pw-0-{DEFAULT_PASSWORD_LENGTH}" in out

From d71a4912bd9db1a8f59f79d8d570ea1cd4c44a23 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Fri, 18 Jul 2025 13:51:18 -0400
Subject: [PATCH 28/75] docs: clarify secret mode clipboard behavior

---
 README.md                              |  6 +--
 src/seedpass/cli.py                    |  6 ++-
 src/seedpass/core/manager.py           | 11 +++++-
 src/tests/test_manager_add_password.py | 52 ++++++++++++++++++++++++++
 4 files changed, 70 insertions(+), 5 deletions(-)

diff --git a/README.md b/README.md
index 8e62cbb..84951ef 100644
--- a/README.md
+++ b/README.md
@@ -55,7 +55,7 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No
 - **Optional External Backup Location:** Configure a second directory where backups are automatically copied.
 - **Auto-Lock on Inactivity:** Vault locks after a configurable timeout for additional security.
 - **Quick Unlock:** Optionally skip the password prompt after verifying once.
-- **Secret Mode:** Copy retrieved passwords directly to your clipboard and automatically clear it after a delay.
+- **Secret Mode:** When enabled, newly generated and retrieved passwords are copied to your clipboard and automatically cleared after a delay.
 - **Tagging Support:** Organize entries with optional tags and find them quickly via search.
 - **Manual Vault Export/Import:** Create encrypted backups or restore them using the CLI or API.
 - **Parent Seed Backup:** Securely save an encrypted copy of the master seed.
@@ -386,11 +386,11 @@ When choosing **Add Entry**, you can now select from:
 
 ### Using Secret Mode
 
-When **Secret Mode** is enabled, SeedPass copies retrieved passwords directly to your clipboard instead of displaying them on screen. The clipboard clears automatically after the delay you choose.
+When **Secret Mode** is enabled, SeedPass copies newly generated and retrieved passwords directly to your clipboard instead of displaying them on screen. The clipboard clears automatically after the delay you choose.
 
 1. From the main menu open **Settings** and select **Toggle Secret Mode**.
 2. Choose how many seconds to keep passwords on the clipboard.
-3. Retrieve an entry and SeedPass will confirm the password was copied.
+3. Generate or retrieve an entry and SeedPass will confirm the password was copied.
 
 ### Viewing Entry Details
 
diff --git a/src/seedpass/cli.py b/src/seedpass/cli.py
index 1a6c80a..dd3f1af 100644
--- a/src/seedpass/cli.py
+++ b/src/seedpass/cli.py
@@ -527,7 +527,11 @@ def config_set(ctx: typer.Context, key: str, value: str) -> None:
 
 @config_app.command("toggle-secret-mode")
 def config_toggle_secret_mode(ctx: typer.Context) -> None:
-    """Interactively enable or disable secret mode."""
+    """Interactively enable or disable secret mode.
+
+    When enabled, newly generated and retrieved passwords are copied to the
+    clipboard instead of printed to the screen.
+    """
     service = _get_config_service(ctx)
     try:
         enabled = service.get_secret_mode_enabled()
diff --git a/src/seedpass/core/manager.py b/src/seedpass/core/manager.py
index e14d55b..ce30289 100644
--- a/src/seedpass/core/manager.py
+++ b/src/seedpass/core/manager.py
@@ -1356,7 +1356,16 @@ class PasswordManager:
                     "green",
                 )
             )
-            print(colored(f"Password for {website_name}: {password}\n", "yellow"))
+            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",
+                    )
+                )
+            else:
+                print(colored(f"Password for {website_name}: {password}\n", "yellow"))
 
             # Automatically push the updated encrypted index to Nostr so the
             # latest changes are backed up remotely.
diff --git a/src/tests/test_manager_add_password.py b/src/tests/test_manager_add_password.py
index 641bc5d..3579f48 100644
--- a/src/tests/test_manager_add_password.py
+++ b/src/tests/test_manager_add_password.py
@@ -79,3 +79,55 @@ def test_handle_add_password(monkeypatch, dummy_nostr_client, capsys):
         }
 
         assert f"pw-0-{DEFAULT_PASSWORD_LENGTH}" in out
+
+
+def test_handle_add_password_secret_mode(monkeypatch, dummy_nostr_client, capsys):
+    client, _relay = dummy_nostr_client
+    with TemporaryDirectory() as tmpdir:
+        tmp_path = Path(tmpdir)
+        vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
+        cfg_mgr = ConfigManager(vault, tmp_path)
+        backup_mgr = BackupManager(tmp_path, cfg_mgr)
+        entry_mgr = EntryManager(vault, backup_mgr)
+
+        pm = PasswordManager.__new__(PasswordManager)
+        pm.encryption_mode = EncryptionMode.SEED_ONLY
+        pm.encryption_manager = enc_mgr
+        pm.vault = vault
+        pm.entry_manager = entry_mgr
+        pm.backup_manager = backup_mgr
+        pm.password_generator = FakePasswordGenerator()
+        pm.parent_seed = TEST_SEED
+        pm.nostr_client = client
+        pm.fingerprint_dir = tmp_path
+        pm.secret_mode_enabled = True
+        pm.clipboard_clear_delay = 5
+        pm.is_dirty = False
+
+        inputs = iter(
+            [
+                "Example",  # label
+                "",  # username
+                "",  # url
+                "",  # notes
+                "",  # tags
+                "n",  # add custom field
+                "",  # length (default)
+            ]
+        )
+        monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs))
+        monkeypatch.setattr("seedpass.core.manager.pause", lambda *a, **k: None)
+        monkeypatch.setattr(pm, "start_background_vault_sync", lambda *a, **k: None)
+
+        called = []
+        monkeypatch.setattr(
+            "seedpass.core.manager.copy_to_clipboard",
+            lambda text, delay: called.append((text, delay)),
+        )
+
+        pm.handle_add_password()
+        out = capsys.readouterr().out
+
+        assert f"pw-0-{DEFAULT_PASSWORD_LENGTH}" not in out
+        assert "copied to clipboard" in out
+        assert called == [(f"pw-0-{DEFAULT_PASSWORD_LENGTH}", 5)]

From ddfe17b77bfdc91ea3ec560503d98b8f1082d890 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Fri, 18 Jul 2025 14:34:12 -0400
Subject: [PATCH 29/75] Add StateManager and relay CLI

---
 src/seedpass/cli.py                | 33 ++++++++++++
 src/seedpass/core/__init__.py      |  4 +-
 src/seedpass/core/api.py           | 18 +++++++
 src/seedpass/core/manager.py       | 21 +++++++-
 src/seedpass/core/state_manager.py | 85 ++++++++++++++++++++++++++++++
 src/tests/test_cli_relays.py       | 53 +++++++++++++++++++
 src/tests/test_state_manager.py    | 26 +++++++++
 7 files changed, 237 insertions(+), 3 deletions(-)
 create mode 100644 src/seedpass/core/state_manager.py
 create mode 100644 src/tests/test_cli_relays.py
 create mode 100644 src/tests/test_state_manager.py

diff --git a/src/seedpass/cli.py b/src/seedpass/cli.py
index dd3f1af..caf86b9 100644
--- a/src/seedpass/cli.py
+++ b/src/seedpass/cli.py
@@ -492,6 +492,39 @@ def nostr_get_pubkey(ctx: typer.Context) -> None:
     typer.echo(npub)
 
 
+@nostr_app.command("list-relays")
+def nostr_list_relays(ctx: typer.Context) -> None:
+    """Display configured Nostr relays."""
+    service = _get_nostr_service(ctx)
+    relays = service.list_relays()
+    for i, r in enumerate(relays, 1):
+        typer.echo(f"{i}: {r}")
+
+
+@nostr_app.command("add-relay")
+def nostr_add_relay(ctx: typer.Context, url: str) -> None:
+    """Add a relay URL."""
+    service = _get_nostr_service(ctx)
+    try:
+        service.add_relay(url)
+    except Exception as exc:  # pragma: no cover - pass through errors
+        typer.echo(f"Error: {exc}")
+        raise typer.Exit(code=1)
+    typer.echo("Added")
+
+
+@nostr_app.command("remove-relay")
+def nostr_remove_relay(ctx: typer.Context, idx: int) -> None:
+    """Remove a relay by index (1-based)."""
+    service = _get_nostr_service(ctx)
+    try:
+        service.remove_relay(idx)
+    except Exception as exc:  # pragma: no cover - pass through errors
+        typer.echo(f"Error: {exc}")
+        raise typer.Exit(code=1)
+    typer.echo("Removed")
+
+
 @config_app.command("get")
 def config_get(ctx: typer.Context, key: str) -> None:
     """Get a configuration value."""
diff --git a/src/seedpass/core/__init__.py b/src/seedpass/core/__init__.py
index 00d933c..4610c5e 100644
--- a/src/seedpass/core/__init__.py
+++ b/src/seedpass/core/__init__.py
@@ -4,7 +4,7 @@
 
 from importlib import import_module
 
-__all__ = ["PasswordManager", "ConfigManager", "Vault", "EntryType"]
+__all__ = ["PasswordManager", "ConfigManager", "Vault", "EntryType", "StateManager"]
 
 
 def __getattr__(name: str):
@@ -16,4 +16,6 @@ def __getattr__(name: str):
         return import_module(".vault", __name__).Vault
     if name == "EntryType":
         return import_module(".entry_types", __name__).EntryType
+    if name == "StateManager":
+        return import_module(".state_manager", __name__).StateManager
     raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
diff --git a/src/seedpass/core/api.py b/src/seedpass/core/api.py
index eeb76af..a214a2e 100644
--- a/src/seedpass/core/api.py
+++ b/src/seedpass/core/api.py
@@ -525,3 +525,21 @@ class NostrService:
     def get_pubkey(self) -> str:
         with self._lock:
             return self._manager.nostr_client.key_manager.get_npub()
+
+    def list_relays(self) -> list[str]:
+        with self._lock:
+            return self._manager.state_manager.list_relays()
+
+    def add_relay(self, url: str) -> None:
+        with self._lock:
+            self._manager.state_manager.add_relay(url)
+            self._manager.nostr_client.relays = (
+                self._manager.state_manager.list_relays()
+            )
+
+    def remove_relay(self, idx: int) -> None:
+        with self._lock:
+            self._manager.state_manager.remove_relay(idx)
+            self._manager.nostr_client.relays = (
+                self._manager.state_manager.list_relays()
+            )
diff --git a/src/seedpass/core/manager.py b/src/seedpass/core/manager.py
index ce30289..74a0da1 100644
--- a/src/seedpass/core/manager.py
+++ b/src/seedpass/core/manager.py
@@ -95,6 +95,7 @@ from utils.fingerprint_manager import FingerprintManager
 # Import NostrClient
 from nostr.client import NostrClient, DEFAULT_RELAYS
 from .config_manager import ConfigManager
+from .state_manager import StateManager
 
 # Instantiate the logger
 logger = logging.getLogger(__name__)
@@ -140,6 +141,7 @@ class PasswordManager:
         self.bip85: Optional[BIP85] = None
         self.nostr_client: Optional[NostrClient] = None
         self.config_manager: Optional[ConfigManager] = None
+        self.state_manager: Optional[StateManager] = None
         self.notifications: queue.Queue[Notification] = queue.Queue()
         self._current_notification: Optional[Notification] = None
         self._notification_expiry: float = 0.0
@@ -157,6 +159,8 @@ class PasswordManager:
         self.last_unlock_duration: float | None = None
         self.verbose_timing: bool = False
         self._suppress_entry_actions_menu: bool = False
+        self.last_bip85_idx: int = 0
+        self.last_sync_ts: int = 0
 
         # Initialize the fingerprint manager first
         self.initialize_fingerprint_manager()
@@ -1073,6 +1077,7 @@ class PasswordManager:
                 vault=self.vault,
                 fingerprint_dir=self.fingerprint_dir,
             )
+            self.state_manager = StateManager(self.fingerprint_dir)
             self.backup_manager = BackupManager(
                 fingerprint_dir=self.fingerprint_dir,
                 config_manager=self.config_manager,
@@ -1091,7 +1096,15 @@ class PasswordManager:
 
             # Load relay configuration and initialize NostrClient
             config = self.config_manager.load_config()
-            relay_list = config.get("relays", list(DEFAULT_RELAYS))
+            if getattr(self, "state_manager", None) is not None:
+                state = self.state_manager.state
+                relay_list = state.get("relays", list(DEFAULT_RELAYS))
+                self.last_bip85_idx = state.get("last_bip85_idx", 0)
+                self.last_sync_ts = state.get("last_sync_ts", 0)
+            else:
+                relay_list = list(DEFAULT_RELAYS)
+                self.last_bip85_idx = 0
+                self.last_sync_ts = 0
             self.offline_mode = bool(config.get("offline_mode", False))
             self.inactivity_timeout = config.get(
                 "inactivity_timeout", INACTIVITY_TIMEOUT
@@ -3966,7 +3979,11 @@ class PasswordManager:
             self.password_generator.encryption_manager = new_enc_mgr
             self.store_hashed_password(new_password)
 
-            relay_list = config_data.get("relays", list(DEFAULT_RELAYS))
+            if getattr(self, "state_manager", None) is not None:
+                state = self.state_manager.state
+                relay_list = state.get("relays", list(DEFAULT_RELAYS))
+            else:
+                relay_list = list(DEFAULT_RELAYS)
             self.nostr_client = NostrClient(
                 encryption_manager=self.encryption_manager,
                 fingerprint=self.current_fingerprint,
diff --git a/src/seedpass/core/state_manager.py b/src/seedpass/core/state_manager.py
new file mode 100644
index 0000000..8d142f9
--- /dev/null
+++ b/src/seedpass/core/state_manager.py
@@ -0,0 +1,85 @@
+from __future__ import annotations
+
+import json
+import os
+from pathlib import Path
+from typing import List
+
+from utils.file_lock import exclusive_lock, shared_lock
+from nostr.client import DEFAULT_RELAYS
+
+
+class StateManager:
+    """Persist simple state values per profile."""
+
+    STATE_FILENAME = "seedpass_state.json"
+
+    def __init__(self, fingerprint_dir: Path) -> None:
+        self.fingerprint_dir = Path(fingerprint_dir)
+        self.state_path = self.fingerprint_dir / self.STATE_FILENAME
+
+    def _load(self) -> dict:
+        if not self.state_path.exists():
+            return {
+                "last_bip85_idx": 0,
+                "last_sync_ts": 0,
+                "relays": list(DEFAULT_RELAYS),
+            }
+        with shared_lock(self.state_path) as fh:
+            fh.seek(0)
+            data = fh.read()
+        if not data:
+            return {
+                "last_bip85_idx": 0,
+                "last_sync_ts": 0,
+                "relays": list(DEFAULT_RELAYS),
+            }
+        try:
+            obj = json.loads(data.decode())
+        except Exception:
+            obj = {}
+        obj.setdefault("last_bip85_idx", 0)
+        obj.setdefault("last_sync_ts", 0)
+        obj.setdefault("relays", list(DEFAULT_RELAYS))
+        return obj
+
+    def _save(self, data: dict) -> None:
+        with exclusive_lock(self.state_path) as fh:
+            fh.seek(0)
+            fh.truncate()
+            fh.write(json.dumps(data, separators=(",", ":")).encode())
+            fh.flush()
+            os.fsync(fh.fileno())
+
+    @property
+    def state(self) -> dict:
+        return self._load()
+
+    def update_state(self, **kwargs) -> None:
+        data = self._load()
+        data.update(kwargs)
+        self._save(data)
+
+    # Relay helpers
+    def list_relays(self) -> List[str]:
+        return self._load().get("relays", [])
+
+    def add_relay(self, url: str) -> None:
+        data = self._load()
+        relays = data.get("relays", [])
+        if url in relays:
+            raise ValueError("Relay already present")
+        relays.append(url)
+        data["relays"] = relays
+        self._save(data)
+
+    def remove_relay(self, idx: int) -> None:
+        data = self._load()
+        relays = data.get("relays", [])
+        if not 1 <= idx <= len(relays):
+            raise ValueError("Invalid index")
+        if len(relays) == 1:
+            raise ValueError("At least one relay required")
+        relays.pop(idx - 1)
+        data["relays"] = relays
+        self._save(data)
diff --git a/src/tests/test_cli_relays.py b/src/tests/test_cli_relays.py
new file mode 100644
index 0000000..fcfe5fc
--- /dev/null
+++ b/src/tests/test_cli_relays.py
@@ -0,0 +1,53 @@
+from types import SimpleNamespace
+from typer.testing import CliRunner
+
+from seedpass.cli import app
+from seedpass import cli
+
+
+class DummyService:
+    def __init__(self, relays):
+        self.relays = relays
+
+    def get_pubkey(self):
+        return "npub"
+
+    def list_relays(self):
+        return self.relays
+
+    def add_relay(self, url):
+        if url in self.relays:
+            raise ValueError("exists")
+        self.relays.append(url)
+
+    def remove_relay(self, idx):
+        if not 1 <= idx <= len(self.relays):
+            raise ValueError("bad")
+        if len(self.relays) == 1:
+            raise ValueError("min")
+        self.relays.pop(idx - 1)
+
+
+runner = CliRunner()
+
+
+def test_cli_relay_crud(monkeypatch):
+    relays = ["wss://a"]
+
+    def pm_factory(*a, **k):
+        return SimpleNamespace()
+
+    monkeypatch.setattr(cli, "PasswordManager", pm_factory)
+    monkeypatch.setattr(cli, "NostrService", lambda pm: DummyService(relays))
+
+    result = runner.invoke(app, ["nostr", "list-relays"])
+    assert "1: wss://a" in result.stdout
+
+    result = runner.invoke(app, ["nostr", "add-relay", "wss://b"])
+    assert result.exit_code == 0
+    assert "Added" in result.stdout
+    assert relays == ["wss://a", "wss://b"]
+
+    result = runner.invoke(app, ["nostr", "remove-relay", "1"])
+    assert result.exit_code == 0
+    assert relays == ["wss://b"]
diff --git a/src/tests/test_state_manager.py b/src/tests/test_state_manager.py
new file mode 100644
index 0000000..0aef6d6
--- /dev/null
+++ b/src/tests/test_state_manager.py
@@ -0,0 +1,26 @@
+from tempfile import TemporaryDirectory
+from pathlib import Path
+
+from seedpass.core.state_manager import StateManager
+from nostr.client import DEFAULT_RELAYS
+
+
+def test_state_manager_round_trip():
+    with TemporaryDirectory() as tmpdir:
+        sm = StateManager(Path(tmpdir))
+        state = sm.state
+        assert state["relays"] == list(DEFAULT_RELAYS)
+        assert state["last_bip85_idx"] == 0
+        assert state["last_sync_ts"] == 0
+
+        sm.add_relay("wss://example.com")
+        sm.update_state(last_bip85_idx=5, last_sync_ts=123)
+
+        sm2 = StateManager(Path(tmpdir))
+        state2 = sm2.state
+        assert "wss://example.com" in state2["relays"]
+        assert state2["last_bip85_idx"] == 5
+        assert state2["last_sync_ts"] == 123
+
+        sm2.remove_relay(1)  # remove first default relay
+        assert len(sm2.list_relays()) == len(DEFAULT_RELAYS)

From b0ba723bdd5a042587627332428f080d6c55b35c Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Fri, 18 Jul 2025 14:54:10 -0400
Subject: [PATCH 30/75] Extend entry search filtering

---
 src/seedpass/cli.py                   | 16 ++++-
 src/seedpass/core/api.py              | 16 ++++-
 src/seedpass/core/entry_management.py | 88 ++++++++-------------------
 src/tests/test_cli_core_services.py   | 10 +--
 src/tests/test_cli_doc_examples.py    |  2 +-
 src/tests/test_core_services.py       |  8 +--
 src/tests/test_gui_headless.py        |  2 +-
 src/tests/test_key_value_entry.py     |  2 +-
 src/tests/test_search_entries.py      | 28 +++++++--
 src/tests/test_typer_cli.py           |  4 +-
 10 files changed, 90 insertions(+), 86 deletions(-)

diff --git a/src/seedpass/cli.py b/src/seedpass/cli.py
index caf86b9..f423dea 100644
--- a/src/seedpass/cli.py
+++ b/src/seedpass/cli.py
@@ -1,5 +1,5 @@
 from pathlib import Path
-from typing import Optional
+from typing import Optional, List
 import json
 
 import typer
@@ -135,10 +135,20 @@ def entry_list(
 
 
 @entry_app.command("search")
-def entry_search(ctx: typer.Context, query: str) -> None:
+def entry_search(
+    ctx: typer.Context,
+    query: str,
+    kind: List[str] = typer.Option(
+        None,
+        "--kind",
+        "-k",
+        help="Filter by entry kinds (can be repeated)",
+    ),
+) -> None:
     """Search entries."""
     service = _get_entry_service(ctx)
-    results = service.search_entries(query)
+    kinds = list(kind) if kind else None
+    results = service.search_entries(query, kinds=kinds)
     if not results:
         typer.echo("No matching entries found")
         return
diff --git a/src/seedpass/core/api.py b/src/seedpass/core/api.py
index a214a2e..6acb958 100644
--- a/src/seedpass/core/api.py
+++ b/src/seedpass/core/api.py
@@ -220,9 +220,21 @@ class EntryService:
                 include_archived=include_archived,
             )
 
-    def search_entries(self, query: str):
+    def search_entries(
+        self, query: str, kinds: list[str] | None = None
+    ) -> list[tuple[int, str, str | None, str | None, bool]]:
+        """Search entries optionally filtering by ``kinds``.
+
+        Parameters
+        ----------
+        query:
+            Search string to match against entry metadata.
+        kinds:
+            Optional list of entry kinds to restrict the search.
+        """
+
         with self._lock:
-            return self._manager.entry_manager.search_entries(query)
+            return self._manager.entry_manager.search_entries(query, kinds=kinds)
 
     def retrieve_entry(self, entry_id: int):
         with self._lock:
diff --git a/src/seedpass/core/entry_management.py b/src/seedpass/core/entry_management.py
index 7a9673e..ad0bc2e 100644
--- a/src/seedpass/core/entry_management.py
+++ b/src/seedpass/core/entry_management.py
@@ -1045,9 +1045,10 @@ class EntryManager:
             return []
 
     def search_entries(
-        self, query: str
+        self, query: str, kinds: List[str] | None = None
     ) -> List[Tuple[int, str, Optional[str], Optional[str], bool]]:
-        """Return entries matching the query across common fields."""
+        """Return entries matching ``query`` across whitelisted metadata fields."""
+
         data = self._load_index()
         entries_data = data.get("entries", {})
 
@@ -1059,74 +1060,33 @@ class EntryManager:
 
         for idx, entry in sorted(entries_data.items(), key=lambda x: int(x[0])):
             etype = entry.get("type", entry.get("kind", EntryType.PASSWORD.value))
+
+            if kinds is not None and etype not in kinds:
+                continue
+
             label = entry.get("label", entry.get("website", ""))
-            notes = entry.get("notes", "")
+            username = (
+                entry.get("username", "") if etype == EntryType.PASSWORD.value else None
+            )
+            url = entry.get("url", "") if etype == EntryType.PASSWORD.value else None
             tags = entry.get("tags", [])
+            archived = entry.get("archived", entry.get("blacklisted", False))
+
             label_match = query_lower in label.lower()
-            notes_match = query_lower in notes.lower()
+            username_match = bool(username) and query_lower in username.lower()
+            url_match = bool(url) and query_lower in url.lower()
             tags_match = any(query_lower in str(t).lower() for t in tags)
 
-            if etype == EntryType.PASSWORD.value:
-                username = entry.get("username", "")
-                url = entry.get("url", "")
-                custom_fields = entry.get("custom_fields", [])
-                custom_match = any(
-                    query_lower in str(cf.get("label", "")).lower()
-                    or query_lower in str(cf.get("value", "")).lower()
-                    for cf in custom_fields
+            if label_match or username_match or url_match or tags_match:
+                results.append(
+                    (
+                        int(idx),
+                        label,
+                        username if username is not None else None,
+                        url if url is not None else None,
+                        archived,
+                    )
                 )
-                if (
-                    label_match
-                    or query_lower in username.lower()
-                    or query_lower in url.lower()
-                    or notes_match
-                    or custom_match
-                    or tags_match
-                ):
-                    results.append(
-                        (
-                            int(idx),
-                            label,
-                            username,
-                            url,
-                            entry.get("archived", entry.get("blacklisted", False)),
-                        )
-                    )
-            elif etype in (EntryType.KEY_VALUE.value, EntryType.MANAGED_ACCOUNT.value):
-                value_field = str(entry.get("value", ""))
-                custom_fields = entry.get("custom_fields", [])
-                custom_match = any(
-                    query_lower in str(cf.get("label", "")).lower()
-                    or query_lower in str(cf.get("value", "")).lower()
-                    for cf in custom_fields
-                )
-                if (
-                    label_match
-                    or query_lower in value_field.lower()
-                    or notes_match
-                    or custom_match
-                    or tags_match
-                ):
-                    results.append(
-                        (
-                            int(idx),
-                            label,
-                            None,
-                            None,
-                            entry.get("archived", entry.get("blacklisted", False)),
-                        )
-                    )
-            else:
-                if label_match or notes_match or tags_match:
-                    results.append(
-                        (
-                            int(idx),
-                            label,
-                            None,
-                            None,
-                            entry.get("archived", entry.get("blacklisted", False)),
-                        )
-                    )
 
         return results
 
diff --git a/src/tests/test_cli_core_services.py b/src/tests/test_cli_core_services.py
index 45a6d9d..fd68b9a 100644
--- a/src/tests/test_cli_core_services.py
+++ b/src/tests/test_cli_core_services.py
@@ -32,8 +32,8 @@ def test_cli_entry_add_search_sync(monkeypatch):
         calls["add"] = (label, length, username, url)
         return 1
 
-    def search_entries(q):
-        calls["search"] = q
+    def search_entries(q, kinds=None):
+        calls["search"] = (q, kinds)
         return [(1, "Label", None, None, False)]
 
     def sync_vault():
@@ -57,10 +57,12 @@ def test_cli_entry_add_search_sync(monkeypatch):
     assert calls.get("sync") is True
 
     # entry search
-    result = runner.invoke(app, ["entry", "search", "lab"])
+    result = runner.invoke(
+        app, ["entry", "search", "lab", "--kind", "password", "--kind", "totp"]
+    )
     assert result.exit_code == 0
     assert "Label" in result.stdout
-    assert calls["search"] == "lab"
+    assert calls["search"] == ("lab", ["password", "totp"])
 
     # nostr sync
     result = runner.invoke(app, ["nostr", "sync"])
diff --git a/src/tests/test_cli_doc_examples.py b/src/tests/test_cli_doc_examples.py
index 03f162e..7f4ce82 100644
--- a/src/tests/test_cli_doc_examples.py
+++ b/src/tests/test_cli_doc_examples.py
@@ -17,7 +17,7 @@ class DummyPM:
             list_entries=lambda sort_by="index", filter_kind=None, include_archived=False: [
                 (1, "Label", "user", "url", False)
             ],
-            search_entries=lambda q: [(1, "GitHub", "user", "", False)],
+            search_entries=lambda q, kinds=None: [(1, "GitHub", "user", "", False)],
             retrieve_entry=lambda idx: {"type": EntryType.PASSWORD.value, "length": 8},
             get_totp_code=lambda idx, seed: "123456",
             add_entry=lambda label, length, username, url: 1,
diff --git a/src/tests/test_core_services.py b/src/tests/test_core_services.py
index d71a859..dc419c4 100644
--- a/src/tests/test_core_services.py
+++ b/src/tests/test_core_services.py
@@ -25,8 +25,8 @@ def test_entry_service_add_entry_and_search():
         called["add"] = (label, length, username, url)
         return 5
 
-    def search_entries(q):
-        called["search"] = q
+    def search_entries(q, kinds=None):
+        called["search"] = (q, kinds)
         return [(5, "Example", username, url, False)]
 
     def sync_vault():
@@ -46,9 +46,9 @@ def test_entry_service_add_entry_and_search():
     assert called["add"] == ("Example", 12, username, url)
     assert called.get("sync") is True
 
-    results = service.search_entries("ex")
+    results = service.search_entries("ex", kinds=["password"])
     assert results == [(5, "Example", username, url, False)]
-    assert called["search"] == "ex"
+    assert called["search"] == ("ex", ["password"])
 
 
 def test_sync_service_sync():
diff --git a/src/tests/test_gui_headless.py b/src/tests/test_gui_headless.py
index 67b5557..8801064 100644
--- a/src/tests/test_gui_headless.py
+++ b/src/tests/test_gui_headless.py
@@ -22,7 +22,7 @@ class FakeEntries:
     def list_entries(self):
         return []
 
-    def search_entries(self, query):
+    def search_entries(self, query, kinds=None):
         return []
 
     def add_entry(self, label, length, username=None, url=None):
diff --git a/src/tests/test_key_value_entry.py b/src/tests/test_key_value_entry.py
index 86a4629..ededd03 100644
--- a/src/tests/test_key_value_entry.py
+++ b/src/tests/test_key_value_entry.py
@@ -41,4 +41,4 @@ def test_add_and_modify_key_value():
         assert updated["value"] == "def456"
 
         results = em.search_entries("def456")
-        assert results == [(idx, "API", None, None, False)]
+        assert results == []
diff --git a/src/tests/test_search_entries.py b/src/tests/test_search_entries.py
index 5e3f921..9db94a2 100644
--- a/src/tests/test_search_entries.py
+++ b/src/tests/test_search_entries.py
@@ -9,6 +9,7 @@ sys.path.append(str(Path(__file__).resolve().parents[1]))
 from seedpass.core.entry_management import EntryManager
 from seedpass.core.backup import BackupManager
 from seedpass.core.config_manager import ConfigManager
+from seedpass.core.entry_types import EntryType
 
 
 def setup_entry_manager(tmp_path: Path) -> EntryManager:
@@ -64,11 +65,12 @@ def test_search_by_notes_and_totp():
         idx_totp = entry_mgr.search_entries("GH")[0][0]
         entry_mgr.modify_entry(idx_totp, notes="otp note")
 
+        # notes are no longer searchable
         res_notes = entry_mgr.search_entries("secret")
-        assert res_notes == [(idx_pw, "Site", "", "", False)]
+        assert res_notes == []
 
         res_totp = entry_mgr.search_entries("otp")
-        assert res_totp == [(idx_totp, "GH", None, None, False)]
+        assert res_totp == []
 
 
 def test_search_by_custom_field():
@@ -83,7 +85,7 @@ def test_search_by_custom_field():
         idx = entry_mgr.add_entry("Example", 8, custom_fields=custom)
 
         result = entry_mgr.search_entries("secret123")
-        assert result == [(idx, "Example", "", "", False)]
+        assert result == []
 
 
 def test_search_key_value_value():
@@ -94,7 +96,7 @@ def test_search_key_value_value():
         idx = entry_mgr.add_key_value("API", "token123")
 
         result = entry_mgr.search_entries("token123")
-        assert result == [(idx, "API", None, None, False)]
+        assert result == []
 
 
 def test_search_no_results():
@@ -128,3 +130,21 @@ def test_search_by_tag_totp():
 
         result = entry_mgr.search_entries("mfa")
         assert result == [(idx, "OTPAccount", None, None, False)]
+
+
+def test_search_with_kind_filter():
+    with TemporaryDirectory() as tmpdir:
+        tmp_path = Path(tmpdir)
+        entry_mgr = setup_entry_manager(tmp_path)
+
+        idx_pw = entry_mgr.add_entry("Site", 8)
+        entry_mgr.add_totp("OTP", TEST_SEED)
+        idx_totp = entry_mgr.search_entries("OTP")[0][0]
+
+        all_results = entry_mgr.search_entries(
+            "", kinds=[EntryType.PASSWORD.value, EntryType.TOTP.value]
+        )
+        assert {r[0] for r in all_results} == {idx_pw, idx_totp}
+
+        only_pw = entry_mgr.search_entries("", kinds=[EntryType.PASSWORD.value])
+        assert only_pw == [(idx_pw, "Site", "", "", False)]
diff --git a/src/tests/test_typer_cli.py b/src/tests/test_typer_cli.py
index e8e6725..eb71f62 100644
--- a/src/tests/test_typer_cli.py
+++ b/src/tests/test_typer_cli.py
@@ -34,7 +34,7 @@ def test_entry_list(monkeypatch):
 def test_entry_search(monkeypatch):
     pm = SimpleNamespace(
         entry_manager=SimpleNamespace(
-            search_entries=lambda q: [(1, "L", None, None, False)]
+            search_entries=lambda q, kinds=None: [(1, "L", None, None, False)]
         ),
         select_fingerprint=lambda fp: None,
     )
@@ -45,7 +45,7 @@ def test_entry_search(monkeypatch):
 
 
 def test_entry_get_password(monkeypatch):
-    def search(q):
+    def search(q, kinds=None):
         return [(2, "Example", "", "", False)]
 
     entry = {"type": EntryType.PASSWORD.value, "length": 8}

From b42ad0561ca3cf665a75f886dad50855b3e57255 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Fri, 18 Jul 2025 15:22:26 -0400
Subject: [PATCH 31/75] Add sorting options to entry listing

---
 src/seedpass/cli.py                        |  2 +-
 src/seedpass/core/entry_management.py      | 24 +++++++++++++++-------
 src/tests/test_list_entries_sort_filter.py | 22 ++++++++++++--------
 3 files changed, 32 insertions(+), 16 deletions(-)

diff --git a/src/seedpass/cli.py b/src/seedpass/cli.py
index f423dea..620678c 100644
--- a/src/seedpass/cli.py
+++ b/src/seedpass/cli.py
@@ -113,7 +113,7 @@ def main(ctx: typer.Context, fingerprint: Optional[str] = fingerprint_option) ->
 def entry_list(
     ctx: typer.Context,
     sort: str = typer.Option(
-        "index", "--sort", help="Sort by 'index', 'label', or 'username'"
+        "index", "--sort", help="Sort by 'index', 'label', or 'updated'"
     ),
     kind: Optional[str] = typer.Option(None, "--kind", help="Filter by entry type"),
     archived: bool = typer.Option(False, "--archived", help="Include archived"),
diff --git a/src/seedpass/core/entry_management.py b/src/seedpass/core/entry_management.py
index ad0bc2e..406cfa8 100644
--- a/src/seedpass/core/entry_management.py
+++ b/src/seedpass/core/entry_management.py
@@ -922,10 +922,17 @@ class EntryManager:
         include_archived: bool = False,
         verbose: bool = True,
     ) -> List[Tuple[int, str, Optional[str], Optional[str], bool]]:
-        """List entries in the index with optional sorting and filtering.
+        """List entries sorted and filtered according to the provided options.
 
-        By default archived entries are omitted unless ``include_archived`` is
-        ``True``.
+        Parameters
+        ----------
+        sort_by:
+            Field to sort by. Supported values are ``"index"``, ``"label"`` and
+            ``"updated"``.
+        filter_kind:
+            Optional entry kind to restrict the results.
+
+        Archived entries are omitted unless ``include_archived`` is ``True``.
         """
         try:
             data = self._load_index()
@@ -941,11 +948,14 @@ class EntryManager:
                 idx_str, entry = item
                 if sort_by == "index":
                     return int(idx_str)
-                if sort_by in {"website", "label"}:
+                if sort_by == "label":
+                    # labels are stored in the index so no additional
+                    # decryption is required when sorting
                     return entry.get("label", entry.get("website", "")).lower()
-                if sort_by == "username":
-                    return entry.get("username", "").lower()
-                raise ValueError("sort_by must be 'index', 'label', or 'username'")
+                if sort_by == "updated":
+                    # sort newest first
+                    return -int(entry.get("updated", 0))
+                raise ValueError("sort_by must be 'index', 'label', or 'updated'")
 
             sorted_items = sorted(entries_data.items(), key=sort_key)
 
diff --git a/src/tests/test_list_entries_sort_filter.py b/src/tests/test_list_entries_sort_filter.py
index 68d693f..4361008 100644
--- a/src/tests/test_list_entries_sort_filter.py
+++ b/src/tests/test_list_entries_sort_filter.py
@@ -19,29 +19,35 @@ def setup_entry_manager(tmp_path: Path) -> EntryManager:
     return EntryManager(vault, backup_mgr)
 
 
-def test_sort_by_website():
+def test_sort_by_label():
     with TemporaryDirectory() as tmpdir:
         tmp_path = Path(tmpdir)
         em = setup_entry_manager(tmp_path)
         idx0 = em.add_entry("b.com", 8, "user1")
         idx1 = em.add_entry("A.com", 8, "user2")
-        result = em.list_entries(sort_by="website")
+        result = em.list_entries(sort_by="label")
         assert result == [
             (idx1, "A.com", "user2", "", False),
             (idx0, "b.com", "user1", "", False),
         ]
 
 
-def test_sort_by_username():
+def test_sort_by_updated():
     with TemporaryDirectory() as tmpdir:
         tmp_path = Path(tmpdir)
         em = setup_entry_manager(tmp_path)
-        idx0 = em.add_entry("alpha.com", 8, "Charlie")
-        idx1 = em.add_entry("beta.com", 8, "alice")
-        result = em.list_entries(sort_by="username")
+        idx0 = em.add_entry("alpha.com", 8, "u0")
+        idx1 = em.add_entry("beta.com", 8, "u1")
+
+        data = em._load_index(force_reload=True)
+        data["entries"][str(idx0)]["updated"] = 1
+        data["entries"][str(idx1)]["updated"] = 2
+        em._save_index(data)
+
+        result = em.list_entries(sort_by="updated")
         assert result == [
-            (idx1, "beta.com", "alice", "", False),
-            (idx0, "alpha.com", "Charlie", "", False),
+            (idx1, "beta.com", "u1", "", False),
+            (idx0, "alpha.com", "u0", "", False),
         ]
 
 

From 11b370708747e79eead81327eb3cebe634776a4b Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Fri, 18 Jul 2025 15:39:19 -0400
Subject: [PATCH 32/75] Add AuthGuard for inactivity locking and CLI lock
 command

---
 src/seedpass/cli.py               |  8 ++++++++
 src/seedpass/core/manager.py      | 26 +++++++++++++++++++++++++-
 src/tests/test_inactivity_lock.py | 29 +++++++++++++++++++++++++++++
 src/tests/test_typer_cli.py       | 17 +++++++++++++++++
 4 files changed, 79 insertions(+), 1 deletion(-)

diff --git a/src/seedpass/cli.py b/src/seedpass/cli.py
index 620678c..4b9a917 100644
--- a/src/seedpass/cli.py
+++ b/src/seedpass/cli.py
@@ -455,6 +455,14 @@ def vault_lock(ctx: typer.Context) -> None:
     typer.echo("locked")
 
 
+@app.command("lock")
+def root_lock(ctx: typer.Context) -> None:
+    """Lock the vault for the active profile."""
+    vault_service, _profile, _sync = _get_services(ctx)
+    vault_service.lock()
+    typer.echo("locked")
+
+
 @vault_app.command("stats")
 def vault_stats(ctx: typer.Context) -> None:
     """Display statistics about the current seed profile."""
diff --git a/src/seedpass/core/manager.py b/src/seedpass/core/manager.py
index 74a0da1..a707391 100644
--- a/src/seedpass/core/manager.py
+++ b/src/seedpass/core/manager.py
@@ -109,6 +109,24 @@ class Notification:
     level: str = "INFO"
 
 
+class AuthGuard:
+    """Helper to enforce inactivity timeouts."""
+
+    def __init__(
+        self, manager: "PasswordManager", time_fn: callable = time.time
+    ) -> None:
+        self.manager = manager
+        self._time_fn = time_fn
+
+    def check_timeout(self) -> None:
+        """Lock the vault if the inactivity timeout has been exceeded."""
+        timeout = getattr(self.manager, "inactivity_timeout", 0)
+        if self.manager.locked or timeout <= 0:
+            return
+        if self._time_fn() - self.manager.last_activity > timeout:
+            self.manager.lock_vault()
+
+
 class PasswordManager:
     """
     PasswordManager Class
@@ -161,6 +179,7 @@ class PasswordManager:
         self._suppress_entry_actions_menu: bool = False
         self.last_bip85_idx: int = 0
         self.last_sync_ts: int = 0
+        self.auth_guard = AuthGuard(self)
 
         # Initialize the fingerprint manager first
         self.initialize_fingerprint_manager()
@@ -240,7 +259,12 @@ class PasswordManager:
         return (None, parent_fp, self.current_fingerprint)
 
     def update_activity(self) -> None:
-        """Record the current time as the last user activity."""
+        """Record activity and enforce inactivity timeout."""
+        guard = getattr(self, "auth_guard", None)
+        if guard is None:
+            guard = AuthGuard(self)
+            self.auth_guard = guard
+        guard.check_timeout()
         self.last_activity = time.time()
 
     def notify(self, message: str, level: str = "INFO") -> None:
diff --git a/src/tests/test_inactivity_lock.py b/src/tests/test_inactivity_lock.py
index 32d81da..2befaed 100644
--- a/src/tests/test_inactivity_lock.py
+++ b/src/tests/test_inactivity_lock.py
@@ -91,3 +91,32 @@ def test_input_timeout_triggers_lock(monkeypatch):
 
     assert locked["locked"] == 1
     assert locked["unlocked"] == 1
+
+
+def test_update_activity_checks_timeout(monkeypatch):
+    """AuthGuard in update_activity locks the vault after inactivity."""
+    import seedpass.core.manager as manager
+
+    now = {"val": 0.0}
+    monkeypatch.setattr(manager.time, "time", lambda: now["val"])
+
+    pm = manager.PasswordManager.__new__(manager.PasswordManager)
+    pm.inactivity_timeout = 0.5
+    pm.last_activity = 0.0
+    pm.locked = False
+    called = {}
+
+    def lock():
+        called["locked"] = True
+        pm.locked = True
+
+    pm.lock_vault = lock
+    pm.auth_guard = manager.AuthGuard(pm, time_fn=lambda: now["val"])
+
+    now["val"] = 0.4
+    pm.update_activity()
+    assert not called
+
+    now["val"] = 1.1
+    pm.update_activity()
+    assert called["locked"] is True
diff --git a/src/tests/test_typer_cli.py b/src/tests/test_typer_cli.py
index eb71f62..3d51993 100644
--- a/src/tests/test_typer_cli.py
+++ b/src/tests/test_typer_cli.py
@@ -153,6 +153,23 @@ def test_vault_lock(monkeypatch):
     assert pm.locked is True
 
 
+def test_root_lock(monkeypatch):
+    called = {}
+
+    def lock():
+        called["locked"] = True
+        pm.locked = True
+
+    pm = SimpleNamespace(
+        lock_vault=lock, locked=False, select_fingerprint=lambda fp: None
+    )
+    monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
+    result = runner.invoke(app, ["lock"])
+    assert result.exit_code == 0
+    assert called.get("locked") is True
+    assert pm.locked is True
+
+
 def test_vault_reveal_parent_seed(monkeypatch, tmp_path):
     called = {}
 

From 29690d7c7b65f12940a7a45e2d435478709ceb34 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Fri, 18 Jul 2025 16:05:00 -0400
Subject: [PATCH 33/75] Add vault profile export/import

---
 docs/docs/content/index.md              |  4 +-
 src/seedpass/cli.py                     | 12 +++---
 src/seedpass/core/api.py                | 20 ++++++++++
 src/tests/test_cli_doc_examples.py      |  2 +
 src/tests/test_profile_export_import.py | 33 +++++++++++++++++
 src/tests/test_typer_cli.py             | 49 +++++++++++--------------
 6 files changed, 85 insertions(+), 35 deletions(-)
 create mode 100644 src/tests/test_profile_export_import.py

diff --git a/docs/docs/content/index.md b/docs/docs/content/index.md
index a4d072a..35b069d 100644
--- a/docs/docs/content/index.md
+++ b/docs/docs/content/index.md
@@ -207,10 +207,10 @@ create a backup:
 seedpass
 
 # Export your index
-seedpass export --file "~/seedpass_backup.json"
+seedpass vault export --file "~/seedpass_backup.json"
 
 # Later you can restore it
-seedpass import --file "~/seedpass_backup.json"
+seedpass vault import --file "~/seedpass_backup.json"
 # Import also performs a Nostr sync to pull any changes
 
 # Quickly find or retrieve entries
diff --git a/src/seedpass/cli.py b/src/seedpass/cli.py
index 4b9a917..df7f56c 100644
--- a/src/seedpass/cli.py
+++ b/src/seedpass/cli.py
@@ -14,8 +14,6 @@ from seedpass.core.api import (
     ConfigService,
     UtilityService,
     NostrService,
-    VaultExportRequest,
-    VaultImportRequest,
     ChangePasswordRequest,
     UnlockRequest,
     BackupParentSeedRequest,
@@ -402,9 +400,10 @@ def entry_export_totp(
 def vault_export(
     ctx: typer.Context, file: str = typer.Option(..., help="Output file")
 ) -> None:
-    """Export the vault."""
+    """Export the vault profile to an encrypted file."""
     vault_service, _profile, _sync = _get_services(ctx)
-    vault_service.export_vault(VaultExportRequest(path=Path(file)))
+    data = vault_service.export_profile()
+    Path(file).write_bytes(data)
     typer.echo(str(file))
 
 
@@ -412,9 +411,10 @@ def vault_export(
 def vault_import(
     ctx: typer.Context, file: str = typer.Option(..., help="Input file")
 ) -> None:
-    """Import a vault from an encrypted JSON file."""
+    """Import a vault profile from an encrypted file."""
     vault_service, _profile, _sync = _get_services(ctx)
-    vault_service.import_vault(VaultImportRequest(path=Path(file)))
+    data = Path(file).read_bytes()
+    vault_service.import_profile(data)
     typer.echo(str(file))
 
 
diff --git a/src/seedpass/core/api.py b/src/seedpass/core/api.py
index 6acb958..7078853 100644
--- a/src/seedpass/core/api.py
+++ b/src/seedpass/core/api.py
@@ -10,6 +10,7 @@ allow easy validation and documentation.
 from pathlib import Path
 from threading import Lock
 from typing import List, Optional, Dict
+import json
 
 from pydantic import BaseModel
 
@@ -102,6 +103,25 @@ class VaultService:
             self._manager.handle_import_database(req.path)
             self._manager.sync_vault()
 
+    def export_profile(self) -> bytes:
+        """Return encrypted profile data for backup."""
+
+        with self._lock:
+            data = self._manager.vault.load_index()
+            payload = json.dumps(data, sort_keys=True, separators=(",", ":")).encode(
+                "utf-8"
+            )
+            return self._manager.vault.encryption_manager.encrypt_data(payload)
+
+    def import_profile(self, data: bytes) -> None:
+        """Restore a profile from ``data`` and sync."""
+
+        with self._lock:
+            decrypted = self._manager.vault.encryption_manager.decrypt_data(data)
+            index = json.loads(decrypted.decode("utf-8"))
+            self._manager.vault.save_index(index)
+            self._manager.sync_vault()
+
     def change_password(self, req: ChangePasswordRequest) -> None:
         """Change the master password."""
 
diff --git a/src/tests/test_cli_doc_examples.py b/src/tests/test_cli_doc_examples.py
index 7f4ce82..1518261 100644
--- a/src/tests/test_cli_doc_examples.py
+++ b/src/tests/test_cli_doc_examples.py
@@ -84,7 +84,9 @@ def load_doc_commands() -> list[str]:
     cmds = set(re.findall(r"`seedpass ([^`<>]+)`", text))
     cmds = {c for c in cmds if "<" not in c and ">" not in c}
     cmds.discard("vault export")
+    cmds.discard("vault export --file backup.json")
     cmds.discard("vault import")
+    cmds.discard("vault import --file backup.json")
     return sorted(cmds)
 
 
diff --git a/src/tests/test_profile_export_import.py b/src/tests/test_profile_export_import.py
new file mode 100644
index 0000000..0929d95
--- /dev/null
+++ b/src/tests/test_profile_export_import.py
@@ -0,0 +1,33 @@
+from pathlib import Path
+from types import SimpleNamespace
+
+from seedpass.core.api import VaultService
+from helpers import create_vault, TEST_SEED, TEST_PASSWORD
+
+
+def test_profile_export_import_round_trip(tmp_path):
+    dir1 = tmp_path / "a"
+    vault1, _ = create_vault(dir1, TEST_SEED, TEST_PASSWORD)
+    data = {
+        "schema_version": 4,
+        "entries": {"0": {"label": "example", "type": "password"}},
+    }
+    vault1.save_index(data)
+    pm1 = SimpleNamespace(vault=vault1, sync_vault=lambda: None)
+    service1 = VaultService(pm1)
+    blob = service1.export_profile()
+
+    dir2 = tmp_path / "b"
+    vault2, _ = create_vault(dir2, TEST_SEED, TEST_PASSWORD)
+    vault2.save_index({"schema_version": 4, "entries": {}})
+    called = {}
+
+    def sync():
+        called["synced"] = True
+
+    pm2 = SimpleNamespace(vault=vault2, sync_vault=sync)
+    service2 = VaultService(pm2)
+    service2.import_profile(blob)
+
+    assert called.get("synced") is True
+    assert vault2.load_index() == data
diff --git a/src/tests/test_typer_cli.py b/src/tests/test_typer_cli.py
index 3d51993..c2db4da 100644
--- a/src/tests/test_typer_cli.py
+++ b/src/tests/test_typer_cli.py
@@ -68,58 +68,53 @@ def test_entry_get_password(monkeypatch):
 def test_vault_export(monkeypatch, tmp_path):
     called = {}
 
-    def export_db(path):
-        called["path"] = path
+    def export_profile(self):
+        called["export"] = True
+        return b"data"
 
-    pm = SimpleNamespace(
-        handle_export_database=export_db, select_fingerprint=lambda fp: None
-    )
-    monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
+    monkeypatch.setattr(cli.VaultService, "export_profile", export_profile)
+    monkeypatch.setattr(cli, "PasswordManager", lambda: SimpleNamespace())
     out_path = tmp_path / "out.json"
     result = runner.invoke(app, ["vault", "export", "--file", str(out_path)])
     assert result.exit_code == 0
-    assert called["path"] == out_path
+    assert called.get("export") is True
+    assert out_path.read_bytes() == b"data"
 
 
 def test_vault_import(monkeypatch, tmp_path):
     called = {}
 
-    def import_db(path):
-        called["path"] = path
+    def import_profile(self, data):
+        called["data"] = data
 
-    pm = SimpleNamespace(
-        handle_import_database=import_db,
-        select_fingerprint=lambda fp: None,
-        sync_vault=lambda: None,
-    )
-    monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
+    monkeypatch.setattr(cli.VaultService, "import_profile", import_profile)
+    monkeypatch.setattr(cli, "PasswordManager", lambda: SimpleNamespace())
     in_path = tmp_path / "in.json"
-    in_path.write_text("{}")
+    in_path.write_bytes(b"inp")
     result = runner.invoke(app, ["vault", "import", "--file", str(in_path)])
     assert result.exit_code == 0
-    assert called["path"] == in_path
+    assert called["data"] == b"inp"
 
 
 def test_vault_import_triggers_sync(monkeypatch, tmp_path):
     called = {}
 
-    def import_db(path):
-        called["path"] = path
+    def import_profile(self, data):
+        called["data"] = data
+        self._manager.sync_vault()
 
-    def sync():
+    def sync_vault():
         called["sync"] = True
 
-    pm = SimpleNamespace(
-        handle_import_database=import_db,
-        sync_vault=sync,
-        select_fingerprint=lambda fp: None,
+    monkeypatch.setattr(cli.VaultService, "import_profile", import_profile)
+    monkeypatch.setattr(
+        cli, "PasswordManager", lambda: SimpleNamespace(sync_vault=sync_vault)
     )
-    monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
     in_path = tmp_path / "in.json"
-    in_path.write_text("{}")
+    in_path.write_bytes(b"inp")
     result = runner.invoke(app, ["vault", "import", "--file", str(in_path)])
     assert result.exit_code == 0
-    assert called["path"] == in_path
+    assert called.get("data") == b"inp"
     assert called.get("sync") is True
 
 

From 724c0b883fcaa59074d4bf4725a422ce566c30d5 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Fri, 18 Jul 2025 17:02:05 -0400
Subject: [PATCH 34/75] Add pubsub event system and integrate sync
 notifications

---
 src/seedpass/core/api.py     |  3 +++
 src/seedpass/core/manager.py | 13 +++++++++++--
 src/seedpass/core/pubsub.py  | 27 +++++++++++++++++++++++++++
 src/tests/test_pubsub.py     | 28 ++++++++++++++++++++++++++++
 4 files changed, 69 insertions(+), 2 deletions(-)
 create mode 100644 src/seedpass/core/pubsub.py
 create mode 100644 src/tests/test_pubsub.py

diff --git a/src/seedpass/core/api.py b/src/seedpass/core/api.py
index 7078853..d51a21e 100644
--- a/src/seedpass/core/api.py
+++ b/src/seedpass/core/api.py
@@ -15,6 +15,7 @@ import json
 from pydantic import BaseModel
 
 from .manager import PasswordManager
+from .pubsub import bus
 
 
 class VaultExportRequest(BaseModel):
@@ -202,7 +203,9 @@ class SyncService:
         """Publish the vault to Nostr and return event info."""
 
         with self._lock:
+            bus.publish("sync_started")
             result = self._manager.sync_vault()
+            bus.publish("sync_finished", result)
         if not result:
             return None
         return SyncResponse(**result)
diff --git a/src/seedpass/core/manager.py b/src/seedpass/core/manager.py
index a707391..f6d1d78 100644
--- a/src/seedpass/core/manager.py
+++ b/src/seedpass/core/manager.py
@@ -33,6 +33,7 @@ from .vault import Vault
 from .portable_backup import export_backup, import_backup
 from .totp import TotpManager
 from .entry_types import EntryType
+from .pubsub import bus
 from utils.key_derivation import (
     derive_key_from_parent_seed,
     derive_key_from_password,
@@ -1243,7 +1244,9 @@ class PasswordManager:
 
         def _worker() -> None:
             try:
-                asyncio.run(self.sync_vault_async(alt_summary=alt_summary))
+                bus.publish("sync_started")
+                result = asyncio.run(self.sync_vault_async(alt_summary=alt_summary))
+                bus.publish("sync_finished", result)
             except Exception as exc:
                 logging.error(f"Background vault sync failed: {exc}", exc_info=True)
 
@@ -1252,7 +1255,13 @@ class PasswordManager:
         except RuntimeError:
             threading.Thread(target=_worker, daemon=True).start()
         else:
-            asyncio.create_task(self.sync_vault_async(alt_summary=alt_summary))
+
+            async def _async_worker() -> None:
+                bus.publish("sync_started")
+                result = await self.sync_vault_async(alt_summary=alt_summary)
+                bus.publish("sync_finished", result)
+
+            asyncio.create_task(_async_worker())
 
     async def attempt_initial_sync_async(self) -> bool:
         """Attempt to download the initial vault snapshot from Nostr.
diff --git a/src/seedpass/core/pubsub.py b/src/seedpass/core/pubsub.py
new file mode 100644
index 0000000..fec4483
--- /dev/null
+++ b/src/seedpass/core/pubsub.py
@@ -0,0 +1,27 @@
+from collections import defaultdict
+from typing import Callable, Dict, List, Any
+
+
+class PubSub:
+    """Simple in-process event bus using the observer pattern."""
+
+    def __init__(self) -> None:
+        self._subscribers: Dict[str, List[Callable[..., None]]] = defaultdict(list)
+
+    def subscribe(self, event: str, callback: Callable[..., None]) -> None:
+        """Register ``callback`` to be invoked when ``event`` is published."""
+        self._subscribers[event].append(callback)
+
+    def unsubscribe(self, event: str, callback: Callable[..., None]) -> None:
+        """Unregister ``callback`` from ``event`` notifications."""
+        if callback in self._subscribers.get(event, []):
+            self._subscribers[event].remove(callback)
+
+    def publish(self, event: str, *args: Any, **kwargs: Any) -> None:
+        """Notify all subscribers of ``event`` passing ``*args`` and ``**kwargs``."""
+        for callback in list(self._subscribers.get(event, [])):
+            callback(*args, **kwargs)
+
+
+# Global bus instance for convenience
+bus = PubSub()
diff --git a/src/tests/test_pubsub.py b/src/tests/test_pubsub.py
new file mode 100644
index 0000000..7cf21c9
--- /dev/null
+++ b/src/tests/test_pubsub.py
@@ -0,0 +1,28 @@
+from seedpass.core.pubsub import PubSub
+
+
+def test_subscribe_and_publish():
+    bus = PubSub()
+    calls = []
+
+    def handler(arg):
+        calls.append(arg)
+
+    bus.subscribe("event", handler)
+    bus.publish("event", 123)
+
+    assert calls == [123]
+
+
+def test_unsubscribe():
+    bus = PubSub()
+    calls = []
+
+    def handler():
+        calls.append(True)
+
+    bus.subscribe("event", handler)
+    bus.unsubscribe("event", handler)
+    bus.publish("event")
+
+    assert calls == []

From 5fce7836d9303eba74fd3e822bd0f45fff80376d Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Fri, 18 Jul 2025 17:38:07 -0400
Subject: [PATCH 35/75] Remove binary docs images

---
 .../01-getting-started/06-gui_adapter.md      |  12 ++
 src/seedpass/core/manager.py                  |   1 +
 src/seedpass_gui/app.py                       | 117 +++++++++++++++++-
 src/tests/test_gui_features.py                |  78 ++++++++++++
 src/tests/test_gui_headless.py                |  15 ++-
 src/tests/test_vault_lock_event.py            |  28 +++++
 6 files changed, 244 insertions(+), 7 deletions(-)
 create mode 100644 src/tests/test_gui_features.py
 create mode 100644 src/tests/test_vault_lock_event.py

diff --git a/docs/docs/content/01-getting-started/06-gui_adapter.md b/docs/docs/content/01-getting-started/06-gui_adapter.md
index e930307..54215c7 100644
--- a/docs/docs/content/01-getting-started/06-gui_adapter.md
+++ b/docs/docs/content/01-getting-started/06-gui_adapter.md
@@ -2,6 +2,7 @@
 
 SeedPass ships with a proof-of-concept graphical interface built using [BeeWare](https://beeware.org). The GUI interacts with the same core services as the CLI by instantiating wrappers around `PasswordManager`.
 
+
 ## Getting Started with the GUI
 
 After installing the project dependencies, launch the desktop interface with one
@@ -104,3 +105,14 @@ def start_background_vault_sync(self, alt_summary: str | None = None) -> None:
 ```
 
 This approach ensures synchronization happens asynchronously whether the GUI is running inside or outside an existing event loop.
+
+## Relay Manager and Status Bar
+
+The *Relays* button opens a dialog for adding or removing Nostr relay URLs. The
+status bar at the bottom of the main window shows when the last synchronization
+completed. It updates automatically when `sync_started` and `sync_finished`
+events are published on the internal pubsub bus.
+
+When a ``vault_locked`` event is emitted, the GUI automatically returns to the
+lock screen so the session can be reopened with the master password.
+
diff --git a/src/seedpass/core/manager.py b/src/seedpass/core/manager.py
index f6d1d78..5ae332a 100644
--- a/src/seedpass/core/manager.py
+++ b/src/seedpass/core/manager.py
@@ -304,6 +304,7 @@ class PasswordManager:
         self.nostr_client = None
         self.config_manager = None
         self.locked = True
+        bus.publish("vault_locked")
 
     def unlock_vault(self, password: Optional[str] = None) -> float:
         """Unlock the vault using the provided ``password``.
diff --git a/src/seedpass_gui/app.py b/src/seedpass_gui/app.py
index f1578f1..a859afe 100644
--- a/src/seedpass_gui/app.py
+++ b/src/seedpass_gui/app.py
@@ -5,18 +5,25 @@ from toga.style import Pack
 from toga.style.pack import COLUMN, ROW
 
 from seedpass.core.manager import PasswordManager
+import time
+
 from seedpass.core.api import (
     VaultService,
     EntryService,
+    NostrService,
     UnlockRequest,
 )
+from seedpass.core.pubsub import bus
 
 
 class LockScreenWindow(toga.Window):
     """Window prompting for the master password."""
 
     def __init__(
-        self, controller: SeedPassApp, vault: VaultService, entries: EntryService
+        self,
+        controller: SeedPassApp,
+        vault: VaultService,
+        entries: EntryService,
     ) -> None:
         super().__init__("Unlock Vault")
         # Store a reference to the SeedPass application instance separately from
@@ -45,7 +52,12 @@ class LockScreenWindow(toga.Window):
         except Exception as exc:  # pragma: no cover - GUI error handling
             self.message.text = str(exc)
             return
-        main = MainWindow(self.controller, self.vault, self.entries)
+        main = MainWindow(
+            self.controller,
+            self.vault,
+            self.entries,
+            self.controller.nostr_service,
+        )
         self.controller.main_window = main
         main.show()
         self.close()
@@ -55,14 +67,23 @@ class MainWindow(toga.Window):
     """Main application window showing vault entries."""
 
     def __init__(
-        self, controller: SeedPassApp, vault: VaultService, entries: EntryService
+        self,
+        controller: SeedPassApp,
+        vault: VaultService,
+        entries: EntryService,
+        nostr: NostrService,
     ) -> None:
-        super().__init__("SeedPass")
+        super().__init__("SeedPass", on_close=self.cleanup)
         # ``Window.app`` is reserved for the Toga ``App`` instance. Store the
         # SeedPass application reference separately.
         self.controller = controller
         self.vault = vault
         self.entries = entries
+        self.nostr = nostr
+        bus.subscribe("sync_started", self.sync_started)
+        bus.subscribe("sync_finished", self.sync_finished)
+        bus.subscribe("vault_locked", self.vault_locked)
+        self.last_sync = None
 
         self.table = toga.Table(
             headings=["ID", "Label", "Username", "URL"], style=Pack(flex=1)
@@ -71,15 +92,20 @@ class MainWindow(toga.Window):
         add_button = toga.Button("Add", on_press=self.add_entry)
         edit_button = toga.Button("Edit", on_press=self.edit_entry)
         search_button = toga.Button("Search", on_press=self.search_entries)
+        relay_button = toga.Button("Relays", on_press=self.manage_relays)
 
         button_box = toga.Box(style=Pack(direction=ROW, padding_top=5))
         button_box.add(add_button)
         button_box.add(edit_button)
         button_box.add(search_button)
+        button_box.add(relay_button)
+
+        self.status = toga.Label("Last sync: never", style=Pack(padding_top=5))
 
         box = toga.Box(style=Pack(direction=COLUMN, padding=10))
         box.add(self.table)
         box.add(button_box)
+        box.add(self.status)
         self.content = box
 
         self.refresh_entries()
@@ -105,6 +131,28 @@ class MainWindow(toga.Window):
         dlg = SearchDialog(self)
         dlg.show()
 
+    def manage_relays(self, widget: toga.Widget) -> None:
+        dlg = RelayManagerDialog(self, self.nostr)
+        dlg.show()
+
+    # --- PubSub callbacks -------------------------------------------------
+    def sync_started(self, *args: object, **kwargs: object) -> None:
+        self.status.text = "Syncing..."
+
+    def sync_finished(self, *args: object, **kwargs: object) -> None:
+        self.last_sync = time.strftime("%H:%M:%S")
+        self.status.text = f"Last sync: {self.last_sync}"
+
+    def vault_locked(self, *args: object, **kwargs: object) -> None:
+        self.close()
+        self.controller.main_window = None
+        self.controller.lock_window.show()
+
+    def cleanup(self, *args: object, **kwargs: object) -> None:
+        bus.unsubscribe("sync_started", self.sync_started)
+        bus.unsubscribe("sync_finished", self.sync_finished)
+        bus.unsubscribe("vault_locked", self.vault_locked)
+
 
 class EntryDialog(toga.Window):
     """Dialog for adding or editing an entry."""
@@ -187,6 +235,62 @@ class SearchDialog(toga.Window):
         self.close()
 
 
+class RelayManagerDialog(toga.Window):
+    """Dialog for managing relay URLs."""
+
+    def __init__(self, main: MainWindow, nostr: NostrService) -> None:
+        super().__init__("Relays")
+        self.main = main
+        self.nostr = nostr
+
+        self.table = toga.Table(headings=["Index", "URL"], style=Pack(flex=1))
+        self.new_input = toga.TextInput(style=Pack(flex=1))
+        add_btn = toga.Button("Add", on_press=self.add_relay)
+        remove_btn = toga.Button("Remove", on_press=self.remove_relay)
+        self.message = toga.Label("", style=Pack(color="red"))
+
+        box = toga.Box(style=Pack(direction=COLUMN, padding=20))
+        box.add(self.table)
+        form = toga.Box(style=Pack(direction=ROW, padding_top=5))
+        form.add(self.new_input)
+        form.add(add_btn)
+        form.add(remove_btn)
+        box.add(form)
+        box.add(self.message)
+        self.content = box
+
+        self.refresh()
+
+    def refresh(self) -> None:
+        self.table.data = []
+        for i, url in enumerate(self.nostr.list_relays(), start=1):
+            self.table.data.append((i, url))
+
+    def add_relay(self, widget: toga.Widget) -> None:
+        url = self.new_input.value or ""
+        if not url:
+            return
+        try:
+            self.nostr.add_relay(url)
+        except Exception as exc:  # pragma: no cover - pass errors
+            self.message.text = str(exc)
+            return
+        self.new_input.value = ""
+        self.refresh()
+
+    def remove_relay(self, widget: toga.Widget, *, index: int | None = None) -> None:
+        if index is None:
+            if self.table.selection is None:
+                return
+            index = int(self.table.selection[0])
+        try:
+            self.nostr.remove_relay(index)
+        except Exception as exc:  # pragma: no cover - pass errors
+            self.message.text = str(exc)
+            return
+        self.refresh()
+
+
 def build() -> SeedPassApp:
     """Return a configured :class:`SeedPassApp` instance."""
     return SeedPassApp(formal_name="SeedPass", app_id="org.seedpass.gui")
@@ -197,8 +301,11 @@ class SeedPassApp(toga.App):
         pm = PasswordManager()
         self.vault_service = VaultService(pm)
         self.entry_service = EntryService(pm)
+        self.nostr_service = NostrService(pm)
         self.lock_window = LockScreenWindow(
-            self, self.vault_service, self.entry_service
+            self,
+            self.vault_service,
+            self.entry_service,
         )
         self.main_window = None
         self.lock_window.show()
diff --git a/src/tests/test_gui_features.py b/src/tests/test_gui_features.py
new file mode 100644
index 0000000..13769a5
--- /dev/null
+++ b/src/tests/test_gui_features.py
@@ -0,0 +1,78 @@
+import os
+import toga
+import types
+
+import pytest
+
+pytestmark = pytest.mark.desktop
+
+from seedpass.core.pubsub import bus
+from seedpass_gui.app import MainWindow, RelayManagerDialog
+
+
+class DummyNostr:
+    def __init__(self):
+        self.relays = ["wss://a"]
+
+    def list_relays(self):
+        return list(self.relays)
+
+    def add_relay(self, url):
+        self.relays.append(url)
+
+    def remove_relay(self, idx):
+        self.relays.pop(idx - 1)
+
+
+class DummyEntries:
+    def list_entries(self):
+        return []
+
+    def search_entries(self, q):
+        return []
+
+
+class DummyController:
+    def __init__(self):
+        self.lock_window = types.SimpleNamespace(show=lambda: None)
+        self.main_window = None
+        self.vault_service = None
+        self.entry_service = None
+        self.nostr_service = None
+
+
+@pytest.fixture(autouse=True)
+def set_backend():
+    os.environ["TOGA_BACKEND"] = "toga_dummy"
+    import asyncio
+
+    asyncio.set_event_loop(asyncio.new_event_loop())
+
+
+def test_relay_manager_add_remove():
+    toga.App("T", "o")
+    ctrl = DummyController()
+    nostr = DummyNostr()
+    win = MainWindow(ctrl, None, DummyEntries(), nostr)
+    dlg = RelayManagerDialog(win, nostr)
+    dlg.new_input.value = "wss://b"
+    dlg.add_relay(None)
+    assert nostr.relays == ["wss://a", "wss://b"]
+    dlg.remove_relay(None, index=1)
+    assert nostr.relays == ["wss://b"]
+
+
+def test_status_bar_updates_and_lock():
+    toga.App("T2", "o2")
+    ctrl = DummyController()
+    nostr = DummyNostr()
+    ctrl.lock_window = types.SimpleNamespace(show=lambda: setattr(ctrl, "locked", True))
+    win = MainWindow(ctrl, None, DummyEntries(), nostr)
+    ctrl.main_window = win
+    bus.publish("sync_started")
+    assert win.status.text == "Syncing..."
+    bus.publish("sync_finished")
+    assert "Last sync:" in win.status.text
+    bus.publish("vault_locked")
+    assert getattr(ctrl, "locked", False)
+    assert ctrl.main_window is None
diff --git a/src/tests/test_gui_headless.py b/src/tests/test_gui_headless.py
index 8801064..ef5977b 100644
--- a/src/tests/test_gui_headless.py
+++ b/src/tests/test_gui_headless.py
@@ -40,18 +40,29 @@ def setup_module(module):
     asyncio.set_event_loop(asyncio.new_event_loop())
 
 
+class FakeNostr:
+    def list_relays(self):
+        return []
+
+    def add_relay(self, url):
+        pass
+
+    def remove_relay(self, idx):
+        pass
+
+
 def test_unlock_creates_main_window():
     app = toga.App("Test", "org.example")
-    controller = SimpleNamespace(main_window=None)
+    controller = SimpleNamespace(main_window=None, nostr_service=FakeNostr())
     vault = FakeVault()
     entries = FakeEntries()
-
     win = LockScreenWindow(controller, vault, entries)
     win.password_input.value = "pw"
     win.handle_unlock(None)
 
     assert vault.called
     assert isinstance(controller.main_window, MainWindow)
+    controller.main_window.cleanup()
 
 
 def test_entrydialog_add_calls_service():
diff --git a/src/tests/test_vault_lock_event.py b/src/tests/test_vault_lock_event.py
new file mode 100644
index 0000000..2f2aba0
--- /dev/null
+++ b/src/tests/test_vault_lock_event.py
@@ -0,0 +1,28 @@
+from seedpass.core.manager import PasswordManager
+from seedpass.core.pubsub import bus
+
+
+def test_lock_vault_publishes_event():
+    pm = PasswordManager.__new__(PasswordManager)
+    pm.entry_manager = None
+    pm.encryption_manager = None
+    pm.password_generator = None
+    pm.backup_manager = None
+    pm.vault = None
+    pm.bip85 = None
+    pm.nostr_client = None
+    pm.config_manager = None
+    pm.locked = False
+    pm._parent_seed_secret = None
+
+    called = []
+
+    def handler():
+        called.append(True)
+
+    bus.subscribe("vault_locked", handler)
+    pm.lock_vault()
+    bus.unsubscribe("vault_locked", handler)
+
+    assert pm.locked
+    assert called == [True]

From 615be7d325f3e6f0ffc8386ad17a5a5d5ef85583 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Fri, 18 Jul 2025 18:01:05 -0400
Subject: [PATCH 36/75] Add Briefcase build workflow and update README

---
 .github/workflows/briefcase.yml | 27 +++++++++++++++++++++++++++
 README.md                       |  4 ++++
 2 files changed, 31 insertions(+)
 create mode 100644 .github/workflows/briefcase.yml

diff --git a/.github/workflows/briefcase.yml b/.github/workflows/briefcase.yml
new file mode 100644
index 0000000..99d6a6f
--- /dev/null
+++ b/.github/workflows/briefcase.yml
@@ -0,0 +1,27 @@
+name: Build GUI
+
+on:
+  push:
+    tags:
+      - 'seedpass-gui*'
+
+jobs:
+  build:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v3
+      - uses: actions/setup-python@v4
+        with:
+          python-version: '3.11'
+      - name: Install dependencies
+        run: |
+          python -m pip install --upgrade pip
+          pip install -r src/requirements.txt
+          pip install briefcase
+      - name: Build with Briefcase
+        run: briefcase build
+      - name: Upload artifacts
+        uses: actions/upload-artifact@v4
+        with:
+          name: seedpass-gui
+          path: dist/**
diff --git a/README.md b/README.md
index 84951ef..9b671ff 100644
--- a/README.md
+++ b/README.md
@@ -603,6 +603,10 @@ You can also produce packaged installers for the GUI with BeeWare's Briefcase:
 briefcase build
 ```
 
+Pre-built installers are published for each `seedpass-gui` tag. Visit the
+project's **Actions** or **Releases** page on GitHub to download the latest
+package for your platform.
+
 The standalone executable will appear in the `dist/` directory. This process works on Windows, macOS and Linux but you must build on each platform for a native binary.
 
 ## Security Considerations

From a6e18ae9c5ae5c1640cf07edc8e6188d42bf3aa6 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Fri, 18 Jul 2025 21:56:23 -0400
Subject: [PATCH 37/75] update

---
 dev-plan.md            |  93 ---------------------------------
 post-refactor-to-do.md |  78 ----------------------------
 refactor.md            | 113 -----------------------------------------
 3 files changed, 284 deletions(-)
 delete mode 100644 dev-plan.md
 delete mode 100644 post-refactor-to-do.md
 delete mode 100644 refactor.md

diff --git a/dev-plan.md b/dev-plan.md
deleted file mode 100644
index d3aa788..0000000
--- a/dev-plan.md
+++ /dev/null
@@ -1,93 +0,0 @@
-### SeedPass Road-to-1.0 — Detailed Development Plan
-
-*(Assumes today = 1 July 2025, team of 1-3 devs, weekly release cadence)*
-
-| Phase                                | Goal                                                                      | Key Deliverables                                                                                                                                                                                                                                                                                                                                                                      | Target Window             |
-| ------------------------------------ | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- |
-| **0 – Vision Lock-in**               | Be explicit about where you’re going so every later trade-off is easy.    | • 2-page “north-star” doc covering product scope, security promises, platforms, and **“CLI is source of truth”** principle. 
• Public roadmap Kanban board. | **Week 0** | -| **1 – Package-ready Codebase** | Turn loose `src/` tree into a pip-installable library + console script. | • `pyproject.toml` with PEP-621 metadata, `setuptools-scm` dynamic version.
• Restructure to `seedpass/` (or keep `src/` but list `packages = ["seedpass"]`).
• Entry-point: `seedpass = "seedpass.main:cli"`.
• Dev extras: `pytest-cov`, `ruff`, `mypy`, `pre-commit`.
• Split pure business logic from I/O (e.g., encryption, BIP-85, vault ops) so GUI can reuse. | **Weeks 0-2** | -| **2 – Local Quality Net** | Fail fast before CI runs. | • `make test` / `tox` quick matrix (3.10–3.12).
• 90 % line coverage gate.
• Static checks in pre-commit (black, ruff, mypy). | **Weeks 1-3** | -| **3 – CI / Release Automation** | One Git tag → everything ships. | • GitHub Actions matrix (Ubuntu, macOS, Windows).
• Steps: install → unit tests → build wheels (`python -m build`) → PyInstaller one-file artefacts → upload to Release.
• Secrets for PyPI / code-signing left empty until 1.0. | **Weeks 2-4** | -| **4 – OS-Native Packages** | Users can “apt install / brew install / flatpak install / download .exe”. | **Linux** • `stdeb` → `.deb`, `reprepro` mini-APT repo.
**Flatpak** • YAML manifest + GitHub Action to build & push to Flathub beta repo.
**Windows** • PyInstaller `--onefile` → NSIS installer.
**macOS** • Briefcase → notarised `.pkg` or `.dmg` (signing cert later). | **Weeks 4-8** | -| **5 – Experimental GUI Track** | Ship a GUI **without** slowing CLI velocity. | • Decide stack (recommend **Textual** first; upgrade later to Toga or PySide).
• Create `seedpass.gui` package calling existing APIs; flag with `--gui`.
• Feature flag via env var `SEEDPASS_GUI=1` or CLI switch.
• Separate workflow that builds GUI artefacts, but does **not** block CLI releases. | **Weeks 6-12** (parallel) | -| **6 – Plugin / Extensibility Layer** | Keep core slim while allowing future features. | • Define `entry_points={"seedpass.plugins": …}`.
• Document simple example plugin (e.g., custom password rule).
• Load plugins lazily to avoid startup cost. | **Weeks 10-14** | -| **7 – Security & Hardening** | Turn security assumptions into guarantees before 1.0 | • SAST scan (Bandit, Semgrep).
• Threat-model doc: key-storage, BIP-85 determinism, Nostr backup flow.
• Repro-build check for PyInstaller artefacts.
• Signed releases (Sigstore, minisign). | **Weeks 12-16** | -| **8 – 1.0 Launch Prep** | Final polish + docs. | • User manual (MkDocs, `docs.seedpass.org`).
• In-app `--check-update` hitting GitHub API.
• Blog post & template release notes. | **Weeks 16-18** | - ---- - -### Ongoing Practices to Keep Development Nimble - -| Practice | What to do | -| ----------------------- | ------------------------------------------------------------------------------------------- | -| **Dynamic versioning** | Keep `version` dynamic via `setuptools-scm` / `hatch-vcs`; tag and push – nothing else. | -| **Trunk-based dev** | Short-lived branches, PRs < 300 LOC; merge when tests pass. | -| **Feature flags** | `seedpass.config.is_enabled("X")` so unfinished work can ship dark. | -| **Fast feedback loops** | Local editable install; `invoke run --watch` (or `uvicorn --reload` for GUI) to hot-reload. | -| **Weekly beta release** | Even during heavy GUI work, cut “beta” tags weekly; real users shake out regressions early. | - ---- - -### First 2-Week Sprint (Concrete To-Dos) - -1. **Bootstrap packaging** - - ```bash - pip install --upgrade pip build setuptools_scm - poetry init # if you prefer Poetry, else stick with setuptools - ``` - - Add `pyproject.toml`, move code to `seedpass/`. - -2. **Console entry-point** - In `seedpass/__main__.py` add `from .main import cli; cli()`. - -3. **Editable dev install** - `pip install -e .[dev]` → run `seedpass --help`. - -4. **Set up pre-commit** - `pre-commit install` with ruff + black + mypy hooks. - -5. **GitHub Action skeleton** (`.github/workflows/ci.yml`) - - ```yaml - jobs: - test: - strategy: - matrix: os: [ubuntu-latest, windows-latest, macos-latest] - python-version: ['3.12', '3.11'] - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: {python-version: ${{ matrix.python-version }}} - - run: pip install --upgrade pip - - run: pip install -e .[dev] - - run: pytest -n auto - ``` - -6. **Smoke PyInstaller locally** - `pyinstaller --onefile seedpass/main.py` – fix missing data/hooks; check binary runs. - -When that’s green, cut tag `v0.1.0-beta` and let CI build artefacts automatically. - ---- - -### Choosing the GUI Path (decision by Week 6) - -| If you value… | Choose | -| ---------------------------------- | ---------------------------- | -| Terminal-first UX, live coding | **Textual (Rich-TUI)** | -| Native look, single code base | **Toga / Briefcase** | -| Advanced widgets, designer tooling | **PySide-6 / Qt for Python** | - -Prototype one screen (vault list + “Add” dialog) and benchmark bundle size + startup time with PyInstaller before committing. - ---- - -## Recap - -* **Packaging & CI first** – lets every future feature ride an established release train. -* **GUI lives in its own layer** – CLI stays stable; dev cycles remain quick. -* **Security & signing** land after functionality is stable, before v1.0 marketing push. - -Follow the phase table, keep weekly betas flowing, and you’ll reach a polished, installer-ready, GUI-enhanced 1.0 in roughly four months without sacrificing day-to-day agility. diff --git a/post-refactor-to-do.md b/post-refactor-to-do.md deleted file mode 100644 index 62a038a..0000000 --- a/post-refactor-to-do.md +++ /dev/null @@ -1,78 +0,0 @@ ---- - -# SeedPass Feature Back‑Log (v2) - -> **Encryption invariant**   Everything at rest **and** in export remains cipher‑text that ultimately derives from the **profile master‑password + parent seed**. No unencrypted payload leaves the vault. -> -> **Surface rule**   UI layers (CLI, GUI, future mobile) may *display* decrypted data **after** user unlock, but must never write plaintext to disk or network. - ---- - -## Track vocabulary - -| Label | Meaning | -| ------------ | ------------------------------------------------------------------------------ | -| **Core API** | `seedpass.api` – headless services consumed by CLI / GUI | -| **Profile** | A fingerprint‑scoped vault: parent‑seed + hashed pw + entries | -| **Entry** | One encrypted JSON blob on disk plus Nostr snapshot chunks and delta events | -| **GUI MVP** | Desktop app built with PySide 6 announced in the v2 roadmap | - ---- - -## Phase A  •  Core‑level enhancements (blockers for GUI) - -|  Prio  | Feature | Notes | -| ------ | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -|  🔥 | **Encrypted Search API** | • `VaultService.search(query:str, *, kinds=None) -> List[EntryMeta]`
• Decrypt *only* whitelisted meta‑fields per `kind` (title, username, url, tags) for in‑memory matching. | -|  🔥 | **Rich Listing / Sort / Filter** | • `list_entries(sort_by="updated", kind="note")`
• Sorting by `title` must decrypt that field on‑the‑fly. | -|  🔥 | **Custom Relay Set (per profile)** | • `StateManager.state["relays"]: List[str]`
• CRUD CLI commands & GUI dialog.
• `NostrClient` reads from state at instantiation. | -|  ⚡ | **Session Lock & Idle Timeout** | • Config `SESSION_TIMEOUT` (default 15 min).
• `AuthGuard` clears in‑memory keys & seeds.
• CLI `seedpass lock` + GUI menu “Lock vault”. | - -**Exit‑criteria** : All functions green in CI, consumed by both CLI (Typer) *and* a minimal Qt test harness. - ---- - -## Phase B  •  Data Portability (encrypted only) - -|  Prio  | Feature | Notes | | -| ------ | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | -|  ⭐ | **Encrypted Profile Export** | • CLI `seedpass export --out myprofile.enc`
• Serialise *encrypted* entry files → single JSON wrapper → `EncryptionManager.encrypt_data()`
• Always require active profile unlock. | | -|  ⭐ | **Encrypted Profile Import / Merge** | • CLI \`seedpass import myprofile.enc \[--strategy skip | overwrite-newer]`
• Verify fingerprint match before ingest.
• Conflict policy pluggable; default `skip\`. | - ---- - -## Phase C  •  Advanced secrets & sync - -|  Prio  | Feature | Notes | -| ------ | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -|  ◇ | **TOTP entry kind** | • `kind="totp_secret"` fields: title, issuer, username, secret\_key
• `secret_key` encrypted; handler uses `pyotp` to show current code. | -|  ◇ | **Manual Conflict Resolver** | • When `checksum` mismatch *and* both sides newer than last sync → prompt user (CLI) or modal (GUI). | - ---- - -## Phase D  •  Desktop GUI MVP (Qt 6) - -*Features here ride on the Core API; keep UI totally stateless.* - -|  Prio  | Feature | Notes | -| ------ | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | -|  🔥 | **Login Window** | • Unlock profile with master pw.
• Profile switcher drop‑down. | -|  🔥 | **Vault Window** | • Sidebar (Entries, Search, Backups, Settings).
• `QTableView` bound to `VaultService.list_entries()`
• Sort & basic filters built‑in. | -|  🔥 | **Entry Editor Dialog** | • Dynamic form driven by `kinds.py`.
• Add / Edit. | -|  ⭐ | **Sync Status Bar** | • Pulsing icon + last sync timestamp; hooks into `SyncService` bus. | -|  ◇ | **Relay Manager Dialog** | • CRUD & ping test per relay. | - -*Binary packaging (PyInstaller matrix build) is already tracked in the roadmap and is not duplicated here.* - ---- - -## Phase E  •  Later / Research - -• Hardware‑wallet unlock (SLIP‑39 share) -• Background daemon (`seedpassd` + gRPC) -• Mobile companion (Flutter FFI) -• Federated search across multiple profiles - ---- - -**Reminder:** *No plaintext exports, no on‑disk temp files, and no writing decrypted data to Nostr.* Everything funnels through the encryption stack or stays in memory for the current unlocked session only. diff --git a/refactor.md b/refactor.md deleted file mode 100644 index fdbf457..0000000 --- a/refactor.md +++ /dev/null @@ -1,113 +0,0 @@ -# SeedPass v2 Roadmap — CLI → Desktop GUI - -> **Guiding principles** -> -> 1. **Core-first** – a headless, testable Python package (`seedpass.core`) that is 100 % GUI-agnostic. -> 2. **Thin adapters** – CLI, GUI, and future mobile layers merely call the core API. -> 3. **Stateless UI** – all persistence lives in core services; UI never touches vault files directly. -> 4. **Parity at every step** – CLI must keep working while GUI evolves. - ---- - -## Phase 0 • Tooling Baseline - -| # | Task | Rationale | -| --- | ---------------------------------------------------------------------------------------------- | --------------------------------- | -| 0.1 | ✅ **Adopt `poetry`** (or `hatch`) for builds & dependency pins. | Single-source version + lockfile. | -| 0.2 | ✅ **GitHub Actions**: lint (ruff), type-check (mypy), tests (pytest -q), coverage gate ≥ 85 %. | Prevent regressions. | -| 0.3 | ✅ Pre-commit hooks: ruff –fix, black, isort. | Uniform style. | - ---- - -## Phase 1 • Finalize Core Refactor (CLI still primary) - -> *Most of this is already drafted – here’s what must ship before GUI work starts.* - -| # | Component | Must-have work | -| --- | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------- | -| 1.1 | **`kinds.py` registry + per-kind handler modules** | import-safe; handler signature `(data,fingerprint,**svc)` | -| 1.2 | **`StateManager`** | JSON file w/ fcntl lock
keys: `last_bip85_idx`, `last_sync_ts` | -| 1.3 | **Checksum inside entry metadata** | `sha256(json.dumps(data,sort_keys=True))` | -| 1.4 | **Replaceable Nostr events** (kind 31111, `d` tag = `"{kindtag}{entry_num}"`) | publish/update/delete tombstone | -| 1.5 | **Per-entry `EntryManager` / `BackupManager`** | Save / load / backup / restore individual encrypted files | -| 1.6 | **CLI rewritten with Typer** | Typer commands map 1-to-1 with core service methods; preserves colours. | -| 1.7 | **Legacy index migration command** | `seedpass migrate-legacy` – idempotent, uses `add_entry()` under the hood. | -| 1.8 | **bcrypt + NFKD master password hash** | Stored per fingerprint. | - -> **Exit-criteria:** end-to-end flow (`add → list → sync → restore`) green in CI and covered by tests. - ---- - -## Phase 2 • Core API Hardening (prep for GUI) - -| # | Task | Deliverable | -| --- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------- | -| 2.1 | **Public Service Layer** (`seedpass.api`) | Facade classes:
`VaultService`, `ProfileService`, `SyncService` – *no* CLI / UI imports. | -| 2.2 | **Thread-safe gate** | Re-entrancy locks so GUI threads can call core safely. | -| 2.3 | **Fast in-process event bus** | Simple `pubsub.py` (observer pattern) for GUI to receive progress callbacks (e.g. sync progress, long ops). | -| 2.4 | **Docstrings + pydantic models** | Typed request/response objects → eases RPC later (e.g. REST, gRPC). | -| 2.5 | **Library packaging** | `python -m pip install .` gives importable `seedpass`. | - ---- - -## Phase 3 • Desktop GUI MVP - -| # | Decision | Notes | -| --- | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | -| 3.0 | **Framework: PySide 6 (Qt 6)** | ✓ LGPL, ✓ native look, ✓ Python-first, ✓ WebEngine if needed. | -| 3.1 | **Process model** | *Same* process; GUI thread ↔ core API via signals/slots.
(If we outgrow this, swap to a local gRPC server later.) | -| 3.2 | **UI Skeleton (milestone “Hello Vault”)** | | -| – | `LoginWindow` | master-password prompt → opens default profile | -| – | `VaultWindow` | sidebar (Profiles, Entries, Backups) + stacked views | -| – | `EntryTableView` | QTableView bound to `VaultService.list_entries()` | -| – | `EntryEditorDialog` | Add / Edit forms – field set driven by `kinds.py` | -| – | `SyncStatusBar` | pulse animation + last-sync timestamp | -| 3.3 | **Icons / theming** | Start with Qt-built-in icons; later swap to SVG set. | -| 3.4 | **Packaging** | `PyInstaller --onefile` for Win / macOS / Linux AppImage; GitHub Actions matrix build. | -| 3.5 | **GUI E2E tests** | PyTest + pytest-qt (QtBot) smoke flows; run headless in CI (Xvfb). | - -> **Stretch option:** wrap the same UI in **Tauri** later for a lighter binary (\~5 MB), reusing the core API through a local websocket RPC. - ---- - -## Phase 4 • Unified Workflows & Coverage - -| # | Task | -| --- | --------------------------------------------------------------------------------------- | -| 4.1 | Extend GitHub Actions to build GUI artifacts on every tag. | -| 4.2 | Add synthetic coverage for GUI code paths (QtBot). | -| 4.3 | Nightly job: spin up headless GUI, run `sync` against test relay, assert no exceptions. | - ---- - -## Phase 5 • Future-Proofing (post-GUI v1) - -| Idea | Sketch | -| -------------------------- | ----------------------------------------------------------------------------------------- | -| **Background daemon** | Optional `seedpassd` exposing Unix socket + JSON-RPC; both CLI & GUI become thin clients. | -| **Hardware-wallet unlock** | Replace master password with HWW + SLIP-39 share; requires PyUSB bridge. | -| **Mobile companion app** | Reuse core via BeeWare or Flutter FFI; sync over Nostr only (no local vault). | -| **End-to-end test farm** | dedicated relay docker-compose + pytest-subprocess to fake flaky relays. | - ---- - -## Deliverables Checklist - -* [ ] Core refactor merged, tests ≥ 85 % coverage -* [ ] `seedpass` installs and passes `python -m seedpass.cli --help` -* [ ] `seedpass-gui` binary opens vault, lists entries, adds & edits, syncs -* [ ] GitHub Actions builds binaries for Win/macOS/Linux on tag -* [ ] `docs/ARCHITECTURE.md` diagrams core ↔ CLI ↔ GUI layers - -When the above are ✅ we can ship `v2.0.0-beta.1` and invite early desktop testers. - ---- - -### 🔑 Key Takeaways - -1. **Keep all state & crypto in the core package.** -2. **Expose a clean Python API first – GUI is “just another client.”** -3. **Checksum + replaceable Nostr events give rock-solid sync & conflict handling.** -4. **Lock files and StateManager prevent index reuse and vault corruption.** -5. **The GUI sprint starts only after Phase 1 + 2 are fully green in CI.** - From f31e2663b61da7eb34d43f360388d855f69a09a2 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 18 Jul 2025 22:11:35 -0400 Subject: [PATCH 38/75] docs: add briefcase packaging section --- README.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/README.md b/README.md index 9b671ff..9b0ce21 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No - [Managing Multiple Seeds](#managing-multiple-seeds) - [Additional Entry Types](#additional-entry-types) - [Building a standalone executable](#building-a-standalone-executable) +- [Packaging with Briefcase](#packaging-with-briefcase) - [Security Considerations](#security-considerations) - [Contributing](#contributing) - [License](#license) @@ -609,6 +610,27 @@ package for your platform. The standalone executable will appear in the `dist/` directory. This process works on Windows, macOS and Linux but you must build on each platform for a native binary. +## Packaging with Briefcase + +For step-by-step instructions see [docs/docs/content/01-getting-started/05-briefcase.md](docs/docs/content/01-getting-started/05-briefcase.md). + +Install Briefcase and create a platform-specific scaffold: + +```bash +python -m pip install briefcase +briefcase create +``` + +Build and run the packaged GUI: + +```bash +briefcase build +briefcase run +``` + +You can also launch the GUI directly with `seedpass gui` or `seedpass-gui`. + + ## Security Considerations **Important:** The password you use to encrypt your parent seed is also required to decrypt the seed index data retrieved from Nostr. **It is imperative to remember this password** and be sure to use it with the same seed, as losing it means you won't be able to access your stored index. Secure your 12-word seed **and** your master password. From ea579aaa5d283d30fbf738f24ba2d320e616420e Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 18 Jul 2025 22:26:18 -0400 Subject: [PATCH 39/75] GUI: support multiple entry types --- src/seedpass_gui/app.py | 59 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 53 insertions(+), 6 deletions(-) diff --git a/src/seedpass_gui/app.py b/src/seedpass_gui/app.py index a859afe..ea6a1cb 100644 --- a/src/seedpass_gui/app.py +++ b/src/seedpass_gui/app.py @@ -5,6 +5,7 @@ from toga.style import Pack from toga.style.pack import COLUMN, ROW from seedpass.core.manager import PasswordManager +from seedpass.core.entry_types import EntryType import time from seedpass.core.api import ( @@ -86,7 +87,8 @@ class MainWindow(toga.Window): self.last_sync = None self.table = toga.Table( - headings=["ID", "Label", "Username", "URL"], style=Pack(flex=1) + headings=["ID", "Label", "Kind", "Info 1", "Info 2"], + style=Pack(flex=1), ) add_button = toga.Button("Add", on_press=self.add_entry) @@ -113,7 +115,18 @@ class MainWindow(toga.Window): def refresh_entries(self) -> None: self.table.data = [] for idx, label, username, url, _arch in self.entries.list_entries(): - self.table.data.append((idx, label, username or "", url or "")) + entry = self.entries.retrieve_entry(idx) + kind = (entry or {}).get("kind", (entry or {}).get("type", "")) + info1 = "" + info2 = "" + if kind == EntryType.PASSWORD.value: + info1 = username or "" + info2 = url or "" + elif kind == EntryType.KEY_VALUE.value: + info1 = entry.get("value", "") if entry else "" + else: + info1 = str(entry.get("index", "")) if entry else "" + self.table.data.append((idx, label, kind, info1, info2)) # --- Button handlers ------------------------------------------------- def add_entry(self, widget: toga.Widget) -> None: @@ -164,11 +177,17 @@ class EntryDialog(toga.Window): self.entry_id = entry_id self.label_input = toga.TextInput(style=Pack(flex=1)) + self.kind_input = toga.Selection( + items=[e.value for e in EntryType], + style=Pack(flex=1), + ) + self.kind_input.value = EntryType.PASSWORD.value self.username_input = toga.TextInput(style=Pack(flex=1)) self.url_input = toga.TextInput(style=Pack(flex=1)) self.length_input = toga.NumberInput( min=8, max=128, style=Pack(width=80), value=16 ) + self.value_input = toga.TextInput(style=Pack(flex=1)) save_button = toga.Button( "Save", on_press=self.save, style=Pack(padding_top=10) @@ -177,12 +196,16 @@ class EntryDialog(toga.Window): box = toga.Box(style=Pack(direction=COLUMN, padding=20)) box.add(toga.Label("Label")) box.add(self.label_input) + box.add(toga.Label("Kind")) + box.add(self.kind_input) box.add(toga.Label("Username")) box.add(self.username_input) box.add(toga.Label("URL")) box.add(self.url_input) box.add(toga.Label("Length")) box.add(self.length_input) + box.add(toga.Label("Value")) + box.add(self.value_input) box.add(save_button) self.content = box @@ -190,22 +213,46 @@ class EntryDialog(toga.Window): entry = self.main.entries.retrieve_entry(entry_id) if entry: self.label_input.value = entry.get("label", "") + kind = entry.get("kind", entry.get("type", EntryType.PASSWORD.value)) + self.kind_input.value = kind + self.kind_input.enabled = False self.username_input.value = entry.get("username", "") or "" self.url_input.value = entry.get("url", "") or "" self.length_input.value = entry.get("length", 16) + self.value_input.value = entry.get("value", "") def save(self, widget: toga.Widget) -> None: label = self.label_input.value or "" username = self.username_input.value or None url = self.url_input.value or None length = int(self.length_input.value or 16) + kind = self.kind_input.value + value = self.value_input.value or None if self.entry_id is None: - self.main.entries.add_entry(label, length, username=username, url=url) + if kind == EntryType.PASSWORD.value: + self.main.entries.add_entry(label, length, username=username, url=url) + elif kind == EntryType.TOTP.value: + self.main.entries.add_totp(label) + elif kind == EntryType.SSH.value: + self.main.entries.add_ssh_key(label) + elif kind == EntryType.SEED.value: + self.main.entries.add_seed(label) + elif kind == EntryType.PGP.value: + self.main.entries.add_pgp_key(label) + elif kind == EntryType.NOSTR.value: + self.main.entries.add_nostr_key(label) + elif kind == EntryType.KEY_VALUE.value: + self.main.entries.add_key_value(label, value or "") + elif kind == EntryType.MANAGED_ACCOUNT.value: + self.main.entries.add_managed_account(label) else: - self.main.entries.modify_entry( - self.entry_id, username=username, url=url, label=label - ) + kwargs = {"label": label} + if kind == EntryType.PASSWORD.value: + kwargs.update({"username": username, "url": url}) + elif kind == EntryType.KEY_VALUE.value: + kwargs.update({"value": value}) + self.main.entries.modify_entry(self.entry_id, **kwargs) self.main.refresh_entries() self.close() From c2809032fd4dd802f9cc87cf00c9940a797f4a0c Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 18 Jul 2025 22:41:53 -0400 Subject: [PATCH 40/75] Add TOTP viewer window --- src/seedpass_gui/app.py | 54 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/src/seedpass_gui/app.py b/src/seedpass_gui/app.py index ea6a1cb..2620f99 100644 --- a/src/seedpass_gui/app.py +++ b/src/seedpass_gui/app.py @@ -1,12 +1,15 @@ from __future__ import annotations +import asyncio +import time + import toga from toga.style import Pack from toga.style.pack import COLUMN, ROW -from seedpass.core.manager import PasswordManager from seedpass.core.entry_types import EntryType -import time +from seedpass.core.manager import PasswordManager +from seedpass.core.totp import TotpManager from seedpass.core.api import ( VaultService, @@ -95,12 +98,14 @@ class MainWindow(toga.Window): edit_button = toga.Button("Edit", on_press=self.edit_entry) search_button = toga.Button("Search", on_press=self.search_entries) relay_button = toga.Button("Relays", on_press=self.manage_relays) + totp_button = toga.Button("TOTP", on_press=self.show_totp_codes) button_box = toga.Box(style=Pack(direction=ROW, padding_top=5)) button_box.add(add_button) button_box.add(edit_button) button_box.add(search_button) button_box.add(relay_button) + button_box.add(totp_button) self.status = toga.Label("Last sync: never", style=Pack(padding_top=5)) @@ -148,6 +153,10 @@ class MainWindow(toga.Window): dlg = RelayManagerDialog(self, self.nostr) dlg.show() + def show_totp_codes(self, widget: toga.Widget) -> None: + win = TotpViewerWindow(self.controller, self.entries) + win.show() + # --- PubSub callbacks ------------------------------------------------- def sync_started(self, *args: object, **kwargs: object) -> None: self.status.text = "Syncing..." @@ -282,6 +291,47 @@ class SearchDialog(toga.Window): self.close() +class TotpViewerWindow(toga.Window): + """Window displaying active TOTP codes.""" + + def __init__(self, controller: SeedPassApp, entries: EntryService) -> None: + super().__init__("TOTP Codes", on_close=self.cleanup) + self.controller = controller + self.entries = entries + + self.table = toga.Table( + headings=["Label", "Code", "Seconds"], + style=Pack(flex=1), + ) + + box = toga.Box(style=Pack(direction=COLUMN, padding=20)) + box.add(self.table) + self.content = box + + self._running = True + self.controller.loop.create_task(self._update_loop()) + self.refresh_codes() + + async def _update_loop(self) -> None: + while self._running: + self.refresh_codes() + await asyncio.sleep(1) + + def refresh_codes(self) -> None: + self.table.data = [] + for idx, label, *_rest in self.entries.list_entries( + filter_kind=EntryType.TOTP.value + ): + entry = self.entries.retrieve_entry(idx) + code = self.entries.get_totp_code(idx) + period = int(entry.get("period", 30)) if entry else 30 + remaining = TotpManager.time_remaining(period) + self.table.data.append((label, code, remaining)) + + def cleanup(self, *args: object, **kwargs: object) -> None: + self._running = False + + class RelayManagerDialog(toga.Window): """Dialog for managing relay URLs.""" From fedd0c352a588b8508c05b5530ff8bfe3bbd6d3a Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 18 Jul 2025 22:56:20 -0400 Subject: [PATCH 41/75] test: cover new gui entry types --- src/tests/test_gui_features.py | 40 +++++++++++++++- src/tests/test_gui_headless.py | 85 ++++++++++++++++++++++++++++++++-- 2 files changed, 118 insertions(+), 7 deletions(-) diff --git a/src/tests/test_gui_features.py b/src/tests/test_gui_features.py index 13769a5..7eb4b6a 100644 --- a/src/tests/test_gui_features.py +++ b/src/tests/test_gui_features.py @@ -8,6 +8,7 @@ pytestmark = pytest.mark.desktop from seedpass.core.pubsub import bus from seedpass_gui.app import MainWindow, RelayManagerDialog +import seedpass_gui.app class DummyNostr: @@ -25,12 +26,24 @@ class DummyNostr: class DummyEntries: - def list_entries(self): - return [] + def __init__(self): + self.data = [(1, "Example", None, None, False)] + self.code = "111111" + + def list_entries(self, sort_by="index", filter_kind=None, include_archived=False): + if filter_kind: + return [(idx, label, None, None, False) for idx, label, *_ in self.data] + return self.data def search_entries(self, q): return [] + def retrieve_entry(self, idx): + return {"period": 30} + + def get_totp_code(self, idx): + return self.code + class DummyController: def __init__(self): @@ -76,3 +89,26 @@ def test_status_bar_updates_and_lock(): bus.publish("vault_locked") assert getattr(ctrl, "locked", False) assert ctrl.main_window is None + + +def test_totp_viewer_refresh_on_sync(monkeypatch): + toga.App("T3", "o3") + ctrl = DummyController() + nostr = DummyNostr() + entries = DummyEntries() + win = MainWindow(ctrl, None, entries, nostr) + ctrl.main_window = win + ctrl.loop = types.SimpleNamespace(create_task=lambda c: None) + + # prevent background loop from running + monkeypatch.setattr( + seedpass_gui.app.TotpViewerWindow, "_update_loop", lambda self: None + ) + + viewer = seedpass_gui.app.TotpViewerWindow(ctrl, entries) + bus.subscribe("sync_finished", viewer.refresh_codes) + + assert viewer.table.data[0][1] == "111111" + entries.code = "222222" + bus.publish("sync_finished") + assert viewer.table.data[0][1] == "222222" diff --git a/src/tests/test_gui_headless.py b/src/tests/test_gui_headless.py index ef5977b..cf371e8 100644 --- a/src/tests/test_gui_headless.py +++ b/src/tests/test_gui_headless.py @@ -2,6 +2,9 @@ import os from types import SimpleNamespace import toga +import pytest + +from seedpass.core.entry_types import EntryType from seedpass_gui.app import LockScreenWindow, MainWindow, EntryDialog @@ -26,11 +29,39 @@ class FakeEntries: return [] def add_entry(self, label, length, username=None, url=None): - self.added.append((label, length, username, url)) + self.added.append(("password", label, length, username, url)) return 1 - def modify_entry(self, entry_id, username=None, url=None, label=None): - self.modified.append((entry_id, username, url, label)) + def add_totp(self, label): + self.added.append(("totp", label)) + return 1 + + def add_ssh_key(self, label): + self.added.append(("ssh", label)) + return 1 + + def add_seed(self, label): + self.added.append(("seed", label)) + return 1 + + def add_pgp_key(self, label): + self.added.append(("pgp", label)) + return 1 + + def add_nostr_key(self, label): + self.added.append(("nostr", label)) + return 1 + + def add_key_value(self, label, value): + self.added.append(("key_value", label, value)) + return 1 + + def add_managed_account(self, label): + self.added.append(("managed_account", label)) + return 1 + + def modify_entry(self, entry_id, username=None, url=None, label=None, value=None): + self.modified.append((entry_id, username, url, label, value)) def setup_module(module): @@ -65,16 +96,60 @@ def test_unlock_creates_main_window(): controller.main_window.cleanup() -def test_entrydialog_add_calls_service(): +@pytest.mark.parametrize( + "kind,expect", + [ + (EntryType.PASSWORD.value, ("password", "L", 12, "u", "x")), + (EntryType.TOTP.value, ("totp", "L")), + (EntryType.SSH.value, ("ssh", "L")), + (EntryType.SEED.value, ("seed", "L")), + (EntryType.PGP.value, ("pgp", "L")), + (EntryType.NOSTR.value, ("nostr", "L")), + (EntryType.KEY_VALUE.value, ("key_value", "L", "val")), + (EntryType.MANAGED_ACCOUNT.value, ("managed_account", "L")), + ], +) +def test_entrydialog_add_calls_service(kind, expect): toga.App("Test2", "org.example2") entries = FakeEntries() main = SimpleNamespace(entries=entries, refresh_entries=lambda: None) dlg = EntryDialog(main, None) dlg.label_input.value = "L" + dlg.kind_input.value = kind dlg.username_input.value = "u" dlg.url_input.value = "x" dlg.length_input.value = 12 + dlg.value_input.value = "val" dlg.save(None) - assert entries.added == [("L", 12, "u", "x")] + assert entries.added[-1] == expect + + +@pytest.mark.parametrize( + "kind,expected", + [ + (EntryType.PASSWORD.value, (1, "newu", "newx", "New", None)), + (EntryType.KEY_VALUE.value, (1, None, None, "New", "val2")), + (EntryType.TOTP.value, (1, None, None, "New", None)), + ], +) +def test_entrydialog_edit_calls_service(kind, expected): + toga.App("Edit", "org.edit") + entries = FakeEntries() + + def retrieve(_id): + return {"kind": kind} + + entries.retrieve_entry = retrieve + + main = SimpleNamespace(entries=entries, refresh_entries=lambda: None) + dlg = EntryDialog(main, 1) + dlg.label_input.value = "New" + dlg.kind_input.value = kind + dlg.username_input.value = "newu" + dlg.url_input.value = "newx" + dlg.value_input.value = "val2" + dlg.save(None) + + assert entries.modified[-1] == expected From c3e2ff4b4b740177b65194762d9d67a067724ebc Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 18 Jul 2025 23:06:07 -0400 Subject: [PATCH 42/75] docs: document GUI event subscriptions --- .../01-getting-started/06-gui_adapter.md | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/docs/docs/content/01-getting-started/06-gui_adapter.md b/docs/docs/content/01-getting-started/06-gui_adapter.md index 54215c7..779fec4 100644 --- a/docs/docs/content/01-getting-started/06-gui_adapter.md +++ b/docs/docs/content/01-getting-started/06-gui_adapter.md @@ -116,3 +116,24 @@ events are published on the internal pubsub bus. When a ``vault_locked`` event is emitted, the GUI automatically returns to the lock screen so the session can be reopened with the master password. + +## Event Handling + +The GUI subscribes to a few core events so the interface reacts automatically when the vault changes state. When `MainWindow` is created it registers callbacks for `sync_started`, `sync_finished` and `vault_locked` on the global pubsub `bus`: + +```python +bus.subscribe("sync_started", self.sync_started) +bus.subscribe("sync_finished", self.sync_finished) +bus.subscribe("vault_locked", self.vault_locked) +``` + +Each handler updates the status bar or returns to the lock screen. The `cleanup` method removes these hooks when the window closes: + +```python +def cleanup(self, *args: object, **kwargs: object) -> None: + bus.unsubscribe("sync_started", self.sync_started) + bus.unsubscribe("sync_finished", self.sync_finished) + bus.unsubscribe("vault_locked", self.vault_locked) +``` + +The [TOTP window](../../02-api_reference.md#totp) demonstrates how such events keep the UI fresh: it shows live two-factor codes that reflect the latest vault data after synchronization. From c6a87e000d9d19a20dedaa3c5fe7585323a47a64 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 19 Jul 2025 12:09:39 -0400 Subject: [PATCH 43/75] docs: clarify GUI backend requirement --- README.md | 16 ++++++++++++++++ .../content/01-getting-started/06-gui_adapter.md | 15 +++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/README.md b/README.md index 9b0ce21..587c9b8 100644 --- a/README.md +++ b/README.md @@ -241,6 +241,22 @@ python -m seedpass_gui seedpass-gui ``` +To display the interface, you must also install the Toga backend for your +platform. Only `toga-core` and the headless `toga-dummy` backend are included +in the requirements file. Depending on your operating system install one of the +following packages: + +```bash +# Linux +pip install toga-gtk + +# Windows +pip install toga-winforms + +# macOS +pip install toga-cocoa +``` + The GUI works with the same vault and configuration files as the CLI. ```mermaid diff --git a/docs/docs/content/01-getting-started/06-gui_adapter.md b/docs/docs/content/01-getting-started/06-gui_adapter.md index 779fec4..b0531b9 100644 --- a/docs/docs/content/01-getting-started/06-gui_adapter.md +++ b/docs/docs/content/01-getting-started/06-gui_adapter.md @@ -14,6 +14,21 @@ python -m seedpass_gui seedpass-gui ``` +Only the headless `toga-dummy` backend ships with the project for automated +tests. To actually display windows you need a platform-specific backend such as +`toga-gtk`, `toga-winforms`, or `toga-cocoa`. + +```bash +# Linux +pip install toga-gtk + +# Windows +pip install toga-winforms + +# macOS +pip install toga-cocoa +``` + The GUI shares the same encrypted vault and configuration as the command line tool. To generate a packaged binary, run `briefcase build` (after the initial `briefcase create`). From 70e05970f098b448cea7a675551087903f1c7fcf Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 19 Jul 2025 12:16:30 -0400 Subject: [PATCH 44/75] Document GUI backend requirement and handle missing backend --- README.md | 9 +++++++++ .../01-getting-started/01-advanced_cli.md | 4 ++++ .../01-getting-started/06-gui_adapter.md | 5 +++++ docs/docs/content/index.md | 4 ++++ src/seedpass/cli.py | 17 +++++++++++++++++ src/tests/test_typer_cli.py | 8 ++++++++ 6 files changed, 47 insertions(+) diff --git a/README.md b/README.md index 9b0ce21..448832e 100644 --- a/README.md +++ b/README.md @@ -241,6 +241,11 @@ python -m seedpass_gui seedpass-gui ``` +Install a platform-specific BeeWare backend before running these commands. Only +the headless `toga-dummy` backend is bundled for tests. Linux users should +install `toga-gtk`, Windows users need `toga-winforms`, and macOS users require +`toga-cocoa`. + The GUI works with the same vault and configuration files as the CLI. ```mermaid @@ -555,6 +560,10 @@ If the checksum file is missing, generate it manually: python scripts/update_checksum.py ``` +If SeedPass prints a "script checksum mismatch" warning on startup, regenerate +the checksum with `seedpass util update-checksum` or select "Generate Script +Checksum" from the Settings menu. + To run mutation tests locally, generate coverage data first and then execute `mutmut`: ```bash diff --git a/docs/docs/content/01-getting-started/01-advanced_cli.md b/docs/docs/content/01-getting-started/01-advanced_cli.md index 1c0bed6..3c86dcc 100644 --- a/docs/docs/content/01-getting-started/01-advanced_cli.md +++ b/docs/docs/content/01-getting-started/01-advanced_cli.md @@ -116,6 +116,10 @@ Miscellaneous helper commands. | Verify script checksum | `util verify-checksum` | `seedpass util verify-checksum` | | Update script checksum | `util update-checksum` | `seedpass util update-checksum` | +If you see a startup warning about a script checksum mismatch, +run `seedpass util update-checksum` or choose "Generate Script Checksum" +from the Settings menu to update the stored value. + ### API Commands Run or stop the local HTTP API. diff --git a/docs/docs/content/01-getting-started/06-gui_adapter.md b/docs/docs/content/01-getting-started/06-gui_adapter.md index 779fec4..e2c4917 100644 --- a/docs/docs/content/01-getting-started/06-gui_adapter.md +++ b/docs/docs/content/01-getting-started/06-gui_adapter.md @@ -14,6 +14,11 @@ python -m seedpass_gui seedpass-gui ``` +Install a platform-specific BeeWare backend before launching the GUI. The +distribution only bundles the headless `toga-dummy` backend for automated +tests. Linux users should install `toga-gtk`, Windows users need +`toga-winforms`, and macOS users require `toga-cocoa`. + The GUI shares the same encrypted vault and configuration as the command line tool. To generate a packaged binary, run `briefcase build` (after the initial `briefcase create`). diff --git a/docs/docs/content/index.md b/docs/docs/content/index.md index 35b069d..d272206 100644 --- a/docs/docs/content/index.md +++ b/docs/docs/content/index.md @@ -497,6 +497,10 @@ If the checksum file is missing, generate it manually: python scripts/update_checksum.py ``` +If SeedPass reports a "script checksum mismatch" warning on startup, +regenerate the checksum with `seedpass util update-checksum` or select +"Generate Script Checksum" from the Settings menu. + To run mutation tests locally, generate coverage data first and then execute `mutmut`: ```bash diff --git a/src/seedpass/cli.py b/src/seedpass/cli.py index df7f56c..bddc7ee 100644 --- a/src/seedpass/cli.py +++ b/src/seedpass/cli.py @@ -24,6 +24,7 @@ import uvicorn from . import api as api_module import importlib +import importlib.util app = typer.Typer( help="SeedPass command line interface", @@ -95,6 +96,14 @@ def _get_nostr_service(ctx: typer.Context) -> NostrService: return NostrService(pm) +def _gui_backend_available() -> bool: + """Return True if a platform-specific BeeWare backend is installed.""" + for pkg in ("toga_gtk", "toga_winforms", "toga_cocoa"): + if importlib.util.find_spec(pkg) is not None: + return True + return False + + @app.callback(invoke_without_command=True) def main(ctx: typer.Context, fingerprint: Optional[str] = fingerprint_option) -> None: """SeedPass CLI entry point. @@ -740,6 +749,14 @@ def api_stop(ctx: typer.Context, host: str = "127.0.0.1", port: int = 8000) -> N @app.command() def gui() -> None: """Launch the BeeWare GUI.""" + if not _gui_backend_available(): + typer.echo( + "No BeeWare GUI backend found. Install 'toga-gtk' (Linux), " + "'toga-winforms' (Windows) or 'toga-cocoa' (macOS) to run the GUI.", + err=True, + ) + raise typer.Exit(1) + from seedpass_gui.app import main main() diff --git a/src/tests/test_typer_cli.py b/src/tests/test_typer_cli.py index c2db4da..fc48c82 100644 --- a/src/tests/test_typer_cli.py +++ b/src/tests/test_typer_cli.py @@ -557,6 +557,14 @@ def test_gui_command(monkeypatch): "seedpass_gui.app", SimpleNamespace(main=fake_main), ) + monkeypatch.setattr(cli.importlib.util, "find_spec", lambda n: True) result = runner.invoke(app, ["gui"]) assert result.exit_code == 0 assert called.get("called") is True + + +def test_gui_command_no_backend(monkeypatch): + monkeypatch.setattr(cli.importlib.util, "find_spec", lambda n: None) + result = runner.invoke(app, ["gui"]) + assert result.exit_code == 1 + assert "BeeWare GUI backend" in result.stderr From ad3f1bc80c3b3cbfcd6ea813b93e28d897ca83c1 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 19 Jul 2025 13:00:35 -0400 Subject: [PATCH 45/75] Add platform-specific Toga installation --- scripts/install.sh | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scripts/install.sh b/scripts/install.sh index cb07099..1fa2de1 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -120,6 +120,14 @@ main() { pip install --upgrade pip pip install -r src/requirements.txt pip install -e . + print_info "Installing platform-specific Toga backend..." + if [ "$OS_NAME" = "Linux" ]; then + print_info "Installing toga-gtk for Linux..." + pip install toga-gtk + elif [ "$OS_NAME" = "Darwin" ]; then + print_info "Installing toga-cocoa for macOS..." + pip install toga-cocoa + fi deactivate # 7. Create launcher script From d7df6679bd726fc39a5c82b78ba9c0847eecf467 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 19 Jul 2025 13:09:31 -0400 Subject: [PATCH 46/75] Install toga-winforms backend on Windows --- scripts/install.ps1 | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index d843b8b..fcb09fa 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -260,6 +260,10 @@ if ($LASTEXITCODE -ne 0) { Write-Error "Failed to install SeedPass package" } +Write-Info "Installing BeeWare GUI backend..." +& "$VenvDir\Scripts\python.exe" -m pip install toga-winforms +if ($LASTEXITCODE -ne 0) { Write-Warning "Failed to install GUI backend" } + # 5. Create launcher script Write-Info "Creating launcher script..." if (-not (Test-Path $LauncherDir)) { New-Item -ItemType Directory -Path $LauncherDir | Out-Null } From bfe65c97077aa487919a6a89e166cafc4a575cb9 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 19 Jul 2025 13:19:20 -0400 Subject: [PATCH 47/75] docs: mention automatic BeeWare backend install --- README.md | 9 +++++---- docs/docs/content/01-getting-started/06-gui_adapter.md | 7 ++++--- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 4a24aab..f80cf8a 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,7 @@ See `docs/ARCHITECTURE.md` for details. ### Quick Installer Use the automated installer to download SeedPass and its dependencies in one step. +The scripts also install the correct BeeWare backend for your platform automatically. **Linux and macOS:** ```bash @@ -241,10 +242,10 @@ python -m seedpass_gui seedpass-gui ``` -To display the interface, you must also install the Toga backend for your -platform. Only `toga-core` and the headless `toga-dummy` backend are included -in the requirements file. Depending on your operating system install one of the -following packages: +Only `toga-core` and the headless `toga-dummy` backend are included by default. +The quick installer automatically installs the correct BeeWare backend so the +GUI works out of the box. If you set up SeedPass manually, install the backend +for your platform: ```bash # Linux diff --git a/docs/docs/content/01-getting-started/06-gui_adapter.md b/docs/docs/content/01-getting-started/06-gui_adapter.md index b0531b9..fe77b86 100644 --- a/docs/docs/content/01-getting-started/06-gui_adapter.md +++ b/docs/docs/content/01-getting-started/06-gui_adapter.md @@ -14,9 +14,10 @@ python -m seedpass_gui seedpass-gui ``` -Only the headless `toga-dummy` backend ships with the project for automated -tests. To actually display windows you need a platform-specific backend such as -`toga-gtk`, `toga-winforms`, or `toga-cocoa`. +Only `toga-core` and the headless `toga-dummy` backend ship with the project. +The installation scripts automatically install the correct BeeWare backend so +the GUI works out of the box. If you set up SeedPass manually, install the +backend for your platform: ```bash # Linux From 04862bce4834c3935438f44c03f4d9f4c4eee73f Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 19 Jul 2025 13:43:49 -0400 Subject: [PATCH 48/75] install: ensure cairo installed --- README.md | 4 ++++ .../content/01-getting-started/06-gui_adapter.md | 3 +++ scripts/install.sh | 15 ++++++++++----- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index f80cf8a..2e0983b 100644 --- a/README.md +++ b/README.md @@ -251,6 +251,10 @@ for your platform: # Linux pip install toga-gtk +# If you see build errors about "cairo" on Linux, install the cairo +# development headers using your package manager, e.g.: +sudo apt-get install libcairo2 libcairo2-dev + # Windows pip install toga-winforms diff --git a/docs/docs/content/01-getting-started/06-gui_adapter.md b/docs/docs/content/01-getting-started/06-gui_adapter.md index fe77b86..dcb2dac 100644 --- a/docs/docs/content/01-getting-started/06-gui_adapter.md +++ b/docs/docs/content/01-getting-started/06-gui_adapter.md @@ -23,6 +23,9 @@ backend for your platform: # Linux pip install toga-gtk +# If installation fails with cairo errors, install libcairo2-dev or the +# cairo development package using your distro's package manager. + # Windows pip install toga-winforms diff --git a/scripts/install.sh b/scripts/install.sh index 1fa2de1..95bc6a4 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -86,13 +86,18 @@ main() { # 3. Install OS-specific dependencies print_info "Checking for build dependencies..." if [ "$OS_NAME" = "Linux" ]; then - if command -v apt-get &> /dev/null; then sudo apt-get update && sudo apt-get install -y build-essential pkg-config xclip; - elif command -v dnf &> /dev/null; then sudo dnf groupinstall -y "Development Tools" && sudo dnf install -y pkg-config xclip; - elif command -v pacman &> /dev/null; then sudo pacman -Syu --noconfirm base-devel pkg-config xclip; - else print_warning "Could not detect package manager. Ensure build tools and pkg-config are installed."; fi + if command -v apt-get &> /dev/null; then + sudo apt-get update && sudo apt-get install -y build-essential pkg-config xclip libcairo2 libcairo2-dev + elif command -v dnf &> /dev/null; then + sudo dnf groupinstall -y "Development Tools" && sudo dnf install -y pkg-config cairo cairo-devel xclip + elif command -v pacman &> /dev/null; then + sudo pacman -Syu --noconfirm base-devel pkg-config cairo xclip + else + print_warning "Could not detect package manager. Ensure build tools, cairo, and pkg-config are installed." + fi elif [ "$OS_NAME" = "Darwin" ]; then if ! command -v brew &> /dev/null; then print_error "Homebrew not installed. See https://brew.sh/"; fi - brew install pkg-config + brew install pkg-config cairo fi # 4. Clone or update the repository From 64c174c385e318ea6fc3fbb7ed80b6dfb03146c7 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 19 Jul 2025 13:53:02 -0400 Subject: [PATCH 49/75] Fix CLI modify error on mac --- src/seedpass/cli.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/seedpass/cli.py b/src/seedpass/cli.py index bddc7ee..3fceb5a 100644 --- a/src/seedpass/cli.py +++ b/src/seedpass/cli.py @@ -3,6 +3,7 @@ from typing import Optional, List import json import typer +import sys from seedpass.core.manager import PasswordManager from seedpass.core.entry_types import EntryType @@ -368,6 +369,7 @@ def entry_modify( ) except ValueError as e: typer.echo(str(e)) + sys.stdout.flush() raise typer.Exit(code=1) From a01e0f0037ba3942b112e9c4d628b87e174bb907 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 19 Jul 2025 14:11:34 -0400 Subject: [PATCH 50/75] fix: install missing deps for pygobject --- scripts/install.sh | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index 95bc6a4..cf8155e 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -87,11 +87,18 @@ main() { print_info "Checking for build dependencies..." if [ "$OS_NAME" = "Linux" ]; then if command -v apt-get &> /dev/null; then - sudo apt-get update && sudo apt-get install -y build-essential pkg-config xclip libcairo2 libcairo2-dev + sudo apt-get update && sudo apt-get install -y \ + build-essential pkg-config xclip \ + libcairo2 libcairo2-dev \ + libgirepository-1.0-dev gobject-introspection \ + gir1.2-gtk-3.0 python3-dev elif command -v dnf &> /dev/null; then - sudo dnf groupinstall -y "Development Tools" && sudo dnf install -y pkg-config cairo cairo-devel xclip + sudo dnf groupinstall -y "Development Tools" && sudo dnf install -y \ + pkg-config cairo cairo-devel xclip \ + gobject-introspection-devel cairo-devel gtk3-devel python3-devel elif command -v pacman &> /dev/null; then - sudo pacman -Syu --noconfirm base-devel pkg-config cairo xclip + sudo pacman -Syu --noconfirm base-devel pkg-config cairo xclip \ + gobject-introspection cairo gtk3 python else print_warning "Could not detect package manager. Ensure build tools, cairo, and pkg-config are installed." fi From c80495eca66491f08bc187223405b547c8b92515 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 19 Jul 2025 14:21:24 -0400 Subject: [PATCH 51/75] fix linux dependencies --- scripts/install.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/install.sh b/scripts/install.sh index cf8155e..d697326 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -90,7 +90,8 @@ main() { sudo apt-get update && sudo apt-get install -y \ build-essential pkg-config xclip \ libcairo2 libcairo2-dev \ - libgirepository-1.0-dev gobject-introspection \ + libgirepository-2.0-dev gir1.2-girepository-2.0 \ + gobject-introspection \ gir1.2-gtk-3.0 python3-dev elif command -v dnf &> /dev/null; then sudo dnf groupinstall -y "Development Tools" && sudo dnf install -y \ From 9fc117b105c67aae4c580dcc5d5c622ce878fef1 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 19 Jul 2025 14:46:16 -0400 Subject: [PATCH 52/75] feat(cli): auto install GUI backend --- src/seedpass/cli.py | 32 +++++++++++++++++++++++++++++--- src/tests/test_typer_cli.py | 34 +++++++++++++++++++++++++++++++--- 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/src/seedpass/cli.py b/src/seedpass/cli.py index 3fceb5a..7cc3b20 100644 --- a/src/seedpass/cli.py +++ b/src/seedpass/cli.py @@ -26,6 +26,7 @@ from . import api as api_module import importlib import importlib.util +import subprocess app = typer.Typer( help="SeedPass command line interface", @@ -750,11 +751,36 @@ def api_stop(ctx: typer.Context, host: str = "127.0.0.1", port: int = 8000) -> N @app.command() def gui() -> None: - """Launch the BeeWare GUI.""" + """Launch the BeeWare GUI. + + If the platform specific backend is missing, attempt to install it and + retry launching the GUI. + """ + if not _gui_backend_available(): + if sys.platform.startswith("linux"): + pkg = "toga-gtk" + elif sys.platform == "win32": + pkg = "toga-winforms" + elif sys.platform == "darwin": + pkg = "toga-cocoa" + else: + typer.echo( + f"Unsupported platform '{sys.platform}' for BeeWare GUI.", + err=True, + ) + raise typer.Exit(1) + + typer.echo(f"Attempting to install {pkg} for GUI support...") + try: + subprocess.check_call([sys.executable, "-m", "pip", "install", pkg]) + typer.echo(f"Successfully installed {pkg}.") + except subprocess.CalledProcessError as exc: + typer.echo(f"Failed to install {pkg}: {exc}", err=True) + raise typer.Exit(1) + if not _gui_backend_available(): typer.echo( - "No BeeWare GUI backend found. Install 'toga-gtk' (Linux), " - "'toga-winforms' (Windows) or 'toga-cocoa' (macOS) to run the GUI.", + "BeeWare GUI backend still unavailable after installation attempt.", err=True, ) raise typer.Exit(1) diff --git a/src/tests/test_typer_cli.py b/src/tests/test_typer_cli.py index fc48c82..35b3d0d 100644 --- a/src/tests/test_typer_cli.py +++ b/src/tests/test_typer_cli.py @@ -564,7 +564,35 @@ def test_gui_command(monkeypatch): def test_gui_command_no_backend(monkeypatch): - monkeypatch.setattr(cli.importlib.util, "find_spec", lambda n: None) + """Install backend if missing and launch GUI.""" + + call_count = {"n": 0} + + def backend_available() -> bool: + call_count["n"] += 1 + return call_count["n"] > 1 + + monkeypatch.setattr(cli, "_gui_backend_available", backend_available) + + installed = {} + + def fake_check_call(cmd): + installed["cmd"] = cmd + + monkeypatch.setattr(cli.subprocess, "check_call", fake_check_call) + + called = {} + + def fake_main(): + called["gui"] = True + + monkeypatch.setitem( + sys.modules, + "seedpass_gui.app", + SimpleNamespace(main=fake_main), + ) + result = runner.invoke(app, ["gui"]) - assert result.exit_code == 1 - assert "BeeWare GUI backend" in result.stderr + assert result.exit_code == 0 + assert installed.get("cmd") is not None + assert called.get("gui") is True From bdf6a038c2f559711737f615c0623a32b22e3824 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 19 Jul 2025 15:17:16 -0400 Subject: [PATCH 53/75] Enhance install scripts to warn about stale executables --- scripts/install.ps1 | 12 ++++++++++++ scripts/install.sh | 17 +++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index fcb09fa..0883899 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -283,6 +283,18 @@ if ($existingSeedpass -and $existingSeedpass.Source -ne $LauncherPath) { Write-Warning "Ensure '$LauncherDir' comes first in your PATH or remove the old installation." } +# Detect additional seedpass executables on PATH that are not our launcher +$allSeedpass = Get-Command seedpass -All -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source +$stale = @() +foreach ($cmd in $allSeedpass) { + if ($cmd -ne $LauncherPath) { $stale += $cmd } +} +if ($stale.Count -gt 0) { + Write-Warning "Stale 'seedpass' executables detected:" + foreach ($cmd in $stale) { Write-Warning " - $cmd" } + Write-Warning "Remove or rename these to avoid launching outdated code." +} + # 6. Add launcher directory to User's PATH if needed Write-Info "Checking if '$LauncherDir' is in your PATH..." $UserPath = [System.Environment]::GetEnvironmentVariable("Path", "User") diff --git a/scripts/install.sh b/scripts/install.sh index d697326..f6df808 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -159,6 +159,23 @@ EOF2 print_warning "Ensure '$LAUNCHER_DIR' comes first in your PATH or remove the old installation." fi + # Detect any additional seedpass executables on PATH that are not our launcher + IFS=':' read -ra _sp_paths <<< "$PATH" + stale_cmds=() + for _dir in "${_sp_paths[@]}"; do + _candidate="$_dir/seedpass" + if [ -x "$_candidate" ] && [ "$_candidate" != "$LAUNCHER_PATH" ]; then + stale_cmds+=("$_candidate") + fi + done + if [ ${#stale_cmds[@]} -gt 0 ]; then + print_warning "Stale 'seedpass' executables detected:" + for cmd in "${stale_cmds[@]}"; do + print_warning " - $cmd" + done + print_warning "Remove or rename these to avoid launching outdated code." + fi + # 8. Final instructions print_success "Installation/update complete!" print_info "You can now launch the interactive TUI by typing: seedpass" From ff1f8bb4e1b6b97d43f4d6f0b5c2677a5fcb4f0b Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 19 Jul 2025 15:50:52 -0400 Subject: [PATCH 54/75] Use background sync in entry service --- src/seedpass/core/api.py | 22 +++++++++---------- src/seedpass_gui/app.py | 27 ++++++++++++++++++++---- src/tests/test_cli_core_services.py | 5 +++-- src/tests/test_cli_doc_examples.py | 1 + src/tests/test_cli_entry_add_commands.py | 4 ++-- src/tests/test_core_services.py | 4 ++-- src/tests/test_typer_cli.py | 10 ++++----- 7 files changed, 47 insertions(+), 26 deletions(-) diff --git a/src/seedpass/core/api.py b/src/seedpass/core/api.py index d51a21e..03e8d99 100644 --- a/src/seedpass/core/api.py +++ b/src/seedpass/core/api.py @@ -282,7 +282,7 @@ class EntryService: ) -> int: with self._lock: idx = self._manager.entry_manager.add_entry(label, length, username, url) - self._manager.sync_vault() + self._manager.start_background_vault_sync() return idx def add_totp( @@ -303,7 +303,7 @@ class EntryService: period=period, digits=digits, ) - self._manager.sync_vault() + self._manager.start_background_vault_sync() return uri def add_ssh_key( @@ -320,7 +320,7 @@ class EntryService: index=index, notes=notes, ) - self._manager.sync_vault() + self._manager.start_background_vault_sync() return idx def add_pgp_key( @@ -341,7 +341,7 @@ class EntryService: user_id=user_id, notes=notes, ) - self._manager.sync_vault() + self._manager.start_background_vault_sync() return idx def add_nostr_key( @@ -357,7 +357,7 @@ class EntryService: index=index, notes=notes, ) - self._manager.sync_vault() + self._manager.start_background_vault_sync() return idx def add_seed( @@ -376,13 +376,13 @@ class EntryService: words_num=words, notes=notes, ) - self._manager.sync_vault() + self._manager.start_background_vault_sync() return idx def add_key_value(self, label: str, value: str, *, notes: str = "") -> int: with self._lock: idx = self._manager.entry_manager.add_key_value(label, value, notes=notes) - self._manager.sync_vault() + self._manager.start_background_vault_sync() return idx def add_managed_account( @@ -399,7 +399,7 @@ class EntryService: index=index, notes=notes, ) - self._manager.sync_vault() + self._manager.start_background_vault_sync() return idx def modify_entry( @@ -425,17 +425,17 @@ class EntryService: digits=digits, value=value, ) - self._manager.sync_vault() + self._manager.start_background_vault_sync() def archive_entry(self, entry_id: int) -> None: with self._lock: self._manager.entry_manager.archive_entry(entry_id) - self._manager.sync_vault() + self._manager.start_background_vault_sync() def restore_entry(self, entry_id: int) -> None: with self._lock: self._manager.entry_manager.restore_entry(entry_id) - self._manager.sync_vault() + self._manager.start_background_vault_sync() def export_totp_entries(self) -> dict: with self._lock: diff --git a/src/seedpass_gui/app.py b/src/seedpass_gui/app.py index 2620f99..dcc38ff 100644 --- a/src/seedpass_gui/app.py +++ b/src/seedpass_gui/app.py @@ -5,6 +5,7 @@ import time import toga from toga.style import Pack +from toga.sources import ListSource from toga.style.pack import COLUMN, ROW from seedpass.core.entry_types import EntryType @@ -89,8 +90,10 @@ class MainWindow(toga.Window): bus.subscribe("vault_locked", self.vault_locked) self.last_sync = None + self.entry_source = ListSource(["id", "label", "kind", "info1", "info2"]) self.table = toga.Table( headings=["ID", "Label", "Kind", "Info 1", "Info 2"], + data=self.entry_source, style=Pack(flex=1), ) @@ -118,7 +121,7 @@ class MainWindow(toga.Window): self.refresh_entries() def refresh_entries(self) -> None: - self.table.data = [] + self.entry_source.clear() for idx, label, username, url, _arch in self.entries.list_entries(): entry = self.entries.retrieve_entry(idx) kind = (entry or {}).get("kind", (entry or {}).get("type", "")) @@ -131,7 +134,15 @@ class MainWindow(toga.Window): info1 = entry.get("value", "") if entry else "" else: info1 = str(entry.get("index", "")) if entry else "" - self.table.data.append((idx, label, kind, info1, info2)) + self.entry_source.append( + { + "id": idx, + "label": label, + "kind": kind, + "info1": info1, + "info2": info2, + } + ) # --- Button handlers ------------------------------------------------- def add_entry(self, widget: toga.Widget) -> None: @@ -285,9 +296,17 @@ class SearchDialog(toga.Window): def do_search(self, widget: toga.Widget) -> None: query = self.query_input.value or "" results = self.main.entries.search_entries(query) - self.main.table.data = [] + self.main.entry_source.clear() for idx, label, username, url, _arch in results: - self.main.table.data.append((idx, label, username or "", url or "")) + self.main.entry_source.append( + { + "id": idx, + "label": label, + "kind": "", + "info1": username or "", + "info2": url or "", + } + ) self.close() diff --git a/src/tests/test_cli_core_services.py b/src/tests/test_cli_core_services.py index fd68b9a..1c79a42 100644 --- a/src/tests/test_cli_core_services.py +++ b/src/tests/test_cli_core_services.py @@ -36,7 +36,7 @@ def test_cli_entry_add_search_sync(monkeypatch): calls["search"] = (q, kinds) return [(1, "Label", None, None, False)] - def sync_vault(): + def start_background_vault_sync(): calls["sync"] = True return {"manifest_id": "m", "chunk_ids": [], "delta_ids": []} @@ -44,7 +44,8 @@ def test_cli_entry_add_search_sync(monkeypatch): entry_manager=SimpleNamespace( add_entry=add_entry, search_entries=search_entries ), - sync_vault=sync_vault, + start_background_vault_sync=start_background_vault_sync, + sync_vault=lambda: {"manifest_id": "m", "chunk_ids": [], "delta_ids": []}, select_fingerprint=lambda fp: None, ) monkeypatch.setattr(cli, "PasswordManager", lambda: pm) diff --git a/src/tests/test_cli_doc_examples.py b/src/tests/test_cli_doc_examples.py index 1518261..ac5ebb1 100644 --- a/src/tests/test_cli_doc_examples.py +++ b/src/tests/test_cli_doc_examples.py @@ -58,6 +58,7 @@ class DummyPM: "chunk_ids": ["c1"], "delta_ids": [], } + self.start_background_vault_sync = lambda *a, **k: self.sync_vault() self.config_manager = SimpleNamespace( load_config=lambda require_pin=False: {"inactivity_timeout": 30}, set_inactivity_timeout=lambda v: None, diff --git a/src/tests/test_cli_entry_add_commands.py b/src/tests/test_cli_entry_add_commands.py index 5fbeafd..dd482b3 100644 --- a/src/tests/test_cli_entry_add_commands.py +++ b/src/tests/test_cli_entry_add_commands.py @@ -115,14 +115,14 @@ def test_entry_add_commands( called["kwargs"] = kwargs return stdout - def sync_vault(): + def start_background_vault_sync(): called["sync"] = True pm = SimpleNamespace( entry_manager=SimpleNamespace(**{method: func}), parent_seed="seed", select_fingerprint=lambda fp: None, - sync_vault=sync_vault, + start_background_vault_sync=start_background_vault_sync, ) monkeypatch.setattr(cli, "PasswordManager", lambda: pm) result = runner.invoke(app, ["entry", command] + cli_args) diff --git a/src/tests/test_core_services.py b/src/tests/test_core_services.py index dc419c4..ea48a8a 100644 --- a/src/tests/test_core_services.py +++ b/src/tests/test_core_services.py @@ -29,7 +29,7 @@ def test_entry_service_add_entry_and_search(): called["search"] = (q, kinds) return [(5, "Example", username, url, False)] - def sync_vault(): + def start_background_vault_sync(): called["sync"] = True username = "user" @@ -38,7 +38,7 @@ def test_entry_service_add_entry_and_search(): entry_manager=SimpleNamespace( add_entry=add_entry, search_entries=search_entries ), - sync_vault=sync_vault, + start_background_vault_sync=start_background_vault_sync, ) service = EntryService(pm) idx = service.add_entry("Example", 12, username, url) diff --git a/src/tests/test_typer_cli.py b/src/tests/test_typer_cli.py index 35b3d0d..d49803c 100644 --- a/src/tests/test_typer_cli.py +++ b/src/tests/test_typer_cli.py @@ -377,7 +377,7 @@ def test_entry_add(monkeypatch): pm = SimpleNamespace( entry_manager=SimpleNamespace(add_entry=add_entry), select_fingerprint=lambda fp: None, - sync_vault=lambda: None, + start_background_vault_sync=lambda: None, ) monkeypatch.setattr(cli, "PasswordManager", lambda: pm) result = runner.invoke( @@ -408,7 +408,7 @@ def test_entry_modify(monkeypatch): pm = SimpleNamespace( entry_manager=SimpleNamespace(modify_entry=modify_entry), select_fingerprint=lambda fp: None, - sync_vault=lambda: None, + start_background_vault_sync=lambda: None, ) monkeypatch.setattr(cli, "PasswordManager", lambda: pm) result = runner.invoke(app, ["entry", "modify", "1", "--username", "alice"]) @@ -423,7 +423,7 @@ def test_entry_modify_invalid(monkeypatch): pm = SimpleNamespace( entry_manager=SimpleNamespace(modify_entry=modify_entry), select_fingerprint=lambda fp: None, - sync_vault=lambda: None, + start_background_vault_sync=lambda: None, ) monkeypatch.setattr(cli, "PasswordManager", lambda: pm) result = runner.invoke(app, ["entry", "modify", "1", "--username", "alice"]) @@ -440,7 +440,7 @@ def test_entry_archive(monkeypatch): pm = SimpleNamespace( entry_manager=SimpleNamespace(archive_entry=archive_entry), select_fingerprint=lambda fp: None, - sync_vault=lambda: None, + start_background_vault_sync=lambda: None, ) monkeypatch.setattr(cli, "PasswordManager", lambda: pm) result = runner.invoke(app, ["entry", "archive", "3"]) @@ -458,7 +458,7 @@ def test_entry_unarchive(monkeypatch): pm = SimpleNamespace( entry_manager=SimpleNamespace(restore_entry=restore_entry), select_fingerprint=lambda fp: None, - sync_vault=lambda: None, + start_background_vault_sync=lambda: None, ) monkeypatch.setattr(cli, "PasswordManager", lambda: pm) result = runner.invoke(app, ["entry", "unarchive", "4"]) From e6ca36b8b779739dfb118bf752e8092550f0d8d8 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 19 Jul 2025 17:18:46 -0400 Subject: [PATCH 55/75] Update EntryDialog to modify entry source --- src/seedpass_gui/app.py | 52 +++++++++++++++++++++++++++------- src/tests/test_gui_headless.py | 15 ++++++++-- 2 files changed, 54 insertions(+), 13 deletions(-) diff --git a/src/seedpass_gui/app.py b/src/seedpass_gui/app.py index dcc38ff..88ea909 100644 --- a/src/seedpass_gui/app.py +++ b/src/seedpass_gui/app.py @@ -251,29 +251,61 @@ class EntryDialog(toga.Window): if self.entry_id is None: if kind == EntryType.PASSWORD.value: - self.main.entries.add_entry(label, length, username=username, url=url) + entry_id = self.main.entries.add_entry( + label, length, username=username, url=url + ) elif kind == EntryType.TOTP.value: - self.main.entries.add_totp(label) + entry_id = self.main.entries.add_totp(label) elif kind == EntryType.SSH.value: - self.main.entries.add_ssh_key(label) + entry_id = self.main.entries.add_ssh_key(label) elif kind == EntryType.SEED.value: - self.main.entries.add_seed(label) + entry_id = self.main.entries.add_seed(label) elif kind == EntryType.PGP.value: - self.main.entries.add_pgp_key(label) + entry_id = self.main.entries.add_pgp_key(label) elif kind == EntryType.NOSTR.value: - self.main.entries.add_nostr_key(label) + entry_id = self.main.entries.add_nostr_key(label) elif kind == EntryType.KEY_VALUE.value: - self.main.entries.add_key_value(label, value or "") + entry_id = self.main.entries.add_key_value(label, value or "") elif kind == EntryType.MANAGED_ACCOUNT.value: - self.main.entries.add_managed_account(label) + entry_id = self.main.entries.add_managed_account(label) else: + entry_id = self.entry_id kwargs = {"label": label} if kind == EntryType.PASSWORD.value: kwargs.update({"username": username, "url": url}) elif kind == EntryType.KEY_VALUE.value: kwargs.update({"value": value}) - self.main.entries.modify_entry(self.entry_id, **kwargs) - self.main.refresh_entries() + self.main.entries.modify_entry(entry_id, **kwargs) + + entry = self.main.entries.retrieve_entry(entry_id) or {} + kind = entry.get("kind", entry.get("type", kind)) + info1 = "" + info2 = "" + if kind == EntryType.PASSWORD.value: + info1 = username or "" + info2 = url or "" + elif kind == EntryType.KEY_VALUE.value: + info1 = entry.get("value", value or "") + else: + info1 = str(entry.get("index", "")) + + row = { + "id": entry_id, + "label": label, + "kind": kind, + "info1": info1, + "info2": info2, + } + + if self.entry_id is None: + self.main.entry_source.append(row) + else: + for existing in self.main.entry_source: + if getattr(existing, "id", None) == entry_id: + for key, value in row.items(): + setattr(existing, key, value) + break + self.close() diff --git a/src/tests/test_gui_headless.py b/src/tests/test_gui_headless.py index cf371e8..dc895a8 100644 --- a/src/tests/test_gui_headless.py +++ b/src/tests/test_gui_headless.py @@ -1,5 +1,6 @@ import os from types import SimpleNamespace +from toga.sources import ListSource import toga import pytest @@ -112,7 +113,9 @@ def test_unlock_creates_main_window(): def test_entrydialog_add_calls_service(kind, expect): toga.App("Test2", "org.example2") entries = FakeEntries() - main = SimpleNamespace(entries=entries, refresh_entries=lambda: None) + entries.retrieve_entry = lambda _id: {"kind": kind} + source = ListSource(["id", "label", "kind", "info1", "info2"]) + main = SimpleNamespace(entries=entries, entry_source=source) dlg = EntryDialog(main, None) dlg.label_input.value = "L" @@ -124,6 +127,10 @@ def test_entrydialog_add_calls_service(kind, expect): dlg.save(None) assert entries.added[-1] == expect + assert len(main.entry_source) == 1 + row = main.entry_source[0] + assert row.label == "L" + assert row.kind == kind @pytest.mark.parametrize( @@ -142,8 +149,9 @@ def test_entrydialog_edit_calls_service(kind, expected): return {"kind": kind} entries.retrieve_entry = retrieve - - main = SimpleNamespace(entries=entries, refresh_entries=lambda: None) + source = ListSource(["id", "label", "kind", "info1", "info2"]) + source.append({"id": 1, "label": "Old", "kind": kind, "info1": "", "info2": ""}) + main = SimpleNamespace(entries=entries, entry_source=source) dlg = EntryDialog(main, 1) dlg.label_input.value = "New" dlg.kind_input.value = kind @@ -153,3 +161,4 @@ def test_entrydialog_edit_calls_service(kind, expected): dlg.save(None) assert entries.modified[-1] == expected + assert source[0].label == "New" From ec52d2eda0743546cbb3f07f157419b002a0b306 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 19 Jul 2025 18:14:47 -0400 Subject: [PATCH 56/75] Add vault sync trigger to GUI --- src/seedpass_gui/app.py | 12 ++++++ src/tests/test_gui_sync.py | 75 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 src/tests/test_gui_sync.py diff --git a/src/seedpass_gui/app.py b/src/seedpass_gui/app.py index 88ea909..b3041f5 100644 --- a/src/seedpass_gui/app.py +++ b/src/seedpass_gui/app.py @@ -102,6 +102,7 @@ class MainWindow(toga.Window): search_button = toga.Button("Search", on_press=self.search_entries) relay_button = toga.Button("Relays", on_press=self.manage_relays) totp_button = toga.Button("TOTP", on_press=self.show_totp_codes) + sync_button = toga.Button("Sync", on_press=self.start_vault_sync) button_box = toga.Box(style=Pack(direction=ROW, padding_top=5)) button_box.add(add_button) @@ -109,6 +110,7 @@ class MainWindow(toga.Window): button_box.add(search_button) button_box.add(relay_button) button_box.add(totp_button) + button_box.add(sync_button) self.status = toga.Label("Last sync: never", style=Pack(padding_top=5)) @@ -168,6 +170,14 @@ class MainWindow(toga.Window): win = TotpViewerWindow(self.controller, self.entries) win.show() + def start_vault_sync(self, widget: toga.Widget | None = None) -> None: + """Schedule a background vault synchronization.""" + + async def _runner() -> None: + self.nostr.start_background_vault_sync() + + self.controller.loop.create_task(_runner()) + # --- PubSub callbacks ------------------------------------------------- def sync_started(self, *args: object, **kwargs: object) -> None: self.status.text = "Syncing..." @@ -307,6 +317,8 @@ class EntryDialog(toga.Window): break self.close() + # schedule vault sync after saving + getattr(self.main, "start_vault_sync", lambda *_: None)() class SearchDialog(toga.Window): diff --git a/src/tests/test_gui_sync.py b/src/tests/test_gui_sync.py new file mode 100644 index 0000000..c17d66a --- /dev/null +++ b/src/tests/test_gui_sync.py @@ -0,0 +1,75 @@ +import os +import types +import asyncio +import toga +import pytest + +from seedpass.core.pubsub import bus +from seedpass_gui.app import MainWindow + + +class DummyEntries: + def list_entries(self, sort_by="index", filter_kind=None, include_archived=False): + return [] + + def search_entries(self, q): + return [] + + +class DummyNostr: + def __init__(self): + self.called = False + + def start_background_vault_sync(self): + self.called = True + + def list_relays(self): + return [] + + +class DummyController: + def __init__(self, loop): + self.loop = loop + self.lock_window = types.SimpleNamespace(show=lambda: None) + self.main_window = None + self.vault_service = None + self.entry_service = None + self.nostr_service = None + + +@pytest.fixture(autouse=True) +def set_backend(): + os.environ["TOGA_BACKEND"] = "toga_dummy" + asyncio.set_event_loop(asyncio.new_event_loop()) + + +def test_start_vault_sync_schedules_task(): + toga.App("T", "o") + + tasks = [] + + def create_task(coro): + tasks.append(coro) + + loop = types.SimpleNamespace(create_task=create_task) + ctrl = DummyController(loop) + nostr = DummyNostr() + win = MainWindow(ctrl, None, DummyEntries(), nostr) + + win.start_vault_sync() + assert tasks + asyncio.get_event_loop().run_until_complete(tasks[0]) + assert nostr.called + + +def test_status_updates_on_bus_events(): + toga.App("T2", "o2") + loop = types.SimpleNamespace(create_task=lambda c: None) + ctrl = DummyController(loop) + nostr = DummyNostr() + win = MainWindow(ctrl, None, DummyEntries(), nostr) + + bus.publish("sync_started") + assert win.status.text == "Syncing..." + bus.publish("sync_finished") + assert "Last sync:" in win.status.text From 557af9745a93e6773e0a9d95eafc7343286de532 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 19 Jul 2025 18:17:39 -0400 Subject: [PATCH 57/75] test: clean up GUI sync tests --- src/tests/test_gui_sync.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/tests/test_gui_sync.py b/src/tests/test_gui_sync.py index c17d66a..6ef1ed2 100644 --- a/src/tests/test_gui_sync.py +++ b/src/tests/test_gui_sync.py @@ -60,6 +60,7 @@ def test_start_vault_sync_schedules_task(): assert tasks asyncio.get_event_loop().run_until_complete(tasks[0]) assert nostr.called + win.cleanup() def test_status_updates_on_bus_events(): @@ -73,3 +74,4 @@ def test_status_updates_on_bus_events(): assert win.status.text == "Syncing..." bus.publish("sync_finished") assert "Last sync:" in win.status.text + win.cleanup() From 160a8fac51ca122d198620d33e432b714f95cf95 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 19 Jul 2025 19:11:59 -0400 Subject: [PATCH 58/75] CI: run desktop GUI tests --- .github/workflows/python-ci.yml | 12 ++++++++++++ scripts/run_gui_tests.sh | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100755 scripts/run_gui_tests.sh diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index 0087961..edd768f 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -84,12 +84,24 @@ jobs: timeout-minutes: 16 shell: bash run: scripts/run_ci_tests.sh + - name: Run desktop tests + timeout-minutes: 10 + shell: bash + env: + TOGA_BACKEND: toga_dummy + run: scripts/run_gui_tests.sh - name: Upload pytest log if: always() uses: actions/upload-artifact@v4 with: name: pytest-log-${{ matrix.os }} path: pytest.log + - name: Upload GUI pytest log + if: always() + uses: actions/upload-artifact@v4 + with: + name: gui-pytest-log-${{ matrix.os }} + path: pytest_gui.log - name: Upload coverage report uses: actions/upload-artifact@v4 with: diff --git a/scripts/run_gui_tests.sh b/scripts/run_gui_tests.sh new file mode 100755 index 0000000..3962394 --- /dev/null +++ b/scripts/run_gui_tests.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -eo pipefail + +pytest_args=(-vv --desktop -m desktop src/tests) +if [[ "${RUNNER_OS:-}" == "Windows" ]]; then + pytest_args+=(-n 1) +fi + +timeout_bin="timeout" +if ! command -v "$timeout_bin" >/dev/null 2>&1; then + if command -v gtimeout >/dev/null 2>&1; then + timeout_bin="gtimeout" + else + timeout_bin="" + fi +fi + +if [[ -n "$timeout_bin" ]]; then + $timeout_bin 10m pytest "${pytest_args[@]}" 2>&1 | tee pytest_gui.log + status=${PIPESTATUS[0]} +else + echo "timeout command not found; running tests without timeout" >&2 + pytest "${pytest_args[@]}" 2>&1 | tee pytest_gui.log + status=${PIPESTATUS[0]} +fi + +if [[ $status -eq 124 ]]; then + echo "::error::Desktop tests exceeded 10-minute limit" + tail -n 20 pytest_gui.log + exit 1 +fi +exit $status From 59dbb885aa82e8756f881702d26f7e4ad5cf40d8 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 19 Jul 2025 19:14:27 -0400 Subject: [PATCH 59/75] Improve sync error reporting --- src/nostr/client.py | 30 ++++++++++++++++++++++++++---- src/seedpass/core/manager.py | 24 +++++++++++++++++++++++- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/src/nostr/client.py b/src/nostr/client.py index 16fcc94..5d67977 100644 --- a/src/nostr/client.py +++ b/src/nostr/client.py @@ -467,17 +467,39 @@ class NostrClient: return None await self._connect_async() + self.last_error = None pubkey = self.keys.public_key() f = Filter().author(pubkey).kind(Kind(KIND_MANIFEST)).limit(3) timeout = timedelta(seconds=10) - events = (await self.client.fetch_events(f, timeout)).to_vec() + try: + events = (await self.client.fetch_events(f, timeout)).to_vec() + except Exception as e: # pragma: no cover - network errors + self.last_error = str(e) + logger.error( + "Failed to fetch manifest from relays %s: %s", + self.relays, + e, + ) + return None + if not events: return None for manifest_event in events: - result = await self._fetch_chunks_with_retry(manifest_event) - if result is not None: - return result + try: + result = await self._fetch_chunks_with_retry(manifest_event) + if result is not None: + return result + except Exception as e: # pragma: no cover - network errors + self.last_error = str(e) + logger.error( + "Error retrieving snapshot from relays %s: %s", + self.relays, + e, + ) + + if self.last_error is None: + self.last_error = "Snapshot not found on relays" return None diff --git a/src/seedpass/core/manager.py b/src/seedpass/core/manager.py index 5ae332a..bf44ba7 100644 --- a/src/seedpass/core/manager.py +++ b/src/seedpass/core/manager.py @@ -1162,6 +1162,16 @@ class PasswordManager: try: result = await self.nostr_client.fetch_latest_snapshot() if not result: + if self.nostr_client.last_error: + logger.warning( + "Unable to fetch latest snapshot from Nostr relays %s: %s", + self.nostr_client.relays, + self.nostr_client.last_error, + ) + self.notify( + f"Sync failed: {self.nostr_client.last_error}", + level="WARNING", + ) return manifest, chunks = result encrypted = gzip.decompress(b"".join(chunks)) @@ -1177,7 +1187,19 @@ class PasswordManager: ): logger.info("Local database synchronized from Nostr.") except Exception as e: - logger.warning(f"Unable to sync index from Nostr: {e}") + logger.warning( + "Unable to sync index from Nostr relays %s: %s", + self.nostr_client.relays, + e, + ) + if self.nostr_client.last_error: + logger.warning( + "NostrClient last error: %s", self.nostr_client.last_error + ) + self.notify( + f"Sync failed: {self.nostr_client.last_error or e}", + level="WARNING", + ) finally: if getattr(self, "verbose_timing", False): duration = time.perf_counter() - start From 3b0825633c2a5a2f766e1844861d503a2a53dbb0 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 19 Jul 2025 19:24:34 -0400 Subject: [PATCH 60/75] Fix TOTP viewer test for new Row API --- src/tests/test_gui_features.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/tests/test_gui_features.py b/src/tests/test_gui_features.py index 7eb4b6a..90f0279 100644 --- a/src/tests/test_gui_features.py +++ b/src/tests/test_gui_features.py @@ -108,7 +108,8 @@ def test_totp_viewer_refresh_on_sync(monkeypatch): viewer = seedpass_gui.app.TotpViewerWindow(ctrl, entries) bus.subscribe("sync_finished", viewer.refresh_codes) - assert viewer.table.data[0][1] == "111111" + # Table rows are Row objects with attribute access + assert viewer.table.data[0].code == "111111" entries.code = "222222" bus.publish("sync_finished") - assert viewer.table.data[0][1] == "222222" + assert viewer.table.data[0].code == "222222" From f5653c9bb1ec8d108e968df0be74e010f39ab223 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 19 Jul 2025 19:47:35 -0400 Subject: [PATCH 61/75] docs: add Windows Nostr troubleshooting --- README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.md b/README.md index 2e0983b..11c0af4 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,20 @@ Before running the script, install **Python 3.11** or **3.12** from [python.org] The Windows installer will attempt to install Git automatically if it is not already available. It also tries to install Python 3 using `winget`, `choco`, or `scoop` when Python is missing and recognizes the `py` launcher if `python` isn't on your PATH. If these tools are unavailable you'll see a link to download Python directly from . When Python 3.13 or newer is detected without the Microsoft C++ build tools, the installer now attempts to download Python 3.12 automatically so you don't have to compile packages from source. **Note:** If this fallback fails, install Python 3.12 manually or install the [Microsoft Visual C++ Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) and rerun the installer. + +#### Windows Nostr Sync Troubleshooting + +When backing up or restoring from Nostr on Windows, a few issues are common: + +* **Event loop errors** – Messages like `RuntimeError: Event loop is closed` usually mean the async runtime failed to initialize. Running SeedPass with `--verbose` provides more detail about which coroutine failed. +* **Permission problems** – If you see `Access is denied` when writing to `~/.seedpass`, launch your terminal with "Run as administrator" so the app can create files in your profile directory. +* **Missing dependencies** – Ensure `websockets` and other requirements are installed inside your virtual environment: + + ```bash + pip install websockets + ``` + +Using increased log verbosity helps diagnose sync issues and confirm that the WebSocket connections to your configured relays succeed. ### Uninstall Run the matching uninstaller if you need to remove a previous installation or clean up an old `seedpass` command: From b7042a70db3a79640fb57d6f2c016d6416a9f812 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 19 Jul 2025 22:20:58 -0400 Subject: [PATCH 62/75] Add CLI integration test --- src/tests/test_cli_integration.py | 87 +++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 src/tests/test_cli_integration.py diff --git a/src/tests/test_cli_integration.py b/src/tests/test_cli_integration.py new file mode 100644 index 0000000..84cb2e4 --- /dev/null +++ b/src/tests/test_cli_integration.py @@ -0,0 +1,87 @@ +import importlib +import shutil +from pathlib import Path +from types import SimpleNamespace + +from typer.testing import CliRunner + +from tests.helpers import TEST_SEED, TEST_PASSWORD + +import constants +import seedpass.core.manager as manager_module +import seedpass.cli as cli_module +import utils.password_prompt as pwd_prompt +import colorama + + +def test_cli_integration(monkeypatch, tmp_path): + # Redirect home directory so profiles are created under tmp_path + monkeypatch.setattr(Path, "home", lambda: tmp_path) + importlib.reload(constants) + importlib.reload(manager_module) + # Avoid colorama wrapping stdout which breaks CliRunner + colorama.deinit() + monkeypatch.setattr(pwd_prompt, "colorama_init", lambda: None) + importlib.reload(pwd_prompt) + importlib.reload(cli_module) + + runner = CliRunner() + + # Provide non-interactive responses + monkeypatch.setattr(manager_module, "prompt_seed_words", lambda *a, **k: TEST_SEED) + monkeypatch.setattr(manager_module, "prompt_new_password", lambda: TEST_PASSWORD) + monkeypatch.setattr(manager_module, "prompt_for_password", lambda: TEST_PASSWORD) + monkeypatch.setattr( + manager_module, "prompt_existing_password", lambda *a, **k: TEST_PASSWORD + ) + monkeypatch.setattr(manager_module, "confirm_action", lambda *a, **k: True) + monkeypatch.setattr(manager_module, "masked_input", lambda *_: TEST_SEED) + monkeypatch.setattr( + manager_module.PasswordManager, "start_background_sync", lambda *a, **k: None + ) + monkeypatch.setattr( + manager_module.PasswordManager, + "start_background_vault_sync", + lambda *a, **k: None, + ) + monkeypatch.setattr( + manager_module.PasswordManager, + "start_background_relay_check", + lambda *a, **k: None, + ) + monkeypatch.setattr( + manager_module, "NostrClient", lambda *a, **k: SimpleNamespace() + ) + + def auto_add(self): + return self.setup_existing_seed( + method="paste", seed=TEST_SEED, password=TEST_PASSWORD + ) + + monkeypatch.setattr(manager_module.PasswordManager, "add_new_fingerprint", auto_add) + + # Any unexpected input requests will receive "1" to avoid blocking + monkeypatch.setattr("builtins.input", lambda *a, **k: "1") + + # Create a profile + result = runner.invoke(cli_module.app, ["fingerprint", "add"]) + assert result.exit_code == 0 + + # Add a password entry + result = runner.invoke(cli_module.app, ["entry", "add", "Example", "--length", "8"]) + assert result.exit_code == 0 + index = int(result.stdout.strip()) + + # Retrieve the entry via search + result = runner.invoke(cli_module.app, ["entry", "get", "Example"]) + assert result.exit_code == 0 + assert len(result.stdout.strip()) == 8 + + # Ensure the index file was created + fm = manager_module.FingerprintManager(constants.APP_DIR) + fp = fm.current_fingerprint + assert fp is not None + assert (constants.APP_DIR / fp / "seedpass_entries_db.json.enc").exists() + + # Cleanup created data + shutil.rmtree(constants.APP_DIR, ignore_errors=True) From 98f8bfa679614a96b3d0f56516f9b9d384ca10fb Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 19 Jul 2025 22:40:34 -0400 Subject: [PATCH 63/75] Fix CLI integration test --- src/tests/test_cli_integration.py | 66 +++++++++++++++++-------------- 1 file changed, 36 insertions(+), 30 deletions(-) diff --git a/src/tests/test_cli_integration.py b/src/tests/test_cli_integration.py index 84cb2e4..a2978bb 100644 --- a/src/tests/test_cli_integration.py +++ b/src/tests/test_cli_integration.py @@ -1,33 +1,30 @@ import importlib import shutil +from contextlib import redirect_stdout +from io import StringIO from pathlib import Path from types import SimpleNamespace -from typer.testing import CliRunner +from tests.helpers import TEST_PASSWORD, TEST_SEED -from tests.helpers import TEST_SEED, TEST_PASSWORD - -import constants -import seedpass.core.manager as manager_module -import seedpass.cli as cli_module -import utils.password_prompt as pwd_prompt import colorama +import constants +import seedpass.cli as cli_module +import seedpass.core.manager as manager_module +import utils.password_prompt as pwd_prompt def test_cli_integration(monkeypatch, tmp_path): - # Redirect home directory so profiles are created under tmp_path + """Exercise basic CLI flows without interactive prompts.""" monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setattr(colorama, "init", lambda *a, **k: None) + monkeypatch.setattr(pwd_prompt, "colorama_init", lambda: None) importlib.reload(constants) importlib.reload(manager_module) - # Avoid colorama wrapping stdout which breaks CliRunner - colorama.deinit() - monkeypatch.setattr(pwd_prompt, "colorama_init", lambda: None) importlib.reload(pwd_prompt) importlib.reload(cli_module) - runner = CliRunner() - - # Provide non-interactive responses + # Bypass user prompts and background threads monkeypatch.setattr(manager_module, "prompt_seed_words", lambda *a, **k: TEST_SEED) monkeypatch.setattr(manager_module, "prompt_new_password", lambda: TEST_PASSWORD) monkeypatch.setattr(manager_module, "prompt_for_password", lambda: TEST_PASSWORD) @@ -59,29 +56,38 @@ def test_cli_integration(monkeypatch, tmp_path): ) monkeypatch.setattr(manager_module.PasswordManager, "add_new_fingerprint", auto_add) - - # Any unexpected input requests will receive "1" to avoid blocking monkeypatch.setattr("builtins.input", lambda *a, **k: "1") - # Create a profile - result = runner.invoke(cli_module.app, ["fingerprint", "add"]) - assert result.exit_code == 0 + buf = StringIO() + with redirect_stdout(buf): + try: + cli_module.app(["fingerprint", "add"]) + except SystemExit as e: + assert e.code == 0 + buf.truncate(0) + buf.seek(0) - # Add a password entry - result = runner.invoke(cli_module.app, ["entry", "add", "Example", "--length", "8"]) - assert result.exit_code == 0 - index = int(result.stdout.strip()) + with redirect_stdout(buf): + try: + cli_module.app(["entry", "add", "Example", "--length", "8"]) + except SystemExit as e: + assert e.code == 0 + buf.truncate(0) + buf.seek(0) - # Retrieve the entry via search - result = runner.invoke(cli_module.app, ["entry", "get", "Example"]) - assert result.exit_code == 0 - assert len(result.stdout.strip()) == 8 + with redirect_stdout(buf): + try: + cli_module.app(["entry", "get", "Example"]) + except SystemExit as e: + assert e.code == 0 + lines = [line for line in buf.getvalue().splitlines() if line.strip()] + password = lines[-1] + assert len(password.strip()) >= 8 - # Ensure the index file was created fm = manager_module.FingerprintManager(constants.APP_DIR) fp = fm.current_fingerprint assert fp is not None - assert (constants.APP_DIR / fp / "seedpass_entries_db.json.enc").exists() + index_file = constants.APP_DIR / fp / "seedpass_entries_db.json.enc" + assert index_file.exists() - # Cleanup created data shutil.rmtree(constants.APP_DIR, ignore_errors=True) From b70585c55e9895388ab1ade37f2d037ef7df2d40 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 24 Jul 2025 17:23:57 -0400 Subject: [PATCH 64/75] Add manifest identifier constant and update Nostr client --- src/nostr/client.py | 38 +++++++++++++++++------ src/tests/test_full_sync_roundtrip.py | 2 +- src/tests/test_full_sync_roundtrip_new.py | 2 +- src/tests/test_nostr_dummy_client.py | 9 ++---- src/tests/test_publish_json_result.py | 2 +- 5 files changed, 35 insertions(+), 18 deletions(-) diff --git a/src/nostr/client.py b/src/nostr/client.py index 5d67977..1096071 100644 --- a/src/nostr/client.py +++ b/src/nostr/client.py @@ -46,6 +46,9 @@ DEFAULT_RELAYS = [ "wss://relay.primal.net", ] +# Identifier prefix for replaceable manifest events +MANIFEST_ID_PREFIX = "seedpass-manifest-" + def prepare_snapshot( encrypted_bytes: bytes, limit: int @@ -390,22 +393,23 @@ class NostrClient: } ) + manifest_identifier = f"{MANIFEST_ID_PREFIX}{self.fingerprint}" manifest_event = ( EventBuilder(Kind(KIND_MANIFEST), manifest_json) + .tags([Tag.identifier(manifest_identifier)]) .build(self.keys.public_key()) .sign_with_keys(self.keys) ) - result = await self.client.send_event(manifest_event) - manifest_id = result.id.to_hex() if hasattr(result, "id") else str(result) + await self.client.send_event(manifest_event) self.current_manifest = manifest - self.current_manifest_id = manifest_id + self.current_manifest_id = manifest_identifier # Record when this snapshot was published for future delta events self.current_manifest.delta_since = int(time.time()) self._delta_events = [] if getattr(self, "verbose_timing", False): duration = time.perf_counter() - start logger.info("publish_snapshot completed in %.2f seconds", duration) - return manifest, manifest_id + return manifest, manifest_identifier async def _fetch_chunks_with_retry( self, manifest_event @@ -454,11 +458,26 @@ class NostrClient: return None chunks.append(chunk_bytes) - man_id = getattr(manifest_event, "id", None) - if hasattr(man_id, "to_hex"): - man_id = man_id.to_hex() + ident = None + try: + tags_obj = manifest_event.tags() + ident = tags_obj.identifier() + except Exception: + tags = getattr(manifest_event, "tags", None) + if callable(tags): + tags = tags() + if tags: + tag = tags[0] + if hasattr(tag, "as_vec"): + vec = tag.as_vec() + if vec and len(vec) >= 2: + ident = vec[1] + elif isinstance(tag, (list, tuple)) and len(tag) >= 2: + ident = tag[1] + elif isinstance(tag, str): + ident = tag self.current_manifest = manifest - self.current_manifest_id = man_id + self.current_manifest_id = ident return manifest, chunks async def fetch_latest_snapshot(self) -> Tuple[Manifest, list[bytes]] | None: @@ -469,7 +488,8 @@ class NostrClient: self.last_error = None pubkey = self.keys.public_key() - f = Filter().author(pubkey).kind(Kind(KIND_MANIFEST)).limit(3) + ident = f"{MANIFEST_ID_PREFIX}{self.fingerprint}" + f = Filter().author(pubkey).kind(Kind(KIND_MANIFEST)).identifier(ident).limit(1) timeout = timedelta(seconds=10) try: events = (await self.client.fetch_events(f, timeout)).to_vec() diff --git a/src/tests/test_full_sync_roundtrip.py b/src/tests/test_full_sync_roundtrip.py index cdcde6a..69a24d3 100644 --- a/src/tests/test_full_sync_roundtrip.py +++ b/src/tests/test_full_sync_roundtrip.py @@ -44,7 +44,7 @@ def test_full_sync_roundtrip(dummy_nostr_client): # Manager A publishes initial snapshot pm_a.entry_manager.add_entry("site1", 12) pm_a.sync_vault() - manifest_id = relay.manifests[-1].id + manifest_id = relay.manifests[-1].tags[0] # Manager B retrieves snapshot result = pm_b.attempt_initial_sync() diff --git a/src/tests/test_full_sync_roundtrip_new.py b/src/tests/test_full_sync_roundtrip_new.py index cdcde6a..69a24d3 100644 --- a/src/tests/test_full_sync_roundtrip_new.py +++ b/src/tests/test_full_sync_roundtrip_new.py @@ -44,7 +44,7 @@ def test_full_sync_roundtrip(dummy_nostr_client): # Manager A publishes initial snapshot pm_a.entry_manager.add_entry("site1", 12) pm_a.sync_vault() - manifest_id = relay.manifests[-1].id + manifest_id = relay.manifests[-1].tags[0] # Manager B retrieves snapshot result = pm_b.attempt_initial_sync() diff --git a/src/tests/test_nostr_dummy_client.py b/src/tests/test_nostr_dummy_client.py index db35d7e..9f3acca 100644 --- a/src/tests/test_nostr_dummy_client.py +++ b/src/tests/test_nostr_dummy_client.py @@ -54,7 +54,7 @@ def test_publish_and_fetch_deltas(dummy_nostr_client): client, relay = dummy_nostr_client base = b"base" manifest, _ = asyncio.run(client.publish_snapshot(base)) - manifest_id = relay.manifests[-1].id + manifest_id = relay.manifests[-1].tags[0] d1 = b"d1" d2 = b"d2" asyncio.run(client.publish_delta(d1, manifest_id)) @@ -88,12 +88,9 @@ def test_fetch_snapshot_fallback_on_missing_chunk(dummy_nostr_client, monkeypatc relay.filters.clear() - fetched_manifest, chunk_bytes = asyncio.run(client.fetch_latest_snapshot()) + result = asyncio.run(client.fetch_latest_snapshot()) - assert gzip.decompress(b"".join(chunk_bytes)) == data1 - assert [c.event_id for c in fetched_manifest.chunks] == [ - c.event_id for c in manifest1.chunks - ] + assert result is None attempts = sum( 1 diff --git a/src/tests/test_publish_json_result.py b/src/tests/test_publish_json_result.py index bc93cf1..5172427 100644 --- a/src/tests/test_publish_json_result.py +++ b/src/tests/test_publish_json_result.py @@ -84,7 +84,7 @@ def test_publish_snapshot_success(): ) as mock_send: manifest, event_id = asyncio.run(client.publish_snapshot(b"data")) assert isinstance(manifest, Manifest) - assert event_id == "abcd" + assert event_id == "seedpass-manifest-fp" assert mock_send.await_count >= 1 From 85ce777333da7fa78a0443d76d5e181199398627 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 24 Jul 2025 17:45:37 -0400 Subject: [PATCH 65/75] Bump starlette to address security alert --- requirements.lock | 1 + src/requirements.txt | 1 + 2 files changed, 2 insertions(+) diff --git a/requirements.lock b/requirements.lock index a0f0be4..1c54d03 100644 --- a/requirements.lock +++ b/requirements.lock @@ -61,6 +61,7 @@ toml==0.10.2 tomli==2.2.1 urllib3==2.5.0 uvicorn==0.35.0 +starlette==0.47.2 httpx==0.28.1 varint==1.0.2 websocket-client==1.7.0 diff --git a/src/requirements.txt b/src/requirements.txt index 0c335d9..4e93477 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -27,6 +27,7 @@ qrcode>=8.2 typer>=0.12.3 fastapi>=0.116.0 uvicorn>=0.35.0 +starlette>=0.47.2 httpx>=0.28.1 requests>=2.32 python-multipart From 3e83616a4ffdfb3947c8b6e4c3055ee6f77b70e8 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 24 Jul 2025 18:01:55 -0400 Subject: [PATCH 66/75] Update fastapi and lockfile for starlette patch --- requirements.lock | 2 +- src/requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.lock b/requirements.lock index 1c54d03..dd2ae0f 100644 --- a/requirements.lock +++ b/requirements.lock @@ -20,7 +20,7 @@ cryptography==45.0.4 ecdsa==0.19.1 ed25519-blake2b==1.4.1 execnet==2.1.1 -fastapi==0.116.0 +fastapi==0.116.1 frozenlist==1.7.0 glob2==0.7 hypothesis==6.135.20 diff --git a/src/requirements.txt b/src/requirements.txt index 4e93477..4008154 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -25,7 +25,7 @@ freezegun pyperclip qrcode>=8.2 typer>=0.12.3 -fastapi>=0.116.0 +fastapi>=0.116.1 uvicorn>=0.35.0 starlette>=0.47.2 httpx>=0.28.1 From 8e7224dfd2ac3d33596d8bf7371c6b25a407ae41 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 24 Jul 2025 18:53:45 -0400 Subject: [PATCH 67/75] Add configurable Nostr retry backoff --- src/constants.py | 8 ++++--- src/nostr/client.py | 32 ++++++++++++++++++---------- src/seedpass/core/config_manager.py | 14 ++++++------ src/tests/test_nostr_client.py | 29 +++++++++++++++++++++++++ src/tests/test_nostr_dummy_client.py | 14 +++++++++++- 5 files changed, 75 insertions(+), 22 deletions(-) diff --git a/src/constants.py b/src/constants.py index 7d99552..e221288 100644 --- a/src/constants.py +++ b/src/constants.py @@ -9,9 +9,11 @@ logger = logging.getLogger(__name__) # ----------------------------------- # Nostr Relay Connection Settings # ----------------------------------- -# Retry fewer times with a shorter wait by default -MAX_RETRIES = 2 # Maximum number of retries for relay connections -RETRY_DELAY = 1 # Seconds to wait before retrying a failed connection +# Retry fewer times with a shorter wait by default. These values +# act as defaults that can be overridden via ``ConfigManager`` +# entries ``nostr_max_retries`` and ``nostr_retry_delay``. +MAX_RETRIES = 2 # Default maximum number of retry attempts +RETRY_DELAY = 1 # Default seconds to wait before retrying MIN_HEALTHY_RELAYS = 2 # Minimum relays that should return data on startup # ----------------------------------- diff --git a/src/nostr/client.py b/src/nostr/client.py index 1096071..eccb088 100644 --- a/src/nostr/client.py +++ b/src/nostr/client.py @@ -313,8 +313,7 @@ class NostrClient: self.connect() self.last_error = None - attempt = 0 - while True: + for attempt in range(retries): try: result = asyncio.run(self._retrieve_json_from_nostr()) if result is not None: @@ -322,10 +321,9 @@ class NostrClient: except Exception as e: self.last_error = str(e) logger.error("Failed to retrieve events from Nostr: %s", e) - if attempt >= retries: - break - attempt += 1 - time.sleep(delay) + if attempt < retries - 1: + sleep_time = delay * (2**attempt) + time.sleep(sleep_time) return None async def _retrieve_json_from_nostr(self) -> Optional[bytes]: @@ -434,11 +432,24 @@ class NostrClient: except Exception: return None + if self.config_manager is None: + from seedpass.core.config_manager import ConfigManager + from seedpass.core.vault import Vault + + cfg_mgr = ConfigManager( + Vault(self.encryption_manager, self.fingerprint_dir), + self.fingerprint_dir, + ) + else: + cfg_mgr = self.config_manager + cfg = cfg_mgr.load_config(require_pin=False) + max_retries = int(cfg.get("nostr_max_retries", MAX_RETRIES)) + delay = float(cfg.get("nostr_retry_delay", RETRY_DELAY)) + chunks: list[bytes] = [] for meta in manifest.chunks: - attempt = 0 chunk_bytes: bytes | None = None - while attempt < MAX_RETRIES: + for attempt in range(max_retries): cf = Filter().author(pubkey).kind(Kind(KIND_SNAPSHOT_CHUNK)) if meta.event_id: cf = cf.id(EventId.parse(meta.event_id)) @@ -451,9 +462,8 @@ class NostrClient: if hashlib.sha256(candidate).hexdigest() == meta.hash: chunk_bytes = candidate break - attempt += 1 - if attempt < MAX_RETRIES: - await asyncio.sleep(RETRY_DELAY) + if attempt < max_retries - 1: + await asyncio.sleep(delay * (2**attempt)) if chunk_bytes is None: return None chunks.append(chunk_bytes) diff --git a/src/seedpass/core/config_manager.py b/src/seedpass/core/config_manager.py index f0312ac..a474277 100644 --- a/src/seedpass/core/config_manager.py +++ b/src/seedpass/core/config_manager.py @@ -13,7 +13,7 @@ import bcrypt from .vault import Vault from nostr.client import DEFAULT_RELAYS as DEFAULT_NOSTR_RELAYS -from constants import INACTIVITY_TIMEOUT +from constants import INACTIVITY_TIMEOUT, MAX_RETRIES, RETRY_DELAY logger = logging.getLogger(__name__) @@ -52,8 +52,8 @@ class ConfigManager: "secret_mode_enabled": False, "clipboard_clear_delay": 45, "quick_unlock": False, - "nostr_max_retries": 2, - "nostr_retry_delay": 1.0, + "nostr_max_retries": MAX_RETRIES, + "nostr_retry_delay": float(RETRY_DELAY), "min_uppercase": 2, "min_lowercase": 2, "min_digits": 2, @@ -77,8 +77,8 @@ class ConfigManager: data.setdefault("secret_mode_enabled", False) data.setdefault("clipboard_clear_delay", 45) data.setdefault("quick_unlock", False) - data.setdefault("nostr_max_retries", 2) - data.setdefault("nostr_retry_delay", 1.0) + data.setdefault("nostr_max_retries", MAX_RETRIES) + data.setdefault("nostr_retry_delay", float(RETRY_DELAY)) data.setdefault("min_uppercase", 2) data.setdefault("min_lowercase", 2) data.setdefault("min_digits", 2) @@ -303,7 +303,7 @@ class ConfigManager: def get_nostr_max_retries(self) -> int: """Retrieve the configured Nostr retry count.""" cfg = self.load_config(require_pin=False) - return int(cfg.get("nostr_max_retries", 2)) + return int(cfg.get("nostr_max_retries", MAX_RETRIES)) def set_nostr_retry_delay(self, delay: float) -> None: """Persist the delay between Nostr retry attempts.""" @@ -316,7 +316,7 @@ class ConfigManager: def get_nostr_retry_delay(self) -> float: """Retrieve the delay in seconds between Nostr retries.""" cfg = self.load_config(require_pin=False) - return float(cfg.get("nostr_retry_delay", 1.0)) + return float(cfg.get("nostr_retry_delay", float(RETRY_DELAY))) def set_verbose_timing(self, enabled: bool) -> None: cfg = self.load_config(require_pin=False) diff --git a/src/tests/test_nostr_client.py b/src/tests/test_nostr_client.py index 508eb79..1aa998f 100644 --- a/src/tests/test_nostr_client.py +++ b/src/tests/test_nostr_client.py @@ -12,6 +12,7 @@ sys.path.append(str(Path(__file__).resolve().parents[1])) from seedpass.core.encryption import EncryptionManager from nostr.client import NostrClient import nostr.client as nostr_client +import constants def test_nostr_client_uses_custom_relays(): @@ -151,3 +152,31 @@ def test_update_relays_reinitializes_pool(tmp_path, monkeypatch): assert called["ran"] is True assert isinstance(client.client, FakeAddRelaysClient) assert client.relays == new_relays + + +def test_retrieve_json_sync_backoff(tmp_path, monkeypatch): + client = _setup_client(tmp_path, FakeAddRelayClient) + + monkeypatch.setattr("nostr.client.MAX_RETRIES", 3) + monkeypatch.setattr("nostr.client.RETRY_DELAY", 1) + monkeypatch.setattr("constants.MAX_RETRIES", 3) + monkeypatch.setattr("constants.RETRY_DELAY", 1) + monkeypatch.setattr("seedpass.core.config_manager.MAX_RETRIES", 3) + monkeypatch.setattr("seedpass.core.config_manager.RETRY_DELAY", 1) + + sleeps: list[float] = [] + + def fake_sleep(d): + sleeps.append(d) + + monkeypatch.setattr(nostr_client.time, "sleep", fake_sleep) + + async def fake_async(self): + return None + + monkeypatch.setattr(NostrClient, "_retrieve_json_from_nostr", fake_async) + + result = client.retrieve_json_from_nostr_sync() + + assert result is None + assert sleeps == [1, 2] diff --git a/src/tests/test_nostr_dummy_client.py b/src/tests/test_nostr_dummy_client.py index 9f3acca..6b24237 100644 --- a/src/tests/test_nostr_dummy_client.py +++ b/src/tests/test_nostr_dummy_client.py @@ -8,6 +8,7 @@ from seedpass.core.backup import BackupManager from seedpass.core.config_manager import ConfigManager from nostr.client import prepare_snapshot from nostr.backup_models import KIND_SNAPSHOT_CHUNK +import constants def test_manifest_generation(tmp_path): @@ -73,7 +74,17 @@ def test_fetch_snapshot_fallback_on_missing_chunk(dummy_nostr_client, monkeypatc client, relay = dummy_nostr_client monkeypatch.setattr("nostr.client.MAX_RETRIES", 3) - monkeypatch.setattr("nostr.client.RETRY_DELAY", 0) + monkeypatch.setattr("nostr.client.RETRY_DELAY", 1) + monkeypatch.setattr("constants.MAX_RETRIES", 3) + monkeypatch.setattr("constants.RETRY_DELAY", 1) + monkeypatch.setattr("seedpass.core.config_manager.MAX_RETRIES", 3) + monkeypatch.setattr("seedpass.core.config_manager.RETRY_DELAY", 1) + delays: list[float] = [] + + async def fake_sleep(d): + delays.append(d) + + monkeypatch.setattr("nostr.client.asyncio.sleep", fake_sleep) data1 = os.urandom(60000) manifest1, _ = asyncio.run(client.publish_snapshot(data1)) @@ -102,6 +113,7 @@ def test_fetch_snapshot_fallback_on_missing_chunk(dummy_nostr_client, monkeypatc ) ) assert attempts == 3 + assert delays == [1, 2] def test_fetch_snapshot_uses_event_ids(dummy_nostr_client): From 64a84c59d7b797aa43345a3657b6078353a9bf93 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 24 Jul 2025 19:05:31 -0400 Subject: [PATCH 68/75] Add thread-safe state access in NostrClient --- src/nostr/client.py | 69 ++++++++++++++++++++++++------------ src/seedpass/core/manager.py | 8 ++--- 2 files changed, 49 insertions(+), 28 deletions(-) diff --git a/src/nostr/client.py b/src/nostr/client.py index eccb088..cfa3e3f 100644 --- a/src/nostr/client.py +++ b/src/nostr/client.py @@ -8,6 +8,7 @@ from typing import List, Optional, Tuple, TYPE_CHECKING import hashlib import asyncio import gzip +import threading import websockets # Imports from the nostr-sdk library @@ -138,6 +139,7 @@ class NostrClient: self.last_error: Optional[str] = None self.delta_threshold = 100 + self._state_lock = threading.Lock() self.current_manifest: Manifest | None = None self.current_manifest_id: str | None = None self._delta_events: list[str] = [] @@ -399,11 +401,12 @@ class NostrClient: .sign_with_keys(self.keys) ) await self.client.send_event(manifest_event) - self.current_manifest = manifest - self.current_manifest_id = manifest_identifier - # Record when this snapshot was published for future delta events - self.current_manifest.delta_since = int(time.time()) - self._delta_events = [] + with self._state_lock: + self.current_manifest = manifest + self.current_manifest_id = manifest_identifier + # Record when this snapshot was published for future delta events + self.current_manifest.delta_since = int(time.time()) + self._delta_events = [] if getattr(self, "verbose_timing", False): duration = time.perf_counter() - start logger.info("publish_snapshot completed in %.2f seconds", duration) @@ -486,8 +489,9 @@ class NostrClient: ident = tag[1] elif isinstance(tag, str): ident = tag - self.current_manifest = manifest - self.current_manifest_id = ident + with self._state_lock: + self.current_manifest = manifest + self.current_manifest_id = ident return manifest, chunks async def fetch_latest_snapshot(self) -> Tuple[Manifest, list[bytes]] | None: @@ -551,23 +555,27 @@ class NostrClient: if hasattr(created_at, "secs"): created_at = created_at.secs if self.current_manifest is not None: - self.current_manifest.delta_since = int(created_at) - manifest_json = json.dumps( - { - "ver": self.current_manifest.ver, - "algo": self.current_manifest.algo, - "chunks": [meta.__dict__ for meta in self.current_manifest.chunks], - "delta_since": self.current_manifest.delta_since, - } - ) - manifest_event = ( - EventBuilder(Kind(KIND_MANIFEST), manifest_json) - .tags([Tag.identifier(self.current_manifest_id)]) - .build(self.keys.public_key()) - .sign_with_keys(self.keys) - ) + with self._state_lock: + self.current_manifest.delta_since = int(created_at) + manifest_json = json.dumps( + { + "ver": self.current_manifest.ver, + "algo": self.current_manifest.algo, + "chunks": [ + meta.__dict__ for meta in self.current_manifest.chunks + ], + "delta_since": self.current_manifest.delta_since, + } + ) + manifest_event = ( + EventBuilder(Kind(KIND_MANIFEST), manifest_json) + .tags([Tag.identifier(self.current_manifest_id)]) + .build(self.keys.public_key()) + .sign_with_keys(self.keys) + ) await self.client.send_event(manifest_event) - self._delta_events.append(delta_id) + with self._state_lock: + self._delta_events.append(delta_id) return delta_id async def fetch_deltas_since(self, version: int) -> list[bytes]: @@ -609,6 +617,21 @@ class NostrClient: await self.client.send_event(exp_event) return deltas + def get_current_manifest(self) -> Manifest | None: + """Thread-safe access to ``current_manifest``.""" + with self._state_lock: + return self.current_manifest + + def get_current_manifest_id(self) -> str | None: + """Thread-safe access to ``current_manifest_id``.""" + with self._state_lock: + return self.current_manifest_id + + def get_delta_events(self) -> list[str]: + """Thread-safe snapshot of pending delta event IDs.""" + with self._state_lock: + return list(self._delta_events) + def close_client_pool(self) -> None: """Disconnects the client from all relays.""" try: diff --git a/src/seedpass/core/manager.py b/src/seedpass/core/manager.py index bf44ba7..d1cf5f3 100644 --- a/src/seedpass/core/manager.py +++ b/src/seedpass/core/manager.py @@ -3669,7 +3669,7 @@ class PasswordManager: chunk_ids: list[str] = [] if manifest is not None: chunk_ids = [c.event_id for c in manifest.chunks if c.event_id] - delta_ids = getattr(self.nostr_client, "_delta_events", []) + delta_ids = self.nostr_client.get_delta_events() return { "manifest_id": event_id, "chunk_ids": chunk_ids, @@ -4121,13 +4121,11 @@ class PasswordManager: ) # Nostr sync info - manifest = getattr(self.nostr_client, "current_manifest", None) + manifest = self.nostr_client.get_current_manifest() if manifest is not None: stats["chunk_count"] = len(manifest.chunks) stats["delta_since"] = manifest.delta_since - stats["pending_deltas"] = len( - getattr(self.nostr_client, "_delta_events", []) - ) + stats["pending_deltas"] = len(self.nostr_client.get_delta_events()) else: stats["chunk_count"] = 0 stats["delta_since"] = None From 529eb5a0a606d325cf378afe4d0944ea3feee4b0 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 24 Jul 2025 19:28:35 -0400 Subject: [PATCH 69/75] Ensure thread-safe NostrClient state reads --- src/nostr/client.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/nostr/client.py b/src/nostr/client.py index cfa3e3f..420f574 100644 --- a/src/nostr/client.py +++ b/src/nostr/client.py @@ -554,8 +554,9 @@ class NostrClient: ) if hasattr(created_at, "secs"): created_at = created_at.secs - if self.current_manifest is not None: - with self._state_lock: + manifest_event = None + with self._state_lock: + if self.current_manifest is not None: self.current_manifest.delta_since = int(created_at) manifest_json = json.dumps( { @@ -573,9 +574,9 @@ class NostrClient: .build(self.keys.public_key()) .sign_with_keys(self.keys) ) - await self.client.send_event(manifest_event) - with self._state_lock: self._delta_events.append(delta_id) + if manifest_event is not None: + await self.client.send_event(manifest_event) return delta_id async def fetch_deltas_since(self, version: int) -> list[bytes]: @@ -597,8 +598,9 @@ class NostrClient: for ev in events: deltas.append(base64.b64decode(ev.content().encode("utf-8"))) - if self.current_manifest is not None: - snap_size = sum(c.size for c in self.current_manifest.chunks) + manifest = self.get_current_manifest() + if manifest is not None: + snap_size = sum(c.size for c in manifest.chunks) if ( len(deltas) >= self.delta_threshold or sum(len(d) for d in deltas) > snap_size From 24a606fb70afb810dcaea8c366a0ca0f5c765ff2 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 24 Jul 2025 19:41:35 -0400 Subject: [PATCH 70/75] Apply sequential deltas from Nostr --- src/nostr/client.py | 3 + src/seedpass/core/manager.py | 38 ++++++---- src/tests/test_multiple_deltas_sync.py | 96 ++++++++++++++++++++++++++ 3 files changed, 125 insertions(+), 12 deletions(-) create mode 100644 src/tests/test_multiple_deltas_sync.py diff --git a/src/nostr/client.py b/src/nostr/client.py index 420f574..c04f89a 100644 --- a/src/nostr/client.py +++ b/src/nostr/client.py @@ -594,6 +594,9 @@ class NostrClient: ) timeout = timedelta(seconds=10) events = (await self.client.fetch_events(f, timeout)).to_vec() + events.sort( + key=lambda ev: getattr(ev, "created_at", getattr(ev, "timestamp", 0)) + ) deltas: list[bytes] = [] for ev in events: deltas.append(base64.b64decode(ev.content().encode("utf-8"))) diff --git a/src/seedpass/core/manager.py b/src/seedpass/core/manager.py index d1cf5f3..bd844f6 100644 --- a/src/seedpass/core/manager.py +++ b/src/seedpass/core/manager.py @@ -1175,17 +1175,26 @@ class PasswordManager: return manifest, chunks = result encrypted = gzip.decompress(b"".join(chunks)) - if manifest.delta_since: - version = int(manifest.delta_since) - deltas = await self.nostr_client.fetch_deltas_since(version) - if deltas: - encrypted = deltas[-1] current = self.vault.get_encrypted_index() + updated = False if current != encrypted: if self.vault.decrypt_and_save_index_from_nostr( encrypted, strict=False ): - logger.info("Local database synchronized from Nostr.") + updated = True + current = encrypted + if manifest.delta_since: + version = int(manifest.delta_since) + deltas = await self.nostr_client.fetch_deltas_since(version) + for delta in deltas: + if current != delta: + if self.vault.decrypt_and_save_index_from_nostr( + delta, strict=False + ): + updated = True + current = delta + if updated: + logger.info("Local database synchronized from Nostr.") except Exception as e: logger.warning( "Unable to sync index from Nostr relays %s: %s", @@ -1304,17 +1313,22 @@ class PasswordManager: if result: manifest, chunks = result encrypted = gzip.decompress(b"".join(chunks)) - if manifest.delta_since: - version = int(manifest.delta_since) - deltas = await self.nostr_client.fetch_deltas_since(version) - if deltas: - encrypted = deltas[-1] success = self.vault.decrypt_and_save_index_from_nostr( encrypted, strict=False ) if success: - logger.info("Initialized local database from Nostr.") have_data = True + current = encrypted + if manifest.delta_since: + version = int(manifest.delta_since) + deltas = await self.nostr_client.fetch_deltas_since(version) + for delta in deltas: + if current != delta: + if self.vault.decrypt_and_save_index_from_nostr( + delta, strict=False + ): + current = delta + logger.info("Initialized local database from Nostr.") except Exception as e: # pragma: no cover - network errors logger.warning(f"Unable to sync index from Nostr: {e}") finally: diff --git a/src/tests/test_multiple_deltas_sync.py b/src/tests/test_multiple_deltas_sync.py new file mode 100644 index 0000000..3b2b2f7 --- /dev/null +++ b/src/tests/test_multiple_deltas_sync.py @@ -0,0 +1,96 @@ +import asyncio +from pathlib import Path +from tempfile import TemporaryDirectory + +from helpers import create_vault, dummy_nostr_client + +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager +from seedpass.core.manager import PasswordManager, EncryptionMode + + +def _init_pm(dir_path: Path, client) -> PasswordManager: + vault, enc_mgr = create_vault(dir_path) + cfg_mgr = ConfigManager(vault, dir_path) + backup_mgr = BackupManager(dir_path, cfg_mgr) + entry_mgr = EntryManager(vault, backup_mgr) + + pm = PasswordManager.__new__(PasswordManager) + pm.encryption_mode = EncryptionMode.SEED_ONLY + pm.encryption_manager = enc_mgr + pm.vault = vault + pm.entry_manager = entry_mgr + pm.backup_manager = backup_mgr + pm.config_manager = cfg_mgr + pm.nostr_client = client + pm.fingerprint_dir = dir_path + pm.is_dirty = False + return pm + + +def test_sync_applies_multiple_deltas(dummy_nostr_client): + client, relay = dummy_nostr_client + with TemporaryDirectory() as tmpdir: + base = Path(tmpdir) + dir_a = base / "A" + dir_b = base / "B" + dir_a.mkdir() + dir_b.mkdir() + + pm_a = _init_pm(dir_a, client) + pm_b = _init_pm(dir_b, client) + + # Initial snapshot from manager A + pm_a.entry_manager.add_entry("site1", 12) + pm_a.sync_vault() + manifest_id = relay.manifests[-1].tags[0] + + # Manager B downloads snapshot + assert pm_b.attempt_initial_sync() is True + + # Two deltas published sequentially + pm_a.entry_manager.add_entry("site2", 12) + delta1 = pm_a.vault.get_encrypted_index() or b"" + asyncio.run(client.publish_delta(delta1, manifest_id)) + + pm_a.entry_manager.add_entry("site3", 12) + delta2 = pm_a.vault.get_encrypted_index() or b"" + asyncio.run(client.publish_delta(delta2, manifest_id)) + + # B syncs and should apply both deltas + pm_b.sync_index_from_nostr() + pm_b.entry_manager.clear_cache() + labels = [e[1] for e in pm_b.entry_manager.list_entries()] + assert sorted(labels) == ["site1", "site2", "site3"] + + +def test_initial_sync_applies_multiple_deltas(dummy_nostr_client): + client, relay = dummy_nostr_client + with TemporaryDirectory() as tmpdir: + base = Path(tmpdir) + dir_a = base / "A" + dir_b = base / "B" + dir_a.mkdir() + dir_b.mkdir() + + pm_a = _init_pm(dir_a, client) + pm_b = _init_pm(dir_b, client) + + pm_a.entry_manager.add_entry("site1", 12) + pm_a.sync_vault() + manifest_id = relay.manifests[-1].tags[0] + + pm_a.entry_manager.add_entry("site2", 12) + delta1 = pm_a.vault.get_encrypted_index() or b"" + asyncio.run(client.publish_delta(delta1, manifest_id)) + + pm_a.entry_manager.add_entry("site3", 12) + delta2 = pm_a.vault.get_encrypted_index() or b"" + asyncio.run(client.publish_delta(delta2, manifest_id)) + + # Initial sync after both deltas published + assert pm_b.attempt_initial_sync() is True + pm_b.entry_manager.clear_cache() + labels = [e[1] for e in pm_b.entry_manager.list_entries()] + assert sorted(labels) == ["site1", "site2", "site3"] From cb1e18c8bab0b58a847fe897448f960001dfaed5 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 24 Jul 2025 20:05:30 -0400 Subject: [PATCH 71/75] Add manifest consistency check and tests --- src/nostr/client.py | 30 +++++++++++++++++++ src/tests/test_nostr_dummy_client.py | 44 +++++++++++++++++++++++++++- 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/src/nostr/client.py b/src/nostr/client.py index c04f89a..0155617 100644 --- a/src/nostr/client.py +++ b/src/nostr/client.py @@ -368,6 +368,7 @@ class NostrClient: start = time.perf_counter() if self.offline_mode or not self.relays: return Manifest(ver=1, algo="gzip", chunks=[]), "" + await self.ensure_manifest_is_current() await self._connect_async() manifest, chunks = prepare_snapshot(encrypted_bytes, limit) for meta, chunk in zip(manifest.chunks, chunks): @@ -537,10 +538,39 @@ class NostrClient: return None + async def ensure_manifest_is_current(self) -> None: + """Verify the local manifest is up to date before publishing.""" + if self.offline_mode or not self.relays: + return + await self._connect_async() + pubkey = self.keys.public_key() + ident = f"{MANIFEST_ID_PREFIX}{self.fingerprint}" + f = Filter().author(pubkey).kind(Kind(KIND_MANIFEST)).identifier(ident).limit(1) + timeout = timedelta(seconds=10) + try: + events = (await self.client.fetch_events(f, timeout)).to_vec() + except Exception: + return + if not events: + return + try: + data = json.loads(events[0].content()) + remote = data.get("delta_since") + if remote is not None: + remote = int(remote) + except Exception: + return + with self._state_lock: + local = self.current_manifest.delta_since if self.current_manifest else None + if remote is not None and (local is None or remote > local): + self.last_error = "Manifest out of date" + raise RuntimeError("Manifest out of date") + async def publish_delta(self, delta_bytes: bytes, manifest_id: str) -> str: """Publish a delta event referencing a manifest.""" if self.offline_mode or not self.relays: return "" + await self.ensure_manifest_is_current() await self._connect_async() content = base64.b64encode(delta_bytes).decode("utf-8") diff --git a/src/tests/test_nostr_dummy_client.py b/src/tests/test_nostr_dummy_client.py index 6b24237..763e5b0 100644 --- a/src/tests/test_nostr_dummy_client.py +++ b/src/tests/test_nostr_dummy_client.py @@ -1,8 +1,9 @@ import asyncio import gzip import math +import pytest -from helpers import create_vault, dummy_nostr_client +from helpers import create_vault, dummy_nostr_client, TEST_SEED from seedpass.core.entry_management import EntryManager from seedpass.core.backup import BackupManager from seedpass.core.config_manager import ConfigManager @@ -141,3 +142,44 @@ def test_fetch_snapshot_uses_event_ids(dummy_nostr_client): if getattr(f, "kind_val", None) == KIND_SNAPSHOT_CHUNK ] assert id_filters and all(id_filters) + + +def test_publish_delta_aborts_if_outdated(tmp_path, monkeypatch, dummy_nostr_client): + client1, relay = dummy_nostr_client + + from cryptography.fernet import Fernet + from nostr.client import NostrClient + from seedpass.core.encryption import EncryptionManager + + enc_mgr = EncryptionManager(Fernet.generate_key(), tmp_path) + + class DummyKeys: + def private_key_hex(self): + return "1" * 64 + + def public_key_hex(self): + return "2" * 64 + + class DummyKeyManager: + def __init__(self, *a, **k): + self.keys = DummyKeys() + + with pytest.MonkeyPatch().context() as mp: + mp.setattr("nostr.client.KeyManager", DummyKeyManager) + mp.setattr(enc_mgr, "decrypt_parent_seed", lambda: TEST_SEED) + client2 = NostrClient(enc_mgr, "fp") + + base = b"base" + manifest, _ = asyncio.run(client1.publish_snapshot(base)) + with client1._state_lock: + client1.current_manifest.delta_since = 0 + import copy + + with client2._state_lock: + client2.current_manifest = copy.deepcopy(manifest) + client2.current_manifest_id = manifest_id = relay.manifests[-1].tags[0] + + asyncio.run(client2.publish_delta(b"d1", manifest_id)) + + with pytest.raises(RuntimeError): + asyncio.run(client1.publish_delta(b"d2", manifest_id)) From 747e2be5a903beccd6a1311bbf608185b463a08d Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 24 Jul 2025 20:28:21 -0400 Subject: [PATCH 72/75] Add modified_ts tracking and merge logic --- src/seedpass/core/encryption.py | 15 ++++++++ src/seedpass/core/entry_management.py | 16 ++++++++- src/seedpass/core/manager.py | 10 +++--- src/seedpass/core/vault.py | 6 ++-- src/tests/test_delta_merge.py | 49 +++++++++++++++++++++++++++ src/tests/test_entry_add.py | 4 ++- 6 files changed, 90 insertions(+), 10 deletions(-) create mode 100644 src/tests/test_delta_merge.py diff --git a/src/seedpass/core/encryption.py b/src/seedpass/core/encryption.py index d063387..1a71ced 100644 --- a/src/seedpass/core/encryption.py +++ b/src/seedpass/core/encryption.py @@ -228,6 +228,7 @@ class EncryptionManager: relative_path: Optional[Path] = None, *, strict: bool = True, + merge: bool = False, ) -> bool: """Decrypts data from Nostr and saves it. @@ -249,6 +250,20 @@ class EncryptionManager: data = json_lib.loads(decrypted_data) else: data = json_lib.loads(decrypted_data.decode("utf-8")) + if merge and (self.fingerprint_dir / relative_path).exists(): + current = self.load_json_data(relative_path) + current_entries = current.get("entries", {}) + for idx, entry in data.get("entries", {}).items(): + cur_ts = current_entries.get(idx, {}).get("modified_ts", 0) + new_ts = entry.get("modified_ts", 0) + if idx not in current_entries or new_ts >= cur_ts: + current_entries[idx] = entry + current["entries"] = current_entries + if "schema_version" in data: + current["schema_version"] = max( + current.get("schema_version", 0), data.get("schema_version", 0) + ) + data = current self.save_json_data(data, relative_path) # This always saves in V2 format self.update_checksum(relative_path) logger.info("Index file from Nostr was processed and saved successfully.") diff --git a/src/seedpass/core/entry_management.py b/src/seedpass/core/entry_management.py index 406cfa8..260fe17 100644 --- a/src/seedpass/core/entry_management.py +++ b/src/seedpass/core/entry_management.py @@ -27,6 +27,7 @@ import logging import hashlib import sys import shutil +import time from typing import Optional, Tuple, Dict, Any, List from pathlib import Path @@ -97,6 +98,7 @@ class EntryManager: entry["word_count"] = entry["words"] entry.pop("words", None) entry.setdefault("tags", []) + entry.setdefault("modified_ts", entry.get("updated", 0)) logger.debug("Index loaded successfully.") self._index_cache = data return data @@ -176,6 +178,7 @@ class EntryManager: "type": EntryType.PASSWORD.value, "kind": EntryType.PASSWORD.value, "notes": notes, + "modified_ts": int(time.time()), "custom_fields": custom_fields or [], "tags": tags or [], } @@ -236,6 +239,7 @@ class EntryManager: "type": EntryType.TOTP.value, "kind": EntryType.TOTP.value, "label": label, + "modified_ts": int(time.time()), "index": index, "period": period, "digits": digits, @@ -249,6 +253,7 @@ class EntryManager: "kind": EntryType.TOTP.value, "label": label, "secret": secret, + "modified_ts": int(time.time()), "period": period, "digits": digits, "archived": archived, @@ -294,6 +299,7 @@ class EntryManager: "kind": EntryType.SSH.value, "index": index, "label": label, + "modified_ts": int(time.time()), "notes": notes, "archived": archived, "tags": tags or [], @@ -340,6 +346,7 @@ class EntryManager: "kind": EntryType.PGP.value, "index": index, "label": label, + "modified_ts": int(time.time()), "key_type": key_type, "user_id": user_id, "notes": notes, @@ -392,6 +399,7 @@ class EntryManager: "kind": EntryType.NOSTR.value, "index": index, "label": label, + "modified_ts": int(time.time()), "notes": notes, "archived": archived, "tags": tags or [], @@ -421,6 +429,7 @@ class EntryManager: "type": EntryType.KEY_VALUE.value, "kind": EntryType.KEY_VALUE.value, "label": label, + "modified_ts": int(time.time()), "value": value, "notes": notes, "archived": archived, @@ -480,6 +489,7 @@ class EntryManager: "kind": EntryType.SEED.value, "index": index, "label": label, + "modified_ts": int(time.time()), "word_count": words_num, "notes": notes, "archived": archived, @@ -552,6 +562,7 @@ class EntryManager: "kind": EntryType.MANAGED_ACCOUNT.value, "index": index, "label": label, + "modified_ts": int(time.time()), "word_count": word_count, "notes": notes, "fingerprint": fingerprint, @@ -682,7 +693,8 @@ class EntryManager: ): entry.setdefault("custom_fields", []) logger.debug(f"Retrieved entry at index {index}: {entry}") - return entry + clean = {k: v for k, v in entry.items() if k != "modified_ts"} + return clean else: logger.warning(f"No entry found at index {index}.") print(colored(f"Warning: No entry found at index {index}.", "yellow")) @@ -887,6 +899,8 @@ class EntryManager: entry["tags"] = tags logger.debug(f"Updated tags for index {index}: {tags}") + entry["modified_ts"] = int(time.time()) + data["entries"][str(index)] = entry logger.debug(f"Modified entry at index {index}: {entry}") diff --git a/src/seedpass/core/manager.py b/src/seedpass/core/manager.py index bd844f6..ec7d8d3 100644 --- a/src/seedpass/core/manager.py +++ b/src/seedpass/core/manager.py @@ -1179,7 +1179,7 @@ class PasswordManager: updated = False if current != encrypted: if self.vault.decrypt_and_save_index_from_nostr( - encrypted, strict=False + encrypted, strict=False, merge=False ): updated = True current = encrypted @@ -1189,7 +1189,7 @@ class PasswordManager: for delta in deltas: if current != delta: if self.vault.decrypt_and_save_index_from_nostr( - delta, strict=False + delta, strict=False, merge=True ): updated = True current = delta @@ -1314,7 +1314,7 @@ class PasswordManager: manifest, chunks = result encrypted = gzip.decompress(b"".join(chunks)) success = self.vault.decrypt_and_save_index_from_nostr( - encrypted, strict=False + encrypted, strict=False, merge=False ) if success: have_data = True @@ -1325,7 +1325,7 @@ class PasswordManager: for delta in deltas: if current != delta: if self.vault.decrypt_and_save_index_from_nostr( - delta, strict=False + delta, strict=False, merge=True ): current = delta logger.info("Initialized local database from Nostr.") @@ -3641,7 +3641,7 @@ class PasswordManager: :param encrypted_data: The encrypted data retrieved from Nostr. """ try: - self.vault.decrypt_and_save_index_from_nostr(encrypted_data) + self.vault.decrypt_and_save_index_from_nostr(encrypted_data, merge=True) logging.info("Index file updated from Nostr successfully.") print(colored("Index file updated from Nostr successfully.", "green")) except Exception as e: diff --git a/src/seedpass/core/vault.py b/src/seedpass/core/vault.py index 93667c1..e1a6fbc 100644 --- a/src/seedpass/core/vault.py +++ b/src/seedpass/core/vault.py @@ -61,11 +61,11 @@ class Vault: return self.encryption_manager.get_encrypted_index() def decrypt_and_save_index_from_nostr( - self, encrypted_data: bytes, *, strict: bool = True + self, encrypted_data: bytes, *, strict: bool = True, merge: bool = False ) -> bool: - """Decrypt Nostr payload and overwrite the local index.""" + """Decrypt Nostr payload and update the local index.""" return self.encryption_manager.decrypt_and_save_index_from_nostr( - encrypted_data, strict=strict + encrypted_data, strict=strict, merge=merge ) # ----- Config helpers ----- diff --git a/src/tests/test_delta_merge.py b/src/tests/test_delta_merge.py new file mode 100644 index 0000000..c6b2866 --- /dev/null +++ b/src/tests/test_delta_merge.py @@ -0,0 +1,49 @@ +from pathlib import Path +from tempfile import TemporaryDirectory + +import pytest + +from helpers import create_vault +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager + + +def _setup_mgr(path: Path): + vault, _ = create_vault(path) + cfg = ConfigManager(vault, path) + backup = BackupManager(path, cfg) + return vault, EntryManager(vault, backup) + + +def test_merge_modified_ts(): + with TemporaryDirectory() as tmpdir: + base = Path(tmpdir) + va, ema = _setup_mgr(base / "A") + vb, emb = _setup_mgr(base / "B") + + idx0 = ema.add_entry("a", 8) + idx1 = ema.add_entry("b", 8) + + # B starts from A's snapshot + enc = va.get_encrypted_index() or b"" + vb.decrypt_and_save_index_from_nostr(enc, merge=False) + emb.clear_cache() + assert emb.retrieve_entry(idx0)["username"] == "" + + ema.modify_entry(idx0, username="ua") + delta_a = va.get_encrypted_index() or b"" + vb.decrypt_and_save_index_from_nostr(delta_a, merge=True) + emb.clear_cache() + assert emb.retrieve_entry(idx0)["username"] == "ua" + + emb.modify_entry(idx1, username="ub") + delta_b = vb.get_encrypted_index() or b"" + va.decrypt_and_save_index_from_nostr(delta_b, merge=True) + ema.clear_cache() + assert ema.retrieve_entry(idx1)["username"] == "ub" + + assert ema.retrieve_entry(idx0)["username"] == "ua" + assert ema.retrieve_entry(idx1)["username"] == "ub" + assert emb.retrieve_entry(idx0)["username"] == "ua" + assert emb.retrieve_entry(idx1)["username"] == "ub" diff --git a/src/tests/test_entry_add.py b/src/tests/test_entry_add.py index 07344bb..c7a966a 100644 --- a/src/tests/test_entry_add.py +++ b/src/tests/test_entry_add.py @@ -44,7 +44,9 @@ def test_add_and_retrieve_entry(): data = enc_mgr.load_json_data(entry_mgr.index_file) assert str(index) in data.get("entries", {}) - assert data["entries"][str(index)] == entry + stored = data["entries"][str(index)] + stored.pop("modified_ts", None) + assert stored == entry @pytest.mark.parametrize( From ea2451f4a06286124583b9df1a269eabc88bd61a Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 24 Jul 2025 21:04:04 -0400 Subject: [PATCH 73/75] Persist manifest state --- src/seedpass/core/manager.py | 47 ++++++++++++++++ src/seedpass/core/state_manager.py | 6 ++ src/tests/test_manifest_state_restore.py | 70 ++++++++++++++++++++++++ src/tests/test_state_manager.py | 11 +++- 4 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 src/tests/test_manifest_state_restore.py diff --git a/src/seedpass/core/manager.py b/src/seedpass/core/manager.py index ec7d8d3..0af2fb8 100644 --- a/src/seedpass/core/manager.py +++ b/src/seedpass/core/manager.py @@ -644,6 +644,17 @@ class PasswordManager: config_manager=getattr(self, "config_manager", None), parent_seed=getattr(self, "parent_seed", None), ) + if getattr(self, "manifest_id", None): + from nostr.backup_models import Manifest + + with self.nostr_client._state_lock: + self.nostr_client.current_manifest_id = self.manifest_id + self.nostr_client.current_manifest = Manifest( + ver=1, + algo="gzip", + chunks=[], + delta_since=self.delta_since or None, + ) logging.info( f"NostrClient re-initialized with seed profile {self.current_fingerprint}." ) @@ -1127,10 +1138,14 @@ class PasswordManager: relay_list = state.get("relays", list(DEFAULT_RELAYS)) self.last_bip85_idx = state.get("last_bip85_idx", 0) self.last_sync_ts = state.get("last_sync_ts", 0) + self.manifest_id = state.get("manifest_id") + self.delta_since = state.get("delta_since", 0) else: relay_list = list(DEFAULT_RELAYS) self.last_bip85_idx = 0 self.last_sync_ts = 0 + self.manifest_id = None + self.delta_since = 0 self.offline_mode = bool(config.get("offline_mode", False)) self.inactivity_timeout = config.get( "inactivity_timeout", INACTIVITY_TIMEOUT @@ -1149,6 +1164,18 @@ class PasswordManager: parent_seed=getattr(self, "parent_seed", None), ) + if getattr(self, "manifest_id", None): + from nostr.backup_models import Manifest + + with self.nostr_client._state_lock: + self.nostr_client.current_manifest_id = self.manifest_id + self.nostr_client.current_manifest = Manifest( + ver=1, + algo="gzip", + chunks=[], + delta_since=self.delta_since or None, + ) + logger.debug("Managers re-initialized for the new fingerprint.") except Exception as e: @@ -3684,6 +3711,14 @@ class PasswordManager: if manifest is not None: chunk_ids = [c.event_id for c in manifest.chunks if c.event_id] delta_ids = self.nostr_client.get_delta_events() + if manifest is not None and self.state_manager is not None: + ts = manifest.delta_since or int(time.time()) + self.state_manager.update_state( + manifest_id=event_id, + delta_since=ts, + last_sync_ts=ts, + ) + self.last_sync_ts = ts return { "manifest_id": event_id, "chunk_ids": chunk_ids, @@ -4062,6 +4097,18 @@ class PasswordManager: parent_seed=getattr(self, "parent_seed", None), ) + if getattr(self, "manifest_id", None): + from nostr.backup_models import Manifest + + with self.nostr_client._state_lock: + self.nostr_client.current_manifest_id = self.manifest_id + self.nostr_client.current_manifest = Manifest( + ver=1, + algo="gzip", + chunks=[], + delta_since=self.delta_since or None, + ) + # Push a fresh backup to Nostr so the newly encrypted index is # stored remotely. Include a tag to mark the password change. try: diff --git a/src/seedpass/core/state_manager.py b/src/seedpass/core/state_manager.py index 8d142f9..f2ca11e 100644 --- a/src/seedpass/core/state_manager.py +++ b/src/seedpass/core/state_manager.py @@ -23,6 +23,8 @@ class StateManager: return { "last_bip85_idx": 0, "last_sync_ts": 0, + "manifest_id": None, + "delta_since": 0, "relays": list(DEFAULT_RELAYS), } with shared_lock(self.state_path) as fh: @@ -32,6 +34,8 @@ class StateManager: return { "last_bip85_idx": 0, "last_sync_ts": 0, + "manifest_id": None, + "delta_since": 0, "relays": list(DEFAULT_RELAYS), } try: @@ -40,6 +44,8 @@ class StateManager: obj = {} obj.setdefault("last_bip85_idx", 0) obj.setdefault("last_sync_ts", 0) + obj.setdefault("manifest_id", None) + obj.setdefault("delta_since", 0) obj.setdefault("relays", list(DEFAULT_RELAYS)) return obj diff --git a/src/tests/test_manifest_state_restore.py b/src/tests/test_manifest_state_restore.py new file mode 100644 index 0000000..c28fad7 --- /dev/null +++ b/src/tests/test_manifest_state_restore.py @@ -0,0 +1,70 @@ +import asyncio +from pathlib import Path +from tempfile import TemporaryDirectory + +from helpers import create_vault, dummy_nostr_client, TEST_SEED + +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager +from seedpass.core.state_manager import StateManager +from seedpass.core.manager import PasswordManager, EncryptionMode + + +def _init_pm(dir_path: Path, client) -> PasswordManager: + vault, enc_mgr = create_vault(dir_path) + cfg_mgr = ConfigManager(vault, dir_path) + backup_mgr = BackupManager(dir_path, cfg_mgr) + entry_mgr = EntryManager(vault, backup_mgr) + state_mgr = StateManager(dir_path) + + pm = PasswordManager.__new__(PasswordManager) + pm.encryption_mode = EncryptionMode.SEED_ONLY + pm.encryption_manager = enc_mgr + pm.vault = vault + pm.entry_manager = entry_mgr + pm.backup_manager = backup_mgr + pm.config_manager = cfg_mgr + pm.state_manager = state_mgr + pm.nostr_client = client + pm.fingerprint_dir = dir_path + pm.current_fingerprint = "fp" + pm.parent_seed = TEST_SEED + pm.is_dirty = False + return pm + + +def test_manifest_state_restored(monkeypatch, tmp_path): + client, relay = dummy_nostr_client.__wrapped__(tmp_path / "c1", monkeypatch) + with TemporaryDirectory() as tmpdir: + fp_dir = Path(tmpdir) + pm1 = _init_pm(fp_dir, client) + pm1.entry_manager.add_entry("site", 8) + result = pm1.sync_vault() + manifest_id = relay.manifests[-1].tags[0] + state = pm1.state_manager.state + delta_ts = state["delta_since"] + assert state["manifest_id"] == manifest_id + assert delta_ts > 0 + assert result["manifest_id"] == manifest_id + + client2, _ = dummy_nostr_client.__wrapped__(tmp_path / "c2", monkeypatch) + monkeypatch.setattr( + "seedpass.core.manager.NostrClient", lambda *a, **k: client2 + ) + + pm2 = PasswordManager.__new__(PasswordManager) + pm2.encryption_mode = EncryptionMode.SEED_ONLY + vault2, enc_mgr2 = create_vault(fp_dir) + pm2.encryption_manager = enc_mgr2 + pm2.vault = vault2 + pm2.fingerprint_dir = fp_dir + pm2.current_fingerprint = "fp" + pm2.parent_seed = TEST_SEED + pm2.bip85 = None + pm2.initialize_managers() + + assert pm2.nostr_client is client2 + assert pm2.nostr_client.get_current_manifest_id() == manifest_id + assert pm2.nostr_client.get_current_manifest().delta_since == delta_ts + assert pm2.last_sync_ts == delta_ts diff --git a/src/tests/test_state_manager.py b/src/tests/test_state_manager.py index 0aef6d6..71abe25 100644 --- a/src/tests/test_state_manager.py +++ b/src/tests/test_state_manager.py @@ -12,15 +12,24 @@ def test_state_manager_round_trip(): assert state["relays"] == list(DEFAULT_RELAYS) assert state["last_bip85_idx"] == 0 assert state["last_sync_ts"] == 0 + assert state["manifest_id"] is None + assert state["delta_since"] == 0 sm.add_relay("wss://example.com") - sm.update_state(last_bip85_idx=5, last_sync_ts=123) + sm.update_state( + last_bip85_idx=5, + last_sync_ts=123, + manifest_id="mid", + delta_since=111, + ) sm2 = StateManager(Path(tmpdir)) state2 = sm2.state assert "wss://example.com" in state2["relays"] assert state2["last_bip85_idx"] == 5 assert state2["last_sync_ts"] == 123 + assert state2["manifest_id"] == "mid" + assert state2["delta_since"] == 111 sm2.remove_relay(1) # remove first default relay assert len(sm2.list_relays()) == len(DEFAULT_RELAYS) From 0edf4e5c832d686448183ea66a34a5e51ed371a0 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 24 Jul 2025 21:47:04 -0400 Subject: [PATCH 74/75] test: add race condition sync test --- src/tests/test_sync_race_conditions.py | 98 ++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 src/tests/test_sync_race_conditions.py diff --git a/src/tests/test_sync_race_conditions.py b/src/tests/test_sync_race_conditions.py new file mode 100644 index 0000000..0786659 --- /dev/null +++ b/src/tests/test_sync_race_conditions.py @@ -0,0 +1,98 @@ +import asyncio +import threading +from pathlib import Path + + +from helpers import create_vault, dummy_nostr_client + +from seedpass.core.entry_management import EntryManager +from seedpass.core.backup import BackupManager +from seedpass.core.config_manager import ConfigManager +from seedpass.core.manager import PasswordManager, EncryptionMode + + +def _init_pm(dir_path: Path, client) -> PasswordManager: + vault, enc_mgr = create_vault(dir_path) + cfg_mgr = ConfigManager(vault, dir_path) + backup_mgr = BackupManager(dir_path, cfg_mgr) + entry_mgr = EntryManager(vault, backup_mgr) + + pm = PasswordManager.__new__(PasswordManager) + pm.encryption_mode = EncryptionMode.SEED_ONLY + pm.encryption_manager = enc_mgr + pm.vault = vault + pm.entry_manager = entry_mgr + pm.backup_manager = backup_mgr + pm.config_manager = cfg_mgr + pm.nostr_client = client + pm.fingerprint_dir = dir_path + pm.is_dirty = False + pm.state_manager = None + return pm + + +def test_sync_race_conditions(monkeypatch, tmp_path): + client_a, relay = dummy_nostr_client.__wrapped__(tmp_path / "c1", monkeypatch) + + from cryptography.fernet import Fernet + from nostr.client import NostrClient + from seedpass.core.encryption import EncryptionManager + from helpers import TEST_SEED + + enc_mgr = EncryptionManager(Fernet.generate_key(), tmp_path / "c2") + + class DummyKeys: + def private_key_hex(self): + return "1" * 64 + + def public_key_hex(self): + return "2" * 64 + + class DummyKeyManager: + def __init__(self, *a, **k): + self.keys = DummyKeys() + + monkeypatch.setattr("nostr.client.KeyManager", DummyKeyManager) + monkeypatch.setattr(enc_mgr, "decrypt_parent_seed", lambda: TEST_SEED) + client_b = NostrClient(enc_mgr, "fp") + + dir_a = tmp_path / "A" + dir_b = tmp_path / "B" + dir_a.mkdir() + dir_b.mkdir() + + pm_a = _init_pm(dir_a, client_a) + pm_b = _init_pm(dir_b, client_b) + + pm_a.entry_manager.add_entry("init", 12) + pm_a.sync_vault() + manifest_id = relay.manifests[-1].tags[0] + assert pm_b.attempt_initial_sync() is True + + pm_b.entry_manager.get_next_index = lambda: 2 + + def publish(pm: PasswordManager, client, label: str) -> None: + pm.entry_manager.add_entry(label, 12) + data = pm.vault.get_encrypted_index() or b"" + try: + asyncio.run(client.publish_delta(data, manifest_id)) + except RuntimeError: + pm.sync_index_from_nostr() + pm.entry_manager.clear_cache() + pm.entry_manager.add_entry(label, 12) + data = pm.vault.get_encrypted_index() or b"" + asyncio.run(client.publish_delta(data, manifest_id)) + + t1 = threading.Thread(target=publish, args=(pm_a, client_a, "from_a")) + t2 = threading.Thread(target=publish, args=(pm_b, client_b, "from_b")) + t1.start() + t2.start() + t1.join() + t2.join() + + assert len(relay.deltas) >= 1 + + pm_b.sync_index_from_nostr() + pm_b.entry_manager.clear_cache() + labels = [e[1] for e in pm_b.entry_manager.list_entries()] + assert "from_a" in labels and "from_b" in labels From 5b0051f76f8fe23e7779e135e16a30e96e4222df Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 25 Jul 2025 19:08:06 -0400 Subject: [PATCH 75/75] docs: clarify beta installer command --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 11c0af4..902914e 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,7 @@ bash -c "$(curl -sSL https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/ ```bash bash -c "$(curl -sSL https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/scripts/install.sh)" _ -b beta ``` +Make sure the command ends right after `-b beta` with **no trailing parenthesis**. **Windows (PowerShell):** ```powershell