usawa

Signed, immutable accounting.
Log | Files | Refs | Submodules | LICENSE

commit 322a72c568cb37852b350f248bb35064180c3902
parent d7a24992de7b1c244e3c469ee5ce3731027edfad
Author: Carlosokumu <carlosokumu254@gmail.com>
Date:   Tue, 17 Mar 2026 22:56:12 +0300

add a passphrase dialog

Diffstat:
Adummy/usawa/gui/components/passphrase_dialog.py | 187+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 187 insertions(+), 0 deletions(-)

diff --git a/dummy/usawa/gui/components/passphrase_dialog.py b/dummy/usawa/gui/components/passphrase_dialog.py @@ -0,0 +1,187 @@ +import logging +import threading + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") +from gi.repository import Adw, Gtk, GLib + +logg = logging.getLogger("gui.passphrase_dialog") + + +class PassphraseDialog(Adw.Dialog): + """Dialog that prompts the user for a wallet passphrase, decrypts the key, + and calls a success or failure callback. + + :param store: LedgerStore instance to retrieve the key from. + :type store: usawa.LedgerStore + :param wallet_class: Wallet class to instantiate from the stored key. + :type wallet_class: type + :param on_success: Callback called with the unlocked wallet on success. + :type on_success: callable(wallet) + :param on_cancel: Optional callback called if the dialog is dismissed without unlocking. + :type on_cancel: callable or None + """ + + def __init__(self, store, wallet_class, on_success, on_cancel=None): + super().__init__() + self.store = store + self.wallet_class = wallet_class + self.on_success = on_success + self.on_cancel = on_cancel + self._unlocked = False + + self.set_title("Unlock Wallet") + self.set_content_width(360) + self.set_can_close(True) + + self._build_ui() + self.connect("closed", self._on_closed) + + def _build_ui(self): + toolbar_view = Adw.ToolbarView() + self.set_child(toolbar_view) + + header = Adw.HeaderBar() + header.set_show_end_title_buttons(False) + toolbar_view.add_top_bar(header) + + content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=24) + content.set_margin_top(24) + content.set_margin_bottom(24) + content.set_margin_start(24) + content.set_margin_end(24) + toolbar_view.set_content(content) + + icon_label = Gtk.Image.new_from_icon_name("channel-secure-symbolic") + icon_label.set_css_classes(["title-1"]) + content.append(icon_label) + + title = Gtk.Label(label="Wallet Locked") + title.set_css_classes(["title-2"]) + content.append(title) + + subtitle = Gtk.Label(label="Enter your passphrase to unlock the wallet.") + subtitle.set_wrap(True) + subtitle.set_justify(Gtk.Justification.CENTER) + subtitle.set_css_classes(["dim-label"]) + content.append(subtitle) + + self.passphrase_row = Adw.PasswordEntryRow() + self.passphrase_row.set_title("Passphrase") + self.passphrase_row.connect("entry-activated", self._on_unlock_clicked) + + group = Adw.PreferencesGroup() + group.add(self.passphrase_row) + content.append(group) + + self.error_label = Gtk.Label(label="") + self.error_label.set_css_classes(["error"]) + self.error_label.set_visible(False) + self.error_label.set_wrap(True) + self.error_label.set_justify(Gtk.Justification.CENTER) + content.append(self.error_label) + + self.stack = Gtk.Stack() + self.stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE) + self.stack.set_transition_duration(150) + content.append(self.stack) + + self.unlock_button = Gtk.Button(label="Unlock") + self.unlock_button.set_css_classes(["pill", "suggested-action"]) + self.unlock_button.set_halign(Gtk.Align.CENTER) + self.unlock_button.connect("clicked", self._on_unlock_clicked) + self.stack.add_named(self.unlock_button, "button") + + # Spinner + spinner_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + spinner_box.set_halign(Gtk.Align.CENTER) + self.spinner = Gtk.Spinner() + self.spinner.set_size_request(20, 20) + spinner_label = Gtk.Label(label="Unlocking…") + spinner_label.set_css_classes(["dim-label"]) + spinner_box.append(self.spinner) + spinner_box.append(spinner_label) + self.stack.add_named(spinner_box, "spinner") + + self.stack.set_visible_child_name("button") + + def _on_unlock_clicked(self, *_): + passphrase = self.passphrase_row.get_text().strip() + if not passphrase: + self._show_error("Please enter your passphrase.") + return + + self._set_loading(True) + threading.Thread( + target=self._do_unlock, args=(passphrase,), daemon=True + ).start() + + def _do_unlock(self, passphrase): + try: + wallet = self.store.get_key( + wallet_class=self.wallet_class, + passphrase=passphrase, + ) + GLib.idle_add(self._unlock_success, wallet) + except Exception as e: + logg.warning("Passphrase unlock failed: %s", e) + GLib.idle_add(self._unlock_failure, str(e)) + + def _unlock_success(self, wallet): + logg.info("Wallet unlocked successfully") + self._set_loading(False) + self._unlocked = True + self.force_close() + if self.on_success: + self.on_success(wallet) + + def _unlock_failure(self, error_msg): + self._set_loading(False) + self._show_error("Wrong passphrase. Please try again.") + self._shake_entry() + + def _set_loading(self, loading: bool): + self.passphrase_row.set_sensitive(not loading) + if loading: + self.spinner.start() + self.stack.set_visible_child_name("spinner") + else: + self.spinner.stop() + self.stack.set_visible_child_name("button") + + def _show_error(self, message: str): + self.error_label.set_label(message) + self.error_label.set_visible(True) + + def _shake_entry(self): + """Apply shake CSS animation to the passphrase entry row.""" + self.passphrase_row.add_css_class("shake") + + def remove_shake(): + self.passphrase_row.remove_css_class("shake") + return False + + GLib.timeout_add(400, remove_shake) + + def _on_closed(self, *_): + if not self._unlocked and self.on_cancel: + self.on_cancel() + + +# CSS for the shake animation +PASSPHRASE_DIALOG_CSS = """ +@keyframes shake { + 0% { margin-left: 0; } + 20% { margin-left: -8px; } + 40% { margin-left: 8px; } + 60% { margin-left: -6px; } + 80% { margin-left: 6px; } + 100% { margin-left: 0; } +} + +.shake { + animation: shake 0.4s ease; +} +"""