mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2025-09-07 14:58:56 +00:00
Merge pull request #644 from PR0M3TH3AN/codex/implement-relaymanagerdialog-and-other-updates
Add relay manager and session lock handling
This commit is contained in:
@@ -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.
|
||||||
|
|
||||||
|
@@ -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``.
|
||||||
|
@@ -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()
|
||||||
|
78
src/tests/test_gui_features.py
Normal file
78
src/tests/test_gui_features.py
Normal 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
|
@@ -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():
|
||||||
|
28
src/tests/test_vault_lock_event.py
Normal file
28
src/tests/test_vault_lock_event.py
Normal 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]
|
Reference in New Issue
Block a user