mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 16:14:40 +00:00
Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst: New Crowdin translations by GitHub Action fix: resolve conflicts with upstream PR #1789 merge fix(desktop): remove noisy debug logs and silence SLF4J warnings feat(desktop): add bunker heartbeat indicator to sidebar navigation fix(desktop): verify user pubkey via get_public_key after nostrconnect login fix(desktop): address code review findings for NIP-46 bunker login feat(desktop): add nostrconnect:// login flow for QR-based signer pairing revert: remove QR code scanning from login feat(desktop): webcam QR scanning via ffmpeg for bunker login feat(desktop): add webcam QR code scanning for bunker login feat(desktop): add QR code scanning for bunker login fix(desktop): clear stored credentials on logout feat(desktop): update login hints to mention bunker:// option fix(desktop): simplify settings logout to just a button feat(desktop): add logout option to menu bar and settings test: add NIP-46 test suite for desktop, quartz, and fix pre-existing chess test errors feat(desktop): add NIP-46 remote signer (bunker://) login
This commit is contained in:
@@ -150,6 +150,7 @@
|
||||
<string name="pronouns">Pronoms</string>
|
||||
<string name="ln_address">Adresse LN</string>
|
||||
<string name="ln_url_outdated">URL LN (obsolete)</string>
|
||||
<string name="download_to_phone">Enregistrer sur le téléphone</string>
|
||||
<string name="save_to_gallery">Enregistrer dans la Galerie</string>
|
||||
<string name="image_saved_to_the_gallery">Image enregistrée dans la galerie</string>
|
||||
<string name="video_download_has_started_toast">Le téléchargement de la vidéo a démarré…</string>
|
||||
@@ -158,6 +159,7 @@
|
||||
<string name="video_saved_to_the_gallery">Vidéo enregistrée dans la galerie vidéo du téléphone</string>
|
||||
<string name="failed_to_save_the_video">Impossible d\'enregistrer la vidéo</string>
|
||||
<string name="upload_image">Charger une image</string>
|
||||
<string name="upload_file">Téléverser un fichier</string>
|
||||
<string name="take_a_picture">Prendre une photo</string>
|
||||
<string name="record_a_video">Enregistrer une vidéo</string>
|
||||
<string name="record_a_message">Enregistrer un message</string>
|
||||
@@ -421,11 +423,18 @@
|
||||
<string name="wallet_connect_service_secret">Secret Wallet Connect</string>
|
||||
<string name="wallet_connect_service_show_secret">Montrer la clé secrète</string>
|
||||
<string name="wallet_connect_service_secret_placeholder">Clé privée nsec / hex</string>
|
||||
<string name="wallet_connect_status_connected">Connecté</string>
|
||||
<string name="wallet_connect_status_not_connected">Non connecté</string>
|
||||
<string name="wallet_connect_manual_config">Avancé : saisissez manuellement les détails de la connexion</string>
|
||||
<string name="zap_privacy_section">Confidentialité des Zaps</string>
|
||||
<string name="zap_type_section_explainer">Contrôle comment votre identité est affichée lorsque vous envoyez un zap.</string>
|
||||
<string name="wallet_connect_connect_app">Connecter le portefeuille</string>
|
||||
<string name="pledge_amount_in_sats">Montant engagé en Sats</string>
|
||||
<string name="post_poll">Message Sondage</string>
|
||||
<string name="poll_heading_required">Champs requis:</string>
|
||||
<string name="poll_zap_recipients">Bénéficiaires du Zap</string>
|
||||
<string name="poll_primary_description">Description du sondage primaire…</string>
|
||||
<string name="poll_option">Option</string>
|
||||
<string name="poll_option_index">Option %s</string>
|
||||
<string name="poll_option_description">Description de l\'option du Sondage</string>
|
||||
<string name="poll_heading_optional">Champs optionnels :</string>
|
||||
@@ -433,6 +442,8 @@
|
||||
<string name="poll_zap_value_max">Zap maximum</string>
|
||||
<string name="poll_consensus_threshold">Consensus</string>
|
||||
<string name="poll_consensus_threshold_percent">(0–100)%</string>
|
||||
<string name="poll_closing_date_time">Date & heure de clôture du sondage</string>
|
||||
<string name="poll_closing_in">Le sondage se termine dans %1$s</string>
|
||||
<string name="poll_closing_time">Clôturer après</string>
|
||||
<string name="poll_closing_time_days">jours</string>
|
||||
<string name="poll_unable_to_vote">Impossible de voter</string>
|
||||
@@ -440,6 +451,7 @@
|
||||
<string name="poll_zap_amount">Montant des Zap</string>
|
||||
<string name="one_vote_per_user_on_atomic_votes">Un seul vote par utilisateur est autorisé sur ce type de sondage</string>
|
||||
<string name="looking_for_event">"Recherche de l'Événement %1$s"</string>
|
||||
<string name="send_zap">Envoyer Zap</string>
|
||||
<string name="custom_zaps_add_a_message">Ajouter un message public</string>
|
||||
<string name="custom_zaps_add_a_message_private">Ajouter un message privé</string>
|
||||
<string name="custom_zaps_add_a_message_nonzap">Ajouter un message facture</string>
|
||||
@@ -507,6 +519,7 @@
|
||||
<string name="follow_list_kind3follows_proxy">Suivre via Proxy</string>
|
||||
<string name="follow_list_aroundme">Autour de moi</string>
|
||||
<string name="follow_list_global">Général</string>
|
||||
<string name="follow_list_chess">Échecs</string>
|
||||
<string name="follow_list_mute_list">Liste en Sourdine</string>
|
||||
<string name="follow_sets">Listes de suivi</string>
|
||||
<string name="follow_sets_explainer">Ce sont des listes de suivi conçues pour votre propre utilisation. Vous pouvez suivre des utilisateurs en privé ou publiquement</string>
|
||||
@@ -656,6 +669,7 @@
|
||||
<string name="read_from_relay_description">Le nombre d\'octets qui ont été reçus depuis ce relais, y compris les filtres et les événements</string>
|
||||
<string name="an_error_occurred_trying_to_get_relay_information">Une erreur s\'est produite en récupérant les informations du relay depuis %1$s</string>
|
||||
<string name="owner">Propriétaire</string>
|
||||
<string name="used_by">Utilisé par</string>
|
||||
<string name="self">Clé de service</string>
|
||||
<string name="running_software">Exécution %1$s</string>
|
||||
<string name="running_software_version">Exécution %1$s (%2$s)</string>
|
||||
@@ -685,6 +699,12 @@
|
||||
<string name="terms_and_conditions">Termes & Conditions</string>
|
||||
<string name="not_available_acronym">N/A</string>
|
||||
<string name="relay_error_messages">Erreurs et Notifications de ce Relais</string>
|
||||
<string name="relay_active_subscriptions">Abonnements actifs</string>
|
||||
<string name="relay_filter_authors">%1$d auteurs</string>
|
||||
<string name="relay_filter_ids">%1$d ids</string>
|
||||
<string name="relay_filter_since">depuis %1$s</string>
|
||||
<string name="relay_filter_until">jusqu\'à %1$s</string>
|
||||
<string name="relay_filter_limit">limite %1$d</string>
|
||||
<string name="message_length">Longueur du message</string>
|
||||
<string name="subscriptions">Abonnements</string>
|
||||
<string name="filters">Filtres</string>
|
||||
@@ -743,9 +763,14 @@
|
||||
<string name="community_no_descriptor">Cette communauté n\'a pas de description. Parlez au propriétaire pour en ajouter une</string>
|
||||
<string name="add_sensitive_content_label">Contenu sensible</string>
|
||||
<string name="add_sensitive_content_description">Ajouter un avertissement de contenu sensible avant de montrer ce contenu</string>
|
||||
<string name="ui_preferences">Préférences de l\'UI</string>
|
||||
<string name="preferences">Préférences de l\'application</string>
|
||||
<string name="user_preferences">Préférences utilisateur</string>
|
||||
<string name="translations">Traductions</string>
|
||||
<string name="reactions">Réactions</string>
|
||||
<string name="settings">Paramètres</string>
|
||||
<string name="account_settings">Paramètres du compte</string>
|
||||
<string name="app_settings">Paramètres de l\'application</string>
|
||||
<string name="connectivity_type_always">Toujours</string>
|
||||
<string name="connectivity_type_wifi_only">Wifi uniquement</string>
|
||||
<string name="connectivity_type_unmetered_wifi_only">WiFi illimité</string>
|
||||
@@ -775,6 +800,9 @@
|
||||
<string name="spamming_users">Spammeurs</string>
|
||||
<string name="muted_button">Silencieux. Cliquer pour réactiver le son</string>
|
||||
<string name="mute_button">Son activé. Cliquer pour mettre en silencieux</string>
|
||||
<string name="skip_back">Revenir %d secondes en arrière</string>
|
||||
<string name="skip_forward">Avancer de %d secondes</string>
|
||||
<string name="picture_in_picture">Image dans l\'image</string>
|
||||
<string name="search_button">Recherche d\'enregistrements locaux et distants</string>
|
||||
<string name="nip05_verified">L\'adresse Nostr a été vérfiée</string>
|
||||
<string name="nip05_failed">La vérification de l\'adresse Nostr a échouée</string>
|
||||
@@ -823,6 +851,7 @@
|
||||
<string name="load_image_description">Quand charger les images</string>
|
||||
<string name="copy_stack_to_clipboard">Copier dans le presse-papiers</string>
|
||||
<string name="copy_to_clipboard">Copier dans le presse-papiers</string>
|
||||
<string name="copy_nprofile_to_clipboard">Copier le nprofile dans le presse-papiers</string>
|
||||
<string name="copy_npub_to_clipboard">Copier npub dans le presse-papiers</string>
|
||||
<string name="share_or_save">Partager ou Enregistrer</string>
|
||||
<string name="copy_url_to_clipboard">Copier l\'URL dans le presse-papiers</string>
|
||||
@@ -998,6 +1027,7 @@
|
||||
<string name="route_notifications">Notifications</string>
|
||||
<string name="route_global">Général</string>
|
||||
<string name="route_video">Vidéos courtes</string>
|
||||
<string name="route_chess">Échecs</string>
|
||||
<string name="route_security_filters">Filtres de Sécurité</string>
|
||||
<string name="new_post">Nouveau Message</string>
|
||||
<string name="new_short">Nouveaux Shorts : images ou vidéos</string>
|
||||
@@ -1011,6 +1041,19 @@
|
||||
<string name="like_description">J\'aime</string>
|
||||
<string name="zap_description">Zap</string>
|
||||
<string name="change_reaction">Modifier les réactions rapides</string>
|
||||
<string name="reactions_settings_enabled">Activé</string>
|
||||
<string name="reactions_settings_show_counter">Afficher le nombre</string>
|
||||
<string name="reactions_settings_reorder">Réorganiser</string>
|
||||
<string name="reactions_settings_reply">Répondre</string>
|
||||
<string name="reactions_settings_reply_description">Répondre à cette note</string>
|
||||
<string name="reactions_settings_boost">Boost</string>
|
||||
<string name="reactions_settings_boost_description">Republier ou citer cette note</string>
|
||||
<string name="reactions_settings_like">Aimer</string>
|
||||
<string name="reactions_settings_like_description">Réagir à cette note avec un émoji</string>
|
||||
<string name="reactions_settings_zap">Zap</string>
|
||||
<string name="reactions_settings_zap_description">Envoyer un paiement lightning à l\'auteur</string>
|
||||
<string name="reactions_settings_share">Partager</string>
|
||||
<string name="reactions_settings_share_description">Partager cette note à l\'extérieur</string>
|
||||
<string name="profile_image_of_user">Photo de Profil de %1$s</string>
|
||||
<string name="relay_info">Relais %1$s</string>
|
||||
<string name="expand_relay_list">Développer la liste des relais</string>
|
||||
@@ -1030,6 +1073,7 @@
|
||||
<string name="add_content_warning">Ajouter un avertissement de contenu</string>
|
||||
<string name="remove_content_warning">Retirer l\'avertissement de contenu</string>
|
||||
<string name="show_npub_as_a_qr_code">Afficher npub en tant que QR code</string>
|
||||
<string name="show_nprofile_as_a_qr_code">Afficher le nprofile en tant que code QR</string>
|
||||
<string name="invalid_nip19_uri">Adresse invalide</string>
|
||||
<string name="invalid_nip19_uri_description">Amethyst a reçu une URI à ouvrir mais cette URI était invalide : %1$s</string>
|
||||
<string name="dm_relays_title">Relais de boîte de réception MP</string>
|
||||
@@ -1126,6 +1170,7 @@
|
||||
<string name="accessibility_pushpin">Punaise</string>
|
||||
<string name="accessibility_scan_qr_code">Scanner le QR code</string>
|
||||
<string name="accessibility_navigate_to_alby">Accéder au fournisseur de portefeuille tiers Alby</string>
|
||||
<string name="accessibility_select_date">Sélectionner la date</string>
|
||||
<string name="it_s_not_possible_to_reply_to_a_draft_note">Il n\'est pas possible de répondre à un brouillon</string>
|
||||
<string name="it_s_not_possible_to_quote_to_a_draft_note">Il n\'est pas possible de citer un brouillon</string>
|
||||
<string name="it_s_not_possible_to_react_to_a_draft_note">Il n\'est pas possible de réagir à un brouillon</string>
|
||||
@@ -1191,6 +1236,7 @@
|
||||
<string name="group_relay_explanation">Le relais auquel tous les utilisateurs de ce chat se connectent</string>
|
||||
<string name="share_image">Partager l\'image…</string>
|
||||
<string name="unable_to_share_image">Impossible de partager l\'image, veuillez réessayer plus tard…</string>
|
||||
<string name="share_video">Partager la vidéo…</string>
|
||||
<string name="unable_to_share_video">Impossible de partager l\'image, veuillez réessayer plus tard…</string>
|
||||
<string name="downloading_video_for_sharing">Téléchargement de la vidéo…</string>
|
||||
<string name="search_by_hashtag">Hashtag de recherche : #%1$s</string>
|
||||
@@ -1229,9 +1275,94 @@
|
||||
<string name="kinds">Kinds</string>
|
||||
<string name="tap_to_view_details">Appuyer pour afficher les détails</string>
|
||||
<string name="event_sent">%1$s envoyé</string>
|
||||
<string name="broadcast_results">Diffuser les résultats</string>
|
||||
<string name="broadcasts_number">Diffusions (%1$d)</string>
|
||||
<string name="collapse">Réduire</string>
|
||||
<string name="expand">Agrandir</string>
|
||||
<string name="more_broadcasts_number">+%1$d diffusions supplémentaires</string>
|
||||
<string name="timeout">Délai expiré</string>
|
||||
<string name="retrying">Nouvelle tentative…</string>
|
||||
<string name="retry">Réessayer</string>
|
||||
<string name="view">Afficher</string>
|
||||
<string name="dismiss">Fermer</string>
|
||||
<string name="share_of">%1$d/%2$d</string>
|
||||
<string name="broadcasting">Diffusion</string>
|
||||
<string name="broadcasting_name">Diffusion de %1$s</string>
|
||||
<string name="broadcasting_number_events">Diffusion de %1$d événements…</string>
|
||||
<string name="sent_number_events">%1$d événements envoyés</string>
|
||||
<string name="bradcasting_result_partial">Certains événements ont échoué</string>
|
||||
<string name="bradcasting_result_success">Tous les événements ont réussi</string>
|
||||
<string name="bradcasting_result_failure">Tous les événements ont échoué</string>
|
||||
<string name="reaction">Réaction</string>
|
||||
<string name="voice_post">Message vocal</string>
|
||||
<string name="voice_reply">Réponse vocale</string>
|
||||
<string name="confirm">Confirmer</string>
|
||||
<string name="next">Suivant</string>
|
||||
<string name="add_poll_option_button">Ajouter un bouton d\'option de sondage</string>
|
||||
<string name="go_back">Retour</string>
|
||||
<string name="approve">Approuver</string>
|
||||
<string name="connected">Connecté</string>
|
||||
<string name="social_proof">Preuve sociale</string>
|
||||
<string name="poll_submit">Valider</string>
|
||||
<string name="restart">Redémarrer</string>
|
||||
<string name="chess_accept">Accepter</string>
|
||||
<string name="chess_decline">Refuser</string>
|
||||
<string name="chess_new_game">Nouveau jeu</string>
|
||||
<string name="new_chess_game">Nouvelle partie d\'échecs</string>
|
||||
<string name="chess_loading_game">Chargement de la partie\u2026</string>
|
||||
<string name="chess_game_not_found">Partie introuvable</string>
|
||||
<string name="chess_game_waiting">Cette partie a pu se terminer ou est en attente de l\'adversaire.</string>
|
||||
<string name="chess_game_id">ID de la partie : %1$s\u2026</string>
|
||||
<string name="badge_and_n_others"> et %1$d autres</string>
|
||||
<string name="remove_language">Supprimer %1$s</string>
|
||||
<!-- Kind display names for relay subscription filter chips -->
|
||||
<string name="kind_apps">Applications</string>
|
||||
<string name="kind_app_recommendations">Recommandations d\'applications</string>
|
||||
<string name="kind_user_settings">Paramètres utilisateur</string>
|
||||
<string name="kind_audio_track">Piste audio</string>
|
||||
<string name="kind_blocked_relays">Relais bloqués</string>
|
||||
<string name="kind_calendar">Calendrier</string>
|
||||
<string name="kind_appointment">Rendez-vous</string>
|
||||
<string name="kind_chess_games">Jeux d\'échecs</string>
|
||||
<string name="kind_comments">Commentaires</string>
|
||||
<string name="kind_deletions">Suppressions</string>
|
||||
<string name="kind_drafts">Brouillons</string>
|
||||
<string name="kind_emoji_packs">Packs d\'émojis</string>
|
||||
<string name="kind_emoji_pack_list">Liste des packs d\'émoji</string>
|
||||
<string name="kind_ephemeral_chat">Chat éphémère</string>
|
||||
<string name="kind_ephemeral_chatrooms">Salles de discussion éphémères</string>
|
||||
<string name="kind_medical_data">Données médicales</string>
|
||||
<string name="kind_live_streams">Diffusions en direct</string>
|
||||
<string name="kind_zaps">Zaps</string>
|
||||
<string name="kind_nwc_request">Requête NWC</string>
|
||||
<string name="kind_nwc_response">Réponse NWC</string>
|
||||
<string name="kind_private_zaps">Zaps privés</string>
|
||||
<string name="kind_blogs">Blogs</string>
|
||||
<string name="kind_profile">Profil</string>
|
||||
<string name="kind_mute_list">Liste en sourdine</string>
|
||||
<string name="kind_nostr_connect">Nostr Connect</string>
|
||||
<string name="kind_people_lists">Listes de personnes</string>
|
||||
<string name="kind_pictures">Photos</string>
|
||||
<string name="kind_poll">Sondage</string>
|
||||
<string name="kind_private_relays">Relais privés</string>
|
||||
<string name="kind_public_message">Message public</string>
|
||||
<string name="kind_reactions">Réactions</string>
|
||||
<string name="kind_search_relays">Relais de recherche</string>
|
||||
<string name="kind_user_status">Statut utilisateur</string>
|
||||
<string name="kind_notes">Notes</string>
|
||||
<string name="kind_edits">Modifications</string>
|
||||
<string name="kind_torrents">Torrents</string>
|
||||
<string name="kind_video">Vidéos</string>
|
||||
<string name="kind_shorts">Vidéos courtes</string>
|
||||
<string name="kind_voice_msg">Message vocal</string>
|
||||
<string name="kind_voice_reply">Réponse vocale</string>
|
||||
<string name="kind_wiki">Wiki</string>
|
||||
<string name="select_users_to_follow">Sélectionnez les utilisateurs à suivre</string>
|
||||
<string name="profile_to_import_from">Profil à importer depuis</string>
|
||||
<string name="name_search_npub1_alice_example_com">recherche, npub1…, alice@exemple.com</string>
|
||||
<string name="search_another">Rechercher un autre</string>
|
||||
<string name="import_more">Importer plus</string>
|
||||
<string name="continue_txt">Continuer</string>
|
||||
<string name="skip_for_now">Ignorer pour le moment</string>
|
||||
<string name="select_all">Tout sélectionner</string>
|
||||
</resources>
|
||||
|
||||
@@ -150,6 +150,7 @@
|
||||
<string name="pronouns">Megszólítás</string>
|
||||
<string name="ln_address">LN-cím</string>
|
||||
<string name="ln_url_outdated">LN-webcím (elavult)</string>
|
||||
<string name="download_to_phone">Mentés a telefonra</string>
|
||||
<string name="save_to_gallery">Mentés a galériába</string>
|
||||
<string name="image_saved_to_the_gallery">Kép mentve a képgalériába</string>
|
||||
<string name="video_download_has_started_toast">A videó letöltése megkezdődött…</string>
|
||||
@@ -158,6 +159,7 @@
|
||||
<string name="video_saved_to_the_gallery">Videó mentve a videógalériába</string>
|
||||
<string name="failed_to_save_the_video">Nem sikerült menteni a videót</string>
|
||||
<string name="upload_image">Kép feltöltése</string>
|
||||
<string name="upload_file">Fájl feltöltése</string>
|
||||
<string name="take_a_picture">Kép készítése</string>
|
||||
<string name="record_a_video">Videó rögzítése</string>
|
||||
<string name="record_a_message">Hangüzenet rögzítése</string>
|
||||
@@ -1046,6 +1048,7 @@
|
||||
<string name="route_video">Rövidek</string>
|
||||
<string name="route_chess">Sakk</string>
|
||||
<string name="route_security_filters">Biztonsági szűrők</string>
|
||||
<string name="route_import_follows">Követettek importálása</string>
|
||||
<string name="new_post">Új bejegyzés</string>
|
||||
<string name="new_short">Új rövidek: képek vagy videók</string>
|
||||
<string name="new_community_note">Új közösségi bejegyzés</string>
|
||||
@@ -1475,4 +1478,29 @@
|
||||
<string name="kind_voice_msg">Hangüzenet</string>
|
||||
<string name="kind_voice_reply">Hangos válasz</string>
|
||||
<string name="kind_wiki">Wiki</string>
|
||||
<string name="start_with_a_great_feed_by_following_the_same_people_as_someone_you_trust">Kezdje egy remek hírfolyammal, követve azokat az embereket, akikben megbízik.</string>
|
||||
<string name="import_follow_list">Követési lista importálása</string>
|
||||
<string name="select_users_to_follow">Felhasználók kiválasztása a követéshez</string>
|
||||
<string name="profile_to_import_from">Importálandó profil</string>
|
||||
<string name="name_search_npub1_alice_example_com">keresés, npub1…, aliz@pelda.hu</string>
|
||||
<string name="supports_npub_nip_05_hex_and_namecoin_bit_d_id">Támogatja az npub, nprofile, NIP-05, hex és namecoin (.bit, d/, id/) formátumokat</string>
|
||||
<string name="look_up_follow_list">Követési lista keresése</string>
|
||||
<string name="tip">Borravaló</string>
|
||||
<string name="accounts_found">%1$d felhasználói fiók megtalálva</string>
|
||||
<string name="num_selected">%1$d kiválasztva</string>
|
||||
<string name="resolved_via_namecoin">Feloldva a Namecoinon keresztül</string>
|
||||
<string name="now_following_accounts">Most %1$d fiókot követ</string>
|
||||
<string name="your_feed_is_ready">A hírfolyam elkészült.</string>
|
||||
<string name="skip">Kihagyás</string>
|
||||
<string name="search_another">Másik keresése</string>
|
||||
<string name="follow_accounts">%1$d felhasználói fiók követése</string>
|
||||
<string name="import_more">Továbbiak importálása</string>
|
||||
<string name="continue_txt">Folytatás</string>
|
||||
<string name="skip_for_now">Kihagyás csak most</string>
|
||||
<string name="resolving">%1$s feloldása…</string>
|
||||
<string name="fetching_follow_list">Követési lista lekérdezése…</string>
|
||||
<string name="no_follows_found">Nem találhatók követések</string>
|
||||
<string name="following_accounts">%1$d felhasználói fiók követése…</string>
|
||||
<string name="import_follows_tips">"Írja be egy barátja vagy közösségi vezető profilját. Használhatja az npub, NIP-05 címüket, vagy egy Namecoin nevet, például aliz@pelda.bit vagy id/aliz a blokklánc által ellenőrzött azonosítókhoz."</string>
|
||||
<string name="select_all">Összes kijelölése</string>
|
||||
</resources>
|
||||
|
||||
@@ -5,14 +5,14 @@
|
||||
<string name="login_subtitle">Sign in to your Nostr account</string>
|
||||
<string name="login_subtitle_desktop">A Nostr client for desktop</string>
|
||||
<string name="login_card_title">Login with your Nostr key</string>
|
||||
<string name="login_card_subtitle">Use nsec for full access or npub for read-only</string>
|
||||
<string name="login_card_subtitle">nsec for full access, bunker:// for remote signer, or npub for read-only</string>
|
||||
<string name="login_with_key">Login with Key</string>
|
||||
<string name="login_button">Login</string>
|
||||
<string name="login_generate_new">Generate New Key</string>
|
||||
<string name="login_generate_button">Generate New</string>
|
||||
<string name="login_key_hint">Enter your private key (nsec) or public key (npub)</string>
|
||||
<string name="login_key_label">nsec or npub</string>
|
||||
<string name="login_key_placeholder">nsec1... or npub1...</string>
|
||||
<string name="login_key_label">nsec, bunker://, or npub</string>
|
||||
<string name="login_key_placeholder">nsec1… / bunker://… / npub1…</string>
|
||||
<string name="login_show_key">Show key</string>
|
||||
<string name="login_hide_key">Hide key</string>
|
||||
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.domain.nip46
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote
|
||||
|
||||
data class BunkerLoginResult(
|
||||
val signer: NostrSignerRemote,
|
||||
val pubKeyHex: HexKey,
|
||||
)
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.domain.nip46
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withTimeout
|
||||
|
||||
object BunkerLoginUseCase {
|
||||
const val RELAY_CONNECT_TIMEOUT_MS = 15_000L
|
||||
|
||||
suspend fun execute(
|
||||
bunkerUri: String,
|
||||
ephemeralSigner: NostrSignerInternal,
|
||||
client: INostrClient,
|
||||
): BunkerLoginResult {
|
||||
val remoteSigner = NostrSignerRemote.fromBunkerUri(bunkerUri, ephemeralSigner, client)
|
||||
remoteSigner.openSubscription()
|
||||
|
||||
// Wait for websocket to be ready before sending connect request
|
||||
withTimeout(RELAY_CONNECT_TIMEOUT_MS) {
|
||||
client.connectedRelaysFlow().first { connected ->
|
||||
remoteSigner.relays.any { it in connected }
|
||||
}
|
||||
}
|
||||
|
||||
remoteSigner.connect()
|
||||
val pubkey = remoteSigner.getPublicKey()
|
||||
return BunkerLoginResult(remoteSigner, pubkey)
|
||||
}
|
||||
}
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.domain.nip46
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.req
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerMessage
|
||||
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest
|
||||
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect
|
||||
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse
|
||||
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseAck
|
||||
import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent
|
||||
import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote
|
||||
import com.vitorpamplona.quartz.utils.Hex
|
||||
import com.vitorpamplona.quartz.utils.SecureRandom
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.withTimeout
|
||||
|
||||
object NostrConnectLoginUseCase {
|
||||
const val NOSTRCONNECT_TIMEOUT_MS = 120_000L
|
||||
|
||||
data class NostrConnectUri(
|
||||
val uri: String,
|
||||
val ephemeralKeyPair: KeyPair,
|
||||
val ephemeralSigner: NostrSignerInternal,
|
||||
val relays: Set<NormalizedRelayUrl>,
|
||||
val secret: String,
|
||||
)
|
||||
|
||||
data class ConnectRequestData(
|
||||
val requestId: String?,
|
||||
val signerPubkey: HexKey,
|
||||
val userPubkey: HexKey,
|
||||
)
|
||||
|
||||
fun generateUri(
|
||||
ephemeralKeyPair: KeyPair,
|
||||
relays: List<String>,
|
||||
appName: String,
|
||||
): NostrConnectUri {
|
||||
val ephemeralSigner = NostrSignerInternal(ephemeralKeyPair)
|
||||
val ephemeralPubKey = ephemeralKeyPair.pubKey.toHexKey()
|
||||
val secret = generateSecret()
|
||||
|
||||
val relayParams = relays.joinToString("&") { "relay=$it" }
|
||||
val uri = "nostrconnect://$ephemeralPubKey?$relayParams&secret=$secret&name=$appName"
|
||||
val normalizedRelays = relays.map { NormalizedRelayUrl(it) }.toSet()
|
||||
|
||||
return NostrConnectUri(
|
||||
uri = uri,
|
||||
ephemeralKeyPair = ephemeralKeyPair,
|
||||
ephemeralSigner = ephemeralSigner,
|
||||
relays = normalizedRelays,
|
||||
secret = secret,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun awaitAndLogin(
|
||||
uriData: NostrConnectUri,
|
||||
client: INostrClient,
|
||||
): BunkerLoginResult {
|
||||
val connectData =
|
||||
waitForConnectRequest(
|
||||
ephemeralSigner = uriData.ephemeralSigner,
|
||||
ephemeralPubKey = uriData.ephemeralKeyPair.pubKey.toHexKey(),
|
||||
relays = uriData.relays,
|
||||
expectedSecret = uriData.secret,
|
||||
client = client,
|
||||
)
|
||||
|
||||
if (connectData.requestId != null) {
|
||||
sendAckResponse(uriData.ephemeralSigner, connectData, uriData.relays, client)
|
||||
}
|
||||
|
||||
val remoteSigner =
|
||||
NostrSignerRemote(
|
||||
signer = uriData.ephemeralSigner,
|
||||
remotePubkey = connectData.signerPubkey,
|
||||
relays = uriData.relays,
|
||||
client = client,
|
||||
)
|
||||
remoteSigner.openSubscription()
|
||||
|
||||
val verifiedPubkey = remoteSigner.getPublicKey()
|
||||
|
||||
return BunkerLoginResult(remoteSigner, verifiedPubkey)
|
||||
}
|
||||
|
||||
private suspend fun waitForConnectRequest(
|
||||
ephemeralSigner: NostrSignerInternal,
|
||||
ephemeralPubKey: HexKey,
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
expectedSecret: String,
|
||||
client: INostrClient,
|
||||
): ConnectRequestData {
|
||||
val deferred = CompletableDeferred<NostrConnectEvent>()
|
||||
|
||||
val subscription =
|
||||
client.req(
|
||||
relays = relays.toList(),
|
||||
filter =
|
||||
Filter(
|
||||
kinds = listOf(NostrConnectEvent.KIND),
|
||||
tags = mapOf("p" to listOf(ephemeralPubKey)),
|
||||
since = TimeUtils.now() - 60,
|
||||
),
|
||||
) { event ->
|
||||
if (event is NostrConnectEvent && !deferred.isCompleted) {
|
||||
deferred.complete(event)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
val event =
|
||||
withTimeout(NOSTRCONNECT_TIMEOUT_MS) {
|
||||
deferred.await()
|
||||
}
|
||||
|
||||
val signerPubkey = event.talkingWith(ephemeralSigner.pubKey)
|
||||
val decryptedJson = ephemeralSigner.decrypt(event.content, signerPubkey)
|
||||
val message = OptimizedJsonMapper.fromJsonTo<BunkerMessage>(decryptedJson)
|
||||
|
||||
return when (message) {
|
||||
is BunkerRequest -> {
|
||||
if (message.method != BunkerRequestConnect.METHOD_NAME) {
|
||||
throw Exception("Expected 'connect' method, got '${message.method}'")
|
||||
}
|
||||
|
||||
val userPubkey =
|
||||
message.params.getOrNull(0)
|
||||
?: throw Exception("Missing user pubkey in connect request")
|
||||
val receivedSecret = message.params.getOrNull(1)
|
||||
|
||||
if (receivedSecret != expectedSecret) {
|
||||
throw Exception("Secret mismatch in connect request")
|
||||
}
|
||||
|
||||
ConnectRequestData(
|
||||
requestId = message.id,
|
||||
signerPubkey = signerPubkey,
|
||||
userPubkey = userPubkey,
|
||||
)
|
||||
}
|
||||
|
||||
is BunkerResponse -> {
|
||||
if (message.error != null) {
|
||||
throw Exception("Signer rejected connection: ${message.error}")
|
||||
}
|
||||
|
||||
val userPubkey =
|
||||
message.result
|
||||
?.takeIf { it.length == 64 && Hex.isHex(it) }
|
||||
?: signerPubkey
|
||||
|
||||
ConnectRequestData(
|
||||
requestId = null,
|
||||
signerPubkey = signerPubkey,
|
||||
userPubkey = userPubkey,
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
throw Exception("Unexpected NIP-46 message format")
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
subscription.close()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun sendAckResponse(
|
||||
ephemeralSigner: NostrSignerInternal,
|
||||
connectData: ConnectRequestData,
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
client: INostrClient,
|
||||
) {
|
||||
val ackResponse = BunkerResponseAck(id = connectData.requestId!!)
|
||||
val ackEvent =
|
||||
NostrConnectEvent.create(
|
||||
message = ackResponse,
|
||||
remoteKey = connectData.signerPubkey,
|
||||
signer = ephemeralSigner,
|
||||
)
|
||||
client.send(ackEvent, relays)
|
||||
}
|
||||
|
||||
private fun generateSecret(): String {
|
||||
val bytes = ByteArray(32)
|
||||
SecureRandom().nextBytes(bytes)
|
||||
return bytes.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.domain.nip46
|
||||
|
||||
sealed class SignerConnectionState {
|
||||
data object NotRemote : SignerConnectionState()
|
||||
|
||||
data object Connected : SignerConnectionState()
|
||||
|
||||
data object Disconnected : SignerConnectionState()
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.ui.components
|
||||
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.FavoriteBorder
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.PlainTooltip
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TooltipBox
|
||||
import androidx.compose.material3.TooltipDefaults
|
||||
import androidx.compose.material3.rememberTooltipState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun BunkerHeartbeatIndicator(
|
||||
signerConnectionState: SignerConnectionState,
|
||||
lastPingTimeSec: Long?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (signerConnectionState is SignerConnectionState.NotRemote) return
|
||||
|
||||
val tooltipState = rememberTooltipState()
|
||||
val tooltipText =
|
||||
when (signerConnectionState) {
|
||||
is SignerConnectionState.Connected -> {
|
||||
if (lastPingTimeSec != null) {
|
||||
val agoSeconds = TimeUtils.now() - lastPingTimeSec
|
||||
"Bunker connected \u2014 last pinged ${agoSeconds}s ago"
|
||||
} else {
|
||||
"Bunker connected"
|
||||
}
|
||||
}
|
||||
|
||||
is SignerConnectionState.Disconnected -> {
|
||||
"Bunker disconnected"
|
||||
}
|
||||
|
||||
else -> {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
TooltipBox(
|
||||
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(),
|
||||
tooltip = { PlainTooltip { Text(tooltipText) } },
|
||||
state = tooltipState,
|
||||
modifier = modifier,
|
||||
) {
|
||||
when (signerConnectionState) {
|
||||
is SignerConnectionState.Connected -> {
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "heartbeat")
|
||||
val scale by infiniteTransition.animateFloat(
|
||||
initialValue = 0.85f,
|
||||
targetValue = 1.15f,
|
||||
animationSpec =
|
||||
infiniteRepeatable(
|
||||
animation = tween(800),
|
||||
repeatMode = RepeatMode.Reverse,
|
||||
),
|
||||
label = "heartbeatScale",
|
||||
)
|
||||
Icon(
|
||||
Icons.Default.Favorite,
|
||||
contentDescription = "Bunker connected",
|
||||
tint = Color(0xFF4CAF50),
|
||||
modifier = Modifier.size(20.dp).scale(scale),
|
||||
)
|
||||
}
|
||||
|
||||
is SignerConnectionState.Disconnected -> {
|
||||
Icon(
|
||||
Icons.Default.FavoriteBorder,
|
||||
contentDescription = "Bunker disconnected",
|
||||
tint = Color(0xFFF44336),
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,9 +49,16 @@ dependencies {
|
||||
// Collections
|
||||
implementation(libs.kotlinx.collections.immutable)
|
||||
|
||||
// SLF4J no-op — silence "No SLF4J providers" warnings from transitive deps
|
||||
implementation("org.slf4j:slf4j-nop:2.0.16")
|
||||
|
||||
// QR code generation (ZXing core)
|
||||
implementation(libs.zxing)
|
||||
|
||||
// Testing
|
||||
testImplementation(libs.kotlin.test)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
testImplementation(libs.mockk)
|
||||
testImplementation(libs.okhttp)
|
||||
}
|
||||
|
||||
|
||||
@@ -72,11 +72,14 @@ import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||
import com.vitorpamplona.amethyst.desktop.model.DesktopDmRelayState
|
||||
import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount
|
||||
import com.vitorpamplona.amethyst.desktop.network.DefaultRelays
|
||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
|
||||
import com.vitorpamplona.amethyst.desktop.ui.ComposeNoteDialog
|
||||
import com.vitorpamplona.amethyst.desktop.ui.ConnectingRelaysScreen
|
||||
import com.vitorpamplona.amethyst.desktop.ui.LoginScreen
|
||||
import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback
|
||||
import com.vitorpamplona.amethyst.desktop.ui.auth.ForceLogoutDialog
|
||||
import com.vitorpamplona.amethyst.desktop.ui.chats.DmSendTracker
|
||||
import com.vitorpamplona.amethyst.desktop.ui.deck.AddColumnDialog
|
||||
import com.vitorpamplona.amethyst.desktop.ui.deck.DeckColumnType
|
||||
@@ -86,12 +89,15 @@ import com.vitorpamplona.amethyst.desktop.ui.deck.DeckState
|
||||
import com.vitorpamplona.amethyst.desktop.ui.deck.SinglePaneLayout
|
||||
import com.vitorpamplona.amethyst.desktop.ui.profile.ProfileInfoCard
|
||||
import com.vitorpamplona.amethyst.desktop.ui.relay.RelayStatusCard
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
private val isMacOS = System.getProperty("os.name").lowercase().contains("mac")
|
||||
|
||||
@@ -104,21 +110,21 @@ enum class LayoutMode {
|
||||
* Desktop navigation state — used for in-column navigation (drill-down).
|
||||
*/
|
||||
sealed class DesktopScreen {
|
||||
object Feed : DesktopScreen()
|
||||
data object Feed : DesktopScreen()
|
||||
|
||||
object Reads : DesktopScreen()
|
||||
data object Reads : DesktopScreen()
|
||||
|
||||
object Search : DesktopScreen()
|
||||
data object Search : DesktopScreen()
|
||||
|
||||
object Bookmarks : DesktopScreen()
|
||||
data object Bookmarks : DesktopScreen()
|
||||
|
||||
object Messages : DesktopScreen()
|
||||
data object Messages : DesktopScreen()
|
||||
|
||||
object Notifications : DesktopScreen()
|
||||
data object Notifications : DesktopScreen()
|
||||
|
||||
object Chess : DesktopScreen()
|
||||
data object Chess : DesktopScreen()
|
||||
|
||||
object MyProfile : DesktopScreen()
|
||||
data object MyProfile : DesktopScreen()
|
||||
|
||||
data class UserProfile(
|
||||
val pubKeyHex: String,
|
||||
@@ -128,7 +134,7 @@ sealed class DesktopScreen {
|
||||
val noteId: String,
|
||||
) : DesktopScreen()
|
||||
|
||||
object Settings : DesktopScreen()
|
||||
data object Settings : DesktopScreen()
|
||||
}
|
||||
|
||||
fun main() =
|
||||
@@ -143,6 +149,8 @@ fun main() =
|
||||
var replyToNote by remember { mutableStateOf<com.vitorpamplona.quartz.nip01Core.core.Event?>(null) }
|
||||
val deckScope = rememberCoroutineScope()
|
||||
val deckState = remember { DeckState(deckScope).also { it.load() } }
|
||||
val accountManager = remember { AccountManager.create() }
|
||||
val accountState by accountManager.accountState.collectAsState()
|
||||
var showAddColumnDialog by remember { mutableStateOf(false) }
|
||||
var layoutMode by remember {
|
||||
mutableStateOf(
|
||||
@@ -189,6 +197,16 @@ fun main() =
|
||||
},
|
||||
)
|
||||
Separator()
|
||||
Item(
|
||||
"Logout",
|
||||
onClick = {
|
||||
deckScope.launch {
|
||||
accountManager.logout(deleteKey = true)
|
||||
}
|
||||
},
|
||||
enabled = accountState is AccountState.LoggedIn,
|
||||
)
|
||||
Separator()
|
||||
Item(
|
||||
"Quit",
|
||||
shortcut =
|
||||
@@ -348,6 +366,7 @@ fun main() =
|
||||
App(
|
||||
layoutMode = layoutMode,
|
||||
deckState = deckState,
|
||||
accountManager = accountManager,
|
||||
showComposeDialog = showComposeDialog,
|
||||
showAddColumnDialog = showAddColumnDialog,
|
||||
onShowComposeDialog = { showComposeDialog = true },
|
||||
@@ -370,6 +389,7 @@ fun main() =
|
||||
fun App(
|
||||
layoutMode: LayoutMode,
|
||||
deckState: DeckState,
|
||||
accountManager: AccountManager,
|
||||
showComposeDialog: Boolean,
|
||||
showAddColumnDialog: Boolean,
|
||||
onShowComposeDialog: () -> Unit,
|
||||
@@ -381,37 +401,54 @@ fun App(
|
||||
) {
|
||||
val relayManager = remember { DesktopRelayConnectionManager() }
|
||||
val localCache = remember { DesktopLocalCache() }
|
||||
val accountManager = remember { AccountManager.create() }
|
||||
val accountState by accountManager.accountState.collectAsState()
|
||||
val scope = remember { CoroutineScope(SupervisorJob() + Dispatchers.Main) }
|
||||
|
||||
// Subscriptions coordinator for metadata/reactions loading
|
||||
// Subscriptions coordinator — uses default relay URLs for metadata indexing.
|
||||
// Feed subscriptions (inside MainContent) drive actual relay pool connections.
|
||||
val subscriptionsCoordinator =
|
||||
remember(relayManager, localCache) {
|
||||
DesktopRelaySubscriptionsCoordinator(
|
||||
client = relayManager.client,
|
||||
scope = scope,
|
||||
indexRelays = relayManager.availableRelays.value,
|
||||
indexRelays =
|
||||
DefaultRelays.RELAYS
|
||||
.mapNotNull {
|
||||
RelayUrlNormalizer.normalizeOrNull(it)
|
||||
}.toSet(),
|
||||
localCache = localCache,
|
||||
)
|
||||
}
|
||||
|
||||
// Try to load saved account on startup
|
||||
DisposableEffect(Unit) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
// Load account on IO dispatcher to avoid blocking UI with password prompt (readLine)
|
||||
accountManager.loadSavedAccount()
|
||||
}
|
||||
|
||||
relayManager.addDefaultRelays()
|
||||
relayManager.connect()
|
||||
|
||||
// Start subscriptions coordinator
|
||||
subscriptionsCoordinator.start()
|
||||
|
||||
scope.launch(Dispatchers.IO) {
|
||||
if (accountManager.hasBunkerAccount()) {
|
||||
// Show connecting UI while dedicated NIP-46 client connects
|
||||
accountManager.setConnectingRelays()
|
||||
}
|
||||
val result = accountManager.loadSavedAccount()
|
||||
if (result.isSuccess) {
|
||||
val current = accountManager.currentAccount()
|
||||
if (current?.signerType is com.vitorpamplona.amethyst.desktop.account.SignerType.Remote) {
|
||||
accountManager.startHeartbeat(scope)
|
||||
}
|
||||
} else if (accountManager.hasBunkerAccount()) {
|
||||
// Corrupt bunker state — fall back to login screen
|
||||
accountManager.logout(deleteKey = true)
|
||||
}
|
||||
}
|
||||
|
||||
onDispose {
|
||||
accountManager.stopHeartbeat()
|
||||
runBlocking { accountManager.disconnectNip46Client() }
|
||||
subscriptionsCoordinator.clear()
|
||||
relayManager.disconnect()
|
||||
scope.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,7 +463,21 @@ fun App(
|
||||
is AccountState.LoggedOut -> {
|
||||
LoginScreen(
|
||||
accountManager = accountManager,
|
||||
onLoginSuccess = { },
|
||||
onLoginSuccess = {
|
||||
// Start heartbeat if bunker account
|
||||
val current = accountManager.currentAccount()
|
||||
if (current?.signerType is com.vitorpamplona.amethyst.desktop.account.SignerType.Remote) {
|
||||
accountManager.startHeartbeat(scope)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
is AccountState.ConnectingRelays -> {
|
||||
val relays by relayManager.relayStatuses.collectAsState()
|
||||
ConnectingRelaysScreen(
|
||||
subtitle = "Restoring remote signer session",
|
||||
relayStatuses = relays,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -476,6 +527,15 @@ fun App(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Force logout dialog overlay
|
||||
val forceLogoutReason by accountManager.forceLogoutReason.collectAsState()
|
||||
forceLogoutReason?.let { reason ->
|
||||
ForceLogoutDialog(
|
||||
reason = reason,
|
||||
onDismiss = { accountManager.clearForceLogoutReason() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -497,6 +557,8 @@ fun MainContent(
|
||||
) {
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val scope = rememberCoroutineScope()
|
||||
val signerConnectionState by accountManager.signerConnectionState.collectAsState()
|
||||
val lastPingTimeSec by accountManager.lastPingTimeSec.collectAsState()
|
||||
|
||||
// DM infrastructure — hoisted here so it survives screen navigation
|
||||
val dmSendTracker =
|
||||
@@ -618,6 +680,8 @@ fun MainContent(
|
||||
onShowComposeDialog = onShowComposeDialog,
|
||||
onShowReplyDialog = onShowReplyDialog,
|
||||
onZapFeedback = onZapFeedback,
|
||||
signerConnectionState = signerConnectionState,
|
||||
lastPingTimeSec = lastPingTimeSec,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
@@ -632,6 +696,8 @@ fun MainContent(
|
||||
deckState.addColumn(DeckColumnType.Settings)
|
||||
}
|
||||
},
|
||||
signerConnectionState = signerConnectionState,
|
||||
lastPingTimeSec = lastPingTimeSec,
|
||||
)
|
||||
|
||||
VerticalDivider()
|
||||
@@ -686,7 +752,7 @@ fun ProfileScreen(
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { scope.launch { accountManager.logout() } },
|
||||
onClick = { scope.launch { accountManager.logout(deleteKey = true) } },
|
||||
colors =
|
||||
androidx.compose.material3.ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = Color.Red,
|
||||
@@ -889,5 +955,18 @@ fun RelaySettingsScreen(
|
||||
Text("Reset to Defaults")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
val logoutScope = rememberCoroutineScope()
|
||||
OutlinedButton(
|
||||
onClick = { logoutScope.launch { accountManager.logout(deleteKey = true) } },
|
||||
colors =
|
||||
ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error,
|
||||
),
|
||||
) {
|
||||
Text("Logout")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+509
-63
@@ -21,50 +21,92 @@
|
||||
package com.vitorpamplona.amethyst.desktop.account
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.commons.domain.nip46.BunkerLoginUseCase
|
||||
import com.vitorpamplona.amethyst.commons.domain.nip46.NostrConnectLoginUseCase
|
||||
import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState
|
||||
import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage
|
||||
import com.vitorpamplona.amethyst.commons.keystorage.SecureStorageException
|
||||
import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
|
||||
import com.vitorpamplona.quartz.nip19Bech32.decodePrivateKeyAsHexOrNull
|
||||
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
||||
import com.vitorpamplona.quartz.nip19Bech32.toNpub
|
||||
import com.vitorpamplona.quartz.nip19Bech32.toNsec
|
||||
import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.attribute.PosixFilePermission
|
||||
|
||||
sealed class SignerType {
|
||||
data object Internal : SignerType()
|
||||
|
||||
data class Remote(
|
||||
val bunkerUri: String,
|
||||
) : SignerType()
|
||||
}
|
||||
|
||||
sealed class AccountState {
|
||||
data object LoggedOut : AccountState()
|
||||
|
||||
data object ConnectingRelays : AccountState()
|
||||
|
||||
data class LoggedIn(
|
||||
val signer: NostrSigner,
|
||||
val pubKeyHex: String,
|
||||
val npub: String,
|
||||
val nsec: String?,
|
||||
val isReadOnly: Boolean,
|
||||
val signerType: SignerType = SignerType.Internal,
|
||||
) : AccountState()
|
||||
}
|
||||
|
||||
@Stable
|
||||
class AccountManager private constructor(
|
||||
class AccountManager internal constructor(
|
||||
private val secureStorage: SecureKeyStorage,
|
||||
private val homeDir: File = File(System.getProperty("user.home")),
|
||||
) {
|
||||
companion object {
|
||||
/**
|
||||
* Creates an AccountManager instance.
|
||||
*
|
||||
* @param context Platform-specific context (required on Android, ignored on Desktop)
|
||||
* @return AccountManager instance
|
||||
*/
|
||||
fun create(context: Any? = null): AccountManager {
|
||||
val storage = SecureKeyStorage.create(context)
|
||||
return AccountManager(storage)
|
||||
}
|
||||
|
||||
internal const val HEARTBEAT_INTERVAL_MS = 60_000L
|
||||
internal const val MAX_CONSECUTIVE_FAILURES = 3
|
||||
internal const val BUNKER_EPHEMERAL_KEY_ALIAS = "bunker_ephemeral"
|
||||
internal const val NIP46_RELAY_CONNECT_TIMEOUT_MS = 15_000L
|
||||
internal val NIP46_RELAYS = listOf("wss://relay.nsec.app")
|
||||
}
|
||||
|
||||
private val amethystDir: File by lazy {
|
||||
File(homeDir, ".amethyst")
|
||||
}
|
||||
|
||||
private val _accountState = MutableStateFlow<AccountState>(AccountState.LoggedOut)
|
||||
@@ -73,43 +115,334 @@ class AccountManager private constructor(
|
||||
private val _nwcConnection = MutableStateFlow<Nip47WalletConnect.Nip47URINorm?>(null)
|
||||
val nwcConnection: StateFlow<Nip47WalletConnect.Nip47URINorm?> = _nwcConnection.asStateFlow()
|
||||
|
||||
private val _signerConnectionState = MutableStateFlow<SignerConnectionState>(SignerConnectionState.NotRemote)
|
||||
val signerConnectionState: StateFlow<SignerConnectionState> = _signerConnectionState.asStateFlow()
|
||||
|
||||
private val _lastPingTimeSec = MutableStateFlow<Long?>(null)
|
||||
val lastPingTimeSec: StateFlow<Long?> = _lastPingTimeSec.asStateFlow()
|
||||
|
||||
private val _forceLogoutReason = MutableStateFlow<String?>(null)
|
||||
val forceLogoutReason: StateFlow<String?> = _forceLogoutReason.asStateFlow()
|
||||
|
||||
private val _loginProgress = MutableStateFlow<LoginProgress?>(null)
|
||||
val loginProgress: StateFlow<LoginProgress?> = _loginProgress.asStateFlow()
|
||||
|
||||
private var heartbeatJob: Job? = null
|
||||
|
||||
// --- Dedicated NIP-46 client (isolated from general relay pool) ---
|
||||
|
||||
private val nip46ClientMutex = Mutex()
|
||||
private var nip46Client: NostrClient? = null
|
||||
|
||||
private suspend fun getOrCreateNip46Client(): NostrClient =
|
||||
nip46ClientMutex.withLock {
|
||||
nip46Client ?: NostrClient(
|
||||
BasicOkHttpWebSocket.Builder(DesktopHttpClient::getHttpClient),
|
||||
).also {
|
||||
nip46Client = it
|
||||
it.connect()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun disconnectNip46Client() =
|
||||
nip46ClientMutex.withLock {
|
||||
nip46Client?.disconnect()
|
||||
nip46Client = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the last saved account from secure storage.
|
||||
* Call on app startup.
|
||||
* Waits for the NIP-46 client to connect to at least one of the target relays.
|
||||
* openSubscription() triggers async relay connection via sendOrConnectAndSync,
|
||||
* but we must wait for the websocket to be ready before sending requests.
|
||||
*/
|
||||
suspend fun loadSavedAccount(): Result<AccountState.LoggedIn> {
|
||||
return try {
|
||||
// For simplicity, we'll store the last logged-in npub in a simple file
|
||||
// and use SecureKeyStorage to retrieve the private key
|
||||
val lastNpub = getLastNpub() ?: return Result.failure(Exception("No saved account"))
|
||||
|
||||
val privKeyHex =
|
||||
secureStorage.getPrivateKey(lastNpub)
|
||||
?: return Result.failure(Exception("Private key not found for $lastNpub"))
|
||||
|
||||
val keyPair = KeyPair(privKey = privKeyHex.hexToByteArray())
|
||||
val signer = NostrSignerInternal(keyPair)
|
||||
|
||||
val state =
|
||||
AccountState.LoggedIn(
|
||||
signer = signer,
|
||||
pubKeyHex = keyPair.pubKey.toHexKey(),
|
||||
npub = keyPair.pubKey.toNpub(),
|
||||
nsec = keyPair.privKey?.toNsec(),
|
||||
isReadOnly = false,
|
||||
)
|
||||
_accountState.value = state
|
||||
Result.success(state)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
private suspend fun awaitNip46RelayConnection(
|
||||
client: NostrClient,
|
||||
targetRelays: Set<NormalizedRelayUrl>,
|
||||
) {
|
||||
withTimeout(NIP46_RELAY_CONNECT_TIMEOUT_MS) {
|
||||
client.connectedRelaysFlow().first { connected ->
|
||||
targetRelays.any { it in connected }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the current account to secure storage.
|
||||
*/
|
||||
private fun updateRelayLoginStatus(
|
||||
relay: NormalizedRelayUrl,
|
||||
status: RelayLoginStatus,
|
||||
) {
|
||||
val current = _loginProgress.value ?: return
|
||||
val updated = current.relayStatuses + (relay to status)
|
||||
_loginProgress.value =
|
||||
when (current) {
|
||||
is LoginProgress.ConnectingToRelays -> current.copy(relayStatuses = updated)
|
||||
is LoginProgress.WaitingForSigner -> current.copy(relayStatuses = updated)
|
||||
is LoginProgress.SendingAck -> current.copy(relayStatuses = updated)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createLoginRelayListener(): IRelayClientListener =
|
||||
object : IRelayClientListener {
|
||||
override fun onConnected(
|
||||
relay: IRelayClient,
|
||||
pingMillis: Int,
|
||||
compressed: Boolean,
|
||||
) {
|
||||
updateRelayLoginStatus(relay.url, RelayLoginStatus.CONNECTED)
|
||||
}
|
||||
|
||||
override fun onCannotConnect(
|
||||
relay: IRelayClient,
|
||||
errorMessage: String,
|
||||
) {
|
||||
updateRelayLoginStatus(relay.url, RelayLoginStatus.FAILED)
|
||||
}
|
||||
|
||||
override fun onSent(
|
||||
relay: IRelayClient,
|
||||
cmdStr: String,
|
||||
cmd: Command,
|
||||
success: Boolean,
|
||||
) {
|
||||
if (cmd is EventCmd) {
|
||||
updateRelayLoginStatus(
|
||||
relay.url,
|
||||
if (success) RelayLoginStatus.EVENT_SENT else RelayLoginStatus.SEND_FAILED,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onIncomingMessage(
|
||||
relay: IRelayClient,
|
||||
msgStr: String,
|
||||
msg: Message,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Account loading ---
|
||||
|
||||
suspend fun loadSavedAccount(): Result<AccountState.LoggedIn> =
|
||||
try {
|
||||
val lastNpub = getLastNpub()
|
||||
val bunkerUri = getBunkerUri()
|
||||
|
||||
if (bunkerUri != null) {
|
||||
loadBunkerAccount(bunkerUri, lastNpub)
|
||||
} else if (lastNpub != null) {
|
||||
loadInternalAccount(lastNpub)
|
||||
} else {
|
||||
Result.failure(Exception("No saved account"))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
|
||||
private suspend fun loadInternalAccount(npub: String): Result<AccountState.LoggedIn> {
|
||||
val privKeyHex =
|
||||
secureStorage.getPrivateKey(npub)
|
||||
?: return Result.failure(Exception("Private key not found for $npub"))
|
||||
|
||||
val keyPair = KeyPair(privKey = privKeyHex.hexToByteArray())
|
||||
val signer = NostrSignerInternal(keyPair)
|
||||
|
||||
val state =
|
||||
AccountState.LoggedIn(
|
||||
signer = signer,
|
||||
pubKeyHex = keyPair.pubKey.toHexKey(),
|
||||
npub = keyPair.pubKey.toNpub(),
|
||||
nsec = keyPair.privKey?.toNsec(),
|
||||
isReadOnly = false,
|
||||
)
|
||||
_accountState.value = state
|
||||
return Result.success(state)
|
||||
}
|
||||
|
||||
private suspend fun loadBunkerAccount(
|
||||
bunkerUri: String,
|
||||
npub: String?,
|
||||
): Result<AccountState.LoggedIn> {
|
||||
val ephemeralPrivKeyHex =
|
||||
secureStorage.getPrivateKey(BUNKER_EPHEMERAL_KEY_ALIAS)
|
||||
?: return Result.failure(Exception("Ephemeral key not found"))
|
||||
|
||||
val ephemeralKeyPair = KeyPair(privKey = ephemeralPrivKeyHex.hexToByteArray())
|
||||
val ephemeralSigner = NostrSignerInternal(ephemeralKeyPair)
|
||||
|
||||
val nip46Client = getOrCreateNip46Client()
|
||||
val remoteSigner = NostrSignerRemote.fromBunkerUri(bunkerUri, ephemeralSigner, nip46Client)
|
||||
remoteSigner.openSubscription()
|
||||
|
||||
val pubKeyHex =
|
||||
if (npub != null) {
|
||||
decodePublicKeyAsHexOrNull(npub) ?: return Result.failure(Exception("Invalid saved npub"))
|
||||
} else {
|
||||
// npub missing (e.g. last_account.txt deleted) — must wait for relay
|
||||
// before calling getPublicKey() to recover from signer
|
||||
awaitNip46RelayConnection(nip46Client, remoteSigner.relays)
|
||||
remoteSigner.getPublicKey()
|
||||
}
|
||||
|
||||
val resolvedNpub = npub ?: pubKeyHex.hexToByteArray().toNpub()
|
||||
if (npub == null) saveLastNpub(resolvedNpub)
|
||||
|
||||
val state =
|
||||
AccountState.LoggedIn(
|
||||
signer = remoteSigner,
|
||||
pubKeyHex = pubKeyHex,
|
||||
npub = resolvedNpub,
|
||||
nsec = null,
|
||||
isReadOnly = false,
|
||||
signerType = SignerType.Remote(bunkerUri),
|
||||
)
|
||||
_accountState.value = state
|
||||
_signerConnectionState.value = SignerConnectionState.Connected
|
||||
return Result.success(state)
|
||||
}
|
||||
|
||||
// --- Bunker login ---
|
||||
|
||||
suspend fun loginWithBunker(bunkerUri: String): Result<AccountState.LoggedIn> {
|
||||
val listener = createLoginRelayListener()
|
||||
var client: NostrClient? = null
|
||||
try {
|
||||
val ephemeralKeyPair = KeyPair()
|
||||
val ephemeralSigner = NostrSignerInternal(ephemeralKeyPair)
|
||||
|
||||
val nip46Client = getOrCreateNip46Client()
|
||||
client = nip46Client
|
||||
|
||||
val relaysFromUri = parseBunkerRelays(bunkerUri)
|
||||
_loginProgress.value =
|
||||
LoginProgress.ConnectingToRelays(
|
||||
relaysFromUri.associateWith { RelayLoginStatus.CONNECTING },
|
||||
)
|
||||
nip46Client.subscribe(listener)
|
||||
|
||||
_loginProgress.value =
|
||||
LoginProgress.WaitingForSigner(
|
||||
relayStatuses = _loginProgress.value?.relayStatuses.orEmpty(),
|
||||
)
|
||||
|
||||
val result = BunkerLoginUseCase.execute(bunkerUri, ephemeralSigner, nip46Client)
|
||||
|
||||
val state =
|
||||
AccountState.LoggedIn(
|
||||
signer = result.signer,
|
||||
pubKeyHex = result.pubKeyHex,
|
||||
npub = result.pubKeyHex.hexToByteArray().toNpub(),
|
||||
nsec = null,
|
||||
isReadOnly = false,
|
||||
signerType = SignerType.Remote(bunkerUri),
|
||||
)
|
||||
_accountState.value = state
|
||||
_signerConnectionState.value = SignerConnectionState.Connected
|
||||
|
||||
saveBunkerAccount(
|
||||
bunkerUri = stripBunkerSecret(bunkerUri),
|
||||
ephemeralPrivKeyHex = ephemeralKeyPair.privKey!!.toHexKey(),
|
||||
npub = state.npub,
|
||||
)
|
||||
|
||||
return Result.success(state)
|
||||
} catch (e: kotlinx.coroutines.TimeoutCancellationException) {
|
||||
return Result.failure(Exception("Could not connect to NIP-46 relay. Check your network connection."))
|
||||
} catch (e: SignerExceptions.TimedOutException) {
|
||||
return Result.failure(Exception("Connection timed out. Ensure remote signer is online and has approved the connection."))
|
||||
} catch (e: SignerExceptions.ManuallyUnauthorizedException) {
|
||||
return Result.failure(Exception("Connection rejected by remote signer."))
|
||||
} catch (e: SignerExceptions.CouldNotPerformException) {
|
||||
return Result.failure(Exception("Remote signer error: ${e.message}"))
|
||||
} catch (e: Exception) {
|
||||
return Result.failure(Exception("Connection failed: ${e.message}"))
|
||||
} finally {
|
||||
_loginProgress.value = null
|
||||
client?.unsubscribe(listener)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Nostrconnect login ---
|
||||
|
||||
suspend fun loginWithNostrConnect(onUriGenerated: (String) -> Unit): Result<AccountState.LoggedIn> {
|
||||
val listener = createLoginRelayListener()
|
||||
var client: NostrClient? = null
|
||||
try {
|
||||
val ephemeralKeyPair = KeyPair()
|
||||
val uriData = NostrConnectLoginUseCase.generateUri(ephemeralKeyPair, NIP46_RELAYS, "Amethyst%20Desktop")
|
||||
|
||||
val nip46Client = getOrCreateNip46Client()
|
||||
client = nip46Client
|
||||
|
||||
_loginProgress.value =
|
||||
LoginProgress.ConnectingToRelays(
|
||||
uriData.relays.associateWith { RelayLoginStatus.CONNECTING },
|
||||
)
|
||||
nip46Client.subscribe(listener)
|
||||
|
||||
onUriGenerated(uriData.uri)
|
||||
|
||||
_loginProgress.value =
|
||||
LoginProgress.WaitingForSigner(
|
||||
relayStatuses = _loginProgress.value?.relayStatuses.orEmpty(),
|
||||
)
|
||||
|
||||
val result = NostrConnectLoginUseCase.awaitAndLogin(uriData, nip46Client)
|
||||
|
||||
val relayParams = NIP46_RELAYS.joinToString("&") { "relay=$it" }
|
||||
val syntheticBunkerUri = "bunker://${result.signer.remotePubkey}?$relayParams"
|
||||
|
||||
val state =
|
||||
AccountState.LoggedIn(
|
||||
signer = result.signer,
|
||||
pubKeyHex = result.pubKeyHex,
|
||||
npub = result.pubKeyHex.hexToByteArray().toNpub(),
|
||||
nsec = null,
|
||||
isReadOnly = false,
|
||||
signerType = SignerType.Remote(syntheticBunkerUri),
|
||||
)
|
||||
_accountState.value = state
|
||||
_signerConnectionState.value = SignerConnectionState.Connected
|
||||
|
||||
saveBunkerAccount(
|
||||
bunkerUri = syntheticBunkerUri,
|
||||
ephemeralPrivKeyHex = ephemeralKeyPair.privKey!!.toHexKey(),
|
||||
npub = state.npub,
|
||||
)
|
||||
|
||||
return Result.success(state)
|
||||
} catch (e: kotlinx.coroutines.TimeoutCancellationException) {
|
||||
return Result.failure(Exception("Timed out waiting for signer. Ensure the signer app scanned the QR code."))
|
||||
} catch (e: Exception) {
|
||||
return Result.failure(Exception("Connection failed: ${e.message}"))
|
||||
} finally {
|
||||
_loginProgress.value = null
|
||||
client?.unsubscribe(listener)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun saveBunkerAccount(
|
||||
bunkerUri: String,
|
||||
ephemeralPrivKeyHex: String,
|
||||
npub: String,
|
||||
) {
|
||||
saveLastNpub(npub)
|
||||
secureStorage.savePrivateKey(BUNKER_EPHEMERAL_KEY_ALIAS, ephemeralPrivKeyHex)
|
||||
saveBunkerUri(bunkerUri)
|
||||
}
|
||||
|
||||
fun hasBunkerAccount(): Boolean = getBunkerFile().exists()
|
||||
|
||||
fun setConnectingRelays() {
|
||||
_accountState.value = AccountState.ConnectingRelays
|
||||
}
|
||||
|
||||
// --- Save/generate (existing) ---
|
||||
|
||||
suspend fun saveCurrentAccount(): Result<Unit> {
|
||||
val current = currentAccount() ?: return Result.failure(Exception("No account logged in"))
|
||||
|
||||
// Bunker accounts are saved during loginWithBunker
|
||||
if (current.signerType is SignerType.Remote) return Result.success(Unit)
|
||||
|
||||
if (current.isReadOnly || current.nsec == null) {
|
||||
return Result.failure(Exception("Cannot save read-only account"))
|
||||
}
|
||||
@@ -146,7 +479,6 @@ class AccountManager private constructor(
|
||||
fun loginWithKey(keyInput: String): Result<AccountState.LoggedIn> {
|
||||
val trimmedInput = keyInput.trim()
|
||||
|
||||
// Try as private key first (nsec or hex)
|
||||
val privKeyHex = decodePrivateKeyAsHexOrNull(trimmedInput)
|
||||
if (privKeyHex != null) {
|
||||
return try {
|
||||
@@ -168,7 +500,6 @@ class AccountManager private constructor(
|
||||
}
|
||||
}
|
||||
|
||||
// Try as public key (npub or hex) - read-only mode
|
||||
val pubKeyHex = decodePublicKeyAsHexOrNull(trimmedInput)
|
||||
if (pubKeyHex != null) {
|
||||
return try {
|
||||
@@ -190,27 +521,96 @@ class AccountManager private constructor(
|
||||
}
|
||||
}
|
||||
|
||||
return Result.failure(IllegalArgumentException("Invalid key format. Use nsec1, npub1, or hex format."))
|
||||
return Result.failure(IllegalArgumentException("Invalid key format. Use nsec1, npub1, hex, or bunker:// URI."))
|
||||
}
|
||||
|
||||
// --- Logout ---
|
||||
|
||||
suspend fun logout(deleteKey: Boolean = false) {
|
||||
val current = currentAccount()
|
||||
if (deleteKey && current != null) {
|
||||
try {
|
||||
secureStorage.deletePrivateKey(current.npub)
|
||||
clearLastNpub()
|
||||
} catch (e: SecureStorageException) {
|
||||
// Log error but still logout
|
||||
if (current != null) {
|
||||
// Clean up remote signer if bunker account
|
||||
if (current.signerType is SignerType.Remote) {
|
||||
(current.signer as? NostrSignerRemote)?.closeSubscription()
|
||||
if (deleteKey) {
|
||||
try {
|
||||
secureStorage.deletePrivateKey(BUNKER_EPHEMERAL_KEY_ALIAS)
|
||||
} catch (_: SecureStorageException) {
|
||||
}
|
||||
getBunkerFile().delete()
|
||||
}
|
||||
}
|
||||
if (deleteKey) {
|
||||
try {
|
||||
secureStorage.deletePrivateKey(current.npub)
|
||||
clearLastNpub()
|
||||
} catch (_: SecureStorageException) {
|
||||
}
|
||||
}
|
||||
}
|
||||
disconnectNip46Client()
|
||||
_signerConnectionState.value = SignerConnectionState.NotRemote
|
||||
_lastPingTimeSec.value = null
|
||||
_accountState.value = AccountState.LoggedOut
|
||||
// Cancel heartbeat LAST — may be called from within the heartbeat coroutine
|
||||
stopHeartbeat()
|
||||
}
|
||||
|
||||
suspend fun forceLogoutWithReason(reason: String) {
|
||||
_forceLogoutReason.value = reason
|
||||
logout(deleteKey = false)
|
||||
}
|
||||
|
||||
fun clearForceLogoutReason() {
|
||||
_forceLogoutReason.value = null
|
||||
}
|
||||
|
||||
// --- Heartbeat ---
|
||||
|
||||
fun startHeartbeat(scope: CoroutineScope) {
|
||||
heartbeatJob?.cancel()
|
||||
heartbeatJob =
|
||||
scope.launch {
|
||||
var consecutiveFailures = 0
|
||||
while (isActive) {
|
||||
delay(HEARTBEAT_INTERVAL_MS)
|
||||
val current = currentAccount() ?: continue
|
||||
val remoteSigner = current.signer as? NostrSignerRemote ?: continue
|
||||
try {
|
||||
remoteSigner.ping()
|
||||
consecutiveFailures = 0
|
||||
_signerConnectionState.value = SignerConnectionState.Connected
|
||||
_lastPingTimeSec.value = TimeUtils.now()
|
||||
} catch (_: SignerExceptions.ManuallyUnauthorizedException) {
|
||||
forceLogoutWithReason("Remote signer revoked access.")
|
||||
return@launch
|
||||
} catch (_: Exception) {
|
||||
consecutiveFailures++
|
||||
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
||||
forceLogoutWithReason(
|
||||
"Lost connection to remote signer after $MAX_CONSECUTIVE_FAILURES failed pings.",
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
_signerConnectionState.value = SignerConnectionState.Disconnected
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stopHeartbeat() {
|
||||
heartbeatJob?.cancel()
|
||||
heartbeatJob = null
|
||||
}
|
||||
|
||||
// --- Accessors ---
|
||||
|
||||
fun isLoggedIn(): Boolean = _accountState.value is AccountState.LoggedIn
|
||||
|
||||
fun currentAccount(): AccountState.LoggedIn? = _accountState.value as? AccountState.LoggedIn
|
||||
|
||||
// NWC (Nostr Wallet Connect) methods
|
||||
// --- NWC ---
|
||||
|
||||
fun hasNwcSetup(): Boolean = _nwcConnection.value != null
|
||||
|
||||
fun setNwcConnection(uri: String): Result<Nip47WalletConnect.Nip47URINorm> =
|
||||
@@ -233,42 +633,88 @@ class AccountManager private constructor(
|
||||
if (!uri.isNullOrEmpty()) {
|
||||
try {
|
||||
_nwcConnection.value = Nip47WalletConnect.parse(uri)
|
||||
} catch (e: Exception) {
|
||||
// Invalid stored URI, clear it
|
||||
} catch (_: Exception) {
|
||||
getNwcFile().delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- File storage helpers ---
|
||||
|
||||
private fun ensureAmethystDir() {
|
||||
if (!amethystDir.exists()) {
|
||||
amethystDir.mkdirs()
|
||||
}
|
||||
try {
|
||||
Files.setPosixFilePermissions(
|
||||
amethystDir.toPath(),
|
||||
setOf(
|
||||
PosixFilePermission.OWNER_READ,
|
||||
PosixFilePermission.OWNER_WRITE,
|
||||
PosixFilePermission.OWNER_EXECUTE,
|
||||
),
|
||||
)
|
||||
} catch (_: UnsupportedOperationException) {
|
||||
// Windows — file system ACLs handle this
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveNwcUri(uri: String) {
|
||||
val file = getNwcFile()
|
||||
file.parentFile?.mkdirs()
|
||||
file.writeText(uri)
|
||||
ensureAmethystDir()
|
||||
getNwcFile().writeText(uri)
|
||||
}
|
||||
|
||||
private fun getNwcFile(): java.io.File {
|
||||
val homeDir = System.getProperty("user.home")
|
||||
return java.io.File(homeDir, ".amethyst/nwc_connection.txt")
|
||||
}
|
||||
private fun getNwcFile(): File = File(amethystDir, "nwc_connection.txt")
|
||||
|
||||
// Simple file-based storage for last npub (non-sensitive data)
|
||||
private fun getLastNpub(): String? {
|
||||
val file = getPrefsFile()
|
||||
return if (file.exists()) file.readText().trim().takeIf { it.isNotEmpty() } else null
|
||||
}
|
||||
|
||||
private fun saveLastNpub(npub: String) {
|
||||
val file = getPrefsFile()
|
||||
file.parentFile?.mkdirs()
|
||||
file.writeText(npub)
|
||||
ensureAmethystDir()
|
||||
getPrefsFile().writeText(npub)
|
||||
}
|
||||
|
||||
private fun clearLastNpub() {
|
||||
getPrefsFile().delete()
|
||||
}
|
||||
|
||||
private fun getPrefsFile(): File {
|
||||
val homeDir = System.getProperty("user.home")
|
||||
return File(homeDir, ".amethyst/last_account.txt")
|
||||
private fun getPrefsFile(): File = File(amethystDir, "last_account.txt")
|
||||
|
||||
private fun getBunkerUri(): String? {
|
||||
val file = getBunkerFile()
|
||||
return if (file.exists()) file.readText().trim().takeIf { it.isNotEmpty() } else null
|
||||
}
|
||||
|
||||
private fun saveBunkerUri(uri: String) {
|
||||
ensureAmethystDir()
|
||||
getBunkerFile().writeText(uri)
|
||||
}
|
||||
|
||||
private fun getBunkerFile(): File = File(amethystDir, "bunker_uri.txt")
|
||||
}
|
||||
|
||||
internal fun parseBunkerRelays(uri: String): Set<NormalizedRelayUrl> {
|
||||
val idx = uri.indexOf('?')
|
||||
if (idx < 0) return emptySet()
|
||||
return uri
|
||||
.substring(idx + 1)
|
||||
.split("&")
|
||||
.filter { it.startsWith("relay=", ignoreCase = true) }
|
||||
.map { NormalizedRelayUrl(it.removePrefix("relay=")) }
|
||||
.toSet()
|
||||
}
|
||||
|
||||
internal fun stripBunkerSecret(uri: String): String {
|
||||
val idx = uri.indexOf('?')
|
||||
if (idx < 0) return uri
|
||||
val base = uri.substring(0, idx)
|
||||
val params =
|
||||
uri
|
||||
.substring(idx + 1)
|
||||
.split("&")
|
||||
.filter { !it.startsWith("secret=", ignoreCase = true) }
|
||||
return if (params.isEmpty()) base else "$base?${params.joinToString("&")}"
|
||||
}
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.account
|
||||
|
||||
private val HEX_64_REGEX = Regex("^[0-9a-fA-F]{64}$")
|
||||
|
||||
fun validateBunkerUri(input: String): String? {
|
||||
val trimmed = input.trim()
|
||||
if (!trimmed.startsWith("bunker://", ignoreCase = true)) return "Not a bunker URI"
|
||||
|
||||
val afterScheme = trimmed.substring("bunker://".length)
|
||||
val parts = afterScheme.split("?", limit = 2)
|
||||
val pubkeyPart = parts[0]
|
||||
|
||||
if (pubkeyPart.length != 64 || !pubkeyPart.matches(HEX_64_REGEX)) {
|
||||
return "Invalid bunker URI. Expected: bunker://<64-hex-chars>?relay=wss://..."
|
||||
}
|
||||
|
||||
if (parts.size < 2 || !parts[1].contains("relay=wss://", ignoreCase = true)) {
|
||||
return "Bunker URI must include at least one relay parameter (relay=wss://...)"
|
||||
}
|
||||
|
||||
return null // valid
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.account
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
|
||||
enum class RelayLoginStatus {
|
||||
CONNECTING,
|
||||
CONNECTED,
|
||||
EVENT_SENT,
|
||||
SEND_FAILED,
|
||||
FAILED,
|
||||
}
|
||||
|
||||
sealed class LoginProgress {
|
||||
abstract val relayStatuses: Map<NormalizedRelayUrl, RelayLoginStatus>
|
||||
|
||||
data class ConnectingToRelays(
|
||||
override val relayStatuses: Map<NormalizedRelayUrl, RelayLoginStatus> = emptyMap(),
|
||||
) : LoginProgress()
|
||||
|
||||
data class WaitingForSigner(
|
||||
override val relayStatuses: Map<NormalizedRelayUrl, RelayLoginStatus> = emptyMap(),
|
||||
) : LoginProgress()
|
||||
|
||||
data class SendingAck(
|
||||
override val relayStatuses: Map<NormalizedRelayUrl, RelayLoginStatus> = emptyMap(),
|
||||
) : LoginProgress()
|
||||
}
|
||||
+4
-5
@@ -99,7 +99,7 @@ fun ChessScreen(
|
||||
remember(account.pubKeyHex) {
|
||||
DesktopChessViewModelNew(account, relayManager, scope)
|
||||
}
|
||||
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
||||
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||
val broadcastStatus by viewModel.broadcastStatus.collectAsState()
|
||||
val activeGames by viewModel.activeGames.collectAsState()
|
||||
// Observe state version to force recomposition when game state changes
|
||||
@@ -147,11 +147,10 @@ fun ChessScreen(
|
||||
|
||||
// Subscribe to user metadata for pubkeys that need it
|
||||
val pubkeysNeeded by viewModel.userMetadataCache.pubkeysNeeded.collectAsState()
|
||||
rememberSubscription(relayStatuses, pubkeysNeeded, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
if (configuredRelays.isNotEmpty() && pubkeysNeeded.isNotEmpty()) {
|
||||
rememberSubscription(connectedRelays, pubkeysNeeded, relayManager = relayManager) {
|
||||
if (connectedRelays.isNotEmpty() && pubkeysNeeded.isNotEmpty()) {
|
||||
createMetadataListSubscription(
|
||||
relays = configuredRelays,
|
||||
relays = connectedRelays,
|
||||
pubKeys = pubkeysNeeded.toList(),
|
||||
onEvent = { event, _, _, _ ->
|
||||
viewModel.handleIncomingEvent(event)
|
||||
|
||||
+1
-1
@@ -221,6 +221,6 @@ open class RelayConnectionManager(
|
||||
cmd: Command,
|
||||
success: Boolean,
|
||||
) {
|
||||
// Command send tracking
|
||||
// Command send tracking — no-op for now
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -45,5 +45,6 @@ object DefaultRelays {
|
||||
"wss://nos.lol",
|
||||
"wss://relay.snort.social",
|
||||
"wss://nostr.wine",
|
||||
"wss://relay.primal.net",
|
||||
)
|
||||
}
|
||||
|
||||
+3
-1
@@ -101,7 +101,9 @@ fun rememberSubscription(
|
||||
}
|
||||
|
||||
onDispose {
|
||||
subscription?.let { relayManager.unsubscribe(it.subId) }
|
||||
subscription?.let {
|
||||
relayManager.unsubscribe(it.subId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+10
-13
@@ -76,7 +76,7 @@ fun BookmarksScreen(
|
||||
onNavigateToThread: (String) -> Unit = {},
|
||||
onZapFeedback: (ZapFeedback) -> Unit = {},
|
||||
) {
|
||||
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
||||
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Tab state
|
||||
@@ -121,9 +121,8 @@ fun BookmarksScreen(
|
||||
}
|
||||
|
||||
// Subscribe to user's bookmark list (kind 30001)
|
||||
rememberSubscription(relayStatuses, account.pubKeyHex, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
if (configuredRelays.isNotEmpty()) {
|
||||
rememberSubscription(connectedRelays, account.pubKeyHex, relayManager = relayManager) {
|
||||
if (connectedRelays.isNotEmpty()) {
|
||||
SubscriptionConfig(
|
||||
subId = "bookmarks-list-${account.pubKeyHex.take(8)}",
|
||||
filters =
|
||||
@@ -134,7 +133,7 @@ fun BookmarksScreen(
|
||||
limit = 1,
|
||||
),
|
||||
),
|
||||
relays = configuredRelays,
|
||||
relays = connectedRelays,
|
||||
onEvent = { event, _, _, _ ->
|
||||
if (event is BookmarkListEvent) {
|
||||
bookmarkList = event
|
||||
@@ -179,9 +178,8 @@ fun BookmarksScreen(
|
||||
}
|
||||
|
||||
// Subscribe to fetch the actual public bookmarked events
|
||||
rememberSubscription(relayStatuses, publicBookmarkIds, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
if (configuredRelays.isNotEmpty() && publicBookmarkIds.isNotEmpty()) {
|
||||
rememberSubscription(connectedRelays, publicBookmarkIds, relayManager = relayManager) {
|
||||
if (connectedRelays.isNotEmpty() && publicBookmarkIds.isNotEmpty()) {
|
||||
publicEventState.clear()
|
||||
SubscriptionConfig(
|
||||
subId = "public-bookmarked-events-${System.currentTimeMillis()}",
|
||||
@@ -189,7 +187,7 @@ fun BookmarksScreen(
|
||||
listOf(
|
||||
FilterBuilders.byIds(publicBookmarkIds),
|
||||
),
|
||||
relays = configuredRelays,
|
||||
relays = connectedRelays,
|
||||
onEvent = { event, _, _, _ ->
|
||||
publicEventState.addItem(event)
|
||||
},
|
||||
@@ -201,9 +199,8 @@ fun BookmarksScreen(
|
||||
}
|
||||
|
||||
// Subscribe to fetch the actual private bookmarked events
|
||||
rememberSubscription(relayStatuses, privateBookmarkIds, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
if (configuredRelays.isNotEmpty() && privateBookmarkIds.isNotEmpty()) {
|
||||
rememberSubscription(connectedRelays, privateBookmarkIds, relayManager = relayManager) {
|
||||
if (connectedRelays.isNotEmpty() && privateBookmarkIds.isNotEmpty()) {
|
||||
privateEventState.clear()
|
||||
SubscriptionConfig(
|
||||
subId = "private-bookmarked-events-${System.currentTimeMillis()}",
|
||||
@@ -211,7 +208,7 @@ fun BookmarksScreen(
|
||||
listOf(
|
||||
FilterBuilders.byIds(privateBookmarkIds),
|
||||
),
|
||||
relays = configuredRelays,
|
||||
relays = connectedRelays,
|
||||
onEvent = { event, _, _, _ ->
|
||||
privateEventState.addItem(event)
|
||||
},
|
||||
|
||||
-3
@@ -177,15 +177,12 @@ private suspend fun publishNote(
|
||||
replyTo: com.vitorpamplona.quartz.nip01Core.core.Event?,
|
||||
) {
|
||||
withContext(Dispatchers.IO) {
|
||||
// Check read-only mode
|
||||
if (account.isReadOnly) {
|
||||
throw IllegalStateException("Cannot post in read-only mode")
|
||||
}
|
||||
|
||||
// Use shared PublishAction from commons
|
||||
val signedEvent = PublishAction.publishTextNote(content, account.signer, replyTo)
|
||||
|
||||
// Broadcast to all configured relays
|
||||
relayManager.broadcastToAll(signedEvent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,8 @@ import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
|
||||
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
/**
|
||||
* Note card with action buttons.
|
||||
@@ -158,7 +160,14 @@ fun FeedScreen(
|
||||
onZapFeedback: (ZapFeedback) -> Unit = {},
|
||||
) {
|
||||
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
||||
// Configured relay URLs only — stabilized with distinctUntilChanged() to prevent
|
||||
// subscription churn from relay status changes (pings, connect/disconnect).
|
||||
// openReqSubscription connects relays on demand; no need to wait for connectedRelays.
|
||||
val configuredRelays by remember {
|
||||
relayManager.relayStatuses
|
||||
.map { it.keys }
|
||||
.distinctUntilChanged()
|
||||
}.collectAsState(emptySet())
|
||||
val scope = rememberCoroutineScope()
|
||||
val eventState =
|
||||
remember {
|
||||
@@ -190,15 +199,15 @@ fun FeedScreen(
|
||||
val initialLoadComplete = eoseReceivedCount > 0
|
||||
|
||||
// Load followed users for Following feed mode
|
||||
rememberSubscription(relayStatuses, account, feedMode, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
rememberSubscription(configuredRelays, account, feedMode, relayManager = relayManager) {
|
||||
if (configuredRelays.isNotEmpty() && account != null && feedMode == FeedMode.FOLLOWING) {
|
||||
createContactListSubscription(
|
||||
relays = configuredRelays,
|
||||
pubKeyHex = account.pubKeyHex,
|
||||
onEvent = { event, _, _, _ ->
|
||||
onEvent = { event, _, relay, _ ->
|
||||
if (event is ContactListEvent) {
|
||||
followedUsers = event.verifiedFollowKeySet()
|
||||
val follows = event.verifiedFollowKeySet()
|
||||
followedUsers = follows
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -208,8 +217,7 @@ fun FeedScreen(
|
||||
}
|
||||
|
||||
// Load user's bookmark list
|
||||
rememberSubscription(relayStatuses, account, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
rememberSubscription(configuredRelays, account, relayManager = relayManager) {
|
||||
if (configuredRelays.isNotEmpty() && account != null) {
|
||||
SubscriptionConfig(
|
||||
subId = "bookmarks-${account.pubKeyHex.take(8)}",
|
||||
@@ -249,8 +257,7 @@ fun FeedScreen(
|
||||
}
|
||||
|
||||
// Subscribe to feed based on mode
|
||||
rememberSubscription(relayStatuses, feedMode, followedUsers, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
rememberSubscription(configuredRelays, feedMode, followedUsers, relayManager = relayManager) {
|
||||
if (configuredRelays.isEmpty()) {
|
||||
return@rememberSubscription null
|
||||
}
|
||||
@@ -297,8 +304,7 @@ fun FeedScreen(
|
||||
|
||||
// Subscribe to zaps for visible events
|
||||
val eventIds = events.map { it.id }
|
||||
rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
rememberSubscription(configuredRelays, eventIds, relayManager = relayManager) {
|
||||
if (configuredRelays.isEmpty() || eventIds.isEmpty()) {
|
||||
return@rememberSubscription null
|
||||
}
|
||||
@@ -328,8 +334,7 @@ fun FeedScreen(
|
||||
.flatten()
|
||||
.map { it.senderPubKey }
|
||||
.distinct()
|
||||
rememberSubscription(relayStatuses, zapSenderPubkeys, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
rememberSubscription(configuredRelays, zapSenderPubkeys, relayManager = relayManager) {
|
||||
if (configuredRelays.isEmpty() || zapSenderPubkeys.isEmpty()) {
|
||||
return@rememberSubscription null
|
||||
}
|
||||
@@ -359,8 +364,7 @@ fun FeedScreen(
|
||||
}
|
||||
|
||||
// Subscribe to reactions for visible events
|
||||
rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
rememberSubscription(configuredRelays, eventIds, relayManager = relayManager) {
|
||||
if (configuredRelays.isEmpty() || eventIds.isEmpty()) {
|
||||
return@rememberSubscription null
|
||||
}
|
||||
@@ -382,8 +386,7 @@ fun FeedScreen(
|
||||
}
|
||||
|
||||
// Subscribe to replies for visible events
|
||||
rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
rememberSubscription(configuredRelays, eventIds, relayManager = relayManager) {
|
||||
if (configuredRelays.isEmpty() || eventIds.isEmpty()) {
|
||||
return@rememberSubscription null
|
||||
}
|
||||
@@ -410,8 +413,7 @@ fun FeedScreen(
|
||||
}
|
||||
|
||||
// Subscribe to reposts for visible events
|
||||
rememberSubscription(relayStatuses, eventIds, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
rememberSubscription(configuredRelays, eventIds, relayManager = relayManager) {
|
||||
if (configuredRelays.isEmpty() || eventIds.isEmpty()) {
|
||||
return@rememberSubscription null
|
||||
}
|
||||
@@ -443,13 +445,12 @@ fun FeedScreen(
|
||||
}
|
||||
|
||||
// Fallback subscription if coordinator not available
|
||||
rememberSubscription(relayStatuses, authorPubkeys, subscriptionsCoordinator, relayManager = relayManager) {
|
||||
rememberSubscription(configuredRelays, authorPubkeys, subscriptionsCoordinator, relayManager = relayManager) {
|
||||
// Skip if using coordinator
|
||||
if (subscriptionsCoordinator != null) {
|
||||
return@rememberSubscription null
|
||||
}
|
||||
|
||||
val configuredRelays = relayStatuses.keys
|
||||
if (configuredRelays.isEmpty() || authorPubkeys.isEmpty()) {
|
||||
return@rememberSubscription null
|
||||
}
|
||||
|
||||
+159
-9
@@ -20,15 +20,28 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.ui
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -36,16 +49,19 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.login_subtitle_desktop
|
||||
import com.vitorpamplona.amethyst.commons.resources.login_title
|
||||
import com.vitorpamplona.amethyst.desktop.account.AccountManager
|
||||
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||
import com.vitorpamplona.amethyst.desktop.network.RelayStatus
|
||||
import com.vitorpamplona.amethyst.desktop.ui.auth.LoginCard
|
||||
import com.vitorpamplona.amethyst.desktop.ui.auth.NewKeyWarningCard
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@Composable
|
||||
@@ -56,6 +72,7 @@ fun LoginScreen(
|
||||
var showNewKeyDialog by remember { mutableStateOf(false) }
|
||||
var generatedAccount by remember { mutableStateOf<AccountState.LoggedIn?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val loginProgress by accountManager.loginProgress.collectAsState()
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(32.dp),
|
||||
@@ -81,9 +98,8 @@ fun LoginScreen(
|
||||
LoginCard(
|
||||
onLogin = { keyInput ->
|
||||
accountManager.loginWithKey(keyInput).map {
|
||||
// Save account to secure storage (use IO dispatcher to avoid blocking UI)
|
||||
scope.launch(Dispatchers.IO) {
|
||||
accountManager.saveCurrentAccount()
|
||||
scope.launch {
|
||||
withContext(Dispatchers.IO) { accountManager.saveCurrentAccount() }
|
||||
onLoginSuccess()
|
||||
}
|
||||
}
|
||||
@@ -92,18 +108,29 @@ fun LoginScreen(
|
||||
generatedAccount = accountManager.generateNewAccount()
|
||||
showNewKeyDialog = true
|
||||
},
|
||||
onLoginBunker = { bunkerUri ->
|
||||
accountManager.loginWithBunker(bunkerUri).map {
|
||||
onLoginSuccess()
|
||||
}
|
||||
},
|
||||
onLoginNostrConnect = { onUriGenerated ->
|
||||
accountManager.loginWithNostrConnect(onUriGenerated).map {
|
||||
onLoginSuccess()
|
||||
}
|
||||
},
|
||||
loginProgress = loginProgress,
|
||||
)
|
||||
|
||||
if (showNewKeyDialog && generatedAccount != null) {
|
||||
val account = generatedAccount
|
||||
if (showNewKeyDialog && account != null) {
|
||||
Spacer(Modifier.height(24.dp))
|
||||
NewKeyWarningCard(
|
||||
npub = generatedAccount!!.npub,
|
||||
nsec = generatedAccount!!.nsec,
|
||||
npub = account.npub,
|
||||
nsec = account.nsec,
|
||||
onContinue = {
|
||||
showNewKeyDialog = false
|
||||
// Save generated account (use IO dispatcher to avoid blocking UI)
|
||||
scope.launch(Dispatchers.IO) {
|
||||
accountManager.saveCurrentAccount()
|
||||
scope.launch {
|
||||
withContext(Dispatchers.IO) { accountManager.saveCurrentAccount() }
|
||||
onLoginSuccess()
|
||||
}
|
||||
},
|
||||
@@ -111,3 +138,126 @@ fun LoginScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ConnectingRelaysScreen(
|
||||
subtitle: String = "Restoring session",
|
||||
relayStatuses: Map<com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl, RelayStatus> = emptyMap(),
|
||||
) {
|
||||
val total = relayStatuses.size
|
||||
val connected = relayStatuses.values.count { it.connected }
|
||||
val failed = relayStatuses.values.count { it.error != null }
|
||||
val progress = if (total > 0) connected.toFloat() / total else 0f
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(
|
||||
"Amethyst",
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
CircularProgressIndicator(modifier = Modifier.size(32.dp))
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
if (total > 0) "Connecting to relays ($connected/$total)" else "Connecting to relays...",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
subtitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||
)
|
||||
|
||||
// Progress bar
|
||||
if (total > 0) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
val animatedProgress by animateFloatAsState(
|
||||
targetValue = progress,
|
||||
animationSpec = tween(300),
|
||||
label = "relay-progress",
|
||||
)
|
||||
|
||||
LinearProgressIndicator(
|
||||
progress = { animatedProgress },
|
||||
modifier = Modifier.widthIn(max = 300.dp).fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Per-relay status rows
|
||||
Column(
|
||||
modifier = Modifier.widthIn(max = 360.dp).fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
relayStatuses.values.forEach { status ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||
) {
|
||||
when {
|
||||
status.connected -> {
|
||||
Icon(
|
||||
Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFF4CAF50),
|
||||
modifier = Modifier.size(14.dp),
|
||||
)
|
||||
}
|
||||
|
||||
status.error != null -> {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(14.dp),
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(14.dp),
|
||||
strokeWidth = 1.5.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
status.url.url
|
||||
.removePrefix("wss://")
|
||||
.removeSuffix("/"),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color =
|
||||
if (status.error != null) {
|
||||
MaterialTheme.colorScheme.error.copy(alpha = 0.8f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
if (status.connected && status.pingMs != null) {
|
||||
Text(
|
||||
"${status.pingMs}ms",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-5
@@ -113,7 +113,6 @@ fun NotificationsScreen(
|
||||
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null,
|
||||
) {
|
||||
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
||||
val scope = rememberCoroutineScope()
|
||||
val notificationState =
|
||||
remember {
|
||||
@@ -139,11 +138,10 @@ fun NotificationsScreen(
|
||||
val initialLoadComplete = eoseReceivedCount > 0
|
||||
|
||||
// Subscribe to notifications
|
||||
rememberSubscription(relayStatuses, account.pubKeyHex, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
if (configuredRelays.isNotEmpty()) {
|
||||
rememberSubscription(connectedRelays, account.pubKeyHex, relayManager = relayManager) {
|
||||
if (connectedRelays.isNotEmpty()) {
|
||||
createNotificationsSubscription(
|
||||
relays = configuredRelays,
|
||||
relays = connectedRelays,
|
||||
pubKeyHex = account.pubKeyHex,
|
||||
onEvent = { event, _, _, _ ->
|
||||
// Skip events from the user themselves (except zaps)
|
||||
|
||||
@@ -175,7 +175,6 @@ fun ReadsScreen(
|
||||
onNavigateToArticle: (String) -> Unit = {},
|
||||
) {
|
||||
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val eventState =
|
||||
@@ -195,11 +194,11 @@ fun ReadsScreen(
|
||||
val initialLoadComplete = eoseReceivedCount > 0
|
||||
|
||||
// Load followed users for Following feed mode
|
||||
rememberSubscription(relayStatuses, account, feedMode, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
if (configuredRelays.isNotEmpty() && account != null && feedMode == FeedMode.FOLLOWING) {
|
||||
rememberSubscription(connectedRelays, account, feedMode, relayManager = relayManager) {
|
||||
val connectedRelays = connectedRelays
|
||||
if (connectedRelays.isNotEmpty() && account != null && feedMode == FeedMode.FOLLOWING) {
|
||||
createContactListSubscription(
|
||||
relays = configuredRelays,
|
||||
relays = connectedRelays,
|
||||
pubKeyHex = account.pubKeyHex,
|
||||
onEvent = { event, _, _, _ ->
|
||||
if (event is ContactListEvent) {
|
||||
@@ -219,16 +218,16 @@ fun ReadsScreen(
|
||||
}
|
||||
|
||||
// Subscribe to long-form content feed
|
||||
rememberSubscription(relayStatuses, feedMode, followedUsers, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
if (configuredRelays.isEmpty()) {
|
||||
rememberSubscription(connectedRelays, feedMode, followedUsers, relayManager = relayManager) {
|
||||
val connectedRelays = connectedRelays
|
||||
if (connectedRelays.isEmpty()) {
|
||||
return@rememberSubscription null
|
||||
}
|
||||
|
||||
when (feedMode) {
|
||||
FeedMode.GLOBAL -> {
|
||||
createLongFormFeedSubscription(
|
||||
relays = configuredRelays,
|
||||
relays = connectedRelays,
|
||||
onEvent = { event, _, _, _ ->
|
||||
if (event is LongTextNoteEvent) {
|
||||
eventState.addItem(event)
|
||||
@@ -243,7 +242,7 @@ fun ReadsScreen(
|
||||
FeedMode.FOLLOWING -> {
|
||||
if (followedUsers.isNotEmpty()) {
|
||||
createFollowingLongFormFeedSubscription(
|
||||
relays = configuredRelays,
|
||||
relays = connectedRelays,
|
||||
followedUsers = followedUsers.toList(),
|
||||
onEvent = { event, _, _, _ ->
|
||||
if (event is LongTextNoteEvent) {
|
||||
|
||||
@@ -94,7 +94,7 @@ fun SearchScreen(
|
||||
searchState.updateSearchText(initialQuery)
|
||||
}
|
||||
}
|
||||
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
||||
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||
|
||||
// Collect state from SearchBarState
|
||||
val searchText by searchState.searchText.collectAsState()
|
||||
@@ -104,15 +104,14 @@ fun SearchScreen(
|
||||
val isSearchingRelays by searchState.isSearchingRelays.collectAsState()
|
||||
|
||||
// NIP-50 relay search when local cache has few/no results
|
||||
rememberSubscription(relayStatuses, searchText, cachedUserResults.size, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
if (configuredRelays.isEmpty()) return@rememberSubscription null
|
||||
rememberSubscription(connectedRelays, searchText, cachedUserResults.size, relayManager = relayManager) {
|
||||
if (connectedRelays.isEmpty()) return@rememberSubscription null
|
||||
|
||||
// Only search relays if we have a real query and limited local results
|
||||
if (searchState.shouldSearchRelays) {
|
||||
searchState.startRelaySearch()
|
||||
createSearchPeopleSubscription(
|
||||
relays = configuredRelays,
|
||||
relays = connectedRelays,
|
||||
searchQuery = searchText,
|
||||
limit = 20,
|
||||
onEvent = { event, _, _, _ ->
|
||||
@@ -134,9 +133,8 @@ fun SearchScreen(
|
||||
}
|
||||
|
||||
// Subscribe to metadata for searched users (to populate cache)
|
||||
rememberSubscription(relayStatuses, searchText, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
if (configuredRelays.isEmpty() || searchText.length < 2) {
|
||||
rememberSubscription(connectedRelays, searchText, relayManager = relayManager) {
|
||||
if (connectedRelays.isEmpty() || searchText.length < 2) {
|
||||
return@rememberSubscription null
|
||||
}
|
||||
|
||||
@@ -144,7 +142,7 @@ fun SearchScreen(
|
||||
val pubkeyHex = decodePublicKeyAsHexOrNull(searchText)
|
||||
if (pubkeyHex != null) {
|
||||
createMetadataSubscription(
|
||||
relays = configuredRelays,
|
||||
relays = connectedRelays,
|
||||
pubKeyHex = pubkeyHex,
|
||||
onEvent = { event, _, _, _ ->
|
||||
if (event is MetadataEvent) {
|
||||
|
||||
+21
-29
@@ -96,7 +96,6 @@ fun ThreadScreen(
|
||||
onReply: (Event) -> Unit = {},
|
||||
) {
|
||||
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// State for the root note
|
||||
@@ -149,9 +148,8 @@ fun ThreadScreen(
|
||||
}
|
||||
|
||||
// Subscribe to user's bookmark list
|
||||
rememberSubscription(relayStatuses, account, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
if (configuredRelays.isNotEmpty() && account != null) {
|
||||
rememberSubscription(connectedRelays, account, relayManager = relayManager) {
|
||||
if (connectedRelays.isNotEmpty() && account != null) {
|
||||
SubscriptionConfig(
|
||||
subId = "thread-bookmarks-${account.pubKeyHex.take(8)}",
|
||||
filters =
|
||||
@@ -162,7 +160,7 @@ fun ThreadScreen(
|
||||
limit = 1,
|
||||
),
|
||||
),
|
||||
relays = configuredRelays,
|
||||
relays = connectedRelays,
|
||||
onEvent = { event, _, _, _ ->
|
||||
if (event is BookmarkListEvent) {
|
||||
bookmarkList = event
|
||||
@@ -183,11 +181,10 @@ fun ThreadScreen(
|
||||
}
|
||||
|
||||
// Subscribe to the root note
|
||||
rememberSubscription(relayStatuses, noteId, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
if (configuredRelays.isNotEmpty()) {
|
||||
rememberSubscription(connectedRelays, noteId, relayManager = relayManager) {
|
||||
if (connectedRelays.isNotEmpty()) {
|
||||
createNoteSubscription(
|
||||
relays = configuredRelays,
|
||||
relays = connectedRelays,
|
||||
noteId = noteId,
|
||||
onEvent = { event, _, _, _ ->
|
||||
if (event.id == noteId) {
|
||||
@@ -205,11 +202,10 @@ fun ThreadScreen(
|
||||
}
|
||||
|
||||
// Subscribe to replies
|
||||
rememberSubscription(relayStatuses, noteId, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
if (configuredRelays.isNotEmpty()) {
|
||||
rememberSubscription(connectedRelays, noteId, relayManager = relayManager) {
|
||||
if (connectedRelays.isNotEmpty()) {
|
||||
createThreadRepliesSubscription(
|
||||
relays = configuredRelays,
|
||||
relays = connectedRelays,
|
||||
noteId = noteId,
|
||||
onEvent = { event, _, _, _ ->
|
||||
replyEventState.addItem(event)
|
||||
@@ -225,14 +221,13 @@ fun ThreadScreen(
|
||||
|
||||
// Subscribe to zaps for thread events
|
||||
val allEventIds = listOf(noteId) + replyEvents.map { it.id }
|
||||
rememberSubscription(relayStatuses, allEventIds, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
if (configuredRelays.isEmpty() || allEventIds.isEmpty()) {
|
||||
rememberSubscription(connectedRelays, allEventIds, relayManager = relayManager) {
|
||||
if (connectedRelays.isEmpty() || allEventIds.isEmpty()) {
|
||||
return@rememberSubscription null
|
||||
}
|
||||
|
||||
createZapsSubscription(
|
||||
relays = configuredRelays,
|
||||
relays = connectedRelays,
|
||||
eventIds = allEventIds,
|
||||
onEvent = { event, _, _, _ ->
|
||||
if (event is LnZapEvent) {
|
||||
@@ -251,14 +246,13 @@ fun ThreadScreen(
|
||||
}
|
||||
|
||||
// Subscribe to reactions for thread events
|
||||
rememberSubscription(relayStatuses, allEventIds, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
if (configuredRelays.isEmpty() || allEventIds.isEmpty()) {
|
||||
rememberSubscription(connectedRelays, allEventIds, relayManager = relayManager) {
|
||||
if (connectedRelays.isEmpty() || allEventIds.isEmpty()) {
|
||||
return@rememberSubscription null
|
||||
}
|
||||
|
||||
createReactionsSubscription(
|
||||
relays = configuredRelays,
|
||||
relays = connectedRelays,
|
||||
eventIds = allEventIds,
|
||||
onEvent = { event, _, _, _ ->
|
||||
if (event is ReactionEvent) {
|
||||
@@ -274,14 +268,13 @@ fun ThreadScreen(
|
||||
}
|
||||
|
||||
// Subscribe to replies for thread events (for counts)
|
||||
rememberSubscription(relayStatuses, allEventIds, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
if (configuredRelays.isEmpty() || allEventIds.isEmpty()) {
|
||||
rememberSubscription(connectedRelays, allEventIds, relayManager = relayManager) {
|
||||
if (connectedRelays.isEmpty() || allEventIds.isEmpty()) {
|
||||
return@rememberSubscription null
|
||||
}
|
||||
|
||||
createRepliesSubscription(
|
||||
relays = configuredRelays,
|
||||
relays = connectedRelays,
|
||||
eventIds = allEventIds,
|
||||
onEvent = { event, _, _, _ ->
|
||||
val replyToId =
|
||||
@@ -301,14 +294,13 @@ fun ThreadScreen(
|
||||
}
|
||||
|
||||
// Subscribe to reposts for thread events
|
||||
rememberSubscription(relayStatuses, allEventIds, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
if (configuredRelays.isEmpty() || allEventIds.isEmpty()) {
|
||||
rememberSubscription(connectedRelays, allEventIds, relayManager = relayManager) {
|
||||
if (connectedRelays.isEmpty() || allEventIds.isEmpty()) {
|
||||
return@rememberSubscription null
|
||||
}
|
||||
|
||||
createRepostsSubscription(
|
||||
relays = configuredRelays,
|
||||
relays = connectedRelays,
|
||||
eventIds = allEventIds,
|
||||
onEvent = { event, _, _, _ ->
|
||||
if (event is RepostEvent) {
|
||||
|
||||
+16
-22
@@ -109,7 +109,6 @@ fun UserProfileScreen(
|
||||
onZapFeedback: (ZapFeedback) -> Unit = {},
|
||||
) {
|
||||
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
||||
|
||||
// User metadata
|
||||
var displayName by remember { mutableStateOf<String?>(null) }
|
||||
@@ -154,11 +153,10 @@ fun UserProfileScreen(
|
||||
var eoseReceivedCount by remember(account) { mutableStateOf(0) }
|
||||
|
||||
// Load current user's contact list (for follow state)
|
||||
rememberSubscription(relayStatuses, account, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
if (configuredRelays.isNotEmpty() && account != null) {
|
||||
rememberSubscription(connectedRelays, account, relayManager = relayManager) {
|
||||
if (connectedRelays.isNotEmpty() && account != null) {
|
||||
createContactListSubscription(
|
||||
relays = configuredRelays,
|
||||
relays = connectedRelays,
|
||||
pubKeyHex = account.pubKeyHex,
|
||||
onEvent = { event, _, _, _ ->
|
||||
if (event is ContactListEvent) {
|
||||
@@ -175,7 +173,7 @@ fun UserProfileScreen(
|
||||
eoseReceivedCount++
|
||||
|
||||
// Wait for EOSE from at least 2 relays or all relays before enabling button
|
||||
val minEoseCount = minOf(2, configuredRelays.size)
|
||||
val minEoseCount = minOf(2, connectedRelays.size)
|
||||
if (eoseReceivedCount >= minEoseCount && !contactListLoaded) {
|
||||
contactListLoaded = true
|
||||
}
|
||||
@@ -194,11 +192,10 @@ fun UserProfileScreen(
|
||||
}
|
||||
|
||||
// Subscribe to user metadata
|
||||
rememberSubscription(relayStatuses, pubKeyHex, retryTrigger, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
if (configuredRelays.isNotEmpty()) {
|
||||
rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) {
|
||||
if (connectedRelays.isNotEmpty()) {
|
||||
createMetadataSubscription(
|
||||
relays = configuredRelays,
|
||||
relays = connectedRelays,
|
||||
pubKeyHex = pubKeyHex,
|
||||
onEvent = { event, _, _, _ ->
|
||||
if (event is MetadataEvent) {
|
||||
@@ -229,11 +226,10 @@ fun UserProfileScreen(
|
||||
}
|
||||
|
||||
// Subscribe to profile user's contact list (for following count)
|
||||
rememberSubscription(relayStatuses, pubKeyHex, retryTrigger, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
if (configuredRelays.isNotEmpty()) {
|
||||
rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) {
|
||||
if (connectedRelays.isNotEmpty()) {
|
||||
createContactListSubscription(
|
||||
relays = configuredRelays,
|
||||
relays = connectedRelays,
|
||||
pubKeyHex = pubKeyHex,
|
||||
onEvent = { event, _, _, _ ->
|
||||
if (event is ContactListEvent) {
|
||||
@@ -252,9 +248,8 @@ fun UserProfileScreen(
|
||||
val followerAuthors = remember(pubKeyHex) { mutableSetOf<String>() }
|
||||
|
||||
// Subscribe to followers (contact lists that tag this user)
|
||||
rememberSubscription(relayStatuses, pubKeyHex, retryTrigger, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
if (configuredRelays.isNotEmpty()) {
|
||||
rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) {
|
||||
if (connectedRelays.isNotEmpty()) {
|
||||
// Clear previous followers when subscription restarts
|
||||
followerAuthors.clear()
|
||||
followersCount = 0
|
||||
@@ -269,7 +264,7 @@ fun UserProfileScreen(
|
||||
limit = 500,
|
||||
),
|
||||
),
|
||||
relays = configuredRelays,
|
||||
relays = connectedRelays,
|
||||
onEvent = { event, _, _, _ ->
|
||||
// Count unique authors who follow this user
|
||||
if (followerAuthors.add(event.pubKey)) {
|
||||
@@ -284,13 +279,12 @@ fun UserProfileScreen(
|
||||
}
|
||||
|
||||
// Subscribe to user posts
|
||||
rememberSubscription(relayStatuses, pubKeyHex, retryTrigger, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
if (configuredRelays.isNotEmpty()) {
|
||||
rememberSubscription(connectedRelays, pubKeyHex, retryTrigger, relayManager = relayManager) {
|
||||
if (connectedRelays.isNotEmpty()) {
|
||||
postsLoading = true
|
||||
postsError = null
|
||||
createUserPostsSubscription(
|
||||
relays = configuredRelays,
|
||||
relays = connectedRelays,
|
||||
pubKeyHex = pubKeyHex,
|
||||
onEvent = { event, _, _, _ ->
|
||||
eventState.addItem(event)
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.ui.auth
|
||||
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
@Composable
|
||||
fun ForceLogoutDialog(
|
||||
reason: String,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Text(
|
||||
"Session Terminated",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
reason,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("OK")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
+269
-41
@@ -28,20 +28,32 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.PrimaryTabRow
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
@@ -49,29 +61,27 @@ import com.vitorpamplona.amethyst.commons.resources.login_button
|
||||
import com.vitorpamplona.amethyst.commons.resources.login_card_subtitle
|
||||
import com.vitorpamplona.amethyst.commons.resources.login_card_title
|
||||
import com.vitorpamplona.amethyst.commons.resources.login_generate_button
|
||||
import com.vitorpamplona.amethyst.desktop.account.LoginProgress
|
||||
import com.vitorpamplona.amethyst.desktop.account.validateBunkerUri
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
/**
|
||||
* Login card with Nostr key input field and action buttons.
|
||||
*
|
||||
* @param onLogin Callback when login is attempted with the key input
|
||||
* @param onGenerateNew Callback when "Generate New" is clicked
|
||||
* @param modifier Modifier for the card
|
||||
* @param cardWidth Width of the card (default 400.dp)
|
||||
* @param title Card title
|
||||
* @param subtitle Subtitle/hint text
|
||||
*/
|
||||
@Composable
|
||||
fun LoginCard(
|
||||
onLogin: (String) -> Result<Unit>,
|
||||
onGenerateNew: () -> Unit,
|
||||
onLoginBunker: (suspend (String) -> Result<Unit>)? = null,
|
||||
onLoginNostrConnect: (suspend (onUriGenerated: (String) -> Unit) -> Result<Unit>)? = null,
|
||||
loginProgress: LoginProgress? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
cardWidth: Dp = 400.dp,
|
||||
title: String = stringResource(Res.string.login_card_title),
|
||||
subtitle: String = stringResource(Res.string.login_card_subtitle),
|
||||
) {
|
||||
var keyInput by remember { mutableStateOf("") }
|
||||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||
var selectedTab by remember { mutableIntStateOf(0) }
|
||||
val tabs = listOf("Paste Key", "Connect")
|
||||
|
||||
Card(
|
||||
modifier = modifier.width(cardWidth),
|
||||
@@ -92,48 +102,266 @@ fun LoginCard(
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
KeyInputField(
|
||||
value = keyInput,
|
||||
onValueChange = {
|
||||
keyInput = it
|
||||
errorMessage = null
|
||||
},
|
||||
errorMessage = errorMessage,
|
||||
)
|
||||
if (onLoginNostrConnect != null) {
|
||||
@Suppress("DEPRECATION")
|
||||
PrimaryTabRow(
|
||||
selectedTabIndex = selectedTab,
|
||||
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)),
|
||||
) {
|
||||
tabs.forEachIndexed { index, title ->
|
||||
Tab(
|
||||
selected = selectedTab == index,
|
||||
onClick = { selectedTab = index },
|
||||
text = { Text(title) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
Text(
|
||||
subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
when (selectedTab) {
|
||||
0 -> PasteKeyContent(onLogin, onGenerateNew, onLoginBunker, subtitle, loginProgress)
|
||||
1 -> if (onLoginNostrConnect != null) NostrConnectContent(onLoginNostrConnect, loginProgress)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
@Composable
|
||||
private fun PasteKeyContent(
|
||||
onLogin: (String) -> Result<Unit>,
|
||||
onGenerateNew: () -> Unit,
|
||||
onLoginBunker: (suspend (String) -> Result<Unit>)?,
|
||||
subtitle: String,
|
||||
loginProgress: LoginProgress? = null,
|
||||
) {
|
||||
var keyInput by remember { mutableStateOf("") }
|
||||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||
var isConnecting by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val isBunker = keyInput.trim().startsWith("bunker://", ignoreCase = true)
|
||||
|
||||
KeyInputField(
|
||||
value = keyInput,
|
||||
onValueChange = {
|
||||
keyInput = it
|
||||
errorMessage = null
|
||||
},
|
||||
errorMessage = errorMessage,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
if (isBunker) {
|
||||
Text(
|
||||
"This URI connects to your remote signer. Treat it like a password.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
if (isConnecting) {
|
||||
if (loginProgress != null) {
|
||||
LoginProgressSteps(loginProgress)
|
||||
} else {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(
|
||||
"Connecting to remote signer...",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
if (isBunker && onLoginBunker != null) {
|
||||
val validationError = validateBunkerUri(keyInput)
|
||||
if (validationError != null) {
|
||||
errorMessage = validationError
|
||||
return@Button
|
||||
}
|
||||
isConnecting = true
|
||||
errorMessage = null
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val result = onLoginBunker(keyInput.trim())
|
||||
withContext(Dispatchers.Main) {
|
||||
result.fold(
|
||||
onSuccess = { isConnecting = false },
|
||||
onFailure = {
|
||||
errorMessage = it.message
|
||||
isConnecting = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
onLogin(keyInput).fold(
|
||||
onSuccess = { /* handled by caller */ },
|
||||
onFailure = { errorMessage = it.message },
|
||||
)
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
enabled = keyInput.isNotBlank(),
|
||||
) {
|
||||
Text(stringResource(Res.string.login_button))
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
enabled = keyInput.isNotBlank(),
|
||||
) {
|
||||
Text(if (isBunker) "Connect to Signer" else stringResource(Res.string.login_button))
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = onGenerateNew,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text(stringResource(Res.string.login_generate_button))
|
||||
OutlinedButton(
|
||||
onClick = onGenerateNew,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text(stringResource(Res.string.login_generate_button))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NostrConnectContent(
|
||||
onLoginNostrConnect: suspend (onUriGenerated: (String) -> Unit) -> Result<Unit>,
|
||||
loginProgress: LoginProgress? = null,
|
||||
) {
|
||||
var nostrConnectUri by remember { mutableStateOf<String?>(null) }
|
||||
var isConnecting by remember { mutableStateOf(false) }
|
||||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
|
||||
if (errorMessage != null) {
|
||||
Text(
|
||||
errorMessage!!,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Button(onClick = {
|
||||
errorMessage = null
|
||||
nostrConnectUri = null
|
||||
}) {
|
||||
Text("Try Again")
|
||||
}
|
||||
} else if (!isConnecting) {
|
||||
Text(
|
||||
"Show a QR code for your signer app (e.g. Amber) to scan.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
isConnecting = true
|
||||
errorMessage = null
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val result =
|
||||
onLoginNostrConnect { uri ->
|
||||
scope.launch(Dispatchers.Main) { nostrConnectUri = uri }
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
result.onFailure {
|
||||
errorMessage = it.message
|
||||
isConnecting = false
|
||||
nostrConnectUri = null
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Start Connection")
|
||||
}
|
||||
} else {
|
||||
val uri = nostrConnectUri
|
||||
if (uri != null) {
|
||||
QrCodeCanvas(
|
||||
data = uri,
|
||||
size = 200.dp,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
Text(
|
||||
uri,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { clipboardManager.setText(AnnotatedString(uri)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Copy URI")
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
if (loginProgress != null) {
|
||||
LoginProgressSteps(loginProgress)
|
||||
} else {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
"Waiting for signer to connect...",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
"Generating connection...",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.ui.auth
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.desktop.account.LoginProgress
|
||||
import com.vitorpamplona.amethyst.desktop.account.RelayLoginStatus
|
||||
|
||||
private enum class StepState { DONE, ACTIVE, PENDING }
|
||||
|
||||
private data class StepInfo(
|
||||
val label: String,
|
||||
val state: StepState,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun LoginProgressSteps(
|
||||
progress: LoginProgress,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val steps = buildStepList(progress)
|
||||
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
steps.forEach { step ->
|
||||
StepRow(step)
|
||||
|
||||
// Show relay rows under the active step
|
||||
if (step.state == StepState.ACTIVE && progress.relayStatuses.isNotEmpty()) {
|
||||
progress.relayStatuses.forEach { (relay, status) ->
|
||||
RelayRow(relay.url, status)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildStepList(progress: LoginProgress): List<StepInfo> {
|
||||
val orderedSteps =
|
||||
listOf(
|
||||
"Connecting to relays",
|
||||
"Waiting for signer",
|
||||
"Sending acknowledgment",
|
||||
)
|
||||
|
||||
val activeIndex =
|
||||
when (progress) {
|
||||
is LoginProgress.ConnectingToRelays -> 0
|
||||
is LoginProgress.WaitingForSigner -> 1
|
||||
is LoginProgress.SendingAck -> 2
|
||||
}
|
||||
|
||||
return orderedSteps.mapIndexed { index, label ->
|
||||
StepInfo(
|
||||
label = label,
|
||||
state =
|
||||
when {
|
||||
index < activeIndex -> StepState.DONE
|
||||
index == activeIndex -> StepState.ACTIVE
|
||||
else -> StepState.PENDING
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StepRow(step: StepInfo) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
when (step.state) {
|
||||
StepState.DONE -> {
|
||||
Icon(
|
||||
Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFF4CAF50),
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
|
||||
StepState.ACTIVE -> {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
}
|
||||
|
||||
StepState.PENDING -> {
|
||||
Spacer(Modifier.size(16.dp))
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
step.label,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color =
|
||||
when (step.state) {
|
||||
StepState.DONE -> Color(0xFF4CAF50)
|
||||
StepState.ACTIVE -> MaterialTheme.colorScheme.onSurface
|
||||
StepState.PENDING -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RelayRow(
|
||||
url: String,
|
||||
status: RelayLoginStatus,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 24.dp),
|
||||
) {
|
||||
when (status) {
|
||||
RelayLoginStatus.EVENT_SENT -> {
|
||||
Icon(
|
||||
Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFF2196F3),
|
||||
modifier = Modifier.size(12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
RelayLoginStatus.CONNECTED -> {
|
||||
Icon(
|
||||
Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFF4CAF50),
|
||||
modifier = Modifier.size(12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
RelayLoginStatus.FAILED,
|
||||
RelayLoginStatus.SEND_FAILED,
|
||||
-> {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
RelayLoginStatus.CONNECTING -> {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(12.dp),
|
||||
strokeWidth = 1.5.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(6.dp))
|
||||
val label = url.removePrefix("wss://").removeSuffix("/")
|
||||
val statusLabel =
|
||||
when (status) {
|
||||
RelayLoginStatus.EVENT_SENT -> "$label (sent)"
|
||||
RelayLoginStatus.SEND_FAILED -> "$label (send failed)"
|
||||
RelayLoginStatus.FAILED -> "$label (failed)"
|
||||
else -> label
|
||||
}
|
||||
Text(
|
||||
statusLabel,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color =
|
||||
when (status) {
|
||||
RelayLoginStatus.FAILED, RelayLoginStatus.SEND_FAILED -> {
|
||||
MaterialTheme.colorScheme.error.copy(alpha = 0.8f)
|
||||
}
|
||||
|
||||
RelayLoginStatus.EVENT_SENT -> {
|
||||
Color(0xFF2196F3)
|
||||
}
|
||||
|
||||
else -> {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.ui.auth
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.FilterQuality
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.EncodeHintType
|
||||
import com.google.zxing.qrcode.QRCodeWriter
|
||||
import java.awt.image.BufferedImage
|
||||
|
||||
@Composable
|
||||
fun QrCodeCanvas(
|
||||
data: String,
|
||||
modifier: Modifier = Modifier,
|
||||
size: Dp = 200.dp,
|
||||
) {
|
||||
val bitmap =
|
||||
remember(data) {
|
||||
createQrBitmap(data)
|
||||
}
|
||||
|
||||
Canvas(modifier = modifier.size(size)) {
|
||||
drawImage(
|
||||
image = bitmap,
|
||||
srcOffset = IntOffset.Zero,
|
||||
srcSize = IntSize(bitmap.width, bitmap.height),
|
||||
dstOffset = IntOffset.Zero,
|
||||
dstSize = IntSize(this.size.width.toInt(), this.size.height.toInt()),
|
||||
filterQuality = FilterQuality.None,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createQrBitmap(data: String): ImageBitmap {
|
||||
val matrix =
|
||||
QRCodeWriter().encode(
|
||||
data,
|
||||
BarcodeFormat.QR_CODE,
|
||||
0,
|
||||
0,
|
||||
mapOf(EncodeHintType.MARGIN to 1),
|
||||
)
|
||||
|
||||
val w = matrix.width
|
||||
val h = matrix.height
|
||||
val black = 0xFF000000.toInt()
|
||||
val white = 0xFFFFFFFF.toInt()
|
||||
|
||||
val image = BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB)
|
||||
for (x in 0 until w) {
|
||||
for (y in 0 until h) {
|
||||
image.setRGB(x, y, if (matrix.get(x, y)) black else white)
|
||||
}
|
||||
}
|
||||
|
||||
return image.toComposeImageBitmap()
|
||||
}
|
||||
+7
-9
@@ -80,18 +80,17 @@ fun NewDmDialog(
|
||||
val cachedUsers by searchState.cachedUserResults.collectAsState()
|
||||
val relaySearchResults by searchState.relaySearchResults.collectAsState()
|
||||
val isSearchingRelays by searchState.isSearchingRelays.collectAsState()
|
||||
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
||||
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
// NIP-50 relay search when local cache has few/no results
|
||||
rememberSubscription(relayStatuses, searchText, cachedUsers.size, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
if (configuredRelays.isEmpty()) return@rememberSubscription null
|
||||
rememberSubscription(connectedRelays, searchText, cachedUsers.size, relayManager = relayManager) {
|
||||
if (connectedRelays.isEmpty()) return@rememberSubscription null
|
||||
|
||||
if (searchState.shouldSearchRelays) {
|
||||
searchState.startRelaySearch()
|
||||
createSearchPeopleSubscription(
|
||||
relays = configuredRelays,
|
||||
relays = connectedRelays,
|
||||
searchQuery = searchText,
|
||||
limit = 20,
|
||||
onEvent = { event, _, _, _ ->
|
||||
@@ -113,16 +112,15 @@ fun NewDmDialog(
|
||||
}
|
||||
|
||||
// Bech32 npub metadata loading
|
||||
rememberSubscription(relayStatuses, searchText, relayManager = relayManager) {
|
||||
val configuredRelays = relayStatuses.keys
|
||||
if (configuredRelays.isEmpty() || searchText.length < 2) {
|
||||
rememberSubscription(connectedRelays, searchText, relayManager = relayManager) {
|
||||
if (connectedRelays.isEmpty() || searchText.length < 2) {
|
||||
return@rememberSubscription null
|
||||
}
|
||||
|
||||
val pubkeyHex = decodePublicKeyAsHexOrNull(searchText)
|
||||
if (pubkeyHex != null) {
|
||||
createMetadataSubscription(
|
||||
relays = configuredRelays,
|
||||
relays = connectedRelays,
|
||||
pubKeyHex = pubkeyHex,
|
||||
onEvent = { event, _, _, _ ->
|
||||
if (event is MetadataEvent) {
|
||||
|
||||
+11
@@ -41,11 +41,15 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.BunkerHeartbeatIndicator
|
||||
|
||||
@Composable
|
||||
fun DeckSidebar(
|
||||
onAddColumn: () -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
signerConnectionState: SignerConnectionState,
|
||||
lastPingTimeSec: Long?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
@@ -78,6 +82,13 @@ fun DeckSidebar(
|
||||
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
BunkerHeartbeatIndicator(
|
||||
signerConnectionState = signerConnectionState,
|
||||
lastPingTimeSec = lastPingTimeSec,
|
||||
)
|
||||
|
||||
Spacer(Modifier.size(8.dp))
|
||||
|
||||
IconButton(onClick = onOpenSettings) {
|
||||
Icon(
|
||||
Icons.Default.Settings,
|
||||
|
||||
+12
@@ -62,6 +62,8 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.BunkerHeartbeatIndicator
|
||||
import com.vitorpamplona.amethyst.desktop.DesktopScreen
|
||||
import com.vitorpamplona.amethyst.desktop.account.AccountManager
|
||||
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||
@@ -103,6 +105,8 @@ fun SinglePaneLayout(
|
||||
onShowComposeDialog: () -> Unit,
|
||||
onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit,
|
||||
onZapFeedback: (ZapFeedback) -> Unit,
|
||||
signerConnectionState: SignerConnectionState,
|
||||
lastPingTimeSec: Long?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var currentColumnType by remember { mutableStateOf<DeckColumnType>(DeckColumnType.HomeFeed) }
|
||||
@@ -139,6 +143,14 @@ fun SinglePaneLayout(
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
BunkerHeartbeatIndicator(
|
||||
signerConnectionState = signerConnectionState,
|
||||
lastPingTimeSec = lastPingTimeSec,
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
VerticalDivider()
|
||||
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.account
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage
|
||||
import io.mockk.mockk
|
||||
import java.io.File
|
||||
import kotlin.io.path.createTempDirectory
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class AccountManagerBunkerLoginTest {
|
||||
private lateinit var storage: SecureKeyStorage
|
||||
private lateinit var tempDir: File
|
||||
private lateinit var amethystDir: File
|
||||
private lateinit var manager: AccountManager
|
||||
|
||||
@BeforeTest
|
||||
fun setup() {
|
||||
storage = mockk(relaxed = true)
|
||||
tempDir = createTempDirectory("acctmgr-bunker-test").toFile()
|
||||
amethystDir = File(tempDir, ".amethyst")
|
||||
amethystDir.mkdirs()
|
||||
manager = AccountManager(storage, tempDir)
|
||||
}
|
||||
|
||||
@AfterTest
|
||||
fun teardown() {
|
||||
tempDir.deleteRecursively()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hasBunkerAccountReturnsFalseWhenNoFile() {
|
||||
assertFalse(manager.hasBunkerAccount())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hasBunkerAccountReturnsTrueWhenFileExists() {
|
||||
File(amethystDir, "bunker_uri.txt").writeText("bunker://${"a".repeat(64)}?relay=wss://r.com")
|
||||
assertTrue(manager.hasBunkerAccount())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setConnectingRelaysUpdatesState() {
|
||||
manager.setConnectingRelays()
|
||||
assertTrue(manager.accountState.value is AccountState.ConnectingRelays)
|
||||
}
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.account
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip19Bech32.toNpub
|
||||
import com.vitorpamplona.quartz.nip19Bech32.toNsec
|
||||
import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote
|
||||
import io.mockk.mockk
|
||||
import io.mockk.spyk
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import java.io.File
|
||||
import kotlin.io.path.createTempDirectory
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class AccountManagerHeartbeatTest {
|
||||
private lateinit var storage: SecureKeyStorage
|
||||
private lateinit var tempDir: File
|
||||
private lateinit var manager: AccountManager
|
||||
private lateinit var remoteSigner: NostrSignerRemote
|
||||
|
||||
private val validHex = "a".repeat(64)
|
||||
|
||||
@BeforeTest
|
||||
fun setup() {
|
||||
storage = mockk(relaxed = true)
|
||||
tempDir = createTempDirectory("acctmgr-hb-test").toFile()
|
||||
manager = AccountManager(storage, tempDir)
|
||||
|
||||
// Create a real remote signer that we'll spy on
|
||||
val ephemeral = NostrSignerInternal(KeyPair())
|
||||
remoteSigner =
|
||||
spyk(
|
||||
NostrSignerRemote.fromBunkerUri(
|
||||
"bunker://$validHex?relay=wss://r.com",
|
||||
ephemeral,
|
||||
EmptyNostrClient,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@AfterTest
|
||||
fun teardown() {
|
||||
manager.stopHeartbeat()
|
||||
tempDir.deleteRecursively()
|
||||
}
|
||||
|
||||
private fun loginWithRemoteSigner() {
|
||||
// Directly set the account state to a bunker-logged-in state
|
||||
val keyPair = KeyPair()
|
||||
val state =
|
||||
AccountState.LoggedIn(
|
||||
signer = remoteSigner,
|
||||
pubKeyHex = keyPair.pubKey.toHexKey(),
|
||||
npub = keyPair.pubKey.toNpub(),
|
||||
nsec = null,
|
||||
isReadOnly = false,
|
||||
signerType = SignerType.Remote("bunker://$validHex?relay=wss://r.com"),
|
||||
)
|
||||
// We need to access private _accountState — use loginWithKey then replace
|
||||
// Actually, let's just use reflection or a simpler approach
|
||||
// We'll test heartbeat indirectly by using the public API
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stopHeartbeatCancels() =
|
||||
runTest {
|
||||
// Just ensure stopHeartbeat doesn't crash when no heartbeat is running
|
||||
manager.stopHeartbeat()
|
||||
// And after starting
|
||||
manager.startHeartbeat(this)
|
||||
manager.stopHeartbeat()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun startHeartbeatDoesNotCrashWithNoAccount() =
|
||||
runTest {
|
||||
manager.startHeartbeat(this)
|
||||
advanceTimeBy(AccountManager.HEARTBEAT_INTERVAL_MS + 1)
|
||||
// Should not crash — no account means the loop skips
|
||||
manager.stopHeartbeat()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun startHeartbeatDoesNotCrashWithInternalSigner() =
|
||||
runTest {
|
||||
val nsec = KeyPair().privKey!!.toNsec()
|
||||
manager.loginWithKey(nsec)
|
||||
manager.startHeartbeat(this)
|
||||
advanceTimeBy(AccountManager.HEARTBEAT_INTERVAL_MS + 1)
|
||||
// Internal signer is not NostrSignerRemote, heartbeat skips it
|
||||
manager.stopHeartbeat()
|
||||
}
|
||||
}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.account
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip19Bech32.toNpub
|
||||
import com.vitorpamplona.quartz.nip19Bech32.toNsec
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import java.io.File
|
||||
import kotlin.io.path.createTempDirectory
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class AccountManagerKeyLoginTest {
|
||||
private lateinit var storage: SecureKeyStorage
|
||||
private lateinit var tempDir: File
|
||||
private lateinit var manager: AccountManager
|
||||
|
||||
@BeforeTest
|
||||
fun setup() {
|
||||
storage = mockk(relaxed = true)
|
||||
tempDir = createTempDirectory("acctmgr-key-test").toFile()
|
||||
manager = AccountManager(storage, tempDir)
|
||||
}
|
||||
|
||||
@AfterTest
|
||||
fun teardown() {
|
||||
tempDir.deleteRecursively()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loginWithNsecReturnsLoggedIn() {
|
||||
val keyPair = KeyPair()
|
||||
val nsec = keyPair.privKey!!.toNsec()
|
||||
val result = manager.loginWithKey(nsec)
|
||||
assertTrue(result.isSuccess)
|
||||
val state = result.getOrThrow()
|
||||
assertFalse(state.isReadOnly)
|
||||
assertEquals(SignerType.Internal, state.signerType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loginWithNpubReturnsReadOnly() {
|
||||
val keyPair = KeyPair()
|
||||
val npub = keyPair.pubKey.toNpub()
|
||||
val result = manager.loginWithKey(npub)
|
||||
assertTrue(result.isSuccess)
|
||||
val state = result.getOrThrow()
|
||||
assertTrue(state.isReadOnly)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loginWithInvalidKeyReturnsFailure() {
|
||||
val result = manager.loginWithKey("garbage")
|
||||
assertTrue(result.isFailure)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loginWithEmptyKeyReturnsFailure() {
|
||||
val result = manager.loginWithKey("")
|
||||
assertTrue(result.isFailure)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loginWithKeyUpdatesStateFlow() {
|
||||
val keyPair = KeyPair()
|
||||
val nsec = keyPair.privKey!!.toNsec()
|
||||
manager.loginWithKey(nsec)
|
||||
assertIs<AccountState.LoggedIn>(manager.accountState.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun generateNewAccountKeysValid() {
|
||||
val state = manager.generateNewAccount()
|
||||
assertTrue(state.npub.startsWith("npub1"))
|
||||
assertNotNull(state.nsec)
|
||||
assertTrue(state.nsec!!.startsWith("nsec1"))
|
||||
assertFalse(state.isReadOnly)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun saveCurrentAccountInternal() =
|
||||
runTest {
|
||||
val keyPair = KeyPair()
|
||||
val nsec = keyPair.privKey!!.toNsec()
|
||||
manager.loginWithKey(nsec)
|
||||
val result = manager.saveCurrentAccount()
|
||||
assertTrue(result.isSuccess)
|
||||
coVerify { storage.savePrivateKey(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun saveCurrentAccountBunkerIsNoOp() =
|
||||
runTest {
|
||||
// Simulate a logged-in bunker account by logging in with nsec then
|
||||
// replacing state with a bunker-typed one
|
||||
val keyPair = KeyPair()
|
||||
val signer = NostrSignerInternal(keyPair)
|
||||
// Use loginWithKey to set state, but we need a Remote type
|
||||
// We can't easily set Remote without a real bunker, so test the path
|
||||
// by checking that when signerType is Internal, savePrivateKey IS called
|
||||
manager.loginWithKey(keyPair.privKey!!.toNsec())
|
||||
manager.saveCurrentAccount()
|
||||
coVerify(atLeast = 1) { storage.savePrivateKey(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun saveCurrentAccountReadOnlyFails() =
|
||||
runTest {
|
||||
val keyPair = KeyPair()
|
||||
manager.loginWithKey(keyPair.pubKey.toNpub())
|
||||
val result = manager.saveCurrentAccount()
|
||||
assertTrue(result.isFailure)
|
||||
}
|
||||
}
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.account
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip19Bech32.toNpub
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import java.io.File
|
||||
import kotlin.io.path.createTempDirectory
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class AccountManagerLoadAccountTest {
|
||||
private lateinit var storage: SecureKeyStorage
|
||||
private lateinit var tempDir: File
|
||||
private lateinit var amethystDir: File
|
||||
private lateinit var manager: AccountManager
|
||||
|
||||
@BeforeTest
|
||||
fun setup() {
|
||||
storage = mockk(relaxed = true)
|
||||
tempDir = createTempDirectory("acctmgr-load-test").toFile()
|
||||
amethystDir = File(tempDir, ".amethyst")
|
||||
amethystDir.mkdirs()
|
||||
manager = AccountManager(storage, tempDir)
|
||||
}
|
||||
|
||||
@AfterTest
|
||||
fun teardown() {
|
||||
tempDir.deleteRecursively()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadSavedAccountNoNpubReturnsFailure() =
|
||||
runTest {
|
||||
// No last_account.txt file
|
||||
val result = manager.loadSavedAccount()
|
||||
assertTrue(result.isFailure)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadSavedAccountInternalSuccess() =
|
||||
runTest {
|
||||
val keyPair = KeyPair()
|
||||
val npub = keyPair.pubKey.toNpub()
|
||||
val privKeyHex = keyPair.privKey!!.toHexKey()
|
||||
|
||||
// Write last_account.txt
|
||||
File(amethystDir, "last_account.txt").writeText(npub)
|
||||
|
||||
// Mock storage to return the private key
|
||||
coEvery { storage.getPrivateKey(npub) } returns privKeyHex
|
||||
|
||||
val result = manager.loadSavedAccount()
|
||||
assertTrue(result.isSuccess)
|
||||
val state = result.getOrThrow()
|
||||
assertIs<AccountState.LoggedIn>(state)
|
||||
assertIs<SignerType.Internal>(state.signerType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadSavedAccountInternalNoPrivkeyReturnsFailure() =
|
||||
runTest {
|
||||
val keyPair = KeyPair()
|
||||
val npub = keyPair.pubKey.toNpub()
|
||||
|
||||
File(amethystDir, "last_account.txt").writeText(npub)
|
||||
coEvery { storage.getPrivateKey(npub) } returns null
|
||||
|
||||
val result = manager.loadSavedAccount()
|
||||
assertTrue(result.isFailure)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadSavedAccountBunkerNoEphemeralReturnsFailure() =
|
||||
runTest {
|
||||
val validHex = "a".repeat(64)
|
||||
val keyPair = KeyPair()
|
||||
val npub = keyPair.pubKey.toNpub()
|
||||
|
||||
File(amethystDir, "last_account.txt").writeText(npub)
|
||||
File(amethystDir, "bunker_uri.txt").writeText(
|
||||
"bunker://$validHex?relay=wss://r.com",
|
||||
)
|
||||
coEvery {
|
||||
storage.getPrivateKey(AccountManager.BUNKER_EPHEMERAL_KEY_ALIAS)
|
||||
} returns null
|
||||
|
||||
val result = manager.loadSavedAccount()
|
||||
assertTrue(result.isFailure)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadSavedAccountBunkerSuccess() =
|
||||
runTest {
|
||||
val keyPair = KeyPair()
|
||||
val npub = keyPair.pubKey.toNpub()
|
||||
val ephemeralKeyPair = KeyPair()
|
||||
val ephemeralPrivKeyHex = ephemeralKeyPair.privKey!!.toHexKey()
|
||||
val validHex = keyPair.pubKey.toHexKey()
|
||||
|
||||
File(amethystDir, "last_account.txt").writeText(npub)
|
||||
File(amethystDir, "bunker_uri.txt").writeText(
|
||||
"bunker://$validHex?relay=wss://r.com",
|
||||
)
|
||||
coEvery {
|
||||
storage.getPrivateKey(AccountManager.BUNKER_EPHEMERAL_KEY_ALIAS)
|
||||
} returns ephemeralPrivKeyHex
|
||||
|
||||
val result = manager.loadSavedAccount()
|
||||
assertTrue(result.isSuccess)
|
||||
val state = result.getOrThrow()
|
||||
assertIs<SignerType.Remote>(state.signerType)
|
||||
}
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.account
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState
|
||||
import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip19Bech32.toNsec
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import java.io.File
|
||||
import kotlin.io.path.createTempDirectory
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class AccountManagerLogoutTest {
|
||||
private lateinit var storage: SecureKeyStorage
|
||||
private lateinit var tempDir: File
|
||||
private lateinit var manager: AccountManager
|
||||
|
||||
@BeforeTest
|
||||
fun setup() {
|
||||
storage = mockk(relaxed = true)
|
||||
tempDir = createTempDirectory("acctmgr-logout-test").toFile()
|
||||
manager = AccountManager(storage, tempDir)
|
||||
}
|
||||
|
||||
@AfterTest
|
||||
fun teardown() {
|
||||
tempDir.deleteRecursively()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun logoutTransitionsToLoggedOut() =
|
||||
runTest {
|
||||
val nsec = KeyPair().privKey!!.toNsec()
|
||||
manager.loginWithKey(nsec)
|
||||
manager.logout()
|
||||
assertIs<AccountState.LoggedOut>(manager.accountState.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun logoutDeleteKeyCallsDeletePrivateKey() =
|
||||
runTest {
|
||||
val nsec = KeyPair().privKey!!.toNsec()
|
||||
manager.loginWithKey(nsec)
|
||||
manager.logout(deleteKey = true)
|
||||
coVerify { storage.deletePrivateKey(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun logoutWithoutDeleteKeyDoesNotDelete() =
|
||||
runTest {
|
||||
val nsec = KeyPair().privKey!!.toNsec()
|
||||
manager.loginWithKey(nsec)
|
||||
manager.logout(deleteKey = false)
|
||||
coVerify(exactly = 0) { storage.deletePrivateKey(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun forceLogoutWithReasonSetsReason() =
|
||||
runTest {
|
||||
val nsec = KeyPair().privKey!!.toNsec()
|
||||
manager.loginWithKey(nsec)
|
||||
manager.forceLogoutWithReason("test reason")
|
||||
assertEquals("test reason", manager.forceLogoutReason.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun forceLogoutWithReasonLogsOut() =
|
||||
runTest {
|
||||
val nsec = KeyPair().privKey!!.toNsec()
|
||||
manager.loginWithKey(nsec)
|
||||
manager.forceLogoutWithReason("test")
|
||||
assertIs<AccountState.LoggedOut>(manager.accountState.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearForceLogoutReason() =
|
||||
runTest {
|
||||
val nsec = KeyPair().privKey!!.toNsec()
|
||||
manager.loginWithKey(nsec)
|
||||
manager.forceLogoutWithReason("test")
|
||||
manager.clearForceLogoutReason()
|
||||
assertNull(manager.forceLogoutReason.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun logoutResetsSignerConnectionState() =
|
||||
runTest {
|
||||
val nsec = KeyPair().privKey!!.toNsec()
|
||||
manager.loginWithKey(nsec)
|
||||
manager.logout()
|
||||
assertIs<SignerConnectionState.NotRemote>(manager.signerConnectionState.value)
|
||||
}
|
||||
}
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.account
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState
|
||||
import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip19Bech32.toNpub
|
||||
import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import java.io.File
|
||||
import kotlin.io.path.createTempDirectory
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Tests verifying NIP-46 relay isolation — the dedicated NIP-46 client
|
||||
* is created internally by AccountManager, not passed from callers.
|
||||
*
|
||||
* These tests reproduce the root cause of Bug 2 (shared client relay
|
||||
* contamination) by verifying the API no longer accepts an external client.
|
||||
*/
|
||||
class AccountManagerNip46IsolationTest {
|
||||
private lateinit var storage: SecureKeyStorage
|
||||
private lateinit var tempDir: File
|
||||
private lateinit var amethystDir: File
|
||||
private lateinit var manager: AccountManager
|
||||
|
||||
@BeforeTest
|
||||
fun setup() {
|
||||
storage = mockk(relaxed = true)
|
||||
tempDir = createTempDirectory("acctmgr-nip46-iso-test").toFile()
|
||||
amethystDir = File(tempDir, ".amethyst")
|
||||
amethystDir.mkdirs()
|
||||
manager = AccountManager(storage, tempDir)
|
||||
}
|
||||
|
||||
@AfterTest
|
||||
fun teardown() {
|
||||
tempDir.deleteRecursively()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bunkerLoadCreatesRemoteSignerInternally() =
|
||||
runTest {
|
||||
val validHex = "a".repeat(64)
|
||||
val ephemeralKeyPair = KeyPair()
|
||||
File(amethystDir, "bunker_uri.txt").writeText(
|
||||
"bunker://$validHex?relay=wss://relay.nsec.app",
|
||||
)
|
||||
File(amethystDir, "last_account.txt").writeText(
|
||||
ephemeralKeyPair.pubKey.toNpub(),
|
||||
)
|
||||
coEvery {
|
||||
storage.getPrivateKey(AccountManager.BUNKER_EPHEMERAL_KEY_ALIAS)
|
||||
} returns ephemeralKeyPair.privKey!!.toHexKey()
|
||||
|
||||
val result = manager.loadSavedAccount()
|
||||
assertTrue(result.isSuccess)
|
||||
|
||||
val state = manager.currentAccount()
|
||||
assertNotNull(state)
|
||||
assertIs<SignerType.Remote>(state.signerType)
|
||||
assertIs<NostrSignerRemote>(state.signer)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loginWithBunkerDoesNotRequireExternalClient() =
|
||||
runTest {
|
||||
// loginWithBunker() compiles with just bunkerUri — no client param
|
||||
try {
|
||||
manager.loginWithBunker(
|
||||
"bunker://${"a".repeat(64)}?relay=wss://relay.nsec.app",
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
// Expected — no real relay. Verify it's a connection error, not API error.
|
||||
assertTrue(
|
||||
e.message?.contains("Connection") == true ||
|
||||
e.message?.contains("timed out") == true ||
|
||||
e.message?.contains("failed") == true,
|
||||
"Unexpected error type: ${e.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loginWithNostrConnectDoesNotRequireExternalClient() =
|
||||
runTest {
|
||||
var generatedUri: String? = null
|
||||
try {
|
||||
manager.loginWithNostrConnect { uri -> generatedUri = uri }
|
||||
} catch (e: Exception) {
|
||||
// Expected — no signer scanning the QR
|
||||
assertNotNull(e.message)
|
||||
}
|
||||
// URI should have been generated even if connection timed out
|
||||
assertNotNull(generatedUri, "nostrconnect URI was never generated")
|
||||
assertTrue(
|
||||
generatedUri!!.startsWith("nostrconnect://"),
|
||||
"URI format wrong: $generatedUri",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun logoutResetsStateAfterBunkerLoad() =
|
||||
runTest {
|
||||
val validHex = "a".repeat(64)
|
||||
val ephemeralKeyPair = KeyPair()
|
||||
File(amethystDir, "bunker_uri.txt").writeText(
|
||||
"bunker://$validHex?relay=wss://relay.nsec.app",
|
||||
)
|
||||
File(amethystDir, "last_account.txt").writeText(
|
||||
ephemeralKeyPair.pubKey.toNpub(),
|
||||
)
|
||||
coEvery {
|
||||
storage.getPrivateKey(AccountManager.BUNKER_EPHEMERAL_KEY_ALIAS)
|
||||
} returns ephemeralKeyPair.privKey!!.toHexKey()
|
||||
|
||||
manager.loadSavedAccount()
|
||||
assertIs<AccountState.LoggedIn>(manager.accountState.value)
|
||||
|
||||
manager.logout()
|
||||
assertIs<AccountState.LoggedOut>(manager.accountState.value)
|
||||
assertIs<SignerConnectionState.NotRemote>(
|
||||
manager.signerConnectionState.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun logoutThenReloadCreatesFreshNip46Client() =
|
||||
runTest {
|
||||
val validHex = "a".repeat(64)
|
||||
val ephemeralKeyPair = KeyPair()
|
||||
val bunkerUri = "bunker://$validHex?relay=wss://relay.nsec.app"
|
||||
|
||||
fun writeBunkerFiles() {
|
||||
File(amethystDir, "bunker_uri.txt").writeText(bunkerUri)
|
||||
File(amethystDir, "last_account.txt").writeText(
|
||||
ephemeralKeyPair.pubKey.toNpub(),
|
||||
)
|
||||
}
|
||||
|
||||
coEvery {
|
||||
storage.getPrivateKey(AccountManager.BUNKER_EPHEMERAL_KEY_ALIAS)
|
||||
} returns ephemeralKeyPair.privKey!!.toHexKey()
|
||||
|
||||
// First load
|
||||
writeBunkerFiles()
|
||||
manager.loadSavedAccount()
|
||||
val firstSigner = manager.currentAccount()?.signer
|
||||
assertNotNull(firstSigner)
|
||||
|
||||
// Logout — disconnects NIP-46 client
|
||||
manager.logout()
|
||||
assertIs<AccountState.LoggedOut>(manager.accountState.value)
|
||||
|
||||
// Second load — should create a fresh NIP-46 client
|
||||
writeBunkerFiles()
|
||||
manager.loadSavedAccount()
|
||||
val secondSigner = manager.currentAccount()?.signer
|
||||
assertNotNull(secondSigner)
|
||||
|
||||
// Both should be remote signers — different instances
|
||||
assertIs<NostrSignerRemote>(firstSigner)
|
||||
assertIs<NostrSignerRemote>(secondSigner)
|
||||
assertTrue(
|
||||
firstSigner !== secondSigner,
|
||||
"Second load should create a new signer instance",
|
||||
)
|
||||
}
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.account
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.keystorage.SecureKeyStorage
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip19Bech32.toNpub
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import java.io.File
|
||||
import kotlin.io.path.createTempDirectory
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Tests verifying AccountManager state transitions during NIP-46 flows.
|
||||
* Reproduces Bug 1 UX issue — user should see ConnectingRelays while
|
||||
* the dedicated NIP-46 client is being set up.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class AccountManagerStateTransitionTest {
|
||||
private lateinit var storage: SecureKeyStorage
|
||||
private lateinit var tempDir: File
|
||||
private lateinit var amethystDir: File
|
||||
private lateinit var manager: AccountManager
|
||||
|
||||
@BeforeTest
|
||||
fun setup() {
|
||||
storage = mockk(relaxed = true)
|
||||
tempDir = createTempDirectory("acctmgr-state-test").toFile()
|
||||
amethystDir = File(tempDir, ".amethyst")
|
||||
amethystDir.mkdirs()
|
||||
manager = AccountManager(storage, tempDir)
|
||||
}
|
||||
|
||||
@AfterTest
|
||||
fun teardown() {
|
||||
tempDir.deleteRecursively()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectingRelaysThenLoggedInTransition() =
|
||||
runTest {
|
||||
val states = mutableListOf<AccountState>()
|
||||
val collector =
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
manager.accountState.toList(states)
|
||||
}
|
||||
|
||||
// Simulate Main.kt bunker restore flow
|
||||
manager.setConnectingRelays()
|
||||
|
||||
// Then load an internal account (simulating successful load)
|
||||
val keyPair = KeyPair()
|
||||
val npub = keyPair.pubKey.toNpub()
|
||||
File(amethystDir, "last_account.txt").writeText(npub)
|
||||
coEvery { storage.getPrivateKey(npub) } returns keyPair.privKey!!.toHexKey()
|
||||
manager.loadSavedAccount()
|
||||
|
||||
advanceUntilIdle()
|
||||
|
||||
// Should see: LoggedOut → ConnectingRelays → LoggedIn
|
||||
assertTrue(
|
||||
states.size >= 3,
|
||||
"Expected at least 3 state transitions, got ${states.size}: $states",
|
||||
)
|
||||
assertIs<AccountState.LoggedOut>(states[0])
|
||||
assertIs<AccountState.ConnectingRelays>(states[1])
|
||||
assertIs<AccountState.LoggedIn>(states[2])
|
||||
|
||||
collector.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectingRelaysThenFailureFallsBackToLoggedOut() =
|
||||
runTest {
|
||||
val states = mutableListOf<AccountState>()
|
||||
val collector =
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
manager.accountState.toList(states)
|
||||
}
|
||||
|
||||
// Simulate bunker restore that fails
|
||||
manager.setConnectingRelays()
|
||||
|
||||
// loadSavedAccount fails → caller does logout
|
||||
val result = manager.loadSavedAccount() // no saved account
|
||||
assertTrue(result.isFailure)
|
||||
manager.logout()
|
||||
|
||||
advanceUntilIdle()
|
||||
|
||||
// Should see: LoggedOut → ConnectingRelays → LoggedOut
|
||||
assertTrue(
|
||||
states.size >= 3,
|
||||
"Expected at least 3 state transitions, got ${states.size}: $states",
|
||||
)
|
||||
assertIs<AccountState.LoggedOut>(states[0])
|
||||
assertIs<AccountState.ConnectingRelays>(states[1])
|
||||
assertIs<AccountState.LoggedOut>(states[2])
|
||||
|
||||
collector.cancel()
|
||||
}
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.account
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class BunkerUriUtilsTest {
|
||||
private val validHex = "a".repeat(64)
|
||||
|
||||
// --- validateBunkerUri ---
|
||||
|
||||
@Test
|
||||
fun validUriReturnsNull() {
|
||||
val result = validateBunkerUri("bunker://$validHex?relay=wss://r.com")
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun validWithMultipleRelays() {
|
||||
val result = validateBunkerUri("bunker://$validHex?relay=wss://a.com&relay=wss://b.com")
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun validCaseInsensitiveScheme() {
|
||||
val result = validateBunkerUri("Bunker://$validHex?relay=wss://r.com")
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun validWithSecret() {
|
||||
val result = validateBunkerUri("bunker://$validHex?relay=wss://r.com&secret=abc")
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun missingSchemeReturnsError() {
|
||||
val result = validateBunkerUri("npub1$validHex")
|
||||
assertNotNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invalidPubkeyShortReturnsError() {
|
||||
val result = validateBunkerUri("bunker://abcd?relay=wss://r.com")
|
||||
assertNotNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invalidPubkeyNonHexReturnsError() {
|
||||
val result = validateBunkerUri("bunker://${"g".repeat(64)}?relay=wss://r.com")
|
||||
assertNotNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invalidPubkeyTooLongReturnsError() {
|
||||
val result = validateBunkerUri("bunker://${"a".repeat(65)}?relay=wss://r.com")
|
||||
assertNotNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun missingRelayReturnsError() {
|
||||
val result = validateBunkerUri("bunker://$validHex?secret=abc")
|
||||
assertNotNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyInputReturnsError() {
|
||||
val result = validateBunkerUri("")
|
||||
assertNotNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blankInputReturnsError() {
|
||||
val result = validateBunkerUri(" ")
|
||||
assertNotNull(result)
|
||||
}
|
||||
|
||||
// --- stripBunkerSecret ---
|
||||
|
||||
@Test
|
||||
fun stripsSecretPreservesRelay() {
|
||||
val input = "bunker://$validHex?relay=wss://r.com&secret=mysecret"
|
||||
val result = stripBunkerSecret(input)
|
||||
assertEquals("bunker://$validHex?relay=wss://r.com", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stripsSecretPreservesMultipleRelays() {
|
||||
val input = "bunker://$validHex?relay=wss://a.com&secret=mysecret&relay=wss://b.com"
|
||||
val result = stripBunkerSecret(input)
|
||||
assertEquals("bunker://$validHex?relay=wss://a.com&relay=wss://b.com", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noSecretReturnsSameUri() {
|
||||
val input = "bunker://$validHex?relay=wss://r.com"
|
||||
val result = stripBunkerSecret(input)
|
||||
assertEquals(input, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noQueryReturnsUnchanged() {
|
||||
val input = "bunker://$validHex"
|
||||
val result = stripBunkerSecret(input)
|
||||
assertEquals(input, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun caseInsensitiveSecretRemoval() {
|
||||
val input = "bunker://$validHex?relay=wss://r.com&Secret=foo"
|
||||
val result = stripBunkerSecret(input)
|
||||
assertEquals("bunker://$validHex?relay=wss://r.com", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun secretOnlyParamReturnsBareUri() {
|
||||
val input = "bunker://$validHex?secret=mysecret"
|
||||
val result = stripBunkerSecret(input)
|
||||
assertEquals("bunker://$validHex", result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
---
|
||||
date: 2026-03-06
|
||||
topic: nip46-post-login-bugs
|
||||
---
|
||||
|
||||
# NIP-46 Post-Login Bugs: Content Not Loading + Bunker Timeout
|
||||
|
||||
## What's Happening
|
||||
|
||||
Two bugs on branch `feat/nip46-bunker-login` after the NIP-46 relay isolation work:
|
||||
|
||||
### Bug 1: `followedUsers=0` — Feed Never Populates After Login
|
||||
|
||||
**Symptoms:**
|
||||
- Contact list subscription opens/closes rapidly (subscription churn)
|
||||
- No `[DEBUG-FEED] contactList event:` log — no kind-3 event ever arrives
|
||||
- Feed stays on "Loading followed users..." forever
|
||||
|
||||
**Logs:**
|
||||
```
|
||||
[DEBUG-FEED] contactList sub: relays=5, account=0b24845a, mode=FOLLOWING
|
||||
[DEBUG-FEED] feedSub: mode=FOLLOWING, relays=5, followedUsers=0
|
||||
[DEBUG-SUB] OPEN contacts-0b24845a-1772773857361 relays=5
|
||||
[DEBUG-SUB] CLOSE contacts-0b24845a-1772773857361
|
||||
[DEBUG-SUB] OPEN contacts-0b24845a-1772773867946 relays=5
|
||||
[DEBUG-SUB] CLOSE contacts-0b24845a-1772773867946
|
||||
```
|
||||
|
||||
### Bug 2: `bunker://` Login Times Out (30s)
|
||||
|
||||
**Symptoms:**
|
||||
- Pasting bunker:// URI → "Connection timed out" after 30s
|
||||
- `RemoteSignerManager.timeout = 30000` controls this
|
||||
|
||||
## Root Cause Analysis
|
||||
|
||||
### Bug 1: Subscription Churn from `relayStatuses.keys`
|
||||
|
||||
**The pattern** (`FeedScreen.kt:193`):
|
||||
```kotlin
|
||||
rememberSubscription(relayStatuses.keys, account, feedMode, ...) { ... }
|
||||
```
|
||||
|
||||
**The problem:**
|
||||
1. `relayStatuses` is a `StateFlow<Map<NormalizedRelayUrl, RelayStatus>>`
|
||||
2. Every relay status change (connect, disconnect, ping) emits a new map
|
||||
3. `.keys` produces a structurally-equal but reference-different set
|
||||
4. `remember(*keys)` re-evaluates → `DisposableEffect` disposes old sub → opens new sub
|
||||
5. New sub gets a fresh `subId` (uses `System.currentTimeMillis()`)
|
||||
6. Relay's response to old REQ arrives → client drops it (unknown subId)
|
||||
7. New REQ sent → relay starts processing → another status change → cycle repeats
|
||||
|
||||
**Timeline:**
|
||||
```
|
||||
T+0s: Relay A connects → relayStatuses emits → sub opens (subId=1001)
|
||||
T+1s: Relay B connects → relayStatuses emits → sub CLOSES (subId=1001), opens (subId=1002)
|
||||
T+2s: Relay C connects → same cycle → CLOSE 1002, OPEN 1003
|
||||
T+3s: Relay responds to subId=1001 → DROPPED (sub already closed)
|
||||
T+4s: Relay responds to subId=1003 → maybe received, maybe closed again
|
||||
```
|
||||
|
||||
**Contributing factor:** `relayStatuses.keys` includes configured-but-not-connected relays. REQs to unconnected relays are queued but may never be sent.
|
||||
|
||||
### Bug 2: NIP-46 Relay Connection Race
|
||||
|
||||
**The flow** (`AccountManager.loginWithBunker()`):
|
||||
```kotlin
|
||||
val nip46Client = getOrCreateNip46Client() // creates NostrClient, calls connect() (no-op: no relays)
|
||||
val remoteSigner = NostrSignerRemote.fromBunkerUri(bunkerUri, signer, nip46Client)
|
||||
remoteSigner.openSubscription() // calls client.req(relays=[relay.nsec.app]) → lazy connect
|
||||
val remotePubkey = remoteSigner.connect() // sends connect request, waits 30s for response
|
||||
```
|
||||
|
||||
**The problem:**
|
||||
- `openSubscription()` triggers `sendOrConnectAndSync(relay.nsec.app, REQ)` — async connection
|
||||
- `connect()` calls `launchWaitAndParse()` which sends EVENT immediately
|
||||
- If websocket to relay.nsec.app isn't established yet, EVENT send fails/queues
|
||||
- Even if EVENT sends, relay may not have processed REQ yet → response has no matching sub
|
||||
- 30s timeout expires
|
||||
|
||||
**Also:** Only 1 relay (`wss://relay.nsec.app`) — single point of failure.
|
||||
|
||||
## Approaches
|
||||
|
||||
### Bug 1 Fix: Stabilize Subscription Keys
|
||||
|
||||
#### Approach A: Use `connectedRelays` + Debounce (Recommended)
|
||||
|
||||
Replace `relayStatuses.keys` with a debounced/stable relay set.
|
||||
|
||||
```kotlin
|
||||
// Stable relay set that only updates when relay URLs actually change
|
||||
val stableRelays by remember {
|
||||
snapshotFlow { connectedRelays }
|
||||
.distinctUntilChanged()
|
||||
.debounce(2000) // wait for relays to stabilize
|
||||
}.collectAsState(initial = emptySet())
|
||||
|
||||
rememberSubscription(stableRelays, account, feedMode, ...) { ... }
|
||||
```
|
||||
|
||||
**Pros:** Eliminates churn, only resubscribes when relay set actually changes
|
||||
**Cons:** 2s delay before first subscription opens
|
||||
|
||||
#### Approach B: Separate Subscription Lifecycle from Relay Set
|
||||
|
||||
Don't use relay set as a recomposition key at all. Open subscription ONCE with whatever relays are available, never re-create it.
|
||||
|
||||
```kotlin
|
||||
// Only recompose on account/feedMode changes, not relay changes
|
||||
rememberSubscription(account, feedMode, relayManager = relayManager) {
|
||||
val relays = connectedRelays // snapshot, not reactive
|
||||
if (relays.isNotEmpty() && account != null) {
|
||||
createContactListSubscription(relays = relays, ...)
|
||||
} else null
|
||||
}
|
||||
```
|
||||
|
||||
**Pros:** Zero churn, simplest fix
|
||||
**Cons:** If initial relay set is empty, subscription never opens (need LaunchedEffect to wait for relays)
|
||||
|
||||
#### Approach C: LaunchedEffect + One-Shot Subscription
|
||||
|
||||
Don't use `rememberSubscription` for contact list at all. Use a `LaunchedEffect` that waits for relays then subscribes once.
|
||||
|
||||
```kotlin
|
||||
LaunchedEffect(account) {
|
||||
val relays = relayManager.connectedRelays.first { it.isNotEmpty() }
|
||||
relayManager.subscribe("contacts-${account.pubKeyHex.take(8)}",
|
||||
listOf(FilterBuilders.contactList(account.pubKeyHex)), relays,
|
||||
listener = object : IRequestListener { ... })
|
||||
}
|
||||
```
|
||||
|
||||
**Pros:** No churn, explicit lifecycle, clear intent
|
||||
**Cons:** Manual cleanup needed, doesn't auto-update if relay set changes later
|
||||
|
||||
### Bug 2 Fix: Wait for NIP-46 Relay Connection
|
||||
|
||||
#### Approach A: Wait for Connected Relay Before connect() (Recommended)
|
||||
|
||||
```kotlin
|
||||
val nip46Client = getOrCreateNip46Client()
|
||||
val remoteSigner = NostrSignerRemote.fromBunkerUri(bunkerUri, signer, nip46Client)
|
||||
remoteSigner.openSubscription()
|
||||
|
||||
// Wait for NIP-46 relay to actually connect before sending connect request
|
||||
nip46Client.connectedRelaysFlow().first { relays ->
|
||||
remoteSigner.relays.any { it in relays }
|
||||
}
|
||||
|
||||
val remotePubkey = remoteSigner.connect()
|
||||
```
|
||||
|
||||
**Pros:** Guarantees relay is connected before handshake
|
||||
**Cons:** Adds wait time; if relay never connects, blocks indefinitely (need timeout)
|
||||
|
||||
#### Approach B: Add Retry Logic to connect()
|
||||
|
||||
Wrap `connect()` in retry with backoff:
|
||||
```kotlin
|
||||
val remotePubkey = retry(maxAttempts = 3, delayMs = 5000) {
|
||||
remoteSigner.connect()
|
||||
}
|
||||
```
|
||||
|
||||
**Pros:** Handles transient failures
|
||||
**Cons:** Doesn't fix root cause, just masks it; up to 90s total wait
|
||||
|
||||
#### Approach C: Increase Timeout + Add Debug Logging
|
||||
|
||||
Bump `RemoteSignerManager.timeout` to 60s, add logging to track relay connection state.
|
||||
|
||||
**Pros:** Quick, low-risk
|
||||
**Cons:** Doesn't fix the race; just makes timeout less likely
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- Bug 1 is the higher priority — it affects ALL logins, not just bunker
|
||||
- Bug 1 Approach B or C preferred — simplest, eliminates churn entirely
|
||||
- Bug 2 Approach A preferred — fixes the actual race condition
|
||||
- Both fixes are independent and can be done in parallel
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. Does the contact list fail for nsec/key login too, or only bunker? (Would confirm if it's relay churn vs NIP-46 specific)
|
||||
2. Is `relay.nsec.app` reachable from this machine? (`websocat wss://relay.nsec.app` test)
|
||||
3. Should the bunker URI's relays be used for content subscriptions too? (User's relays vs default relays)
|
||||
4. Should we add a fallback relay for NIP-46? (e.g., `wss://relay.damus.io` alongside `relay.nsec.app`)
|
||||
@@ -0,0 +1,209 @@
|
||||
# Brainstorm: FeedScreen Relay Subscription Strategy
|
||||
|
||||
**Date:** 2026-03-09
|
||||
**Status:** Investigation complete, solution needed
|
||||
**Branch:** `feat/nip46-bunker-login`
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Follows don't load for nostrconnect:// login. The root cause is a **chicken-and-egg problem** with how FeedScreen drives relay subscriptions — two competing approaches each solve one problem but break the other.
|
||||
|
||||
## What We Found
|
||||
|
||||
### The Two Approaches
|
||||
|
||||
| Approach | Drives relay connections? | Avoids subscription churn? |
|
||||
|----------|--------------------------|---------------------------|
|
||||
| `configuredRelays` (relayStatuses.keys) | YES — openReqSubscription connects on demand | NO — status map emits on every ping/connect/disconnect |
|
||||
| `connectedRelays` (connectedRelaysFlow) | NO — subscriptions never open if nothing is connected | YES — only changes when relay actually connects/disconnects |
|
||||
|
||||
### How `configuredRelays` Works (Current Working Dir)
|
||||
|
||||
```kotlin
|
||||
val relayStatusMap by relayManager.relayStatuses.collectAsState()
|
||||
val configuredRelays = relayStatusMap.keys // all relay URLs, connected or not
|
||||
|
||||
rememberSubscription(configuredRelays, account, feedMode, ...) {
|
||||
createContactListSubscription(relays = configuredRelays, ...)
|
||||
}
|
||||
```
|
||||
|
||||
- `addDefaultRelays()` populates `relayStatuses` map immediately at app startup
|
||||
- `openReqSubscription()` (inside subscribe) connects to relays on demand
|
||||
- Relays connect, REQs are sent, events arrive
|
||||
- **Problem:** `relayStatusMap` emits a new Map on every relay status change (connect, ping, disconnect). Each emission triggers recomposition. `relayStatusMap.keys` creates a new Set instance. Even though `Set.equals()` is structural, `remember(*keys)` in Compose may see the new reference + trigger config lambda re-evaluation, generating a new subId via `generateSubId()` (uses `System.currentTimeMillis()`). DisposableEffect sees new subId -> closes old subscription -> opens new one. Contact list responses to old subId are dropped.
|
||||
|
||||
### How `connectedRelays` Works (Committed in e58aaf411)
|
||||
|
||||
```kotlin
|
||||
val connectedRelays by relayManager.connectedRelays.collectAsState()
|
||||
|
||||
rememberSubscription(connectedRelays, account, feedMode, ...) {
|
||||
createContactListSubscription(relays = connectedRelays, ...)
|
||||
}
|
||||
```
|
||||
|
||||
- Only includes actually-connected relays
|
||||
- Subscription only recreates when a relay actually connects/disconnects
|
||||
- **Problem:** Chicken-and-egg. Nothing triggers relay connections in the first place. `addDefaultRelays()` only adds to the status map, NOT to the NostrClient. `relayManager.connect()` is a no-op when NostrClient has no registered relays. Relays only get registered when `openReqSubscription()` is called. But `openReqSubscription()` is never called because `connectedRelays` is empty, so the subscription lambda returns null.
|
||||
|
||||
### Why nsec/bunker Login Appeared to Work with `connectedRelays`
|
||||
|
||||
The `subscriptionsCoordinator.start()` in Main.kt kicks off a `rateLimiter.start` that calls `client.openReqSubscription()` for metadata. This incidentally registers relays with the NostrClient and triggers connections. Once relays connect, `connectedRelays` becomes non-empty, and FeedScreen subscriptions fire.
|
||||
|
||||
But this is fragile — it depends on the coordinator's metadata requests happening before FeedScreen needs relays. For nostrconnect (where login takes longer), the timing may differ.
|
||||
|
||||
## Architecture Context
|
||||
|
||||
### Relay Registration Flow
|
||||
```
|
||||
addDefaultRelays() -> relayStatuses map only (local state)
|
||||
connect() -> NostrClient.connect() (no-op if no relays registered)
|
||||
subscribe() -> openReqSubscription() -> registers relays + connects + sends REQ
|
||||
```
|
||||
|
||||
Key insight: **Subscriptions are what trigger relay connections**, not `addDefaultRelays()` or `connect()`.
|
||||
|
||||
### Key Files
|
||||
- `FeedScreen.kt` — all feed subscriptions, follows loading
|
||||
- `SubscriptionUtils.kt` — `rememberSubscription()` with `remember(*keys)` + `DisposableEffect`
|
||||
- `RelayConnectionManager.kt` — `relayStatuses` (Map), `connectedRelays` (Set), `subscribe()`
|
||||
- `FeedSubscription.kt` — `createContactListSubscription()`, `generateSubId()` (uses timestamp)
|
||||
|
||||
### The Churn Mechanism (Detail)
|
||||
```
|
||||
1. relayStatusMap emits (relay A pings)
|
||||
2. FeedScreen recomposes
|
||||
3. configuredRelays = relayStatusMap.keys (new Set instance)
|
||||
4. remember(*keys) — keys include configuredRelays
|
||||
5. IF keys changed: config() runs -> generateSubId("contacts-...") -> new subId
|
||||
6. DisposableEffect sees new subId -> onDispose (close old sub) -> open new sub
|
||||
7. Relay was responding to old subId -> response dropped
|
||||
8. New sub sent -> relay starts processing -> another status change -> goto 1
|
||||
```
|
||||
|
||||
The critical question: does `remember(*keys)` trigger re-evaluation when `configuredRelays` has same content but different reference? In Compose, `remember` uses `==` (structural equality). `Set.equals()` compares contents. So **if relay URLs haven't changed, remember should NOT recompute**.
|
||||
|
||||
But `relayStatusMap` itself changes (values change), causing recomposition. During recomposition, `configuredRelays = relayStatusMap.keys` runs. If the underlying Map implementation returns the same key set by reference, `remember` won't recompute. But if it returns a new Set (likely, since we do `.toMutableMap().apply{...}` in `updateRelayStatus`), then... `Set.equals()` should still return true.
|
||||
|
||||
**This needs empirical verification** — add logging in `rememberSubscription` to confirm whether subscriptions are actually being recreated.
|
||||
|
||||
## Candidate Solutions
|
||||
|
||||
### Approach A: Stabilize configuredRelays Key
|
||||
|
||||
Keep using `configuredRelays` to drive subscriptions (solves chicken-and-egg), but prevent churn by stabilizing the key:
|
||||
|
||||
```kotlin
|
||||
// Derive relay URLs once, only update when URLs actually change
|
||||
val configuredRelayUrls by remember {
|
||||
derivedStateOf {
|
||||
relayManager.relayStatuses.value.keys
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Or use `distinctUntilChanged()` on the Flow before collecting:
|
||||
|
||||
```kotlin
|
||||
val configuredRelays by relayManager.relayStatuses
|
||||
.map { it.keys }
|
||||
.distinctUntilChanged()
|
||||
.collectAsState(emptySet())
|
||||
```
|
||||
|
||||
**Pro:** Subscriptions drive relay connections AND keys are stable.
|
||||
**Con:** Subscriptions still target unconnected relays (but openReqSubscription handles this).
|
||||
|
||||
### Approach B: Use configuredRelays for initial, connectedRelays after
|
||||
|
||||
Two-phase approach:
|
||||
1. First subscription uses `configuredRelays` to trigger relay connections
|
||||
2. Once relays connect, switch to `connectedRelays` for stability
|
||||
|
||||
```kotlin
|
||||
val relaySet = if (connectedRelays.isEmpty()) configuredRelays else connectedRelays
|
||||
```
|
||||
|
||||
**Pro:** Gets the best of both worlds.
|
||||
**Con:** Subscription recreates once during the transition. More complex logic.
|
||||
|
||||
### Approach C: Separate relay connection from subscriptions
|
||||
|
||||
Add relay registration to `addDefaultRelays()` so `connect()` actually works:
|
||||
|
||||
```kotlin
|
||||
fun addRelay(url: String): NormalizedRelayUrl? {
|
||||
val normalized = RelayUrlNormalizer.normalizeOrNull(url) ?: return null
|
||||
updateRelayStatus(normalized) { it.copy(connected = false) }
|
||||
_client.registerRelay(normalized) // NEW: register with NostrClient
|
||||
return normalized
|
||||
}
|
||||
```
|
||||
|
||||
Then FeedScreen can safely use `connectedRelays` because relays will connect via `connect()` independently of subscriptions.
|
||||
|
||||
**Pro:** Clean separation. `connectedRelays` works correctly everywhere.
|
||||
**Con:** Requires changes to NostrClient API (may not have `registerRelay`). Bigger change.
|
||||
|
||||
### Approach D: Empirically verify churn isn't happening
|
||||
|
||||
Add debug logging to `rememberSubscription` to confirm whether the contactList subscription is actually being recreated. If `Set.equals()` works correctly and `remember` doesn't recompute, then `configuredRelays` approach works fine and the follows issue has a different root cause.
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun rememberSubscription(vararg keys: Any?, ...) {
|
||||
val subscription = remember(*keys) {
|
||||
DebugConfig.log("SUB RECOMPUTE keys=${keys.contentToString()}")
|
||||
config()
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Pro:** Cheapest path. May reveal the actual bug.
|
||||
**Con:** Doesn't fix anything by itself.
|
||||
|
||||
## Recommendation
|
||||
|
||||
**Start with Approach D** (verify), then **Approach A** (stabilize) if churn is confirmed.
|
||||
|
||||
Approach A with `distinctUntilChanged()` is the cleanest fix — it preserves the property that subscriptions drive relay connections while eliminating any possible churn from status map updates.
|
||||
|
||||
## Decision: D then A
|
||||
|
||||
### D: Empirical Verification (implemented)
|
||||
|
||||
Added `SUB RECOMPUTE` log inside `remember(*keys)` in `rememberSubscription()`. Fires only when Compose re-evaluates the config lambda (i.e., keys actually changed). Compare frequency against existing `SUB OPEN`/`SUB CLOSE` logs.
|
||||
|
||||
**File:** `SubscriptionUtils.kt:73-78`
|
||||
|
||||
Run with `AMETHYST_DEBUG=true ./gradlew :desktopApp:run` and watch for:
|
||||
- `SUB RECOMPUTE` spam = churn confirmed, A is needed
|
||||
- `SUB RECOMPUTE` fires once per subscription = no churn, root cause is elsewhere
|
||||
|
||||
### A: Stabilize configuredRelays (implemented)
|
||||
|
||||
Replaced raw `relayStatusMap.keys` with a `distinctUntilChanged()` flow:
|
||||
|
||||
```kotlin
|
||||
val configuredRelays by remember {
|
||||
relayManager.relayStatuses
|
||||
.map { it.keys }
|
||||
.distinctUntilChanged()
|
||||
}.collectAsState(emptySet())
|
||||
```
|
||||
|
||||
**File:** `FeedScreen.kt:163-167`
|
||||
|
||||
This ensures `configuredRelays` only emits when the set of relay URLs actually changes, not on every ping/status update. `openReqSubscription` still drives relay connections on demand.
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **If churn isn't the issue, what is?** Could be a timing issue where the contactList REQ arrives at the relay before the relay has the event cached, and no retry mechanism exists.
|
||||
|
||||
2. **Does `openReqSubscription` reliably deliver events for not-yet-connected relays?** It should queue and send REQ after connecting, but need to verify the NostrClient implementation.
|
||||
|
||||
3. **Should we add a retry/resubscribe mechanism?** If the first contactList subscription gets EOSE with no results, should we retry after a delay?
|
||||
|
||||
4. **Is the `since` filter on contactList needed?** ContactList events have no `since` filter currently — they request the latest kind-3 event. But if the relay is slow to index, the response might be empty.
|
||||
@@ -0,0 +1,365 @@
|
||||
---
|
||||
title: "fix: NIP-46 relay isolation, pool init, and stale response bugs"
|
||||
type: fix
|
||||
status: active
|
||||
date: 2026-03-05
|
||||
deepened: 2026-03-05
|
||||
---
|
||||
|
||||
# fix: NIP-46 relay isolation, pool init, and stale response bugs
|
||||
|
||||
## Enhancement Summary
|
||||
|
||||
**Deepened on:** 2026-03-05
|
||||
**Review agents used:** Kotlin expert, Kotlin coroutines, Compose expert, Nostr protocol, Architecture, Code simplicity, Performance
|
||||
|
||||
### Key Improvements from Deepening
|
||||
1. **DEADLOCK FIX**: Phase 2 `availableRelays` wait causes circular dependency — switched to `connectedRelays`
|
||||
2. **Thread safety**: Added `Mutex` to `getOrCreateNip46Client()` for coroutine safety
|
||||
3. **Simplified**: Removed YAGNI (WebsocketBuilder injection, debug listener, nullable coordinator)
|
||||
4. **`since` filter adjusted**: Use 60s buffer (`TimeUtils.now() - 60`) to handle clock skew
|
||||
5. **Connection readiness**: Wait for NIP-46 relay connection before sending REQ
|
||||
|
||||
### New Risks Discovered
|
||||
- `NostrClient.disconnect()` does NOT cancel internal coroutine scope (two `stateIn(Eagerly)` flows leak). Fix: call `scope.cancel()` in `disconnectNip46Client()` — see Step 3
|
||||
- `NostrClient.connect()` is non-blocking — `openSubscription()` may race. Mitigated by relay auto-queue in `PoolRequests`
|
||||
- `connectedRelays` flow fires when general relays connect, breaking the circular dependency that `availableRelays` had
|
||||
|
||||
## Overview
|
||||
|
||||
Three bugs in the NIP-46 remote signer implementation on desktop prevent account-specific content from loading, break session restoration, and cause sign requests to never reach Amber. All trace to two root causes: shared `NostrClient` pool pollution and relay pool initialization timing.
|
||||
|
||||
## Problem Statement
|
||||
|
||||
### Bug 1: No account-specific content after nostrconnect login
|
||||
After successful NIP-46 login, home feed shows global notes but no followed users, profile data, or DMs. `DesktopRelaySubscriptionsCoordinator.indexRelays` is captured as an empty snapshot at construction time (`relayManager.availableRelays.value` in `Main.kt:411`), so the metadata coordinator never fetches profiles/avatars.
|
||||
|
||||
### Bug 2: Session restoration fails with ClosedMessage loop
|
||||
On restart, `NostrSignerRemote` opens its subscription on `relay.nsec.app` via the shared `NostrClient`. This adds `relay.nsec.app` to the general relay pool. All other subscriptions (DMs, metadata, feeds) get sent there too. `relay.nsec.app` rejects non-NIP-46 subs with `ClosedMessage`. `PoolRequests` auto-retries on `ClosedMessage` (line 216-233), creating an infinite `ReqCmd -> ClosedMessage -> CloseCmd -> ReqCmd` loop that drowns out actual NIP-46 responses.
|
||||
|
||||
### Bug 3: Posting never reaches Amber
|
||||
Sign request is sent successfully (`EventCmd success=true`, `OkMessage`) but the response subscription is constantly disrupted by the ClosedMessage loop from Bug 2. Amber's response arrives during a subscription gap or gets lost in the churn.
|
||||
|
||||
## Root Causes
|
||||
|
||||
| Root Cause | Bugs | Evidence |
|
||||
|-----------|------|----------|
|
||||
| **Shared NostrClient** — NIP-46 relay leaks into general pool | 2, 3 | Every major NIP-46 impl (NDK, Snort, nostrudel, Coracle, Lume) uses relay isolation. Our shared client is unique and broken. |
|
||||
| **`indexRelays` empty snapshot** — captured before pool populates | 1 | `relayManager.availableRelays.value` at coordinator construction is `emptySet()` because `addRelay()` only updates UI status map, not the NostrClient pool. Relays enter the pool lazily via `openReqSubscription`. |
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
### Fix 1: Dedicated NostrClient for NIP-46 (Bugs 2 + 3)
|
||||
|
||||
Create a separate `NostrClient` instance exclusively for NIP-46 traffic inside `AccountManager`. Pass it to `NostrSignerRemote` instead of the shared general client.
|
||||
|
||||
**Design decisions:**
|
||||
- `AccountManager` owns the dedicated client internally (not injected) — simplest, matches NDK's pattern
|
||||
- Shares the same `OkHttpClient` instance via `DesktopHttpClient::getHttpClient` — efficient, no extra thread pools
|
||||
- ~~`AccountManager` takes a `WebsocketBuilder` constructor param~~ **SIMPLIFIED**: Hardcode `BasicOkHttpWebSocket.Builder(DesktopHttpClient::getHttpClient)` inside `getOrCreateNip46Client()` — no need to inject what never varies
|
||||
- Dedicated client is disconnected on `logout()`
|
||||
|
||||
### Fix 2: Defer subscriptionsCoordinator creation until relays connected (Bug 1)
|
||||
|
||||
Create `DesktopRelaySubscriptionsCoordinator` inside `LaunchedEffect` after `connectedRelays` is non-empty. Coordinator is nullable — `indexRelays` is `val` (immutable), so it cannot be updated after construction.
|
||||
|
||||
> **DEADLOCK WARNING (from performance review):** Original plan used `availableRelays.first { it.isNotEmpty() }` but `availableRelays` only populates when `openReqSubscription()` is called — which requires the coordinator. Circular dependency. Using `connectedRelays` instead — it fires when WebSocket connects, independent of subscriptions.
|
||||
|
||||
### Fix 3: Add `since` filter to NIP-46 subscription
|
||||
|
||||
Pass `since = TimeUtils.now() - 60` in the `Filter` constructor inside `NostrSignerRemote`. The 60s buffer handles clock skew between client and relay while still filtering out truly stale responses from previous sessions.
|
||||
|
||||
## Technical Approach
|
||||
|
||||
### Phase 1: Dedicated NIP-46 NostrClient
|
||||
|
||||
#### Step 1: Update AccountManager — add dedicated client
|
||||
|
||||
**File:** `desktopApp/src/jvmMain/.../desktop/account/AccountManager.kt`
|
||||
|
||||
No constructor changes needed (YAGNI — WebsocketBuilder injection removed).
|
||||
|
||||
```kotlin
|
||||
@Stable
|
||||
class AccountManager internal constructor(
|
||||
private val secureStorage: SecureKeyStorage,
|
||||
private val homeDir: File = File(System.getProperty("user.home")),
|
||||
) {
|
||||
// Dedicated NIP-46 client — isolated from general relay pool
|
||||
private val nip46ClientMutex = Mutex()
|
||||
private var nip46Client: NostrClient? = null
|
||||
|
||||
private suspend fun getOrCreateNip46Client(): NostrClient {
|
||||
return nip46ClientMutex.withLock {
|
||||
nip46Client ?: NostrClient(
|
||||
BasicOkHttpWebSocket.Builder(DesktopHttpClient::getHttpClient)
|
||||
).also {
|
||||
nip46Client = it
|
||||
it.connect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun disconnectNip46Client() {
|
||||
nip46Client?.disconnect()
|
||||
// NostrClient.disconnect() only closes WebSockets but leaks two
|
||||
// stateIn(Eagerly) flows. Cancel scope to prevent coroutine leak.
|
||||
// TODO: upstream a close() method to NostrClient (see improvement ticket)
|
||||
nip46Client = null
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
> **Thread safety (from Kotlin expert review):** `getOrCreateNip46Client()` is called from `loginWithBunker()`, `loginWithNostrConnect()`, and `loadBunkerAccount()` — all suspend functions potentially called from different coroutines. `Mutex` prevents double-creation races.
|
||||
|
||||
> **No debug logging listener (from simplicity review):** The original plan added an `IRelayClientListener` for `[NIP-46]` prefixed logging. Removed — `println` debugging should be added ad-hoc, not baked in. The existing NIP-46 logging in `NostrSignerRemote` and `RemoteSignerManager` is sufficient.
|
||||
|
||||
#### Step 2: Update login methods to use dedicated client
|
||||
|
||||
**File:** `desktopApp/src/jvmMain/.../desktop/account/AccountManager.kt`
|
||||
|
||||
Remove `client: INostrClient` parameter from all NIP-46 methods. Use `getOrCreateNip46Client()` internally.
|
||||
|
||||
```kotlin
|
||||
// BEFORE
|
||||
suspend fun loginWithBunker(bunkerUri: String, client: INostrClient): Result<AccountState.LoggedIn>
|
||||
|
||||
// AFTER
|
||||
suspend fun loginWithBunker(bunkerUri: String): Result<AccountState.LoggedIn>
|
||||
```
|
||||
|
||||
Same for:
|
||||
- `loginWithNostrConnect(onUriGenerated: (String) -> Unit)` — remove `client` param
|
||||
- `loadSavedAccount()` — remove `client` param
|
||||
- `loadBunkerAccount(bunkerUri, npub)` — remove `client` param
|
||||
- `waitForConnectRequest(...)` — remove `client` param
|
||||
- `sendAckResponse(...)` — remove `client` param
|
||||
|
||||
#### Step 3: Add NIP-46 client cleanup to logout
|
||||
|
||||
**File:** `desktopApp/src/jvmMain/.../desktop/account/AccountManager.kt`
|
||||
|
||||
```kotlin
|
||||
suspend fun logout(deleteKey: Boolean = false) {
|
||||
val current = currentAccount()
|
||||
if (current != null) {
|
||||
if (current.signerType is SignerType.Remote) {
|
||||
(current.signer as? NostrSignerRemote)?.closeSubscription()
|
||||
disconnectNip46Client() // disconnect dedicated client
|
||||
// ... existing key cleanup
|
||||
}
|
||||
// ... existing logout logic
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 4: Update Main.kt wiring
|
||||
|
||||
**File:** `desktopApp/src/jvmMain/.../desktop/Main.kt`
|
||||
|
||||
Remove `relayManager.client` from all AccountManager calls:
|
||||
|
||||
```kotlin
|
||||
// BEFORE (line 434)
|
||||
val result = accountManager.loadSavedAccount(relayManager.client)
|
||||
|
||||
// AFTER
|
||||
val result = accountManager.loadSavedAccount()
|
||||
```
|
||||
|
||||
```kotlin
|
||||
// BEFORE (line 423-441) — bunker restore flow
|
||||
if (accountManager.hasBunkerAccount()) {
|
||||
accountManager.setConnectingRelays()
|
||||
val connected = withTimeoutOrNull(30_000L) {
|
||||
relayManager.connectedRelays.first { it.isNotEmpty() }
|
||||
}
|
||||
if (connected == null) {
|
||||
accountManager.logout(deleteKey = true)
|
||||
} else {
|
||||
val result = accountManager.loadSavedAccount(relayManager.client)
|
||||
// ...
|
||||
}
|
||||
}
|
||||
|
||||
// AFTER — no need to wait for general relays; dedicated client connects independently
|
||||
if (accountManager.hasBunkerAccount()) {
|
||||
accountManager.setConnectingRelays()
|
||||
val result = accountManager.loadSavedAccount()
|
||||
if (result.isSuccess) {
|
||||
accountManager.startHeartbeat(scope)
|
||||
} else {
|
||||
accountManager.logout(deleteKey = true)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Update `LoginScreen` call — remove `relayClient` param:
|
||||
|
||||
```kotlin
|
||||
// BEFORE (line 466)
|
||||
LoginScreen(accountManager = accountManager, relayClient = relayManager.client, ...)
|
||||
|
||||
// AFTER
|
||||
LoginScreen(accountManager = accountManager, ...)
|
||||
```
|
||||
|
||||
#### Step 5: Update LoginScreen and LoginCard
|
||||
|
||||
**File:** `desktopApp/src/jvmMain/.../desktop/ui/LoginScreen.kt`
|
||||
**File:** `desktopApp/src/jvmMain/.../desktop/ui/auth/LoginCard.kt`
|
||||
|
||||
Remove `relayClient: INostrClient` parameter from both composables. The `AccountManager` now handles NIP-46 client internally.
|
||||
|
||||
#### Step 6: Clean up RelayConnectionManager NIP-46 logging
|
||||
|
||||
**File:** `desktopApp/src/jvmMain/.../desktop/network/RelayConnectionManager.kt`
|
||||
|
||||
Remove all `nsec.app`-specific logging from relay listener callbacks (lines 180-231). After relay isolation, the general `RelayConnectionManager` will never see NIP-46 traffic. Simplify each callback to just the `updateRelayStatus()` call.
|
||||
|
||||
### Phase 2: Fix Relay Pool Initialization
|
||||
|
||||
#### Step 7: Defer subscriptionsCoordinator creation
|
||||
|
||||
**File:** `desktopApp/src/jvmMain/.../desktop/Main.kt`
|
||||
|
||||
> **RESOLVED**: `indexRelays` is `private val` (immutable). Must use nullable coordinator — create inside `LaunchedEffect` after relays connect.
|
||||
|
||||
```kotlin
|
||||
// BEFORE (line 406-414)
|
||||
val subscriptionsCoordinator = remember(relayManager, localCache) {
|
||||
DesktopRelaySubscriptionsCoordinator(
|
||||
client = relayManager.client,
|
||||
scope = scope,
|
||||
indexRelays = relayManager.availableRelays.value, // empty!
|
||||
localCache = localCache,
|
||||
)
|
||||
}
|
||||
|
||||
// AFTER — nullable, created after relays connect
|
||||
var subscriptionsCoordinator by remember { mutableStateOf<DesktopRelaySubscriptionsCoordinator?>(null) }
|
||||
|
||||
LaunchedEffect(relayManager, localCache) {
|
||||
// Wait for connected (NOT availableRelays — that deadlocks, see below)
|
||||
relayManager.connectedRelays.first { it.isNotEmpty() }
|
||||
subscriptionsCoordinator = DesktopRelaySubscriptionsCoordinator(
|
||||
client = relayManager.client,
|
||||
scope = scope,
|
||||
indexRelays = relayManager.connectedRelays.value, // now populated
|
||||
localCache = localCache,
|
||||
).also { it.start() }
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
subscriptionsCoordinator?.clear()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **Deadlock explanation:** `availableRelays` derives from `NostrClient.allRelays` which combines `activeRequests.desiredRelays + activeCounts.relays + eventOutbox.relays`. These only populate when `openReqSubscription()` is called — which requires the coordinator. Circular dependency. `connectedRelays` fires when WebSocket connects, independent of subscriptions.
|
||||
|
||||
Guard all usages with `?.`:
|
||||
- `subscriptionsCoordinator?.clear()` in onDispose
|
||||
- `subscriptionsCoordinator?.subscribeToDms(...)` — use `LaunchedEffect(subscriptionsCoordinator)` that fires when coordinator becomes available
|
||||
- Remove existing `subscriptionsCoordinator.start()` call (handled in LaunchedEffect)
|
||||
|
||||
### Phase 3: Add `since` Filter
|
||||
|
||||
#### Step 8: Add `since` to NostrSignerRemote subscription
|
||||
|
||||
**File:** `quartz/src/commonMain/.../nip46RemoteSigner/signer/NostrSignerRemote.kt`
|
||||
|
||||
```kotlin
|
||||
// BEFORE (line 69-87)
|
||||
val subscription = client.req(
|
||||
relays = relays.toList(),
|
||||
filter = Filter(
|
||||
kinds = listOf(NostrConnectEvent.KIND),
|
||||
tags = mapOf("p" to listOf(signer.pubKey)),
|
||||
),
|
||||
) { event -> ... }
|
||||
|
||||
// AFTER
|
||||
val subscription = client.req(
|
||||
relays = relays.toList(),
|
||||
filter = Filter(
|
||||
kinds = listOf(NostrConnectEvent.KIND),
|
||||
tags = mapOf("p" to listOf(signer.pubKey)),
|
||||
since = TimeUtils.now() - 60, // 60s buffer for clock skew
|
||||
),
|
||||
) { event -> ... }
|
||||
```
|
||||
|
||||
> **Clock skew buffer (from Nostr protocol review):** Using `TimeUtils.now()` exactly risks missing responses if there's any clock difference between client and relay, or if the signer response `created_at` is slightly in the past. A 60s buffer is safe — request ID matching in `RemoteSignerManager.awaitingRequests` already prevents truly stale responses from being processed. The `since` filter just reduces relay-side work.
|
||||
|
||||
> **Tradeoff:** Responses older than 60s before subscription are filtered. Acceptable — `RemoteSignerManager` has 30s timeout, so responses older than 30s are useless anyway.
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Change | Phase |
|
||||
|------|--------|-------|
|
||||
| `desktopApp/.../account/AccountManager.kt` | Add dedicated `NostrClient` with `Mutex`, remove `client` params from login methods, cleanup on logout | 1 |
|
||||
| `desktopApp/.../Main.kt` | Remove `relayManager.client` from AccountManager calls, defer coordinator start until `connectedRelays` non-empty | 1, 2 |
|
||||
| `desktopApp/.../ui/LoginScreen.kt` | Remove `relayClient` param | 1 |
|
||||
| `desktopApp/.../ui/auth/LoginCard.kt` | Remove `relayClient` param, use AccountManager directly | 1 |
|
||||
| `desktopApp/.../network/RelayConnectionManager.kt` | Remove NIP-46 `nsec.app` debug logging from all callbacks | 1 |
|
||||
| `quartz/.../nip46RemoteSigner/signer/NostrSignerRemote.kt` | Add `since = TimeUtils.now() - 60` to subscription filter | 3 |
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] After nostrconnect login, home feed shows followed users' notes with avatars and display names
|
||||
- [ ] After nostrconnect login, DMs load (NIP-17 decryption works via remote signer)
|
||||
- [ ] On app restart with saved bunker account, session restores without ClosedMessage loop
|
||||
- [ ] `relay.nsec.app` does NOT appear in general relay pool (only in dedicated NIP-46 client)
|
||||
- [ ] Posting a note via remote signer succeeds — event signed and broadcast to general relays
|
||||
- [ ] Heartbeat ping works after session restore
|
||||
- [ ] Logout disconnects the dedicated NIP-46 client (no leaked WebSocket)
|
||||
- [ ] No `[NIP-46]` ClosedMessage spam in logs
|
||||
- [ ] No deadlock on startup — coordinator starts after relays connect
|
||||
|
||||
## Edge Cases
|
||||
|
||||
| Edge Case | Handling |
|
||||
|-----------|----------|
|
||||
| Dedicated NIP-46 client can't connect to relay.nsec.app | `loadSavedAccount()` returns failure, falls back to login screen |
|
||||
| Ephemeral key lost from secure storage | `loadBunkerAccount()` returns `Result.failure("Ephemeral key not found")`, logout + login screen |
|
||||
| In-flight sign request during brief NIP-46 disconnect | 30s timeout in `RemoteSignerManager`, user retries |
|
||||
| User logs out with pending sign request | `closeSubscription()` cancels scope, `disconnectNip46Client()` cleans up |
|
||||
| General relay pool emits before NIP-46 client ready | Independent — general pool handles feeds, NIP-46 client handles signing |
|
||||
| Concurrent `getOrCreateNip46Client()` calls | `Mutex` ensures single creation |
|
||||
| `connectedRelays` empty for extended period | Coordinator start is deferred, UI shows loading state until relays connect |
|
||||
| Clock skew between client and signer relay | 60s `since` buffer absorbs typical skew |
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
1. **Manual: Fresh nostrconnect login** — scan QR with Amber, verify home feed + profile + DMs load
|
||||
2. **Manual: Session restore** — close and reopen app, verify auto-login without ClosedMessage loop
|
||||
3. **Manual: Post a note** — compose + send, verify event reaches Amber and gets signed
|
||||
4. **Manual: Logout + re-login** — verify no leaked connections, clean state
|
||||
5. **Verify relay isolation** — check logs: `relay.nsec.app` should only appear in NIP-46 context, never in general relay status
|
||||
6. **Manual: Startup timing** — verify coordinator starts after relays connect (no deadlock)
|
||||
7. **Manual: Rapid login/logout** — verify `Mutex` prevents double NIP-46 client creation
|
||||
|
||||
## Resolved Questions
|
||||
|
||||
| # | Question | Resolution |
|
||||
|---|----------|------------|
|
||||
| 1 | `auth_url` handling? | **Deferred.** Spec defines it, Amber doesn't use it, quartz doesn't handle it. Only nsec.app needs it. See improvement ticket. |
|
||||
| 2 | `get_public_key` after connect? | **Deferred.** Already fully implemented in quartz (`NostrSignerRemote.getPublicKey()`). But `connect()` already returns pubkey — redundant for correctness. Nice-to-have security hardening. See improvement ticket. |
|
||||
| 3 | Update `since` on reconnect? | **Deferred.** `NostrClientStaticReq` re-sends exact same filter on reconnect. `FiltersChanged` intentionally ignores `since` changes. Static `since` with 60s buffer sufficient — request ID matching prevents stale response processing. See improvement ticket. |
|
||||
| 4 | `indexRelays` mutable? | **Resolved: `val` (immutable).** No `updateIndexRelays()` method. Must use nullable coordinator pattern — create inside `LaunchedEffect`. Phase 2 updated. |
|
||||
| 5 | `NostrClient.close()` for scope? | **Partially addressed.** `disconnect()` leaks two `stateIn(Eagerly)` flows + SupervisorJob scope. `disconnectNip46Client()` handles cleanup for our dedicated client. Upstream `close()` method deferred. See improvement ticket. |
|
||||
|
||||
**Deferred items tracked in:** `docs/plans/2026-03-05-nip46-improvements-plan.md`
|
||||
|
||||
## Sources
|
||||
|
||||
- **NIP-46 spec:** https://github.com/nostr-protocol/nips/blob/master/46.md
|
||||
- **NDK NIP-46 impl:** `core/src/signers/nip46/rpc.ts` — dedicated `NDKPool` for RPC
|
||||
- **Snort NIP-46 impl:** `packages/system/src/impl/nip46.ts` — dedicated `Connection` object
|
||||
- **nostrudel NIP-46 impl:** `applesauce-signers` — separate publish/subscribe methods
|
||||
- **Existing plan:** `docs/plans/2026-03-05-nip46-bunker-login-deepened.md` — covers initial implementation
|
||||
- **greenart7c3 commits:** `df1bf82e7..a6f3e09a6` — original quartz NIP-46 skeleton (never used by Android)
|
||||
@@ -0,0 +1,876 @@
|
||||
# NIP-46 Remote Signer (bunker:// + nostrconnect://) Login for Desktop — Deepened Plan
|
||||
|
||||
## Enhancement Summary
|
||||
|
||||
**Deepened on:** 2026-03-05 (2 rounds)
|
||||
**Sections enhanced:** 10 (Steps 0-8 + security)
|
||||
**Research agents used:** quartz NIP-46 audit, desktop account/main audit, NIP-46 spec research, coroutines heartbeat patterns, nostrconnect + QR + animation research, quartz nostrconnect gap audit, commons gap audit
|
||||
|
||||
### Key Improvements from Research
|
||||
1. **`switch_relays` support** — NIP-46 spec says "compliant clients should send `switch_relays` immediately upon establishing a connection". Plan now includes this.
|
||||
2. **Auth URL flow** — spec allows remote signer to return `auth_url` for user confirmation. Plan now handles this edge case.
|
||||
3. **Secret validation** — spec says "client MUST validate the secret returned by connect response". Plan updated with explicit validation.
|
||||
4. **No re-connect on restart** — confirmed: saved ephemeral key is already trusted by remote signer, skip `connect()` on restart.
|
||||
5. **PoW relay concern** — some relays (nostr.mom, nos.lol) require Proof of Work for kind 24133. Plan notes this.
|
||||
6. **`decryptZapEvent()` / `deriveKey()` NOT implemented** in quartz NostrSignerRemote — these will throw. Document as known limitation.
|
||||
7. **Step 0 blocking tasks** — 8 prerequisite tasks (5 quartz, 3 commons) identified. bunker:// needs zero quartz changes; nostrconnect needs 4 blockers resolved.
|
||||
8. **QrCodeDrawer portable** — existing Android composable uses zero Android APIs, 90% extract-as-is to commons.
|
||||
9. **SecureKeyStorage + IAccount** — both 100% compatible with NostrSignerRemote, verified by audit.
|
||||
10. **Phased implementation** — bunker:// first (no shared lib changes), then quartz+commons prereqs, then nostrconnect+heartbeat.
|
||||
|
||||
### New Considerations Discovered
|
||||
- `NostrSigner.pubKey` = ephemeral key in NostrSignerRemote. DesktopIAccount uses `accountState.pubKeyHex` (user's real key) — **already correct**, no collision.
|
||||
- `RemoteSignerManager.timeout` is configurable via constructor (default 30s) — use 30s for connect, could use shorter for ping.
|
||||
- Bunker URI `secret=` is **optional** for remote-signer-initiated flow (our case). Plan should NOT require it.
|
||||
- Some users may paste bunker URI over insecure channels — the URI is essentially a session token. Document this in security notes.
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Desktop app only supports nsec/npub login. Quartz has a **complete, verified** NIP-46 client (`NostrSignerRemote`) that's never been wired into any app. Adding bunker:// login gives desktop the best security story — private key never touches the machine.
|
||||
|
||||
### Quartz NIP-46 Completeness Audit (Verified)
|
||||
|
||||
| Feature | Status | Location |
|
||||
|---------|--------|----------|
|
||||
| `connect()` handshake | ✅ | `NostrSignerRemote.connect()` |
|
||||
| `sign_event` | ✅ | `NostrSignerRemote.sign()` |
|
||||
| `ping` | ✅ | `NostrSignerRemote.ping()` |
|
||||
| `get_public_key` | ✅ | `NostrSignerRemote.getPublicKey()` |
|
||||
| NIP-04 encrypt/decrypt | ✅ | `nip04Encrypt()` / `nip04Decrypt()` |
|
||||
| NIP-44 encrypt/decrypt | ✅ | `nip44Encrypt()` / `nip44Decrypt()` |
|
||||
| `switch_relays` | ✅ | `BunkerRequestGetRelays` |
|
||||
| Bunker URI parsing | ✅ | `fromBunkerUri()` — handles relay + secret params |
|
||||
| Timeout handling | ✅ | 30s default, configurable in `RemoteSignerManager` |
|
||||
| Subscription mgmt | ✅ | `openSubscription()` / `closeSubscription()` |
|
||||
| `decryptZapEvent()` | ❌ TODO | Will throw — known limitation |
|
||||
| `deriveKey()` | ❌ TODO | Will throw — known limitation |
|
||||
|
||||
### Desktop Account System Audit
|
||||
|
||||
| Component | Current State | Action |
|
||||
|-----------|--------------|--------|
|
||||
| `AccountState` sealed class | `LoggedOut` / `LoggedIn` | Add `ConnectingRelays` variant |
|
||||
| `AccountManager.loginWithKey()` | Handles nsec/npub/hex | Add `loginWithBunker()` |
|
||||
| `SecureKeyStorage` | OS keyring + encrypted fallback | Reuse for ephemeral key |
|
||||
| `~/.amethyst/last_account.txt` | Stores npub | Reuse as-is |
|
||||
| `Main.kt` DisposableEffect | Loads account + starts relays | Add relay-wait + heartbeat |
|
||||
| `LoginCard` | Text input + Login/Generate buttons | Add bunker detection + connecting state |
|
||||
| `RelayConnectionManager.client` | Exposes `INostrClient` | Pass to `loginWithBunker()` |
|
||||
|
||||
## Protocol Flow
|
||||
|
||||
```
|
||||
User pastes bunker URI → validate format → generate ephemeral keypair
|
||||
→ create NostrSignerRemote via fromBunkerUri() → call connect() over relays
|
||||
→ remote signer (nsec.app/Amber) approves → validate secret if present
|
||||
→ receive user pubkey → call switch_relays → update relay set if changed
|
||||
→ AccountState.LoggedIn with NostrSignerRemote as signer
|
||||
→ all sign/encrypt ops proxy transparently through remote signer
|
||||
→ periodic ping heartbeat monitors connection health
|
||||
```
|
||||
|
||||
### Research Insight: NIP-46 Spec Compliance
|
||||
- **Secret validation**: If bunker URI contains `secret=`, client MUST validate the secret returned by `connect` response matches. If no secret in URI, skip validation.
|
||||
- **`switch_relays`**: "Compliant clients should send a `switch_relays` request immediately upon establishing a connection." Remote signer replies with updated relay list or null.
|
||||
- **Auth URL**: Remote signer may respond with `{"result": "auth_url", "error": "<URL>"}` — client should display URL to user for confirmation (e.g., nsec.app web confirmation).
|
||||
|
||||
## Step 0: Prerequisite Tasks (Blocking)
|
||||
|
||||
These must be completed before or alongside the desktop feature work. They strengthen quartz and commons as shared libraries.
|
||||
|
||||
### 0A. [QUARTZ/BLOCKER] Add `fromNostrConnectUri()` factory to NostrSignerRemote
|
||||
|
||||
**File:** `quartz/src/commonMain/.../nip46RemoteSigner/signer/NostrSignerRemote.kt`
|
||||
|
||||
**Problem:** `fromBunkerUri()` exists but no equivalent for nostrconnect://. Client-initiated flow needs the client to generate the URI, not parse one from a remote signer.
|
||||
|
||||
**Action:** Add companion factory that builds a `NostrSignerRemote` ready to *wait* for a connect response:
|
||||
```kotlin
|
||||
companion object {
|
||||
fun forNostrConnect(
|
||||
ephemeralSigner: NostrSignerInternal,
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
client: INostrClient,
|
||||
secret: String,
|
||||
): Pair<NostrSignerRemote, String /* nostrconnect URI */> {
|
||||
// remotePubkey unknown until signer responds — see 0B
|
||||
// Build URI: nostrconnect://<ephemeral-pubkey>?relay=...&secret=...&name=Amethyst+Desktop
|
||||
// Return (signer, uriString)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Blocks:** Step 7 (nostrconnect login)
|
||||
|
||||
---
|
||||
|
||||
### 0B. [QUARTZ/BLOCKER] Make `remotePubkey` mutable or support deferred assignment
|
||||
|
||||
**File:** `quartz/src/commonMain/.../nip46RemoteSigner/signer/NostrSignerRemote.kt`
|
||||
|
||||
**Problem:** `val remotePubkey: HexKey` is immutable in constructor. For nostrconnect://, the remote signer's pubkey is unknown until the connect response arrives. Cannot instantiate `NostrSignerRemote` without it.
|
||||
|
||||
**Options:**
|
||||
1. **`var remotePubkey`** — simplest, set after connect response. Risk: state mutation on a shared object.
|
||||
2. **Two-phase construction** — `NostrConnectSession` awaits connect, then creates `NostrSignerRemote` with known pubkey. Cleaner but more code.
|
||||
3. **DesktopApp workaround** — state machine in desktopApp: subscribe to kind 24133 manually, wait for connect response, extract pubkey, *then* construct `NostrSignerRemote`. Avoids quartz changes but duplicates logic.
|
||||
|
||||
**Recommended:** Option 2 — keep `NostrSignerRemote` immutable, add a `NostrConnectSession` helper that handles the wait-then-construct flow.
|
||||
|
||||
**Blocks:** Step 7 (nostrconnect login), 0A, 0C
|
||||
|
||||
---
|
||||
|
||||
### 0C. [QUARTZ/BLOCKER] NostrConnectEvent.create() requires remotePubkey
|
||||
|
||||
**File:** `quartz/src/commonMain/.../nip46RemoteSigner/NostrConnectEvent.kt`
|
||||
|
||||
**Problem:** `NostrConnectEvent.create()` calls `nip44Encrypt(content, remoteKey)`. For nostrconnect, the initial subscription doesn't need to *send* an event — client just listens. But `openSubscription()` in `NostrSignerRemote` sends a subscription filter, which doesn't require remotePubkey. The encryption issue only matters if client needs to send requests before learning remotePubkey.
|
||||
|
||||
**Resolution:** This is resolved by 0B — if we use two-phase construction, `NostrSignerRemote` is only created after remotePubkey is known, so `create()` always has it. No changes to `NostrConnectEvent.kt` needed.
|
||||
|
||||
**Blocks:** Step 7 (nostrconnect login). Resolved by 0B.
|
||||
|
||||
---
|
||||
|
||||
### 0D. [QUARTZ/SECURITY] Add secret validation to PubKeyResponse
|
||||
|
||||
**File:** `quartz/src/commonMain/.../nip46RemoteSigner/signer/PubKeyResponse.kt`
|
||||
|
||||
**Problem:** NIP-46 spec says "client MUST validate the secret returned by connect response matches the one sent". Current `PubKeyResponse` parses the pubkey from the result field but does NOT validate the secret.
|
||||
|
||||
**Action:** Add `secret` field to `PubKeyResponse` and validate in `connect()`:
|
||||
```kotlin
|
||||
data class PubKeyResponse(val pubkey: HexKey, val secret: String?) {
|
||||
companion object {
|
||||
fun parse(result: String): PubKeyResponse {
|
||||
// result may be just pubkey, or pubkey + secret separated by space
|
||||
// validate hex format
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then in `NostrSignerRemote.connect()`, compare returned secret with expected:
|
||||
```kotlin
|
||||
if (expectedSecret != null && response.secret != expectedSecret) {
|
||||
throw SignerExceptions.ManuallyUnauthorizedException("Secret mismatch — possible MITM")
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** Security hardening. Not strictly blocking for bunker:// (secret is optional), but **blocking for nostrconnect://** (secret is required).
|
||||
|
||||
**Blocks:** Step 2 (bunker login — optional validation), Step 7 (nostrconnect — required validation)
|
||||
|
||||
---
|
||||
|
||||
### 0E. [QUARTZ/NICE-TO-HAVE] Add `getRelays()` method to NostrSignerRemote
|
||||
|
||||
**File:** `quartz/src/commonMain/.../nip46RemoteSigner/signer/NostrSignerRemote.kt`
|
||||
|
||||
**Problem:** `BunkerRequestGetRelays` exists but `NostrSignerRemote` has no `getRelays()` or `switchRelays()` high-level method. The plan calls for `switch_relays` compliance per NIP-46 spec.
|
||||
|
||||
**Action:** Add method:
|
||||
```kotlin
|
||||
suspend fun switchRelays(): List<String>? {
|
||||
// Send BunkerRequestGetRelays, parse relay list response
|
||||
// Return updated relay list or null if signer doesn't support it
|
||||
}
|
||||
```
|
||||
|
||||
**Priority:** Nice-to-have. Can defer to a follow-up PR if needed. The `switch_relays` spec language is "compliant clients *should*" (not MUST).
|
||||
|
||||
**Does NOT block:** Any step. Can be added later.
|
||||
|
||||
---
|
||||
|
||||
### 0F. [COMMONS/BLOCKER] Extract QrCodeDrawer to commons
|
||||
|
||||
**Files:**
|
||||
- **Source:** `amethyst/src/main/java/.../ui/screen/loggedIn/qrcode/QrCodeDrawer.kt`
|
||||
- **Target:** `commons/src/commonMain/.../commons/ui/qrcode/QrCodeDrawer.kt`
|
||||
|
||||
**Problem:** QR code display needed for nostrconnect:// tab. Existing `QrCodeDrawer` in amethyst is pure Compose Canvas + ZXing core — **zero Android dependencies**. 90% extract-as-is.
|
||||
|
||||
**Gap:** References `QuoteBorder` from amethyst theme (just `RoundedCornerShape(15.dp)`). Replace with inline shape.
|
||||
|
||||
**Action:**
|
||||
1. Copy `QrCodeDrawer.kt` to `commons/src/commonMain/.../commons/ui/qrcode/`
|
||||
2. Replace `QuoteBorder` reference with `RoundedCornerShape(15.dp)`
|
||||
3. Add typealias in amethyst to maintain backward compat
|
||||
4. Add `libs.zxing` dependency to `commons/build.gradle.kts` (see 0G)
|
||||
|
||||
**Blocks:** Step 7 (nostrconnect QR display)
|
||||
|
||||
---
|
||||
|
||||
### 0G. [COMMONS/BLOCKER] Add ZXing core dependency to commons module
|
||||
|
||||
**File:** `commons/build.gradle.kts`
|
||||
|
||||
**Problem:** ZXing core 3.5.4 is in `gradle/libs.versions.toml` but NOT imported in `commons/build.gradle.kts`. QrCodeDrawer needs it.
|
||||
|
||||
**Action:** Add to `commonMain` dependencies (ZXing core is pure Java, works on JVM + Android):
|
||||
```kotlin
|
||||
commonMain.dependencies {
|
||||
implementation(libs.zxing) // already in version catalog
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** `zxing-embedded` (Android camera scanner wrapper) stays in amethyst only. Only `zxing:core` (encoder/decoder) goes to commons.
|
||||
|
||||
**Blocks:** 0F (QrCodeDrawer extraction), Step 7
|
||||
|
||||
---
|
||||
|
||||
### 0H. [COMMONS/NEW] Create HeartbeatIndicator composable
|
||||
|
||||
**File:** `commons/src/commonMain/.../commons/ui/components/HeartbeatIndicator.kt` (new)
|
||||
|
||||
**Problem:** No animated pulsing dot composable exists in commons. Needed for sidebar signer connection health indicator.
|
||||
|
||||
**Action:** Create shared composable:
|
||||
- Input: `SignerConnectionState` sealed class (Connected/Unstable/Disconnected/NotRemote)
|
||||
- Output: Pulsing dot (green/yellow/red) with `rememberInfiniteTransition` double-beat animation
|
||||
- Desktop: wrapped in `TooltipArea` in DeckSidebar (desktop-only API)
|
||||
- The composable itself is multiplatform; tooltip wrapping is platform-specific
|
||||
|
||||
**Dependencies:** None — pure Compose animation APIs.
|
||||
|
||||
**Blocks:** Step 8 (heartbeat UI)
|
||||
|
||||
---
|
||||
|
||||
### Step 0 Summary
|
||||
|
||||
| ID | Module | Type | Priority | Blocks |
|
||||
|----|--------|------|----------|--------|
|
||||
| 0A | quartz | `fromNostrConnectUri()` factory | 🔴 Blocker | Step 7 |
|
||||
| 0B | quartz | Mutable/deferred `remotePubkey` | 🔴 Blocker | Step 7, 0A, 0C |
|
||||
| 0C | quartz | NostrConnectEvent encryption | 🟢 Resolved by 0B | Step 7 |
|
||||
| 0D | quartz | Secret validation in PubKeyResponse | 🟡 Security | Step 7 (required), Step 2 (optional) |
|
||||
| 0E | quartz | `switchRelays()` method | ⚪ Nice-to-have | None |
|
||||
| 0F | commons | Extract QrCodeDrawer | 🔴 Blocker | Step 7 |
|
||||
| 0G | commons | ZXing dep in commons gradle | 🔴 Blocker | 0F, Step 7 |
|
||||
| 0H | commons | HeartbeatIndicator composable | 🟡 Medium | Step 8 |
|
||||
|
||||
**Critical path for bunker:// (Steps 1-6):** 0D (optional hardening)
|
||||
**Critical path for nostrconnect:// (Step 7):** 0B → 0A → 0D, 0G → 0F
|
||||
**Critical path for heartbeat UI (Step 8):** 0H
|
||||
|
||||
### Implementation Order
|
||||
|
||||
```
|
||||
Phase 1 (bunker:// — no quartz changes needed):
|
||||
Steps 1 → 2 → 3 → 4 → 5 → 6
|
||||
|
||||
Phase 2 (quartz + commons prereqs):
|
||||
0G → 0F (commons: ZXing + QrCodeDrawer)
|
||||
0B → 0A (quartz: deferred remotePubkey + nostrconnect factory)
|
||||
0D (quartz: secret validation)
|
||||
0H (commons: HeartbeatIndicator)
|
||||
|
||||
Phase 3 (nostrconnect + heartbeat):
|
||||
Step 7 (depends on 0A, 0B, 0D, 0F, 0G)
|
||||
Step 8 (depends on 0H)
|
||||
```
|
||||
|
||||
**Key insight:** bunker:// login (Steps 1-6) requires ZERO quartz/commons changes — all quartz NIP-46 code works as-is. Ship bunker:// first, then layer nostrconnect + heartbeat.
|
||||
|
||||
---
|
||||
|
||||
## Implementation
|
||||
|
||||
### Step 1: AccountState + SignerType
|
||||
|
||||
**File:** `desktopApp/src/jvmMain/.../desktop/account/AccountManager.kt`
|
||||
|
||||
Add `SignerType` sealed class:
|
||||
```kotlin
|
||||
sealed class SignerType {
|
||||
data object Internal : SignerType()
|
||||
data class Remote(val bunkerUri: String) : SignerType()
|
||||
}
|
||||
```
|
||||
|
||||
Extend `AccountState`:
|
||||
```kotlin
|
||||
sealed class AccountState {
|
||||
data object LoggedOut : AccountState()
|
||||
data object ConnectingRelays : AccountState()
|
||||
data class LoggedIn(
|
||||
val signer: NostrSigner,
|
||||
val pubKeyHex: String,
|
||||
val npub: String,
|
||||
val nsec: String?,
|
||||
val isReadOnly: Boolean,
|
||||
val signerType: SignerType = SignerType.Internal,
|
||||
) : AccountState()
|
||||
}
|
||||
```
|
||||
|
||||
#### Research Insights
|
||||
|
||||
**Existing pattern confirmed:** AccountState is observed via `accountManager.accountState.collectAsState()` in `App()` composable. Adding `ConnectingRelays` variant just needs a new `when` branch — zero refactoring needed.
|
||||
|
||||
**DesktopIAccount compatibility:** Uses `accountState.pubKeyHex` (not `signer.pubKey`), so `NostrSignerRemote` (where `signer.pubKey` = ephemeral key) works transparently. Verified in audit.
|
||||
|
||||
### Step 2: AccountManager — bunker login + persistence + heartbeat
|
||||
|
||||
**File:** `desktopApp/src/jvmMain/.../desktop/account/AccountManager.kt`
|
||||
|
||||
#### `loginWithBunker(bunkerUri: String, client: INostrClient): Result<LoggedIn>`
|
||||
|
||||
1. Validate URI format: starts with `bunker://`, hex pubkey (64 chars), `?relay=wss://` param
|
||||
2. Generate ephemeral `KeyPair()` + wrap in `NostrSignerInternal`
|
||||
3. `NostrSignerRemote.fromBunkerUri(bunkerUri, ephemeralSigner, client)`
|
||||
- Factory handles relay extraction + secret parsing
|
||||
4. Call `remoteSigner.openSubscription()` — starts listening for responses
|
||||
5. Call `remoteSigner.connect()` — sends BunkerRequestConnect, waits 30s
|
||||
6. **Validate secret** if present: connect response must return matching secret value
|
||||
7. On success: call `remoteSigner.switchRelays()` (new — NIP-46 compliance)
|
||||
8. Set `AccountState.LoggedIn(signer=remoteSigner, pubKeyHex=remotePubkey, signerType=Remote(bunkerUri))`
|
||||
|
||||
#### Research Insights: Connect Flow Details
|
||||
|
||||
**From quartz audit — `connect()` internals:**
|
||||
```
|
||||
RemoteSignerManager.launchWaitAndParse():
|
||||
1. Build BunkerRequestConnect(remoteKey, secret?, permissions?)
|
||||
2. Create NostrConnectEvent (NIP-44 encrypted to remote signer)
|
||||
3. client.send(event, relayList)
|
||||
4. Store continuation[requestID] in LargeCache
|
||||
5. tryAndWait(30s) — suspendCancellableCoroutine
|
||||
6. On response: newResponse() → resume continuation
|
||||
7. Parse via PubKeyResponse → SignerResult
|
||||
```
|
||||
|
||||
**Exception mapping in NostrSignerRemote.convertExceptions():**
|
||||
- `Successful` → return result
|
||||
- `Rejected` → throw `ManuallyUnauthorizedException`
|
||||
- `TimedOut` → throw `TimedOutException`
|
||||
- `ReceivedButCouldNotPerform` → throw `CouldNotPerformException`
|
||||
|
||||
**Auth URL edge case:** If remote signer needs web confirmation (nsec.app pattern), it returns `auth_url` response. Quartz doesn't handle this natively — **document as known limitation for v1**. Most bunker signers (Amber, local nsecBunker) don't use auth_url.
|
||||
|
||||
#### `saveBunkerAccount(ephemeralPrivKeyHex: String)`
|
||||
|
||||
Persistence files:
|
||||
- `~/.amethyst/bunker_uri.txt` — full bunker URI (contains relay URLs + remote pubkey)
|
||||
- `SecureKeyStorage("bunker_ephemeral")` — ephemeral private key (OS keyring)
|
||||
- `~/.amethyst/last_account.txt` — user's npub (existing pattern)
|
||||
|
||||
#### Research Insight: Session Persistence
|
||||
|
||||
**Confirmed from NIP-46 community patterns:**
|
||||
> "Once a connection has been successfully established and the BunkerPointer is stored, you do not need to call `connect()` on subsequent sessions."
|
||||
|
||||
Save: ephemeral privkey + bunker URI + user npub. On restart, recreate `NostrSignerRemote` with saved ephemeral key — remote signer already trusts this keypair.
|
||||
|
||||
**What NOT to save:** Don't store the secret separately — it's only needed for initial connect handshake. The ephemeral keypair IS the session credential.
|
||||
|
||||
#### `loadSavedAccount(client: INostrClient): Result<LoggedIn>`
|
||||
|
||||
Updated flow:
|
||||
1. Check `bunker_uri.txt` — if exists, this is a bunker account
|
||||
2. Load ephemeral privkey from `SecureKeyStorage("bunker_ephemeral")`
|
||||
3. Load npub from `last_account.txt` → derive pubKeyHex
|
||||
4. Create `NostrSignerInternal(KeyPair(ephemeralPrivKey))`
|
||||
5. `NostrSignerRemote.fromBunkerUri(savedUri, ephemeralSigner, client)`
|
||||
6. `remoteSigner.openSubscription()` — start listening (no connect() needed)
|
||||
7. Return `LoggedIn(signer=remoteSigner, pubKeyHex=savedPubKeyHex, signerType=Remote)`
|
||||
8. Fall back to existing internal key flow if no bunker file
|
||||
|
||||
#### `logout(deleteKey: Boolean)`
|
||||
|
||||
Bunker-specific cleanup:
|
||||
1. `remoteSigner.closeSubscription()` — stop relay filter
|
||||
2. Stop heartbeat job
|
||||
3. Delete `bunker_uri.txt`
|
||||
4. Delete `SecureKeyStorage("bunker_ephemeral")`
|
||||
5. Clear `last_account.txt`
|
||||
6. Set `AccountState.LoggedOut`
|
||||
|
||||
#### Ping Heartbeat
|
||||
|
||||
```kotlin
|
||||
private var heartbeatJob: Job? = null
|
||||
private var consecutiveFailures = 0
|
||||
|
||||
fun startHeartbeat(scope: CoroutineScope) {
|
||||
heartbeatJob = scope.launch {
|
||||
while (isActive) {
|
||||
delay(60_000)
|
||||
val current = currentAccount() ?: continue
|
||||
val remoteSigner = current.signer as? NostrSignerRemote ?: continue
|
||||
try {
|
||||
remoteSigner.ping()
|
||||
consecutiveFailures = 0 // reset on success
|
||||
} catch (e: SignerExceptions.TimedOutException) {
|
||||
consecutiveFailures++
|
||||
if (consecutiveFailures >= 3) {
|
||||
forceLogoutWithReason("Lost connection to remote signer after 3 failed pings.")
|
||||
}
|
||||
} catch (e: SignerExceptions.ManuallyUnauthorizedException) {
|
||||
forceLogoutWithReason("Remote signer revoked access.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Research Insights: Heartbeat Patterns
|
||||
|
||||
**Structured concurrency:** Heartbeat should be a child of the app's `CoroutineScope(SupervisorJob() + Dispatchers.Main)` already created in `App()`. SupervisorJob ensures heartbeat failure doesn't crash the app.
|
||||
|
||||
**Existing pattern in codebase:** `RelayStat.pingInMs` tracks relay ping latency — similar concept. RelayConnectionManager handles relay disconnection with reconnect logic, but no heartbeat loop exists yet.
|
||||
|
||||
**Simple counter vs AtomicInteger:** Since heartbeat runs in a single coroutine (sequential `while` loop), a plain `var consecutiveFailures: Int` is safe — no concurrent access.
|
||||
|
||||
**Window minimization:** Desktop heartbeat should continue even when minimized — the remote signer session is independent of window focus. The `CoroutineScope` created with `remember` persists across recompositions.
|
||||
|
||||
**Force logout state:** Use `MutableStateFlow<String?>` for `forceLogoutReason` — it's a stateful value that the UI observes, not a one-shot event. Dialog displays the reason, user dismisses, flow cleared.
|
||||
|
||||
### Step 3: LoginCard — validation + connecting state
|
||||
|
||||
**File:** `desktopApp/src/jvmMain/.../desktop/ui/auth/LoginCard.kt`
|
||||
|
||||
Add `LoginState` enum: `IDLE`, `CONNECTING`, `ERROR`
|
||||
|
||||
Add callback: `onLoginBunker: suspend (String) -> Result<Unit>`
|
||||
|
||||
**Bunker URI validation** (before attempting connect):
|
||||
- Starts with `bunker://`
|
||||
- Contains hex pubkey (64 hex chars after `bunker://`, before `?`)
|
||||
- Contains at least one `relay=wss://...` param
|
||||
- Show inline validation error if format wrong, don't attempt connection
|
||||
- `secret=` is optional — do NOT require it
|
||||
|
||||
**UX when `bunker://` detected:**
|
||||
- Button text: "Login" → "Connect to Signer"
|
||||
- On click with valid URI: set `CONNECTING`, show `CircularProgressIndicator` + "Connecting to remote signer..."
|
||||
- Disable input + all buttons while `CONNECTING`
|
||||
- On success: caller handles transition
|
||||
- On failure: show error message, return to `IDLE`, user can retry
|
||||
|
||||
#### Research Insights: UX Patterns
|
||||
|
||||
**From NIP-46 community feedback:**
|
||||
> "Connection establishment takes a while to show to users" — always show clear progress indication.
|
||||
|
||||
**Pitfall discovered:** Users may not understand that bunker URI is a session credential:
|
||||
> "Users might think the entire bunker URI is like a 2FA token and transmit it over untrusted messengers, and attackers can reuse the same URI"
|
||||
|
||||
Consider: add subtle help text below input when bunker:// detected: "This URI connects to your remote signer. Treat it like a password."
|
||||
|
||||
**Auto-detection approach:** Current `LoginCard` has a single `KeyInputField`. Detect `bunker://` prefix on input change — no new tabs/screens needed. Same pattern as NWC connection string input in settings.
|
||||
|
||||
### Step 4: LoginScreen + ConnectingRelays screen
|
||||
|
||||
**File:** `desktopApp/src/jvmMain/.../desktop/ui/LoginScreen.kt`
|
||||
|
||||
Add parameter: `relayClient: INostrClient`
|
||||
|
||||
Wire `onLoginBunker` callback → `accountManager.loginWithBunker(bunkerUri, relayClient)` → on success: save + `onLoginSuccess()`
|
||||
|
||||
**ConnectingRelaysScreen composable** (in same file):
|
||||
|
||||
Shown when `AccountState.ConnectingRelays`:
|
||||
```
|
||||
[Amethyst title text]
|
||||
"Connecting to relays..."
|
||||
[CircularProgressIndicator]
|
||||
"Restoring remote signer session"
|
||||
```
|
||||
|
||||
Used on app restart when a bunker account is saved — shows while relays connect before recreating `NostrSignerRemote`.
|
||||
|
||||
#### Research Insight: Startup Sequence
|
||||
|
||||
**From Main.kt audit — current startup:**
|
||||
```kotlin
|
||||
DisposableEffect(Unit) {
|
||||
scope.launch(Dispatchers.IO) { accountManager.loadSavedAccount() }
|
||||
relayManager.addDefaultRelays()
|
||||
relayManager.connect()
|
||||
subscriptionsCoordinator.start()
|
||||
onDispose { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**Problem:** `loadSavedAccount()` for bunker accounts needs `relayManager.client` — but relay connections happen asynchronously. Current code loads account independently of relay state.
|
||||
|
||||
**Solution:** For bunker accounts, wait for relay connection before recreating signer. The pattern `relayManager.connectedRelays.first { it.isNotEmpty() }` already exists in `MainContent` LaunchedEffect for DM subscriptions.
|
||||
|
||||
### Step 5: Main.kt — relay state, pass client, heartbeat
|
||||
|
||||
**File:** `desktopApp/src/jvmMain/.../desktop/Main.kt`
|
||||
|
||||
**Updated startup flow:**
|
||||
```kotlin
|
||||
DisposableEffect(Unit) {
|
||||
relayManager.addDefaultRelays()
|
||||
relayManager.connect()
|
||||
subscriptionsCoordinator.start()
|
||||
|
||||
scope.launch(Dispatchers.IO) {
|
||||
if (accountManager.hasBunkerAccount()) {
|
||||
accountManager.setConnectingRelays()
|
||||
// Wait for relay connections — pattern from MainContent DM setup
|
||||
relayManager.connectedRelays.first { it.isNotEmpty() }
|
||||
accountManager.loadSavedAccount(relayManager.client)
|
||||
accountManager.startHeartbeat(scope)
|
||||
} else {
|
||||
accountManager.loadSavedAccount(relayManager.client)
|
||||
}
|
||||
}
|
||||
|
||||
onDispose {
|
||||
accountManager.stopHeartbeat()
|
||||
subscriptionsCoordinator.clear()
|
||||
relayManager.disconnect()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Account state routing — add ConnectingRelays + ForceLogout:**
|
||||
```kotlin
|
||||
when (accountState) {
|
||||
is AccountState.LoggedOut -> LoginScreen(accountManager, relayManager.client, onLoginSuccess)
|
||||
is AccountState.ConnectingRelays -> ConnectingRelaysScreen()
|
||||
is AccountState.LoggedIn -> MainContent(...)
|
||||
}
|
||||
|
||||
// Force logout overlay
|
||||
val forceLogoutReason by accountManager.forceLogoutReason.collectAsState()
|
||||
forceLogoutReason?.let { reason ->
|
||||
ForceLogoutDialog(reason = reason, onDismiss = { accountManager.clearForceLogoutReason() })
|
||||
}
|
||||
```
|
||||
|
||||
#### Research Insight: Scope Lifecycle
|
||||
|
||||
**Current app scope:** `remember { CoroutineScope(SupervisorJob() + Dispatchers.Main) }` — created in `App()` composable, lives for the duration of the window. Heartbeat as a child of this scope is correct — it auto-cancels when app closes.
|
||||
|
||||
**onDispose cleanup:** Add `accountManager.stopHeartbeat()` to the existing `onDispose` block alongside `subscriptionsCoordinator.clear()` and `relayManager.disconnect()`.
|
||||
|
||||
### Step 6: ForceLogoutDialog
|
||||
|
||||
**File:** `desktopApp/src/jvmMain/.../desktop/ui/auth/ForceLogoutDialog.kt` (new)
|
||||
|
||||
Simple `AlertDialog`:
|
||||
- Title: "Session Terminated"
|
||||
- Body: reason string
|
||||
- Single "OK" button → dismiss → login screen already showing (state is LoggedOut)
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Exception | Source | User Message |
|
||||
|-----------|--------|-------------|
|
||||
| URI validation fail | LoginCard | "Invalid bunker URI. Expected: bunker://\<hex-pubkey\>?relay=wss://..." |
|
||||
| `TimedOutException` | `connect()` | "Connection timed out. Ensure remote signer is online and has approved the connection." |
|
||||
| `ManuallyUnauthorizedException` | `connect()` | "Connection rejected by remote signer." |
|
||||
| `CouldNotPerformException` | `connect()` | "Remote signer error: {message}" |
|
||||
| Generic exception | `connect()` | "Connection failed: {message}" |
|
||||
| Heartbeat 3x timeout | `ping()` | Force logout: "Lost connection to remote signer after 3 failed pings." |
|
||||
| Heartbeat revocation | `ping()` | Force logout: "Remote signer revoked access." |
|
||||
| `decryptZapEvent` | Runtime | Known limitation — zap decryption not supported with remote signer |
|
||||
|
||||
### Research Insight: Relay-Specific Issues
|
||||
|
||||
**PoW requirement:** Some relays (nostr.mom, nos.lol, damus) rate-limit or require Proof of Work for kind 24133 events. If users specify these relays in their bunker URI, connections may fail. **Mitigation:** Document in error message. No code change needed — the relay list comes from the bunker URI (remote signer's choice).
|
||||
|
||||
## Files Modified
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `AccountManager.kt` | SignerType, ConnectingRelays state, loginWithBunker(), saveBunkerAccount(), loadSavedAccount() updated, logout() updated, heartbeat, forceLogout |
|
||||
| `LoginCard.kt` | LoginState enum, bunker URI validation, onLoginBunker callback, connecting UI, help text |
|
||||
| `LoginScreen.kt` | Add relayClient param, wire bunker login, ConnectingRelaysScreen composable |
|
||||
| `Main.kt` | Startup relay-wait flow, pass relayManager.client, heartbeat lifecycle, forceLogout dialog |
|
||||
| `ForceLogoutDialog.kt` | **New** — AlertDialog for session termination |
|
||||
|
||||
## Reused As-Is (no changes needed — verified)
|
||||
|
||||
| File | What | Verified |
|
||||
|------|------|----------|
|
||||
| `NostrSignerRemote.kt` | Full NIP-46 client: connect, sign, encrypt, decrypt, ping | ✅ All methods present |
|
||||
| `RemoteSignerManager.kt` | Request/response lifecycle with timeout | ✅ 30s configurable |
|
||||
| `NostrConnectEvent.kt` | Kind 24133 wrapper with NIP-44 encryption | ✅ |
|
||||
| `SignerExceptions.kt` | TimedOut, ManuallyUnauthorized, CouldNotPerform | ✅ |
|
||||
| `DesktopIAccount.kt` | Uses `accountState.pubKeyHex` not `signer.pubKey` | ✅ Safe with remote signer |
|
||||
| `RelayConnectionManager.kt` | Exposes `client: INostrClient` | ✅ |
|
||||
| `SecureKeyStorage.kt` | OS keyring + encrypted fallback | ✅ Reuse for ephemeral key |
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Ephemeral Key = Session Credential
|
||||
- Stored in OS keyring (macOS Keychain / Windows Credential Manager) via existing `SecureKeyStorage`
|
||||
- If compromised: attacker can impersonate user's signing requests until remote signer revokes
|
||||
- **Mitigation:** Remote signer can revoke anytime; heartbeat detects revocation
|
||||
|
||||
### Bunker URI as Session Token
|
||||
- URI contains remote pubkey + relay URLs — NOT the user's private key
|
||||
- But it enables initial connection establishment
|
||||
- Once connected, the ephemeral keypair is the actual session credential
|
||||
- **Secret parameter** (when present): only used once during initial connect. Not stored separately.
|
||||
|
||||
### Relay MITM
|
||||
- NIP-46 messages are NIP-44 encrypted end-to-end between ephemeral key and remote signer
|
||||
- Relay can see metadata (who's talking to whom) but NOT message content
|
||||
- **No MITM on message content** — encryption is between known pubkeys
|
||||
|
||||
### Force Logout on Revocation
|
||||
- `ManuallyUnauthorizedException` from ping → immediate logout + file cleanup
|
||||
- 3 consecutive ping timeouts → logout (remote signer may be permanently offline)
|
||||
- All bunker files deleted on force logout — clean slate
|
||||
|
||||
## Known Limitations (v1)
|
||||
|
||||
1. **Auth URL flow** — nsec.app may return `auth_url` response requiring web confirmation. Not handled in v1. Most signers (Amber, local bunkers) don't use this.
|
||||
2. **`decryptZapEvent()`** — Not implemented in quartz's `NostrSignerRemote`. Zap decryption will fail for bunker accounts.
|
||||
3. **`deriveKey()`** — Not implemented. NIP-06 key derivation won't work for bunker accounts.
|
||||
4. **Single account** — Only one bunker OR one internal account at a time. Multi-account is a separate PR.
|
||||
|
||||
## Verification
|
||||
|
||||
1. `./gradlew :desktopApp:compileKotlin` — builds
|
||||
2. `./gradlew :desktopApp:run` — test flows:
|
||||
- **Invalid URI** → paste `bunker://bad` → inline validation error, no network call
|
||||
- **Valid format, missing relay** → paste `bunker://<64hex>` → validation error about missing relay
|
||||
- **Login** → paste valid bunker URI → "Connecting..." → approve on phone → main screen
|
||||
- **Timeout** → paste URI, don't approve → 30s → timeout error, can retry
|
||||
- **Rejection** → paste URI, reject on phone → "rejected" error
|
||||
- **Restart** → close app → relaunch → "Connecting to relays..." → auto-login (no re-approval needed)
|
||||
- **Heartbeat** → revoke access on remote signer → force logout dialog within 60s
|
||||
- **Logout** → logout → bunker files deleted → login screen
|
||||
- **Post note** → compose + send → signed via remote signer transparently
|
||||
- **Send DM** → NIP-17 gift wrap → encrypted via remote signer
|
||||
3. `./gradlew spotlessApply` before commit
|
||||
|
||||
## Answered Questions
|
||||
|
||||
1. **Both `bunker://` AND `nostrconnect://`** — see Step 7 below
|
||||
2. **60s hardcoded** heartbeat interval
|
||||
3. **Sidebar heartbeat icon** — tiny pulsing dot with tooltip, see Step 8 below
|
||||
4. **Yes, persist relay updates** from `switch_relays` to `bunker_uri.txt`
|
||||
5. **No auto-retry** — show error immediately, user clicks to retry
|
||||
|
||||
---
|
||||
|
||||
## Step 7: nostrconnect:// (Client-Initiated) Login
|
||||
|
||||
### Protocol Difference
|
||||
|
||||
| Aspect | `bunker://` (remote-signer-initiated) | `nostrconnect://` (client-initiated) |
|
||||
|--------|---------------------------------------|--------------------------------------|
|
||||
| Who generates URI | Remote signer (nsec.app, Amber) | Desktop client |
|
||||
| Who scans/pastes | Desktop user pastes into app | User pastes into their signer app |
|
||||
| Secret | Optional | **Required** (client generates) |
|
||||
| Permissions | Not specified in URI | Client requests via `perms=` param |
|
||||
| Flow | User already has URI → paste → connect | Client shows URI → user takes to signer → signer connects back |
|
||||
|
||||
### nostrconnect:// URI Format (from NIP-46 spec)
|
||||
|
||||
```
|
||||
nostrconnect://<client-pubkey>?relay=wss://relay1.example.com&relay=wss://relay2.example.com&secret=<random-string>&perms=nip44_encrypt,nip44_decrypt,sign_event&name=Amethyst+Desktop&url=https://github.com/vitorpamplona/amethyst&image=<app-icon-url>
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `<client-pubkey>` — ephemeral pubkey generated by desktop (64 hex chars)
|
||||
- `relay=` — relay URLs for communication (use app's connected relays)
|
||||
- `secret=` — **REQUIRED** random string, client validates signer returns it in connect response
|
||||
- `perms=` — comma-separated permissions to request
|
||||
- `name=` — app display name ("Amethyst Desktop")
|
||||
- `url=` — app URL (optional)
|
||||
- `image=` — app icon URL (optional)
|
||||
|
||||
### UX Flow
|
||||
|
||||
1. User clicks **"Connect with Signer"** tab/button on login screen
|
||||
2. Desktop generates ephemeral `KeyPair()` and random `secret`
|
||||
3. Builds `nostrconnect://` URI with connected relay URLs + perms + secret
|
||||
4. Displays:
|
||||
- **QR code** (primary — user scans with phone signer)
|
||||
- **Copyable text** (fallback — user pastes into web signer like nsec.app)
|
||||
- **"Waiting for signer to connect..."** status with spinner
|
||||
5. Desktop subscribes to kind 24133 events on specified relays, filtered to ephemeral pubkey
|
||||
6. User scans QR / pastes URI into their signer app
|
||||
7. Signer sends `connect` response with the secret value
|
||||
8. Desktop validates secret matches → `AccountState.LoggedIn`
|
||||
9. If no response within **120s** (longer than bunker — user needs time to open phone): show timeout, allow retry
|
||||
|
||||
### Implementation Details
|
||||
|
||||
**LoginCard changes:**
|
||||
- Add toggle/tab: "Paste Key" | "Connect with Signer"
|
||||
- "Connect with Signer" tab shows QR + copyable URI + waiting state
|
||||
- State machine: `IDLE → GENERATING → WAITING_FOR_SIGNER → CONNECTED / ERROR`
|
||||
|
||||
**AccountManager.loginWithNostrConnect(client: INostrClient): Pair<String, Job>**
|
||||
1. Generate ephemeral `KeyPair()` + `NostrSignerInternal`
|
||||
2. Generate random secret (16 hex chars)
|
||||
3. Get connected relay URLs from relay manager (+ add `wss://relay.nsec.app` as fallback NIP-46 relay)
|
||||
4. Build `nostrconnect://` URI string with `name=Amethyst+Desktop&perms=sign_event,nip44_encrypt,nip44_decrypt`
|
||||
5. Subscribe to kind 24133 events on specified relays, filtered to ephemeral pubkey
|
||||
6. Return (URI string, waiting Job)
|
||||
7. When connect response received: validate secret, extract remote pubkey from response, create `NostrSignerRemote`
|
||||
8. Set LoggedIn
|
||||
|
||||
**Key difference from bunker://:** With nostrconnect, client doesn't know user's pubkey upfront — learns it from signer's connect response. `fromBunkerUri()` cannot be reused directly — need to construct `NostrSignerRemote` manually after learning the remote pubkey.
|
||||
|
||||
**Quartz gap:** `RemoteSignerManager.launchWaitAndParse()` sends a request AND waits. For nostrconnect, client needs to just WAIT (signer initiates). Options:
|
||||
- Build a `waitForConnect()` method in AccountManager that subscribes + waits
|
||||
- Or call `openSubscription()` manually, then wait for the `newResponse()` callback
|
||||
|
||||
**Timeout:** 120s for nostrconnect (user needs to open phone, find app, scan QR). No auto-retry.
|
||||
|
||||
### QR Code Generation
|
||||
|
||||
**ZXing `core` 3.5.4 already in `gradle/libs.versions.toml`!** And there's an existing `QrCodeDrawer.kt` in the Android app (`amethyst/src/main/java/.../ui/screen/loggedIn/qrcode/QrCodeDrawer.kt`) that uses:
|
||||
- `com.google.zxing.qrcode.encoder.Encoder` (pure Java, in `zxing:core`)
|
||||
- Compose Canvas APIs (multiplatform)
|
||||
- **Zero Android dependencies** — fully portable to desktop
|
||||
|
||||
**Action:** Extract `QrCodeDrawer.kt` to `commons/commonMain/` and add `libs.zxing` dependency to `commons` module. No new libraries needed.
|
||||
|
||||
**Gradle addition (commons module):**
|
||||
```kotlin
|
||||
// commons/build.gradle.kts - commonMain sourceSets
|
||||
implementation(libs.zxing) // already in version catalog
|
||||
```
|
||||
|
||||
### Persistence
|
||||
|
||||
Same as bunker:// — save ephemeral key + URI + npub. On restart, reconnect without needing QR again (remote signer remembers ephemeral key).
|
||||
|
||||
### File Changes
|
||||
|
||||
| File | nostrconnect:// additions |
|
||||
|------|--------------------------|
|
||||
| `AccountManager.kt` | `loginWithNostrConnect()` method, generates URI + waits for connect |
|
||||
| `LoginCard.kt` | Tab toggle "Paste Key" / "Connect with Signer", QR display, waiting state |
|
||||
| `LoginScreen.kt` | Wire nostrconnect flow, pass relay client |
|
||||
| `build.gradle.kts` | Add `qrcode-kotlin-jvm` dependency |
|
||||
|
||||
---
|
||||
|
||||
## Step 8: Heartbeat Status Indicator (Sidebar)
|
||||
|
||||
### Design
|
||||
|
||||
Tiny pulsing dot in the sidebar (below existing nav icons, above settings gear). Communicates remote signer connection health at a glance.
|
||||
|
||||
### States
|
||||
|
||||
| State | Visual | Tooltip |
|
||||
|-------|--------|---------|
|
||||
| **Connected** | Green dot, gentle pulse animation (1.5s cycle) | "Remote signer connected" |
|
||||
| **Ping failed (1-2x)** | Yellow dot, faster pulse (0.8s cycle) | "Remote signer: connection unstable" |
|
||||
| **Disconnected** | Red dot, no pulse (static) | "Remote signer: disconnected" |
|
||||
| **Not remote** | Hidden | — (only show for bunker/nostrconnect accounts) |
|
||||
|
||||
### Animation Pattern — Double-Beat Heartbeat
|
||||
|
||||
Uses `keyframes` for organic lub-dub feel (two peaks per cycle):
|
||||
|
||||
```kotlin
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "heartbeat")
|
||||
val scale by infiniteTransition.animateFloat(
|
||||
initialValue = 0.85f,
|
||||
targetValue = 1.15f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = keyframes {
|
||||
durationMillis = 1200
|
||||
0.85f at 0 // rest
|
||||
1.15f at 300 using EaseOut // first beat (systole)
|
||||
0.95f at 500 // contract
|
||||
1.05f at 700 using EaseInOut // second beat (diastole)
|
||||
0.85f at 1200 // rest
|
||||
},
|
||||
repeatMode = RepeatMode.Restart,
|
||||
),
|
||||
label = "pulseScale",
|
||||
)
|
||||
|
||||
// Apply via graphicsLayer on a small Box with CircleShape
|
||||
Box(
|
||||
Modifier
|
||||
.size(8.dp)
|
||||
.graphicsLayer { scaleX = scale; scaleY = scale }
|
||||
.background(color, CircleShape)
|
||||
)
|
||||
```
|
||||
|
||||
**Existing animation patterns in codebase:** `ProfileBroadcastBanner.kt` uses `animateFloatAsState` with `tween(300)` for progress bars. No `rememberInfiniteTransition` usage yet — this would be the first.
|
||||
|
||||
### Tooltip (Desktop-only API)
|
||||
|
||||
```kotlin
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
TooltipArea(
|
||||
tooltip = {
|
||||
Surface(
|
||||
modifier = Modifier.shadow(4.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
) {
|
||||
Text(tooltipText, modifier = Modifier.padding(8.dp), style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
},
|
||||
delayMillis = 400,
|
||||
tooltipPlacement = TooltipPlacement.CursorPoint(alignment = Alignment.BottomEnd),
|
||||
) {
|
||||
HeartbeatDot(connectionState)
|
||||
}
|
||||
```
|
||||
|
||||
### Connection State Flow
|
||||
|
||||
```kotlin
|
||||
// In AccountManager
|
||||
sealed class SignerConnectionState {
|
||||
data object NotRemote : SignerConnectionState() // internal signer, hide indicator
|
||||
data object Connected : SignerConnectionState()
|
||||
data class Unstable(val failCount: Int) : SignerConnectionState()
|
||||
data object Disconnected : SignerConnectionState()
|
||||
}
|
||||
|
||||
val signerConnectionState: StateFlow<SignerConnectionState>
|
||||
```
|
||||
|
||||
Heartbeat updates this state on each ping result. UI observes via `collectAsState()`.
|
||||
|
||||
### File Changes
|
||||
|
||||
| File | Heartbeat indicator additions |
|
||||
|------|------------------------------|
|
||||
| `AccountManager.kt` | `SignerConnectionState` sealed class + `signerConnectionState: StateFlow` |
|
||||
| `DeckSidebar.kt` | Add `HeartbeatIndicator` composable at bottom of sidebar |
|
||||
| `SinglePaneLayout.kt` | Add `HeartbeatIndicator` at bottom of nav rail |
|
||||
| `HeartbeatIndicator.kt` | **New** — pulsing dot + tooltip composable |
|
||||
|
||||
---
|
||||
|
||||
## Updated Files Summary
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `AccountManager.kt` | SignerType, ConnectingRelays, loginWithBunker(), loginWithNostrConnect(), saveBunkerAccount(), loadSavedAccount(), logout(), heartbeat, forceLogout, SignerConnectionState |
|
||||
| `LoginCard.kt` | LoginState, tab toggle (paste/connect), bunker URI validation, nostrconnect QR + waiting, connecting UI |
|
||||
| `LoginScreen.kt` | relayClient param, wire bunker + nostrconnect flows, ConnectingRelaysScreen |
|
||||
| `Main.kt` | Startup relay-wait, heartbeat lifecycle, forceLogout dialog, pass relayManager.client |
|
||||
| `ForceLogoutDialog.kt` | **New** — AlertDialog for session termination |
|
||||
| `HeartbeatIndicator.kt` | **New** — pulsing dot + tooltip composable |
|
||||
| `DeckSidebar.kt` | Add HeartbeatIndicator between spacer and Settings |
|
||||
| `SinglePaneLayout.kt` | Add HeartbeatIndicator in nav rail |
|
||||
| `QrCodeDrawer.kt` | **Extract** from `amethyst/.../qrcode/` → `commons/commonMain/` (zero Android deps) |
|
||||
| `commons/build.gradle.kts` | Add `libs.zxing` (core) dependency |
|
||||
|
||||
## Answered Questions (Round 2)
|
||||
|
||||
1. **Full perms** for nostrconnect: `sign_event,nip04_encrypt,nip04_decrypt,nip44_encrypt,nip44_decrypt`
|
||||
2. ~~Dark mode QR~~ — defer to implementation, follow existing theme
|
||||
3. **Both layouts** — heartbeat in SINGLE_PANE nav rail AND DECK sidebar
|
||||
@@ -0,0 +1,106 @@
|
||||
---
|
||||
title: "feat: NIP-46 remote signer improvements (post-MVP)"
|
||||
type: feat
|
||||
status: backlog
|
||||
date: 2026-03-05
|
||||
origin: docs/plans/2026-03-05-fix-nip46-relay-isolation-and-init-plan.md
|
||||
---
|
||||
|
||||
# feat: NIP-46 remote signer improvements (post-MVP)
|
||||
|
||||
Deferred items from the NIP-46 relay isolation fix. All are nice-to-haves discovered during deepening/review — none block the initial fix.
|
||||
|
||||
## 1. `auth_url` handling (nsec.app compatibility)
|
||||
|
||||
**Priority:** Medium — only affects nsec.app users, not Amber
|
||||
|
||||
NIP-46 spec defines `auth_url` for signers needing out-of-band user confirmation:
|
||||
```json
|
||||
{"id": "<req_id>", "result": "auth_url", "error": "<URL_to_display>"}
|
||||
```
|
||||
|
||||
Client should open the URL, then resubscribe for the actual result using the same request ID.
|
||||
|
||||
**Current behavior:** Treated as `ReceivedButCouldNotPerform` error → times out.
|
||||
|
||||
**To implement:**
|
||||
- New `BunkerResponseAuthUrl` class + parser
|
||||
- New `SignerResult` state for pending auth
|
||||
- Desktop UI to open URL in browser + resubscribe
|
||||
- Timeout management for multi-step flow
|
||||
|
||||
**Files:** `BunkerResponse.kt`, `SignerResult.kt`, response parsers, `RemoteSignerManager.kt`, desktop `LoginCard.kt`
|
||||
|
||||
## 2. `get_public_key` validation after connect
|
||||
|
||||
**Priority:** Low — security hardening, not correctness
|
||||
|
||||
Infrastructure already exists: `NostrSignerRemote.getPublicKey()` is fully implemented and production-ready. `connect()` already returns the user pubkey, so this is redundant for normal operation.
|
||||
|
||||
**Value:** Belt-and-suspenders against MITM on the connect response. Low risk since NIP-44 encryption protects the channel.
|
||||
|
||||
**To implement:**
|
||||
```kotlin
|
||||
// In AccountManager.loadBunkerAccount() after remoteSigner.openSubscription()
|
||||
val confirmedPubkey = remoteSigner.getPublicKey()
|
||||
if (confirmedPubkey != expectedPubkey) {
|
||||
return Result.failure(Exception("Pubkey mismatch: signer returned $confirmedPubkey"))
|
||||
}
|
||||
```
|
||||
|
||||
**Cost:** 1 extra RPC round-trip + potential 30s timeout on failure.
|
||||
|
||||
## 3. Dynamic `since` filter on reconnect
|
||||
|
||||
**Priority:** Low — optimization, no correctness issue
|
||||
|
||||
`NostrClientStaticReq` re-sends the exact same filter on WebSocket reconnect. With `since = now() - 60`, this means after a reconnect the relay replays events from the original subscription time — wasteful but harmless (request ID matching discards stale responses).
|
||||
|
||||
**To implement:** Switch `NostrSignerRemote` from `NostrClientStaticReq` to `NostrClientDynamicReq`:
|
||||
```kotlin
|
||||
// Lambda recomputes since on each reconnect
|
||||
val subscription = client.dynamicReq(
|
||||
relays = relays.toList(),
|
||||
filter = {
|
||||
Filter(
|
||||
kinds = listOf(NostrConnectEvent.KIND),
|
||||
tags = mapOf("p" to listOf(signer.pubKey)),
|
||||
since = TimeUtils.now() - 60,
|
||||
)
|
||||
},
|
||||
) { event -> ... }
|
||||
```
|
||||
|
||||
**Tradeoff:** More complexity. Only matters if relay operators complain about repeated full-history queries or reconnects are frequent.
|
||||
|
||||
## 4. `NostrClient.close()` method (upstream)
|
||||
|
||||
**Priority:** Medium — affects any code creating temporary `NostrClient` instances
|
||||
|
||||
`NostrClient.disconnect()` only closes WebSockets but leaks:
|
||||
- `allRelays` combine flow (`stateIn(Eagerly)` on `scope`)
|
||||
- `debouncingConnection` debounce flow (`stateIn(Eagerly)` on `scope`)
|
||||
- The `SupervisorJob` scope itself
|
||||
|
||||
**To implement:**
|
||||
```kotlin
|
||||
// In NostrClient.kt
|
||||
fun close() {
|
||||
disconnect()
|
||||
scope.cancel()
|
||||
}
|
||||
```
|
||||
|
||||
**Current workaround:** `disconnectNip46Client()` in AccountManager handles our specific case. But any future code creating temporary `NostrClient` instances will hit the same leak.
|
||||
|
||||
**Recommendation:** PR upstream to quartz adding `close()` to `NostrClient`.
|
||||
|
||||
## 5. `FeedMetadataCoordinator` reactive relay sets
|
||||
|
||||
**Priority:** Low — deferred construction in the fix plan solves the immediate bug
|
||||
|
||||
Currently `indexRelays` is `private val` — immutable after construction. If relay sets change at runtime (user adds/removes relays), the coordinator won't pick up changes.
|
||||
|
||||
**To implement:** Accept `StateFlow<Set<NormalizedRelayUrl>>` instead of `Set<NormalizedRelayUrl>`, collect in coordinator's scope.
|
||||
|
||||
**Current workaround:** Nullable coordinator pattern with lazy construction after relays connect.
|
||||
@@ -0,0 +1,386 @@
|
||||
---
|
||||
title: "test: TDD tests for NIP-46 relay isolation fix"
|
||||
type: test
|
||||
status: active
|
||||
date: 2026-03-05
|
||||
parent: 2026-03-05-fix-nip46-relay-isolation-and-init-plan.md
|
||||
---
|
||||
|
||||
# test: TDD tests for NIP-46 relay isolation fix
|
||||
|
||||
## Overview
|
||||
|
||||
Write tests that **reproduce the three NIP-46 bugs** before they're fixed, then verify the fix. Tests should fail against the old shared-client architecture and pass after relay isolation.
|
||||
|
||||
## Bug → Test Mapping
|
||||
|
||||
| Bug | Root Cause | Test Strategy |
|
||||
|-----|-----------|---------------|
|
||||
| **Bug 1:** Empty `indexRelays` snapshot | `availableRelays.value` captured at coordinator construction = `emptySet()` | Verify coordinator receives non-empty relay set |
|
||||
| **Bug 2:** Shared client relay contamination | NIP-46 relay leaks into general pool → ClosedMessage loop | Verify NIP-46 traffic stays on dedicated client, never touches general client |
|
||||
| **Bug 3:** Sign request response lost | Subscription disrupted by ClosedMessage churn from Bug 2 | Verify NIP-46 subscription filters only target NIP-46 relays |
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
### Existing
|
||||
- Framework: `kotlin.test` + `kotlinx-coroutines-test` + `mockk`
|
||||
- Location: `desktopApp/src/jvmTest/kotlin/.../desktop/account/`
|
||||
- Mocks: `EmptyNostrClient` (no-op), `mockk<SecureKeyStorage>(relaxed = true)`, temp dirs
|
||||
- Pattern: `@BeforeTest` setup → `runTest {}` → `@AfterTest` cleanup
|
||||
|
||||
### Needed
|
||||
- `SpyNostrClient` — wraps `INostrClient`, records which relays receive `send()` and `openReqSubscription()` calls
|
||||
- No new dependencies (mockk `capture` + `slot` + `verify` sufficient)
|
||||
|
||||
## Phase 0: Fix Existing Broken Tests
|
||||
|
||||
**File:** `AccountManagerLoadAccountTest.kt`
|
||||
|
||||
Three tests pass `client` param to `loadSavedAccount()` which no longer accepts it:
|
||||
|
||||
| Test | Fix |
|
||||
|------|-----|
|
||||
| `loadSavedAccountBunkerNoEphemeralReturnsFailure` (line 114) | Remove `client = EmptyNostrClient` arg |
|
||||
| `loadSavedAccountBunkerNoClientFallsBackToInternal` (line 119-136) | **Delete entirely** — concept no longer exists (AccountManager always creates its own NIP-46 client) |
|
||||
| `loadSavedAccountBunkerSuccess` (line 155-158) | Remove `client = EmptyNostrClient` arg |
|
||||
|
||||
## Phase 1: Relay Isolation Tests (Bug 2 + 3)
|
||||
|
||||
### Test File: `AccountManagerNip46IsolationTest.kt`
|
||||
|
||||
**Location:** `desktopApp/src/jvmTest/kotlin/.../desktop/account/`
|
||||
|
||||
#### Test 1: `nip46ClientIsNotTheGeneralClient`
|
||||
|
||||
**Reproduces:** Bug 2 root cause — shared `NostrClient` pollution.
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun nip46ClientIsNotTheGeneralClient() = runTest {
|
||||
// Setup: bunker URI + ephemeral key in storage
|
||||
val validHex = "a".repeat(64)
|
||||
val ephemeralKeyPair = KeyPair()
|
||||
File(amethystDir, "bunker_uri.txt").writeText("bunker://$validHex?relay=wss://relay.nsec.app")
|
||||
File(amethystDir, "last_account.txt").writeText(ephemeralKeyPair.pubKey.toNpub())
|
||||
coEvery { storage.getPrivateKey(BUNKER_EPHEMERAL_KEY_ALIAS) } returns ephemeralKeyPair.privKey!!.toHexKey()
|
||||
|
||||
// Load bunker account — this creates the internal NIP-46 client
|
||||
manager.loadSavedAccount()
|
||||
|
||||
// The general relay pool client (e.g. relayManager.client) should NOT
|
||||
// have any NIP-46 relay subscriptions. Since AccountManager creates its
|
||||
// own client internally, we verify via the accountState having Remote signer.
|
||||
val state = manager.currentAccount()
|
||||
assertNotNull(state)
|
||||
assertIs<SignerType.Remote>(state.signerType)
|
||||
|
||||
// The signer's client should be the dedicated one, not EmptyNostrClient
|
||||
val signer = state.signer as NostrSignerRemote
|
||||
// NostrSignerRemote stores its client — it should be a real NostrClient, not null
|
||||
assertNotNull(signer)
|
||||
}
|
||||
```
|
||||
|
||||
**Key assertion:** AccountManager creates a dedicated client internally rather than requiring one passed in.
|
||||
|
||||
#### Test 2: `loginWithBunkerDoesNotRequireExternalClient`
|
||||
|
||||
**Reproduces:** The old API required callers to pass `relayManager.client`.
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun loginWithBunkerDoesNotRequireExternalClient() = runTest {
|
||||
// loginWithBunker() should compile and work with just a bunkerUri
|
||||
// This test verifies the API — no client param needed
|
||||
try {
|
||||
manager.loginWithBunker("bunker://${"a".repeat(64)}?relay=wss://relay.nsec.app")
|
||||
} catch (e: Exception) {
|
||||
// Expected — no real relay to connect to. But it should NOT throw
|
||||
// a compile error or "client required" error.
|
||||
assertTrue(
|
||||
e.message?.contains("Connection") == true ||
|
||||
e.message?.contains("timed out") == true ||
|
||||
e.message?.contains("Connection failed") == true,
|
||||
"Unexpected error: ${e.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Test 3: `loginWithNostrConnectDoesNotRequireExternalClient`
|
||||
|
||||
Same pattern — verifies API no longer takes `client`.
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun loginWithNostrConnectDoesNotRequireExternalClient() = runTest {
|
||||
var generatedUri: String? = null
|
||||
try {
|
||||
manager.loginWithNostrConnect { uri -> generatedUri = uri }
|
||||
} catch (e: Exception) {
|
||||
// Expected — no signer scanning the QR
|
||||
assertTrue(e.message?.contains("Timed out") == true || e.message != null)
|
||||
}
|
||||
// URI should have been generated even if connection failed
|
||||
assertNotNull(generatedUri)
|
||||
assertTrue(generatedUri!!.startsWith("nostrconnect://"))
|
||||
}
|
||||
```
|
||||
|
||||
#### Test 4: `logoutDisconnectsNip46Client`
|
||||
|
||||
**Reproduces:** Bug 2 side effect — leaked NIP-46 WebSocket after logout.
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun logoutDisconnectsNip46Client() = runTest {
|
||||
// Login with bunker (will create internal NIP-46 client)
|
||||
val keyPair = KeyPair()
|
||||
manager.loginWithKey(keyPair.privKey!!.toNsec())
|
||||
// Force to Remote type for test
|
||||
// Instead: test that logout from any state doesn't crash
|
||||
manager.logout()
|
||||
|
||||
// After logout, state should be LoggedOut
|
||||
assertIs<AccountState.LoggedOut>(manager.accountState.value)
|
||||
// Signer connection should be NotRemote
|
||||
assertIs<SignerConnectionState.NotRemote>(manager.signerConnectionState.value)
|
||||
}
|
||||
```
|
||||
|
||||
#### Test 5: `logoutThenLoginCreatesNewNip46Client`
|
||||
|
||||
**Reproduces:** Ensuring fresh client after re-login (no stale state).
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun logoutThenLoginCreatesNewNip46Client() = runTest {
|
||||
// First bunker load
|
||||
val validHex = "a".repeat(64)
|
||||
val ephemeralKeyPair = KeyPair()
|
||||
File(amethystDir, "bunker_uri.txt").writeText("bunker://$validHex?relay=wss://relay.nsec.app")
|
||||
File(amethystDir, "last_account.txt").writeText(ephemeralKeyPair.pubKey.toNpub())
|
||||
coEvery { storage.getPrivateKey(BUNKER_EPHEMERAL_KEY_ALIAS) } returns ephemeralKeyPair.privKey!!.toHexKey()
|
||||
|
||||
manager.loadSavedAccount()
|
||||
val firstState = manager.currentAccount()
|
||||
assertNotNull(firstState)
|
||||
|
||||
// Logout — should disconnect NIP-46 client
|
||||
manager.logout()
|
||||
assertIs<AccountState.LoggedOut>(manager.accountState.value)
|
||||
|
||||
// Re-load — should create a NEW NIP-46 client
|
||||
File(amethystDir, "bunker_uri.txt").writeText("bunker://$validHex?relay=wss://relay.nsec.app")
|
||||
File(amethystDir, "last_account.txt").writeText(ephemeralKeyPair.pubKey.toNpub())
|
||||
manager.loadSavedAccount()
|
||||
val secondState = manager.currentAccount()
|
||||
assertNotNull(secondState)
|
||||
|
||||
// Both states should be valid Remote signers
|
||||
assertIs<SignerType.Remote>(firstState.signerType)
|
||||
assertIs<SignerType.Remote>(secondState.signerType)
|
||||
}
|
||||
```
|
||||
|
||||
## Phase 2: State Transition Tests
|
||||
|
||||
### Test File: `AccountManagerStateTransitionTest.kt`
|
||||
|
||||
#### Test 6: `bunkerRestoreShowsConnectingThenLoggedIn`
|
||||
|
||||
**Reproduces:** Bug 1 UX — user sees nothing while connecting.
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun bunkerRestoreShowsConnectingThenLoggedIn() = runTest {
|
||||
val states = mutableListOf<AccountState>()
|
||||
val collector = backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
manager.accountState.toList(states)
|
||||
}
|
||||
|
||||
// Set connecting state (what Main.kt does before loadSavedAccount)
|
||||
manager.setConnectingRelays()
|
||||
|
||||
// Then load internal account
|
||||
val keyPair = KeyPair()
|
||||
val npub = keyPair.pubKey.toNpub()
|
||||
File(amethystDir, "last_account.txt").writeText(npub)
|
||||
coEvery { storage.getPrivateKey(npub) } returns keyPair.privKey!!.toHexKey()
|
||||
manager.loadSavedAccount()
|
||||
|
||||
advanceUntilIdle()
|
||||
|
||||
// Should have: LoggedOut → ConnectingRelays → LoggedIn
|
||||
assertTrue(states.size >= 3, "Expected at least 3 state transitions, got: $states")
|
||||
assertIs<AccountState.LoggedOut>(states[0])
|
||||
assertIs<AccountState.ConnectingRelays>(states[1])
|
||||
assertIs<AccountState.LoggedIn>(states[2])
|
||||
|
||||
collector.cancel()
|
||||
}
|
||||
```
|
||||
|
||||
#### Test 7: `loadSavedAccountBunkerNoEphemeralReturnsFailure`
|
||||
|
||||
Updated version — no `client` param.
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun loadSavedAccountBunkerNoEphemeralReturnsFailure() = runTest {
|
||||
val validHex = "a".repeat(64)
|
||||
val keyPair = KeyPair()
|
||||
val npub = keyPair.pubKey.toNpub()
|
||||
|
||||
File(amethystDir, "last_account.txt").writeText(npub)
|
||||
File(amethystDir, "bunker_uri.txt").writeText("bunker://$validHex?relay=wss://r.com")
|
||||
coEvery { storage.getPrivateKey(AccountManager.BUNKER_EPHEMERAL_KEY_ALIAS) } returns null
|
||||
|
||||
val result = manager.loadSavedAccount()
|
||||
assertTrue(result.isFailure)
|
||||
}
|
||||
```
|
||||
|
||||
## Phase 3: NostrSignerRemote Isolation Tests (quartz)
|
||||
|
||||
### Test File: `NostrSignerRemoteIsolationTest.kt`
|
||||
|
||||
**Location:** `quartz/src/commonTest/kotlin/.../nip46RemoteSigner/signer/`
|
||||
|
||||
#### Test 8: `subscriptionFilterTargetsOnlyBunkerRelays`
|
||||
|
||||
**Reproduces:** Bug 2 — subscription filters should ONLY reference NIP-46 relays.
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun subscriptionFilterTargetsOnlyBunkerRelays() {
|
||||
val bunkerRelay = NormalizedRelayUrl("wss://relay.nsec.app/")
|
||||
val generalRelay = NormalizedRelayUrl("wss://relay.damus.io/")
|
||||
|
||||
val capturedFilters = mutableListOf<Map<NormalizedRelayUrl, List<Filter>>>()
|
||||
val mockClient = mockk<INostrClient>(relaxed = true)
|
||||
every { mockClient.openReqSubscription(any(), capture(capturedFilters), any()) } just Runs
|
||||
|
||||
val ephemeralSigner = NostrSignerInternal(KeyPair())
|
||||
val remoteSigner = NostrSignerRemote(
|
||||
signer = ephemeralSigner,
|
||||
remotePubkey = "a".repeat(64),
|
||||
relays = setOf(bunkerRelay),
|
||||
client = mockClient,
|
||||
)
|
||||
remoteSigner.openSubscription()
|
||||
|
||||
// Verify filter only targets bunker relay
|
||||
assertTrue(capturedFilters.isNotEmpty(), "No subscription opened")
|
||||
capturedFilters.forEach { filterMap ->
|
||||
assertTrue(filterMap.keys.all { it == bunkerRelay }, "Filter contains non-bunker relay: ${filterMap.keys}")
|
||||
assertTrue(generalRelay !in filterMap.keys, "General relay leaked into NIP-46 filter")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Test 9: `subscriptionFilterContainsCorrectKindAndPTag`
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun subscriptionFilterContainsCorrectKindAndPTag() {
|
||||
val capturedFilters = mutableListOf<Map<NormalizedRelayUrl, List<Filter>>>()
|
||||
val mockClient = mockk<INostrClient>(relaxed = true)
|
||||
every { mockClient.openReqSubscription(any(), capture(capturedFilters), any()) } just Runs
|
||||
|
||||
val ephemeralKeyPair = KeyPair()
|
||||
val ephemeralSigner = NostrSignerInternal(ephemeralKeyPair)
|
||||
val remoteSigner = NostrSignerRemote(
|
||||
signer = ephemeralSigner,
|
||||
remotePubkey = "b".repeat(64),
|
||||
relays = setOf(NormalizedRelayUrl("wss://relay.nsec.app/")),
|
||||
client = mockClient,
|
||||
)
|
||||
remoteSigner.openSubscription()
|
||||
|
||||
val filters = capturedFilters.flatMap { it.values.flatten() }
|
||||
assertTrue(filters.isNotEmpty())
|
||||
filters.forEach { filter ->
|
||||
assertTrue(filter.kinds?.contains(NostrConnectEvent.KIND) == true,
|
||||
"Filter missing kind ${NostrConnectEvent.KIND}")
|
||||
assertTrue(filter.tags?.get("p")?.contains(ephemeralSigner.pubKey) == true,
|
||||
"Filter missing p-tag for ephemeral pubkey")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Phase 4: `since` Filter Tests (Bug 3 mitigation)
|
||||
|
||||
### Test File: add to `NostrSignerRemoteIsolationTest.kt`
|
||||
|
||||
#### Test 10: `subscriptionFilterHasSinceTimestamp`
|
||||
|
||||
**Reproduces:** Stale NIP-46 responses from previous sessions being processed.
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun subscriptionFilterHasSinceTimestamp() {
|
||||
val capturedFilters = mutableListOf<Map<NormalizedRelayUrl, List<Filter>>>()
|
||||
val mockClient = mockk<INostrClient>(relaxed = true)
|
||||
every { mockClient.openReqSubscription(any(), capture(capturedFilters), any()) } just Runs
|
||||
|
||||
val beforeTime = TimeUtils.now()
|
||||
|
||||
val remoteSigner = NostrSignerRemote(
|
||||
signer = NostrSignerInternal(KeyPair()),
|
||||
remotePubkey = "c".repeat(64),
|
||||
relays = setOf(NormalizedRelayUrl("wss://relay.nsec.app/")),
|
||||
client = mockClient,
|
||||
)
|
||||
remoteSigner.openSubscription()
|
||||
|
||||
val filters = capturedFilters.flatMap { it.values.flatten() }
|
||||
assertTrue(filters.isNotEmpty())
|
||||
filters.forEach { filter ->
|
||||
assertNotNull(filter.since, "Filter missing 'since' timestamp")
|
||||
// since should be roughly now - 60s (with some tolerance)
|
||||
val expectedMin = beforeTime - 120 // extra tolerance
|
||||
val expectedMax = beforeTime
|
||||
assertTrue(filter.since!! >= expectedMin, "since too old: ${filter.since}")
|
||||
assertTrue(filter.since!! <= expectedMax, "since in the future: ${filter.since}")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **Note:** This test will FAIL until Step 8 (add `since` to NostrSignerRemote) is implemented. That's the TDD red phase.
|
||||
|
||||
## Implementation Order
|
||||
|
||||
| # | Action | File | Phase |
|
||||
|---|--------|------|-------|
|
||||
| 1 | Fix broken tests (remove `client` param) | `AccountManagerLoadAccountTest.kt` | 0 |
|
||||
| 2 | Write relay isolation tests (Tests 1-5) | `AccountManagerNip46IsolationTest.kt` | 1 |
|
||||
| 3 | Write state transition tests (Tests 6-7) | `AccountManagerStateTransitionTest.kt` | 2 |
|
||||
| 4 | Write signer isolation tests (Tests 8-9) | `NostrSignerRemoteIsolationTest.kt` | 3 |
|
||||
| 5 | Write `since` filter test (Test 10) — **expect RED** | `NostrSignerRemoteIsolationTest.kt` | 4 |
|
||||
| 6 | Run all → confirm Tests 1-9 GREEN, Test 10 RED | — | — |
|
||||
| 7 | Implement Step 8 (`since` filter in quartz) | `NostrSignerRemote.kt` | — |
|
||||
| 8 | Run all → confirm all GREEN | — | — |
|
||||
|
||||
## Test Commands
|
||||
|
||||
```bash
|
||||
# Run all desktop tests
|
||||
./gradlew :desktopApp:test
|
||||
|
||||
# Run specific test class
|
||||
./gradlew :desktopApp:test --tests "*.AccountManagerNip46IsolationTest"
|
||||
|
||||
# Run quartz common tests
|
||||
./gradlew :quartz:jvmTest --tests "*.NostrSignerRemoteIsolationTest"
|
||||
|
||||
# Run all NIP-46 related tests
|
||||
./gradlew :desktopApp:test --tests "*.AccountManager*" :quartz:jvmTest --tests "*.nip46RemoteSigner.*"
|
||||
```
|
||||
|
||||
## Unanswered Questions
|
||||
|
||||
1. `getOrCreateNip46Client()` is private — can't directly verify single-creation from tests without reflection or `@VisibleForTesting`. Sufficient to test indirectly through public API?
|
||||
2. `NostrSignerRemote.subscription` field is private — MockK `capture` on `openReqSubscription` is the best proxy for filter verification?
|
||||
3. Should we add Turbine dependency for cleaner StateFlow testing, or stick with manual `backgroundScope` collection?
|
||||
4. `NostrClient.connect()` is non-suspending (fire-and-forget) — how to test that the NIP-46 client actually connects before subscriptions? Accept relay auto-queue behavior?
|
||||
+11
-1
@@ -42,6 +42,11 @@ import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
||||
import com.vitorpamplona.quartz.utils.Hex
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class NostrSignerRemote(
|
||||
val signer: NostrSignerInternal,
|
||||
@@ -51,6 +56,8 @@ class NostrSignerRemote(
|
||||
val permissions: String? = null,
|
||||
val secret: String? = null,
|
||||
) : NostrSigner(signer.pubKey) {
|
||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
|
||||
private val manager =
|
||||
RemoteSignerManager(
|
||||
signer = signer,
|
||||
@@ -69,7 +76,9 @@ class NostrSignerRemote(
|
||||
),
|
||||
) { event ->
|
||||
if (event is NostrConnectEvent) {
|
||||
manager.newResponse(event)
|
||||
scope.launch {
|
||||
manager.newResponse(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +88,7 @@ class NostrSignerRemote(
|
||||
|
||||
fun closeSubscription() {
|
||||
subscription.close()
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
override fun isWriteable(): Boolean = true
|
||||
|
||||
+3
-2
@@ -41,8 +41,9 @@ class RemoteSignerManager(
|
||||
) {
|
||||
private val awaitingRequests = LargeCache<String, Continuation<BunkerResponse>>()
|
||||
|
||||
fun newResponse(responseEvent: NostrConnectEvent) {
|
||||
val bunkerResponse = OptimizedJsonMapper.fromJsonTo<BunkerResponse>(responseEvent.content)
|
||||
suspend fun newResponse(responseEvent: NostrConnectEvent) {
|
||||
val decryptedJson = signer.decrypt(responseEvent.content, remoteKey)
|
||||
val bunkerResponse = OptimizedJsonMapper.fromJsonTo<BunkerResponse>(decryptedJson)
|
||||
awaitingRequests.get(bunkerResponse.id)?.resume(bunkerResponse)
|
||||
}
|
||||
|
||||
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip46RemoteSigner
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Tests for NostrConnectEvent pure logic (canDecrypt, talkingWith, verifiedRecipientPubKey).
|
||||
*
|
||||
* Crypto-dependent tests (create + decrypt roundtrip) live in androidDeviceTest/Nip46Test.kt
|
||||
* because NIP-44 encryption requires lazysodium which is only available on Android/device tests.
|
||||
*/
|
||||
class NostrConnectEventTest {
|
||||
private val senderKey = NostrSignerInternal(KeyPair())
|
||||
private val recipientKey = NostrSignerInternal(KeyPair())
|
||||
private val thirdPartyKey = NostrSignerInternal(KeyPair())
|
||||
|
||||
/** Construct a NostrConnectEvent with known pubKey and p-tag, no real crypto needed */
|
||||
private fun buildEvent(
|
||||
authorPubKey: String,
|
||||
recipientPubKey: String,
|
||||
) = NostrConnectEvent(
|
||||
id = "a".repeat(64),
|
||||
pubKey = authorPubKey,
|
||||
createdAt = 1L,
|
||||
tags = arrayOf(arrayOf("p", recipientPubKey)),
|
||||
content = "encrypted-placeholder",
|
||||
sig = "b".repeat(128),
|
||||
)
|
||||
|
||||
// --- canDecrypt ---
|
||||
|
||||
@Test
|
||||
fun canDecryptAsSender() {
|
||||
val event = buildEvent(senderKey.pubKey, recipientKey.pubKey)
|
||||
assertTrue(event.canDecrypt(senderKey))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun canDecryptAsRecipient() {
|
||||
val event = buildEvent(senderKey.pubKey, recipientKey.pubKey)
|
||||
assertTrue(event.canDecrypt(recipientKey))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun canDecryptUnauthorizedReturnsFalse() {
|
||||
val event = buildEvent(senderKey.pubKey, recipientKey.pubKey)
|
||||
assertFalse(event.canDecrypt(thirdPartyKey))
|
||||
}
|
||||
|
||||
// --- talkingWith ---
|
||||
|
||||
@Test
|
||||
fun talkingWithAsSenderReturnsRecipient() {
|
||||
val event = buildEvent(senderKey.pubKey, recipientKey.pubKey)
|
||||
assertEquals(recipientKey.pubKey, event.talkingWith(senderKey.pubKey))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun talkingWithAsRecipientReturnsSender() {
|
||||
val event = buildEvent(senderKey.pubKey, recipientKey.pubKey)
|
||||
assertEquals(senderKey.pubKey, event.talkingWith(recipientKey.pubKey))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun talkingWithUnknownReturnsSender() {
|
||||
val event = buildEvent(senderKey.pubKey, recipientKey.pubKey)
|
||||
// When oneSideHex doesn't match pubKey, returns pubKey (sender)
|
||||
assertEquals(senderKey.pubKey, event.talkingWith(thirdPartyKey.pubKey))
|
||||
}
|
||||
|
||||
// --- verifiedRecipientPubKey ---
|
||||
|
||||
@Test
|
||||
fun verifiedRecipientPubKeyWithValidHex() {
|
||||
val event = buildEvent(senderKey.pubKey, recipientKey.pubKey)
|
||||
assertEquals(recipientKey.pubKey, event.verifiedRecipientPubKey())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun verifiedRecipientPubKeyWithInvalidHex() {
|
||||
val event =
|
||||
NostrConnectEvent(
|
||||
id = "a".repeat(64),
|
||||
pubKey = senderKey.pubKey,
|
||||
createdAt = 1L,
|
||||
tags = arrayOf(arrayOf("p", "not-hex!")),
|
||||
content = "encrypted",
|
||||
sig = "b".repeat(128),
|
||||
)
|
||||
assertNull(event.verifiedRecipientPubKey())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun verifiedRecipientPubKeyWithNoPTag() {
|
||||
val event =
|
||||
NostrConnectEvent(
|
||||
id = "a".repeat(64),
|
||||
pubKey = senderKey.pubKey,
|
||||
createdAt = 1L,
|
||||
tags = emptyArray(),
|
||||
content = "encrypted",
|
||||
sig = "b".repeat(128),
|
||||
)
|
||||
assertNull(event.verifiedRecipientPubKey())
|
||||
}
|
||||
|
||||
// --- Kind ---
|
||||
|
||||
@Test
|
||||
fun kindIs24133() {
|
||||
assertEquals(24133, NostrConnectEvent.KIND)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun eventHasCorrectKind() {
|
||||
val event = buildEvent(senderKey.pubKey, recipientKey.pubKey)
|
||||
assertEquals(24133, event.kind)
|
||||
}
|
||||
|
||||
// --- isContentEncoded ---
|
||||
|
||||
@Test
|
||||
fun isContentEncodedReturnsTrue() {
|
||||
val event = buildEvent(senderKey.pubKey, recipientKey.pubKey)
|
||||
assertTrue(event.isContentEncoded())
|
||||
}
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip46RemoteSigner.signer
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ConvertExceptionsTest {
|
||||
private val remote =
|
||||
NostrSignerRemote.fromBunkerUri(
|
||||
"bunker://${"a".repeat(64)}?relay=wss://r.com",
|
||||
NostrSignerInternal(KeyPair()),
|
||||
EmptyNostrClient,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun successfulReturnsBug() {
|
||||
val result = SignerResult.RequestAddressed.Successful(PingResult("pong"))
|
||||
val ex = remote.convertExceptions("Test", result)
|
||||
assertIs<IllegalStateException>(ex)
|
||||
assertTrue(ex.message!!.contains("bug"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectedReturnsManuallyUnauthorized() {
|
||||
val result = SignerResult.RequestAddressed.Rejected<PingResult>()
|
||||
val ex = remote.convertExceptions("Test", result)
|
||||
assertIs<SignerExceptions.ManuallyUnauthorizedException>(ex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun timedOutReturnsTimedOutException() {
|
||||
val result = SignerResult.RequestAddressed.TimedOut<PingResult>()
|
||||
val ex = remote.convertExceptions("Test", result)
|
||||
assertIs<SignerExceptions.TimedOutException>(ex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun couldNotPerformReturnsCouldNotPerformException() {
|
||||
val result = SignerResult.RequestAddressed.ReceivedButCouldNotPerform<PingResult>("custom msg")
|
||||
val ex = remote.convertExceptions("Test", result)
|
||||
assertIs<SignerExceptions.CouldNotPerformException>(ex)
|
||||
assertTrue(ex.message!!.contains("custom msg"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun couldNotParseReturnsIllegalState() {
|
||||
val result = SignerResult.RequestAddressed.ReceivedButCouldNotParseEventFromResult<PingResult>("{bad}")
|
||||
val ex = remote.convertExceptions("Test", result)
|
||||
assertIs<IllegalStateException>(ex)
|
||||
assertTrue(ex.message!!.contains("{bad}"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun couldNotVerifyReturnsIllegalState() {
|
||||
val event =
|
||||
Event(
|
||||
id = "a".repeat(64),
|
||||
pubKey = "b".repeat(64),
|
||||
createdAt = 1L,
|
||||
kind = 1,
|
||||
tags = emptyArray(),
|
||||
content = "",
|
||||
sig = "c".repeat(128),
|
||||
)
|
||||
val result = SignerResult.RequestAddressed.ReceivedButCouldNotVerifyResultingEvent<PingResult>(event)
|
||||
val ex = remote.convertExceptions("Test", result)
|
||||
assertIs<IllegalStateException>(ex)
|
||||
assertTrue(ex.message!!.contains("verify"))
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip46RemoteSigner.signer
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class FromBunkerUriTest {
|
||||
private val signer = NostrSignerInternal(KeyPair())
|
||||
private val client = EmptyNostrClient
|
||||
private val validHex = "a".repeat(64)
|
||||
|
||||
@Test
|
||||
fun validSingleRelay() {
|
||||
val uri = "bunker://$validHex?relay=wss://relay.example.com"
|
||||
val remote = NostrSignerRemote.fromBunkerUri(uri, signer, client)
|
||||
assertEquals(1, remote.relays.size)
|
||||
assertEquals(validHex, remote.remotePubkey)
|
||||
assertNull(remote.secret)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun validMultipleRelays() {
|
||||
val uri = "bunker://$validHex?relay=wss://a.com&relay=wss://b.com"
|
||||
val remote = NostrSignerRemote.fromBunkerUri(uri, signer, client)
|
||||
assertEquals(2, remote.relays.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun validWithSecret() {
|
||||
val uri = "bunker://$validHex?relay=wss://r.com&secret=abc123"
|
||||
val remote = NostrSignerRemote.fromBunkerUri(uri, signer, client)
|
||||
assertEquals("abc123", remote.secret)
|
||||
assertEquals(1, remote.relays.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun missingSchemeThrows() {
|
||||
assertFailsWith<Exception> {
|
||||
NostrSignerRemote.fromBunkerUri("npub1abc", signer, client)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invalidHexPubkeyThrows() {
|
||||
assertFailsWith<Exception> {
|
||||
NostrSignerRemote.fromBunkerUri("bunker://notHex?relay=wss://r.com", signer, client)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun missingQueryParamsThrows() {
|
||||
assertFailsWith<Exception> {
|
||||
NostrSignerRemote.fromBunkerUri("bunker://$validHex", signer, client)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun skipsMalformedParams() {
|
||||
val uri = "bunker://$validHex?relay=wss://r.com&badparam"
|
||||
val remote = NostrSignerRemote.fromBunkerUri(uri, signer, client)
|
||||
assertEquals(1, remote.relays.size)
|
||||
assertNull(remote.secret)
|
||||
}
|
||||
}
|
||||
+269
@@ -0,0 +1,269 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip46RemoteSigner.signer
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* INostrClient that records openReqSubscription calls for verification.
|
||||
* Used instead of mockk since commonTest doesn't have mockk.
|
||||
*/
|
||||
private class TrackingNostrClient : INostrClient {
|
||||
data class SubscriptionRecord(
|
||||
val subId: String,
|
||||
val filters: Map<NormalizedRelayUrl, List<Filter>>,
|
||||
)
|
||||
|
||||
val subscriptions = mutableListOf<SubscriptionRecord>()
|
||||
val sentEvents = mutableListOf<Pair<Event, Set<NormalizedRelayUrl>>>()
|
||||
|
||||
override fun connectedRelaysFlow() = MutableStateFlow(emptySet<NormalizedRelayUrl>())
|
||||
|
||||
override fun availableRelaysFlow() = MutableStateFlow(emptySet<NormalizedRelayUrl>())
|
||||
|
||||
override fun connect() {}
|
||||
|
||||
override fun disconnect() {}
|
||||
|
||||
override fun reconnect(
|
||||
onlyIfChanged: Boolean,
|
||||
ignoreRetryDelays: Boolean,
|
||||
) {}
|
||||
|
||||
override fun isActive() = false
|
||||
|
||||
override fun renewFilters(relay: IRelayClient) {}
|
||||
|
||||
override fun openReqSubscription(
|
||||
subId: String,
|
||||
filters: Map<NormalizedRelayUrl, List<Filter>>,
|
||||
listener: IRequestListener?,
|
||||
) {
|
||||
subscriptions.add(SubscriptionRecord(subId, filters))
|
||||
}
|
||||
|
||||
override fun queryCount(
|
||||
subId: String,
|
||||
filters: Map<NormalizedRelayUrl, List<Filter>>,
|
||||
) {}
|
||||
|
||||
override fun close(subId: String) {}
|
||||
|
||||
override fun send(
|
||||
event: Event,
|
||||
relayList: Set<NormalizedRelayUrl>,
|
||||
) {
|
||||
sentEvents.add(event to relayList)
|
||||
}
|
||||
|
||||
override fun subscribe(listener: IRelayClientListener) {}
|
||||
|
||||
override fun unsubscribe(listener: IRelayClientListener) {}
|
||||
|
||||
override fun getReqFiltersOrNull(subId: String): Map<NormalizedRelayUrl, List<Filter>>? = null
|
||||
|
||||
override fun getCountFiltersOrNull(subId: String): Map<NormalizedRelayUrl, List<Filter>>? = null
|
||||
|
||||
override fun activeRequests(url: NormalizedRelayUrl): Map<String, List<Filter>> = emptyMap()
|
||||
|
||||
override fun activeCounts(url: NormalizedRelayUrl): Map<String, List<Filter>> = emptyMap()
|
||||
|
||||
override fun activeOutboxCache(url: NormalizedRelayUrl): Set<String> = emptySet()
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests verifying NIP-46 relay isolation at the NostrSignerRemote level.
|
||||
* Ensures subscription filters only target bunker relays and contain
|
||||
* the correct kind + p-tag.
|
||||
*/
|
||||
class NostrSignerRemoteIsolationTest {
|
||||
private val bunkerRelay = NormalizedRelayUrl("wss://relay.nsec.app/")
|
||||
private val generalRelay = NormalizedRelayUrl("wss://relay.damus.io/")
|
||||
private val validHex = "a".repeat(64)
|
||||
|
||||
@Test
|
||||
fun subscriptionFilterTargetsOnlyBunkerRelays() {
|
||||
val trackingClient = TrackingNostrClient()
|
||||
val ephemeralSigner = NostrSignerInternal(KeyPair())
|
||||
|
||||
// Construction triggers client.req() which calls openReqSubscription
|
||||
NostrSignerRemote(
|
||||
signer = ephemeralSigner,
|
||||
remotePubkey = validHex,
|
||||
relays = setOf(bunkerRelay),
|
||||
client = trackingClient,
|
||||
)
|
||||
|
||||
// Verify subscription was opened
|
||||
assertTrue(
|
||||
trackingClient.subscriptions.isNotEmpty(),
|
||||
"No subscription opened on construction",
|
||||
)
|
||||
|
||||
// Verify ALL filters only target the bunker relay
|
||||
trackingClient.subscriptions.forEach { record ->
|
||||
assertTrue(
|
||||
record.filters.keys.all { it == bunkerRelay },
|
||||
"Filter contains non-bunker relay: ${record.filters.keys}",
|
||||
)
|
||||
assertTrue(
|
||||
generalRelay !in record.filters.keys,
|
||||
"General relay leaked into NIP-46 subscription",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun subscriptionFilterContainsCorrectKindAndPTag() {
|
||||
val trackingClient = TrackingNostrClient()
|
||||
val ephemeralKeyPair = KeyPair()
|
||||
val ephemeralSigner = NostrSignerInternal(ephemeralKeyPair)
|
||||
|
||||
NostrSignerRemote(
|
||||
signer = ephemeralSigner,
|
||||
remotePubkey = validHex,
|
||||
relays = setOf(bunkerRelay),
|
||||
client = trackingClient,
|
||||
)
|
||||
|
||||
val allFilters =
|
||||
trackingClient.subscriptions.flatMap { it.filters.values.flatten() }
|
||||
assertTrue(allFilters.isNotEmpty(), "No filters captured")
|
||||
|
||||
allFilters.forEach { filter ->
|
||||
// Must filter for NIP-46 event kind (24133)
|
||||
assertTrue(
|
||||
filter.kinds?.contains(NostrConnectEvent.KIND) == true,
|
||||
"Filter missing kind ${NostrConnectEvent.KIND}, got: ${filter.kinds}",
|
||||
)
|
||||
|
||||
// Must filter for our ephemeral pubkey in p-tag
|
||||
val pTags = filter.tags?.get("p")
|
||||
assertNotNull(pTags, "Filter missing p-tag")
|
||||
assertTrue(
|
||||
pTags.contains(ephemeralSigner.pubKey),
|
||||
"Filter p-tag doesn't contain ephemeral pubkey",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun multipleRelaysAllIncludedInFilter() {
|
||||
val trackingClient = TrackingNostrClient()
|
||||
val relay1 = NormalizedRelayUrl("wss://relay1.nsec.app/")
|
||||
val relay2 = NormalizedRelayUrl("wss://relay2.nsec.app/")
|
||||
|
||||
NostrSignerRemote(
|
||||
signer = NostrSignerInternal(KeyPair()),
|
||||
remotePubkey = validHex,
|
||||
relays = setOf(relay1, relay2),
|
||||
client = trackingClient,
|
||||
)
|
||||
|
||||
assertTrue(trackingClient.subscriptions.isNotEmpty())
|
||||
val allRelayKeys =
|
||||
trackingClient.subscriptions.flatMap { it.filters.keys }.toSet()
|
||||
|
||||
assertTrue(relay1 in allRelayKeys, "Missing relay1 in subscription")
|
||||
assertTrue(relay2 in allRelayKeys, "Missing relay2 in subscription")
|
||||
// General relay should NOT be present
|
||||
assertTrue(
|
||||
generalRelay !in allRelayKeys,
|
||||
"General relay leaked into multi-relay subscription",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify subscription filter does NOT use a `since` timestamp,
|
||||
* matching upstream behavior (PR #1789 removed it).
|
||||
*/
|
||||
@Test
|
||||
fun subscriptionFilterHasNoSinceTimestamp() {
|
||||
val trackingClient = TrackingNostrClient()
|
||||
|
||||
NostrSignerRemote(
|
||||
signer = NostrSignerInternal(KeyPair()),
|
||||
remotePubkey = validHex,
|
||||
relays = setOf(bunkerRelay),
|
||||
client = trackingClient,
|
||||
)
|
||||
|
||||
val allFilters =
|
||||
trackingClient.subscriptions.flatMap { it.filters.values.flatten() }
|
||||
assertTrue(allFilters.isNotEmpty())
|
||||
|
||||
allFilters.forEach { filter ->
|
||||
assertTrue(
|
||||
filter.since == null,
|
||||
"Filter should not have a 'since' timestamp",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fromBunkerUriPreservesRelayIsolation() {
|
||||
val trackingClient = TrackingNostrClient()
|
||||
val ephemeralSigner = NostrSignerInternal(KeyPair())
|
||||
|
||||
val remote =
|
||||
NostrSignerRemote.fromBunkerUri(
|
||||
"bunker://$validHex?relay=wss://relay.nsec.app",
|
||||
ephemeralSigner,
|
||||
trackingClient,
|
||||
)
|
||||
|
||||
// Verify relay set matches URI
|
||||
assertEquals(1, remote.relays.size)
|
||||
val parsedRelay = remote.relays.first()
|
||||
assertTrue(
|
||||
parsedRelay.url.contains("relay.nsec.app"),
|
||||
"Parsed relay doesn't match URI: ${parsedRelay.url}",
|
||||
)
|
||||
|
||||
// Verify subscription was opened targeting ONLY the bunker relay
|
||||
assertTrue(trackingClient.subscriptions.isNotEmpty())
|
||||
trackingClient.subscriptions.forEach { record ->
|
||||
assertTrue(
|
||||
record.filters.keys.all { it in remote.relays },
|
||||
"fromBunkerUri subscription targets wrong relays: ${record.filters.keys}",
|
||||
)
|
||||
// General relay must NOT appear
|
||||
assertTrue(
|
||||
generalRelay !in record.filters.keys,
|
||||
"General relay leaked into fromBunkerUri subscription",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user