* '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:
Vitor Pamplona
2026-03-11 08:42:44 -04:00
53 changed files with 6037 additions and 268 deletions
@@ -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">(0100)%</string>
<string name="poll_closing_date_time">Date &amp; 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 &amp; 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>
@@ -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,
)
@@ -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)
}
}
@@ -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) }
}
}
@@ -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()
}
@@ -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 -> {}
}
}
}
+7
View File
@@ -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")
}
}
}
@@ -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("&")}"
}
@@ -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
}
@@ -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()
}
@@ -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)
@@ -221,6 +221,6 @@ open class RelayConnectionManager(
cmd: Command,
success: Boolean,
) {
// Command send tracking
// Command send tracking — no-op for now
}
}
@@ -45,5 +45,6 @@ object DefaultRelays {
"wss://nos.lol",
"wss://relay.snort.social",
"wss://nostr.wine",
"wss://relay.primal.net",
)
}
@@ -101,7 +101,9 @@ fun rememberSubscription(
}
onDispose {
subscription?.let { relayManager.unsubscribe(it.subId) }
subscription?.let {
relayManager.unsubscribe(it.subId)
}
}
}
@@ -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)
},
@@ -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
}
@@ -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),
)
}
}
}
}
}
}
}
@@ -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) {
@@ -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) {
@@ -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)
@@ -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")
}
},
)
}
@@ -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,
)
}
}
}
@@ -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
}
},
)
}
}
@@ -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()
}
@@ -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) {
@@ -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,
@@ -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()
@@ -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)
}
}
@@ -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()
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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",
)
}
}
@@ -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()
}
}
@@ -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?
@@ -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
@@ -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)
}
@@ -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())
}
}
@@ -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"))
}
}
@@ -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)
}
}
@@ -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",
)
}
}
}