Merge pull request #644 from PR0M3TH3AN/codex/implement-relaymanagerdialog-and-other-updates

Add relay manager and session lock handling
This commit is contained in:
thePR0M3TH3AN
2025-07-18 17:43:28 -04:00
committed by GitHub
6 changed files with 244 additions and 7 deletions

View File

@@ -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`. 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 ## Getting Started with the GUI
After installing the project dependencies, launch the desktop interface with one 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. 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.

View File

@@ -304,6 +304,7 @@ class PasswordManager:
self.nostr_client = None self.nostr_client = None
self.config_manager = None self.config_manager = None
self.locked = True self.locked = True
bus.publish("vault_locked")
def unlock_vault(self, password: Optional[str] = None) -> float: def unlock_vault(self, password: Optional[str] = None) -> float:
"""Unlock the vault using the provided ``password``. """Unlock the vault using the provided ``password``.

View File

@@ -5,18 +5,25 @@ from toga.style import Pack
from toga.style.pack import COLUMN, ROW from toga.style.pack import COLUMN, ROW
from seedpass.core.manager import PasswordManager from seedpass.core.manager import PasswordManager
import time
from seedpass.core.api import ( from seedpass.core.api import (
VaultService, VaultService,
EntryService, EntryService,
NostrService,
UnlockRequest, UnlockRequest,
) )
from seedpass.core.pubsub import bus
class LockScreenWindow(toga.Window): class LockScreenWindow(toga.Window):
"""Window prompting for the master password.""" """Window prompting for the master password."""
def __init__( def __init__(
self, controller: SeedPassApp, vault: VaultService, entries: EntryService self,
controller: SeedPassApp,
vault: VaultService,
entries: EntryService,
) -> None: ) -> None:
super().__init__("Unlock Vault") super().__init__("Unlock Vault")
# Store a reference to the SeedPass application instance separately from # 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 except Exception as exc: # pragma: no cover - GUI error handling
self.message.text = str(exc) self.message.text = str(exc)
return 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 self.controller.main_window = main
main.show() main.show()
self.close() self.close()
@@ -55,14 +67,23 @@ class MainWindow(toga.Window):
"""Main application window showing vault entries.""" """Main application window showing vault entries."""
def __init__( def __init__(
self, controller: SeedPassApp, vault: VaultService, entries: EntryService self,
controller: SeedPassApp,
vault: VaultService,
entries: EntryService,
nostr: NostrService,
) -> None: ) -> None:
super().__init__("SeedPass") super().__init__("SeedPass", on_close=self.cleanup)
# ``Window.app`` is reserved for the Toga ``App`` instance. Store the # ``Window.app`` is reserved for the Toga ``App`` instance. Store the
# SeedPass application reference separately. # SeedPass application reference separately.
self.controller = controller self.controller = controller
self.vault = vault self.vault = vault
self.entries = entries 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( self.table = toga.Table(
headings=["ID", "Label", "Username", "URL"], style=Pack(flex=1) 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) add_button = toga.Button("Add", on_press=self.add_entry)
edit_button = toga.Button("Edit", on_press=self.edit_entry) edit_button = toga.Button("Edit", on_press=self.edit_entry)
search_button = toga.Button("Search", on_press=self.search_entries) 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 = toga.Box(style=Pack(direction=ROW, padding_top=5))
button_box.add(add_button) button_box.add(add_button)
button_box.add(edit_button) button_box.add(edit_button)
button_box.add(search_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 = toga.Box(style=Pack(direction=COLUMN, padding=10))
box.add(self.table) box.add(self.table)
box.add(button_box) box.add(button_box)
box.add(self.status)
self.content = box self.content = box
self.refresh_entries() self.refresh_entries()
@@ -105,6 +131,28 @@ class MainWindow(toga.Window):
dlg = SearchDialog(self) dlg = SearchDialog(self)
dlg.show() 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): class EntryDialog(toga.Window):
"""Dialog for adding or editing an entry.""" """Dialog for adding or editing an entry."""
@@ -187,6 +235,62 @@ class SearchDialog(toga.Window):
self.close() 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: def build() -> SeedPassApp:
"""Return a configured :class:`SeedPassApp` instance.""" """Return a configured :class:`SeedPassApp` instance."""
return SeedPassApp(formal_name="SeedPass", app_id="org.seedpass.gui") return SeedPassApp(formal_name="SeedPass", app_id="org.seedpass.gui")
@@ -197,8 +301,11 @@ class SeedPassApp(toga.App):
pm = PasswordManager() pm = PasswordManager()
self.vault_service = VaultService(pm) self.vault_service = VaultService(pm)
self.entry_service = EntryService(pm) self.entry_service = EntryService(pm)
self.nostr_service = NostrService(pm)
self.lock_window = LockScreenWindow( self.lock_window = LockScreenWindow(
self, self.vault_service, self.entry_service self,
self.vault_service,
self.entry_service,
) )
self.main_window = None self.main_window = None
self.lock_window.show() self.lock_window.show()

View File

@@ -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

View File

@@ -40,18 +40,29 @@ def setup_module(module):
asyncio.set_event_loop(asyncio.new_event_loop()) 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(): def test_unlock_creates_main_window():
app = toga.App("Test", "org.example") app = toga.App("Test", "org.example")
controller = SimpleNamespace(main_window=None) controller = SimpleNamespace(main_window=None, nostr_service=FakeNostr())
vault = FakeVault() vault = FakeVault()
entries = FakeEntries() entries = FakeEntries()
win = LockScreenWindow(controller, vault, entries) win = LockScreenWindow(controller, vault, entries)
win.password_input.value = "pw" win.password_input.value = "pw"
win.handle_unlock(None) win.handle_unlock(None)
assert vault.called assert vault.called
assert isinstance(controller.main_window, MainWindow) assert isinstance(controller.main_window, MainWindow)
controller.main_window.cleanup()
def test_entrydialog_add_calls_service(): def test_entrydialog_add_calls_service():

View File

@@ -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]