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]