Compare commits
42 Commits
6f0f95f4fb
...
3465acc57b
| Author | SHA1 | Date | |
|---|---|---|---|
| 3465acc57b | |||
| 30bd0d99ff | |||
| 29fbe46cce | |||
| fc414eeed4 | |||
| fce873936e | |||
| 7351d8a06d | |||
| 76093fe82d | |||
| ef247c95ff | |||
| c291e8a587 | |||
| 06e55d6394 | |||
| 2072f70a60 | |||
| ee9ea15233 | |||
| 11de117331 | |||
| fac0245297 | |||
| 9cb91415d9 | |||
| 24c661f743 | |||
| fecc9015fb | |||
| bce69362eb | |||
| 7f46d9e2d5 | |||
| 100ed01469 | |||
| 055685a4c4 | |||
| 46f93e380b | |||
| 6f6ad89c9f | |||
| d9911a9bc9 | |||
| faf77de9ce | |||
| 7f414b3dcf | |||
| 994ddea6cd | |||
| 41e4e73e63 | |||
| 88091bd77c | |||
| 02839dc415 | |||
| 17b70a1388 | |||
| 42d59709f9 | |||
| 30bc80b2f2 | |||
| 3808b3ee82 | |||
| bb5124c1ae | |||
| e6c78da062 | |||
| 5874142186 | |||
| 928335dd6b | |||
| 63dcda0ad1 | |||
| 122db9d903 | |||
| fc509a9340 | |||
| f546d3e2b1 |
@@ -67,7 +67,8 @@
|
||||
//#define IMGUI_USE_LEGACY_CRC32_ADLER
|
||||
|
||||
//---- Use 32-bit for ImWchar (default is 16-bit) to support Unicode planes 1-16. (e.g. point beyond 0xFFFF like emoticons, dingbats, symbols, shapes, ancient languages, etc...)
|
||||
//#define IMGUI_USE_WCHAR32
|
||||
//---- Enabled so chat can render emoji (U+1F300+, above the BMP) — see Typography::loadFont emoji merge (Q12).
|
||||
#define IMGUI_USE_WCHAR32
|
||||
|
||||
//---- Avoid multiple STB libraries implementations, or redefine path/filenames to prioritize another version
|
||||
// By default the embedded implementations are declared static and not available outside of Dear ImGui sources files.
|
||||
|
||||
BIN
res/fonts/NotoEmoji-Subset.ttf
Normal file
BIN
res/fonts/NotoEmoji-Subset.ttf
Normal file
Binary file not shown.
Binary file not shown.
@@ -135,25 +135,48 @@
|
||||
"change_pass_title": "Passphrase ändern",
|
||||
"characters": "Zeichen",
|
||||
"chat": "Chat",
|
||||
"chat_add_contact": "Kontakt hinzufügen",
|
||||
"chat_cancel": "Abbrechen",
|
||||
"chat_contact_added": "Kontakt hinzugefügt – benenne ihn in Kontakte um",
|
||||
"chat_contact_request": "kontaktanfrage",
|
||||
"chat_empty_hint": "Noch keine Unterhaltungen. Nachrichten, die du erhältst, erscheinen hier.",
|
||||
"chat_empty_start": "Starte eine mit \"Neue Unterhaltung\".",
|
||||
"chat_empty_title": "Noch keine Unterhaltungen",
|
||||
"chat_export": "Chat exportieren…",
|
||||
"chat_export_done": "Unterhaltung exportiert",
|
||||
"chat_export_failed": "Exportdatei konnte nicht geschrieben werden.",
|
||||
"chat_export_warn": "Speichert die entschlüsselten Nachrichten als Klartext. Bewahre die Datei sicher auf.",
|
||||
"chat_hidden_toast": "Unterhaltung ausgeblendet – eine neue Nachricht holt sie zurück",
|
||||
"chat_hide": "Ausblenden",
|
||||
"chat_jump_latest": "Neueste",
|
||||
"chat_len_over": "Nachricht zu lang",
|
||||
"chat_locked_hint": "Entsperre deine Wallet, um deine Chats zu laden.",
|
||||
"chat_mute": "Stummschalten",
|
||||
"chat_new_button": "Neue Unterhaltung",
|
||||
"chat_new_message": "Nachricht",
|
||||
"chat_new_message_toast": "Neue verschlüsselte Chat-Nachricht",
|
||||
"chat_new_send": "Anfrage senden",
|
||||
"chat_new_title": "Neue Unterhaltung",
|
||||
"chat_new_zaddr": "z-Adresse des Empfängers",
|
||||
"chat_no_matches": "Keine Unterhaltungen entsprechen deiner Suche.",
|
||||
"chat_no_z_contacts": "Noch keine Kontakte mit geschützter Adresse",
|
||||
"chat_pick_contact": "Aus Kontakten wählen…",
|
||||
"chat_retry": "Wiederholen",
|
||||
"chat_search": "Unterhaltungen durchsuchen",
|
||||
"chat_select_hint": "Wähle eine Unterhaltung aus, um sie anzuzeigen.",
|
||||
"chat_send": "Senden",
|
||||
"chat_send_failed": "nicht gesendet",
|
||||
"chat_sending": "senden…",
|
||||
"chat_time_now": "jetzt",
|
||||
"chat_toast_compose_failed": "Nachricht konnte nicht erstellt werden (zu lang?).",
|
||||
"chat_toast_lite_busy": "Es wird bereits gesendet, oder es ist keine Wallet geöffnet.",
|
||||
"chat_toast_need_funds": "Ein kleines geschütztes Guthaben ist nötig, um Chats zu senden (zur Deckung der Gebühr).",
|
||||
"chat_toast_no_zaddr": "Keine z-Adresse verfügbar, um den Chat zu senden.",
|
||||
"chat_toast_not_connected": "Nicht verbunden – Nachricht nicht gesendet.",
|
||||
"chat_toast_request_compose_failed": "Kontaktanfrage konnte nicht erstellt werden (ungültige Adresse / ungültiger Text?).",
|
||||
"chat_toast_request_queued": "Kontaktanfrage in Warteschlange.",
|
||||
"chat_toast_waiting_reply": "Warte auf die Antwort des Kontakts, bevor du ihm schreiben kannst.",
|
||||
"chat_unmute": "Stummschaltung aufheben",
|
||||
"chat_waiting_reply": "Warte auf die Antwort dieses Kontakts – sobald er antwortet, kannst du ihm schreiben.",
|
||||
"chat_you": "Du",
|
||||
"choose_icon": "Symbol wählen",
|
||||
@@ -305,6 +328,7 @@
|
||||
"contact_global_tt": "Ein: Dieser Kontakt bleibt sichtbar, egal welche Wallet Sie laden. Aus: Er gehört nur zur aktuellen Wallet.",
|
||||
"contact_preview_addr": "Adresse erscheint hier",
|
||||
"contact_preview_name": "Kontaktname",
|
||||
"contact_wallet_loading": "Die Wallet lädt noch — aktiviere „In jeder Wallet anzeigen“ oder versuche es gleich erneut.",
|
||||
"contacts": "Kontakte",
|
||||
"contacts_search_no_match": "Keine passenden Kontakte",
|
||||
"contacts_search_placeholder": "Kontakte durchsuchen...",
|
||||
@@ -977,6 +1001,7 @@
|
||||
"portfolio_style_featured": "Hervorgehobene Zeilen",
|
||||
"portfolio_style_label": "Portfolio-Stil",
|
||||
"portfolio_untitled": "Ohne Titel",
|
||||
"portfolio_wallet_loading": "Warte, bis die Wallet fertig geladen ist, um eine Gruppe hinzuzufügen.",
|
||||
"price_chart": "Preisdiagramm",
|
||||
"privacy_great": "Großartige Privatsphäre!",
|
||||
"privacy_low": "Geringe Privatsphäre — Gelder abschirmen",
|
||||
@@ -1292,6 +1317,23 @@
|
||||
"sweep_to": "Gefegt an:",
|
||||
"sweep_toggle": "In meine Wallet fegen (Schlüssel nicht behalten)",
|
||||
"sweep_tx": "Transaktion:",
|
||||
"switch_corrupt_body": "Diese Wallet scheint beschädigt zu sein – der Knoten konnte sie nicht öffnen. Aus einem Backup wiederherstellen, neu erstellen oder eine Reparatur versuchen.",
|
||||
"switch_corrupt_repair": "Reparatur versuchen (Salvage)",
|
||||
"switch_progress_background": "Im Hintergrund fortsetzen",
|
||||
"switch_progress_default_wallet": "Standard-Wallet",
|
||||
"switch_progress_elapsed": "Vergangen",
|
||||
"switch_progress_external_wallet": "Externe Wallet",
|
||||
"switch_progress_failed_title": "Wallet-Wechsel fehlgeschlagen",
|
||||
"switch_progress_from_label": "von",
|
||||
"switch_progress_hint": "Ein sauberes Herunterfahren kann bis zu einer Minute dauern.",
|
||||
"switch_progress_reconnecting": "Neu verbinden",
|
||||
"switch_progress_starting": "Knoten wird mit der neuen Wallet gestartet",
|
||||
"switch_progress_stopping": "Aktueller Knoten wird gestoppt",
|
||||
"switch_progress_title": "Wallet wird gewechselt",
|
||||
"switch_stopnode_body": "Beim Wallet-Wechsel wird der Knoten mit der ausgewählten Wallet neu gestartet. Der laufende Knoten wird gestoppt und mit der neuen Wallet neu gestartet – wenn Sie ihn absichtlich laufen ließen, startet er automatisch wieder.",
|
||||
"switch_stopnode_confirm": "Knoten stoppen & wechseln",
|
||||
"switch_stopnode_title": "Laufenden Knoten stoppen?",
|
||||
"switch_stopnode_warn": "Es läuft bereits ein Knoten, den diese Wallet nicht gestartet hat.",
|
||||
"syncing": "Synchronisiere...",
|
||||
"t_address": "T-Adresse",
|
||||
"t_addresses": "T-Adressen",
|
||||
@@ -1472,6 +1514,8 @@
|
||||
"wallets_add_folder_toggle": "+ Weiteren Ordner nach Wallets durchsuchen…",
|
||||
"wallets_badge_encrypted": "Verschlüsselt (passphrasengeschützt)",
|
||||
"wallets_badge_encrypted_short": "Verschlüsselt",
|
||||
"wallets_badge_hd": "HD-Wallet – Seed-Phrase ohne Öffnen nicht bestätigbar",
|
||||
"wallets_badge_hd_short": "HD-Wallet",
|
||||
"wallets_badge_legacy": "Legacy-Wallet (keine Seed-Phrase)",
|
||||
"wallets_badge_legacy_short": "Legacy",
|
||||
"wallets_badge_seed": "Seed-Phrase-Wallet (HD)",
|
||||
|
||||
@@ -135,25 +135,48 @@
|
||||
"change_pass_title": "Cambiar frase de contraseña",
|
||||
"characters": "caracteres",
|
||||
"chat": "Chat",
|
||||
"chat_add_contact": "Añadir contacto",
|
||||
"chat_cancel": "Cancelar",
|
||||
"chat_contact_added": "Contacto añadido: renómbralo en Contactos",
|
||||
"chat_contact_request": "solicitud de contacto",
|
||||
"chat_empty_hint": "Aún no hay conversaciones. Los mensajes que recibas aparecerán aquí.",
|
||||
"chat_empty_start": "Inicia una con \"Nueva conversación\".",
|
||||
"chat_empty_title": "Aún no hay conversaciones",
|
||||
"chat_export": "Exportar chat…",
|
||||
"chat_export_done": "Conversación exportada",
|
||||
"chat_export_failed": "No se pudo escribir el archivo de exportación.",
|
||||
"chat_export_warn": "Guarda los mensajes descifrados como texto sin cifrar. Guarda el archivo de forma segura.",
|
||||
"chat_hidden_toast": "Conversación oculta: un mensaje nuevo la recupera",
|
||||
"chat_hide": "Ocultar",
|
||||
"chat_jump_latest": "Recientes",
|
||||
"chat_len_over": "Mensaje demasiado largo",
|
||||
"chat_locked_hint": "Desbloquea tu monedero para cargar tus chats.",
|
||||
"chat_mute": "Silenciar",
|
||||
"chat_new_button": "Nueva conversación",
|
||||
"chat_new_message": "Mensaje",
|
||||
"chat_new_message_toast": "Nuevo mensaje de chat cifrado",
|
||||
"chat_new_send": "Enviar solicitud",
|
||||
"chat_new_title": "Nueva conversación",
|
||||
"chat_new_zaddr": "Dirección z del destinatario",
|
||||
"chat_no_matches": "Ninguna conversación coincide con tu búsqueda.",
|
||||
"chat_no_z_contacts": "Aún no hay contactos con dirección blindada",
|
||||
"chat_pick_contact": "Elegir de contactos…",
|
||||
"chat_retry": "Reintentar",
|
||||
"chat_search": "Buscar conversaciones",
|
||||
"chat_select_hint": "Selecciona una conversación para verla.",
|
||||
"chat_send": "Enviar",
|
||||
"chat_send_failed": "no enviado",
|
||||
"chat_sending": "enviando…",
|
||||
"chat_time_now": "ahora",
|
||||
"chat_toast_compose_failed": "No se pudo componer el mensaje (¿demasiado largo?).",
|
||||
"chat_toast_lite_busy": "Ya hay un envío en curso, o no hay ningún monedero abierto.",
|
||||
"chat_toast_need_funds": "Necesitas un pequeño saldo blindado para enviar chats (para cubrir la comisión).",
|
||||
"chat_toast_no_zaddr": "No hay ninguna dirección z disponible desde la que enviar el chat.",
|
||||
"chat_toast_not_connected": "Sin conexión: mensaje de chat no enviado.",
|
||||
"chat_toast_request_compose_failed": "No se pudo componer la solicitud de contacto (¿dirección o texto no válidos?).",
|
||||
"chat_toast_request_queued": "Solicitud de contacto en cola.",
|
||||
"chat_toast_waiting_reply": "Espera a que este contacto responda antes de poder escribirle.",
|
||||
"chat_unmute": "Reactivar",
|
||||
"chat_waiting_reply": "Esperando a que este contacto responda: podrás escribirle una vez lo haga.",
|
||||
"chat_you": "Tú",
|
||||
"choose_icon": "Elegir Icono",
|
||||
@@ -305,6 +328,7 @@
|
||||
"contact_global_tt": "Activado: este contacto permanece visible sin importar qué cartera cargues. Desactivado: pertenece solo a la cartera actual.",
|
||||
"contact_preview_addr": "La dirección aparecerá aquí",
|
||||
"contact_preview_name": "Nombre del contacto",
|
||||
"contact_wallet_loading": "La cartera aún se está cargando: marca «Mostrar en todas las carteras» o inténtalo de nuevo en un momento.",
|
||||
"contacts": "Contactos",
|
||||
"contacts_search_no_match": "No hay contactos coincidentes",
|
||||
"contacts_search_placeholder": "Buscar contactos...",
|
||||
@@ -977,6 +1001,7 @@
|
||||
"portfolio_style_featured": "Filas destacadas",
|
||||
"portfolio_style_label": "Estilo de cartera",
|
||||
"portfolio_untitled": "Sin título",
|
||||
"portfolio_wallet_loading": "Espera a que la cartera termine de cargar para añadir un grupo.",
|
||||
"price_chart": "Gráfico de Precios",
|
||||
"privacy_great": "¡Excelente privacidad!",
|
||||
"privacy_low": "Privacidad baja: protege los fondos",
|
||||
@@ -1292,6 +1317,23 @@
|
||||
"sweep_to": "Barrido a:",
|
||||
"sweep_toggle": "Barrer a mi monedero (no conservar la clave)",
|
||||
"sweep_tx": "Transacción:",
|
||||
"switch_corrupt_body": "Esta cartera parece dañada: el nodo no pudo abrirla. Restáurala desde una copia de seguridad, vuelve a crearla o intenta repararla.",
|
||||
"switch_corrupt_repair": "Intentar reparar (salvage)",
|
||||
"switch_progress_background": "Continuar en segundo plano",
|
||||
"switch_progress_default_wallet": "Cartera predeterminada",
|
||||
"switch_progress_elapsed": "Transcurrido",
|
||||
"switch_progress_external_wallet": "Cartera externa",
|
||||
"switch_progress_failed_title": "Error al cambiar de cartera",
|
||||
"switch_progress_from_label": "desde",
|
||||
"switch_progress_hint": "Un apagado ordenado puede tardar hasta un minuto.",
|
||||
"switch_progress_reconnecting": "Reconectando",
|
||||
"switch_progress_starting": "Iniciando el nodo con la nueva cartera",
|
||||
"switch_progress_stopping": "Deteniendo el nodo actual",
|
||||
"switch_progress_title": "Cambiando de cartera",
|
||||
"switch_stopnode_body": "Cambiar de cartera reinicia el nodo con la cartera seleccionada. El nodo en ejecución se detendrá y se reiniciará con la nueva cartera; si lo dejaste en marcha a propósito, volverá a iniciarse automáticamente.",
|
||||
"switch_stopnode_confirm": "Detener nodo y cambiar",
|
||||
"switch_stopnode_title": "¿Detener el nodo en ejecución?",
|
||||
"switch_stopnode_warn": "Ya hay un nodo en ejecución que esta cartera no inició.",
|
||||
"syncing": "Sincronizando...",
|
||||
"t_address": "Dirección T",
|
||||
"t_addresses": "Direcciones T",
|
||||
@@ -1472,6 +1514,8 @@
|
||||
"wallets_add_folder_toggle": "+ Buscar wallets en otra carpeta…",
|
||||
"wallets_badge_encrypted": "Cifrada (protegida con contraseña)",
|
||||
"wallets_badge_encrypted_short": "Cifrada",
|
||||
"wallets_badge_hd": "Cartera HD: no se puede confirmar la frase semilla sin abrirla",
|
||||
"wallets_badge_hd_short": "Cartera HD",
|
||||
"wallets_badge_legacy": "Billetera heredada (sin frase semilla)",
|
||||
"wallets_badge_legacy_short": "Heredada",
|
||||
"wallets_badge_seed": "Billetera con frase semilla (HD)",
|
||||
|
||||
@@ -135,25 +135,48 @@
|
||||
"change_pass_title": "Changer la phrase secrète",
|
||||
"characters": "caractères",
|
||||
"chat": "Discussion",
|
||||
"chat_add_contact": "Ajouter un contact",
|
||||
"chat_cancel": "Annuler",
|
||||
"chat_contact_added": "Contact ajouté — renommez-le dans Contacts",
|
||||
"chat_contact_request": "demande de contact",
|
||||
"chat_empty_hint": "Aucune conversation pour l'instant. Les messages que vous recevez apparaîtront ici.",
|
||||
"chat_empty_start": "Commencez-en une avec « Nouvelle conversation ».",
|
||||
"chat_empty_title": "Aucune conversation pour l'instant",
|
||||
"chat_export": "Exporter le chat…",
|
||||
"chat_export_done": "Conversation exportée",
|
||||
"chat_export_failed": "Impossible d'écrire le fichier d'exportation.",
|
||||
"chat_export_warn": "Enregistre les messages déchiffrés en texte clair. Conservez le fichier en lieu sûr.",
|
||||
"chat_hidden_toast": "Conversation masquée — un nouveau message la fait réapparaître",
|
||||
"chat_hide": "Masquer",
|
||||
"chat_jump_latest": "Récents",
|
||||
"chat_len_over": "Message trop long",
|
||||
"chat_locked_hint": "Déverrouillez votre portefeuille pour charger vos discussions.",
|
||||
"chat_mute": "Muet",
|
||||
"chat_new_button": "Nouvelle conversation",
|
||||
"chat_new_message": "Message",
|
||||
"chat_new_message_toast": "Nouveau message chiffré",
|
||||
"chat_new_send": "Envoyer la demande",
|
||||
"chat_new_title": "Nouvelle conversation",
|
||||
"chat_new_zaddr": "Adresse Z du destinataire",
|
||||
"chat_no_matches": "Aucune conversation ne correspond à votre recherche.",
|
||||
"chat_no_z_contacts": "Aucun contact avec adresse blindée pour l'instant",
|
||||
"chat_pick_contact": "Choisir dans les contacts…",
|
||||
"chat_retry": "Réessayer",
|
||||
"chat_search": "Rechercher des conversations",
|
||||
"chat_select_hint": "Sélectionnez une conversation pour l'afficher.",
|
||||
"chat_send": "Envoyer",
|
||||
"chat_send_failed": "non envoyé",
|
||||
"chat_sending": "envoi…",
|
||||
"chat_time_now": "à l'instant",
|
||||
"chat_toast_compose_failed": "Impossible de composer le message (trop long ?).",
|
||||
"chat_toast_lite_busy": "Un envoi est déjà en cours, ou aucun portefeuille n'est ouvert.",
|
||||
"chat_toast_need_funds": "Un petit solde blindé est nécessaire pour envoyer des messages (pour couvrir les frais).",
|
||||
"chat_toast_no_zaddr": "Aucune adresse Z disponible pour envoyer le message.",
|
||||
"chat_toast_not_connected": "Non connecté — message non envoyé.",
|
||||
"chat_toast_request_compose_failed": "Impossible de composer la demande de contact (adresse / texte invalide ?).",
|
||||
"chat_toast_request_queued": "Demande de contact mise en file d'attente.",
|
||||
"chat_toast_waiting_reply": "En attente de la réponse de ce contact avant de pouvoir lui écrire.",
|
||||
"chat_unmute": "Réactiver",
|
||||
"chat_waiting_reply": "En attente de la réponse de ce contact — vous pourrez lui écrire dès qu'il aura répondu.",
|
||||
"chat_you": "Vous",
|
||||
"choose_icon": "Choisir une icône",
|
||||
@@ -305,6 +328,7 @@
|
||||
"contact_global_tt": "Activé : ce contact reste visible quel que soit le portefeuille chargé. Désactivé : il appartient au portefeuille actuel uniquement.",
|
||||
"contact_preview_addr": "L'adresse apparaîtra ici",
|
||||
"contact_preview_name": "Nom du contact",
|
||||
"contact_wallet_loading": "Le portefeuille se charge encore — cochez « Afficher dans chaque portefeuille » ou réessayez dans un instant.",
|
||||
"contacts": "Contacts",
|
||||
"contacts_search_no_match": "Aucun contact correspondant",
|
||||
"contacts_search_placeholder": "Rechercher des contacts...",
|
||||
@@ -977,6 +1001,7 @@
|
||||
"portfolio_style_featured": "Lignes en vedette",
|
||||
"portfolio_style_label": "Style du portefeuille",
|
||||
"portfolio_untitled": "Sans titre",
|
||||
"portfolio_wallet_loading": "Attendez la fin du chargement du portefeuille pour ajouter un groupe.",
|
||||
"price_chart": "Graphique des prix",
|
||||
"privacy_great": "Excellente confidentialité !",
|
||||
"privacy_low": "Faible confidentialité — blindez vos fonds",
|
||||
@@ -1292,6 +1317,23 @@
|
||||
"sweep_to": "Balayé vers :",
|
||||
"sweep_toggle": "Balayer vers mon portefeuille (ne pas conserver la clé)",
|
||||
"sweep_tx": "Transaction :",
|
||||
"switch_corrupt_body": "Ce portefeuille semble corrompu — le nœud n'a pas pu l'ouvrir. Restaurez-le depuis une sauvegarde, recréez-le ou tentez de le réparer.",
|
||||
"switch_corrupt_repair": "Tenter une réparation (salvage)",
|
||||
"switch_progress_background": "Continuer en arrière-plan",
|
||||
"switch_progress_default_wallet": "Portefeuille par défaut",
|
||||
"switch_progress_elapsed": "Écoulé",
|
||||
"switch_progress_external_wallet": "Portefeuille externe",
|
||||
"switch_progress_failed_title": "Échec du changement de portefeuille",
|
||||
"switch_progress_from_label": "depuis",
|
||||
"switch_progress_hint": "Un arrêt propre peut prendre jusqu'à une minute.",
|
||||
"switch_progress_reconnecting": "Reconnexion",
|
||||
"switch_progress_starting": "Démarrage du nœud sur le nouveau portefeuille",
|
||||
"switch_progress_stopping": "Arrêt du nœud actuel",
|
||||
"switch_progress_title": "Changement de portefeuille",
|
||||
"switch_stopnode_body": "Changer de portefeuille redémarre le nœud sur le portefeuille sélectionné. Le nœud en cours sera arrêté puis relancé sur le nouveau portefeuille — si vous l'avez laissé tourner exprès, il redémarre automatiquement.",
|
||||
"switch_stopnode_confirm": "Arrêter le nœud et changer",
|
||||
"switch_stopnode_title": "Arrêter le nœud en cours d'exécution ?",
|
||||
"switch_stopnode_warn": "Un nœud que ce portefeuille n'a pas démarré est déjà en cours d'exécution.",
|
||||
"syncing": "Synchronisation...",
|
||||
"t_address": "Adresse T",
|
||||
"t_addresses": "Adresses T",
|
||||
@@ -1472,6 +1514,8 @@
|
||||
"wallets_add_folder_toggle": "+ Analyser un autre dossier pour les portefeuilles…",
|
||||
"wallets_badge_encrypted": "Chiffré (protégé par phrase secrète)",
|
||||
"wallets_badge_encrypted_short": "Chiffré",
|
||||
"wallets_badge_hd": "Portefeuille HD — impossible de confirmer une phrase de récupération sans l'ouvrir",
|
||||
"wallets_badge_hd_short": "Portefeuille HD",
|
||||
"wallets_badge_legacy": "Portefeuille hérité (sans phrase de récupération)",
|
||||
"wallets_badge_legacy_short": "Hérité",
|
||||
"wallets_badge_seed": "Portefeuille à phrase de récupération (HD)",
|
||||
|
||||
@@ -135,25 +135,48 @@
|
||||
"change_pass_title": "パスフレーズを変更",
|
||||
"characters": "文字",
|
||||
"chat": "チャット",
|
||||
"chat_add_contact": "連絡先に追加",
|
||||
"chat_cancel": "キャンセル",
|
||||
"chat_contact_added": "連絡先を追加しました — 連絡先で名前を変更できます",
|
||||
"chat_contact_request": "連絡リクエスト",
|
||||
"chat_empty_hint": "まだ会話はありません。受信したメッセージはここに表示されます。",
|
||||
"chat_empty_start": "「新しい会話」から始めましょう。",
|
||||
"chat_empty_title": "会話はまだありません",
|
||||
"chat_export": "チャットをエクスポート…",
|
||||
"chat_export_done": "会話をエクスポートしました",
|
||||
"chat_export_failed": "エクスポートファイルを書き込めませんでした。",
|
||||
"chat_export_warn": "復号したメッセージを平文で保存します。ファイルは安全に保管してください。",
|
||||
"chat_hidden_toast": "会話を非表示にしました — 新しいメッセージが届くと再表示されます",
|
||||
"chat_hide": "非表示",
|
||||
"chat_jump_latest": "最新",
|
||||
"chat_len_over": "メッセージが長すぎます",
|
||||
"chat_locked_hint": "チャットを読み込むにはウォレットのロックを解除してください。",
|
||||
"chat_mute": "ミュート",
|
||||
"chat_new_button": "新しい会話",
|
||||
"chat_new_message": "メッセージ",
|
||||
"chat_new_message_toast": "新しい暗号化チャットメッセージ",
|
||||
"chat_new_send": "リクエストを送信",
|
||||
"chat_new_title": "新しい会話",
|
||||
"chat_new_zaddr": "宛先Zアドレス",
|
||||
"chat_no_matches": "検索に一致する会話がありません。",
|
||||
"chat_no_z_contacts": "シールドアドレスの連絡先はまだありません",
|
||||
"chat_pick_contact": "連絡先から選択…",
|
||||
"chat_retry": "再送信",
|
||||
"chat_search": "会話を検索",
|
||||
"chat_select_hint": "表示する会話を選択してください。",
|
||||
"chat_send": "送信",
|
||||
"chat_send_failed": "未送信",
|
||||
"chat_sending": "送信中…",
|
||||
"chat_time_now": "たった今",
|
||||
"chat_toast_compose_failed": "メッセージを作成できませんでした(長すぎませんか?)。",
|
||||
"chat_toast_lite_busy": "すでに送信処理が進行中か、ウォレットが開かれていません。",
|
||||
"chat_toast_need_funds": "チャットを送信するには、手数料を賄うための少額のシールド残高が必要です。",
|
||||
"chat_toast_no_zaddr": "送信元に使えるZアドレスがありません。",
|
||||
"chat_toast_not_connected": "未接続 — チャットメッセージは送信されませんでした。",
|
||||
"chat_toast_request_compose_failed": "連絡リクエストを作成できませんでした(アドレスまたはテキストが無効?)。",
|
||||
"chat_toast_request_queued": "連絡リクエストを送信待ちに追加しました。",
|
||||
"chat_toast_waiting_reply": "メッセージを送るには、この相手からの返信を待つ必要があります。",
|
||||
"chat_unmute": "ミュート解除",
|
||||
"chat_waiting_reply": "この相手からの返信を待っています — 返信があればメッセージを送れます。",
|
||||
"chat_you": "自分",
|
||||
"choose_icon": "アイコンを選択",
|
||||
@@ -305,6 +328,7 @@
|
||||
"contact_global_tt": "オン:この連絡先はどのウォレットを読み込んでも表示されます。オフ:現在のウォレットにのみ属します。",
|
||||
"contact_preview_addr": "ここにアドレスが表示されます",
|
||||
"contact_preview_name": "連絡先名",
|
||||
"contact_wallet_loading": "ウォレットを読み込み中です。「すべてのウォレットに表示」にチェックするか、少し待ってから再試行してください。",
|
||||
"contacts": "連絡先",
|
||||
"contacts_search_no_match": "一致する連絡先がありません",
|
||||
"contacts_search_placeholder": "連絡先を検索...",
|
||||
@@ -977,6 +1001,7 @@
|
||||
"portfolio_style_featured": "注目行",
|
||||
"portfolio_style_label": "ポートフォリオスタイル",
|
||||
"portfolio_untitled": "無題",
|
||||
"portfolio_wallet_loading": "ウォレットの読み込みが終わってからグループを追加してください。",
|
||||
"price_chart": "価格チャート",
|
||||
"privacy_great": "優れたプライバシーです!",
|
||||
"privacy_low": "プライバシーが低い — 資金をシールドしてください",
|
||||
@@ -1292,6 +1317,23 @@
|
||||
"sweep_to": "集約先:",
|
||||
"sweep_toggle": "ウォレットに集約(鍵は保持しない)",
|
||||
"sweep_tx": "取引:",
|
||||
"switch_corrupt_body": "このウォレットは破損しているようです。ノードが開けませんでした。バックアップから復元するか、作り直すか、修復を試してください。",
|
||||
"switch_corrupt_repair": "修復を試す(salvage)",
|
||||
"switch_progress_background": "バックグラウンドで続行",
|
||||
"switch_progress_default_wallet": "既定のウォレット",
|
||||
"switch_progress_elapsed": "経過",
|
||||
"switch_progress_external_wallet": "外部ウォレット",
|
||||
"switch_progress_failed_title": "ウォレットの切り替えに失敗しました",
|
||||
"switch_progress_from_label": "元:",
|
||||
"switch_progress_hint": "正常なシャットダウンには最大1分かかることがあります。",
|
||||
"switch_progress_reconnecting": "再接続しています",
|
||||
"switch_progress_starting": "新しいウォレットでノードを起動しています",
|
||||
"switch_progress_stopping": "現在のノードを停止しています",
|
||||
"switch_progress_title": "ウォレットを切り替え中",
|
||||
"switch_stopnode_body": "ウォレットを切り替えると、選択したウォレットでノードが再起動します。実行中のノードは停止され、新しいウォレットで再起動されます。意図的に起動したままにしていた場合は、自動的に復帰します。",
|
||||
"switch_stopnode_confirm": "ノードを停止して切り替え",
|
||||
"switch_stopnode_title": "実行中のノードを停止しますか?",
|
||||
"switch_stopnode_warn": "このウォレットが起動していないノードがすでに実行中です。",
|
||||
"syncing": "同期中...",
|
||||
"t_address": "Tアドレス",
|
||||
"t_addresses": "Tアドレス",
|
||||
@@ -1472,6 +1514,8 @@
|
||||
"wallets_add_folder_toggle": "+ 別のフォルダーをウォレット検索…",
|
||||
"wallets_badge_encrypted": "暗号化済み(パスフレーズ保護)",
|
||||
"wallets_badge_encrypted_short": "暗号化",
|
||||
"wallets_badge_hd": "HDウォレット — 開かないとシードフレーズを確認できません",
|
||||
"wallets_badge_hd_short": "HDウォレット",
|
||||
"wallets_badge_legacy": "レガシーウォレット(シードフレーズなし)",
|
||||
"wallets_badge_legacy_short": "レガシー",
|
||||
"wallets_badge_seed": "シードフレーズウォレット (HD)",
|
||||
|
||||
@@ -135,25 +135,48 @@
|
||||
"change_pass_title": "암호 변경",
|
||||
"characters": "문자",
|
||||
"chat": "채팅",
|
||||
"chat_add_contact": "연락처 추가",
|
||||
"chat_cancel": "취소",
|
||||
"chat_contact_added": "연락처 추가됨 — 연락처에서 이름을 변경하세요",
|
||||
"chat_contact_request": "연락 요청",
|
||||
"chat_empty_hint": "아직 대화가 없습니다. 받은 메시지가 여기에 표시됩니다.",
|
||||
"chat_empty_start": "\"새 대화\"로 시작하세요.",
|
||||
"chat_empty_title": "아직 대화가 없습니다",
|
||||
"chat_export": "채팅 내보내기…",
|
||||
"chat_export_done": "대화를 내보냈습니다",
|
||||
"chat_export_failed": "내보내기 파일을 쓸 수 없습니다.",
|
||||
"chat_export_warn": "복호화된 메시지를 일반 텍스트로 저장합니다. 파일을 안전하게 보관하세요.",
|
||||
"chat_hidden_toast": "대화를 숨겼습니다 — 새 메시지가 오면 다시 표시됩니다",
|
||||
"chat_hide": "숨기기",
|
||||
"chat_jump_latest": "최신",
|
||||
"chat_len_over": "메시지가 너무 깁니다",
|
||||
"chat_locked_hint": "채팅을 불러오려면 지갑 잠금을 해제하세요.",
|
||||
"chat_mute": "음소거",
|
||||
"chat_new_button": "새 대화",
|
||||
"chat_new_message": "메시지",
|
||||
"chat_new_message_toast": "새 암호화 채팅 메시지",
|
||||
"chat_new_send": "요청 보내기",
|
||||
"chat_new_title": "새 대화",
|
||||
"chat_new_zaddr": "받는 사람 z-주소",
|
||||
"chat_no_matches": "검색과 일치하는 대화가 없습니다.",
|
||||
"chat_no_z_contacts": "보호 주소 연락처가 아직 없습니다",
|
||||
"chat_pick_contact": "연락처에서 선택…",
|
||||
"chat_retry": "다시 시도",
|
||||
"chat_search": "대화 검색",
|
||||
"chat_select_hint": "볼 대화를 선택하세요.",
|
||||
"chat_send": "전송",
|
||||
"chat_send_failed": "전송 안 됨",
|
||||
"chat_sending": "전송 중…",
|
||||
"chat_time_now": "방금",
|
||||
"chat_toast_compose_failed": "메시지를 작성할 수 없습니다 (너무 긴가요?).",
|
||||
"chat_toast_lite_busy": "이미 전송이 진행 중이거나 열린 지갑이 없습니다.",
|
||||
"chat_toast_need_funds": "채팅을 보내려면 수수료를 낼 소액의 보호 잔액이 필요합니다.",
|
||||
"chat_toast_no_zaddr": "채팅을 보낼 z-주소가 없습니다.",
|
||||
"chat_toast_not_connected": "연결되지 않음 — 채팅 메시지가 전송되지 않았습니다.",
|
||||
"chat_toast_request_compose_failed": "연락 요청을 작성할 수 없습니다 (잘못된 주소 / 텍스트?).",
|
||||
"chat_toast_request_queued": "연락 요청이 대기열에 추가되었습니다.",
|
||||
"chat_toast_waiting_reply": "상대방이 답장해야 메시지를 보낼 수 있습니다.",
|
||||
"chat_unmute": "음소거 해제",
|
||||
"chat_waiting_reply": "상대방의 답장을 기다리는 중입니다 — 답장하면 메시지를 보낼 수 있습니다.",
|
||||
"chat_you": "나",
|
||||
"choose_icon": "아이콘 선택",
|
||||
@@ -305,6 +328,7 @@
|
||||
"contact_global_tt": "켜짐: 어떤 지갑을 불러오든 이 연락처가 계속 표시됩니다. 꺼짐: 현재 지갑에만 속합니다.",
|
||||
"contact_preview_addr": "여기에 주소가 표시됩니다",
|
||||
"contact_preview_name": "연락처 이름",
|
||||
"contact_wallet_loading": "지갑을 아직 불러오는 중입니다 — “모든 지갑에 표시”를 선택하거나 잠시 후 다시 시도하세요.",
|
||||
"contacts": "연락처",
|
||||
"contacts_search_no_match": "일치하는 연락처 없음",
|
||||
"contacts_search_placeholder": "연락처 검색...",
|
||||
@@ -977,6 +1001,7 @@
|
||||
"portfolio_style_featured": "강조 행",
|
||||
"portfolio_style_label": "포트폴리오 스타일",
|
||||
"portfolio_untitled": "제목 없음",
|
||||
"portfolio_wallet_loading": "지갑 로딩이 끝난 후 그룹을 추가하세요.",
|
||||
"price_chart": "가격 차트",
|
||||
"privacy_great": "프라이버시가 우수합니다!",
|
||||
"privacy_low": "낮은 프라이버시 — 자금을 차폐하세요",
|
||||
@@ -1292,6 +1317,23 @@
|
||||
"sweep_to": "쓸어담은 주소:",
|
||||
"sweep_toggle": "내 지갑으로 쓸어담기 (키 보관 안 함)",
|
||||
"sweep_tx": "거래:",
|
||||
"switch_corrupt_body": "이 지갑이 손상된 것 같습니다. 노드가 열 수 없습니다. 백업에서 복원하거나 다시 만들거나 복구를 시도하세요.",
|
||||
"switch_corrupt_repair": "복구 시도(salvage)",
|
||||
"switch_progress_background": "백그라운드에서 계속",
|
||||
"switch_progress_default_wallet": "기본 지갑",
|
||||
"switch_progress_elapsed": "경과",
|
||||
"switch_progress_external_wallet": "외부 지갑",
|
||||
"switch_progress_failed_title": "지갑 전환 실패",
|
||||
"switch_progress_from_label": "이전:",
|
||||
"switch_progress_hint": "정상 종료에는 최대 1분이 걸릴 수 있습니다.",
|
||||
"switch_progress_reconnecting": "다시 연결하는 중",
|
||||
"switch_progress_starting": "새 지갑으로 노드를 시작하는 중",
|
||||
"switch_progress_stopping": "현재 노드를 중지하는 중",
|
||||
"switch_progress_title": "지갑 전환 중",
|
||||
"switch_stopnode_body": "지갑을 전환하면 선택한 지갑으로 노드가 다시 시작됩니다. 실행 중인 노드가 중지되고 새 지갑으로 다시 시작됩니다. 일부러 계속 실행해 두었다면 자동으로 다시 켜집니다.",
|
||||
"switch_stopnode_confirm": "노드 중지 후 전환",
|
||||
"switch_stopnode_title": "실행 중인 노드를 중지할까요?",
|
||||
"switch_stopnode_warn": "이 지갑이 시작하지 않은 노드가 이미 실행 중입니다.",
|
||||
"syncing": "동기화 중...",
|
||||
"t_address": "T 주소",
|
||||
"t_addresses": "T 주소",
|
||||
@@ -1472,6 +1514,8 @@
|
||||
"wallets_add_folder_toggle": "+ 다른 폴더에서 지갑 검색…",
|
||||
"wallets_badge_encrypted": "암호화됨 (암호로 보호됨)",
|
||||
"wallets_badge_encrypted_short": "암호화됨",
|
||||
"wallets_badge_hd": "HD 지갑 — 열지 않으면 시드 문구를 확인할 수 없습니다",
|
||||
"wallets_badge_hd_short": "HD 지갑",
|
||||
"wallets_badge_legacy": "레거시 지갑 (시드 문구 없음)",
|
||||
"wallets_badge_legacy_short": "레거시",
|
||||
"wallets_badge_seed": "시드 문구 지갑 (HD)",
|
||||
|
||||
@@ -135,25 +135,48 @@
|
||||
"change_pass_title": "Alterar senha",
|
||||
"characters": "caracteres",
|
||||
"chat": "Chat",
|
||||
"chat_add_contact": "Adicionar contato",
|
||||
"chat_cancel": "Cancelar",
|
||||
"chat_contact_added": "Contato adicionado — renomeie em Contatos",
|
||||
"chat_contact_request": "solicitação de contato",
|
||||
"chat_empty_hint": "Nenhuma conversa ainda. As mensagens que você receber aparecerão aqui.",
|
||||
"chat_empty_start": "Inicie uma com \"Nova conversa\".",
|
||||
"chat_empty_title": "Ainda não há conversas",
|
||||
"chat_export": "Exportar conversa…",
|
||||
"chat_export_done": "Conversa exportada",
|
||||
"chat_export_failed": "Não foi possível gravar o arquivo de exportação.",
|
||||
"chat_export_warn": "Salva as mensagens descriptografadas como texto simples. Guarde o arquivo com segurança.",
|
||||
"chat_hidden_toast": "Conversa ocultada — uma nova mensagem a traz de volta",
|
||||
"chat_hide": "Ocultar",
|
||||
"chat_jump_latest": "Recentes",
|
||||
"chat_len_over": "Mensagem muito longa",
|
||||
"chat_locked_hint": "Desbloqueie sua carteira para carregar suas conversas.",
|
||||
"chat_mute": "Silenciar",
|
||||
"chat_new_button": "Nova conversa",
|
||||
"chat_new_message": "Mensagem",
|
||||
"chat_new_message_toast": "Nova mensagem de chat criptografada",
|
||||
"chat_new_send": "Enviar solicitação",
|
||||
"chat_new_title": "Nova conversa",
|
||||
"chat_new_zaddr": "Endereço-z do destinatário",
|
||||
"chat_no_matches": "Nenhuma conversa corresponde à sua pesquisa.",
|
||||
"chat_no_z_contacts": "Ainda não há contatos com endereço blindado",
|
||||
"chat_pick_contact": "Escolher dos contatos…",
|
||||
"chat_retry": "Tentar novamente",
|
||||
"chat_search": "Pesquisar conversas",
|
||||
"chat_select_hint": "Selecione uma conversa para visualizá-la.",
|
||||
"chat_send": "Enviar",
|
||||
"chat_send_failed": "não enviada",
|
||||
"chat_sending": "enviando…",
|
||||
"chat_time_now": "agora",
|
||||
"chat_toast_compose_failed": "Não foi possível compor a mensagem (muito longa?).",
|
||||
"chat_toast_lite_busy": "Já há um envio em andamento, ou nenhuma carteira está aberta.",
|
||||
"chat_toast_need_funds": "É necessário um pequeno saldo blindado para enviar chats (para cobrir a taxa).",
|
||||
"chat_toast_no_zaddr": "Nenhum endereço-z disponível para enviar o chat.",
|
||||
"chat_toast_not_connected": "Não conectado — mensagem de chat não enviada.",
|
||||
"chat_toast_request_compose_failed": "Não foi possível compor a solicitação de contato (endereço / texto inválido?).",
|
||||
"chat_toast_request_queued": "Solicitação de contato na fila.",
|
||||
"chat_toast_waiting_reply": "Aguardando a resposta do contato antes que você possa enviar mensagens a ele.",
|
||||
"chat_unmute": "Reativar som",
|
||||
"chat_waiting_reply": "Aguardando a resposta deste contato — você poderá enviar mensagens assim que ele responder.",
|
||||
"chat_you": "Você",
|
||||
"choose_icon": "Escolher Ícone",
|
||||
@@ -305,6 +328,7 @@
|
||||
"contact_global_tt": "Ativado: este contato permanece visível em qualquer carteira que você carregar. Desativado: ele pertence apenas à carteira atual.",
|
||||
"contact_preview_addr": "O endereço aparecerá aqui",
|
||||
"contact_preview_name": "Nome do contato",
|
||||
"contact_wallet_loading": "A carteira ainda está carregando — marque “Mostrar em todas as carteiras” ou tente novamente em um momento.",
|
||||
"contacts": "Contatos",
|
||||
"contacts_search_no_match": "Nenhum contato correspondente",
|
||||
"contacts_search_placeholder": "Pesquisar contatos...",
|
||||
@@ -977,6 +1001,7 @@
|
||||
"portfolio_style_featured": "Linhas em destaque",
|
||||
"portfolio_style_label": "Estilo do portfólio",
|
||||
"portfolio_untitled": "Sem título",
|
||||
"portfolio_wallet_loading": "Aguarde a carteira terminar de carregar para adicionar um grupo.",
|
||||
"price_chart": "Gráfico de Preços",
|
||||
"privacy_great": "Ótima privacidade!",
|
||||
"privacy_low": "Baixa privacidade — blinde os fundos",
|
||||
@@ -1292,6 +1317,23 @@
|
||||
"sweep_to": "Varrido para:",
|
||||
"sweep_toggle": "Varrer para minha carteira (não manter a chave)",
|
||||
"sweep_tx": "Transação:",
|
||||
"switch_corrupt_body": "Esta carteira parece corrompida — o nó não conseguiu abri-la. Restaure de um backup, recrie-a ou tente repará-la.",
|
||||
"switch_corrupt_repair": "Tentar reparar (salvage)",
|
||||
"switch_progress_background": "Continuar em segundo plano",
|
||||
"switch_progress_default_wallet": "Carteira padrão",
|
||||
"switch_progress_elapsed": "Decorrido",
|
||||
"switch_progress_external_wallet": "Carteira externa",
|
||||
"switch_progress_failed_title": "Falha ao trocar de carteira",
|
||||
"switch_progress_from_label": "de",
|
||||
"switch_progress_hint": "Um desligamento normal pode levar até um minuto.",
|
||||
"switch_progress_reconnecting": "Reconectando",
|
||||
"switch_progress_starting": "Iniciando o nó com a nova carteira",
|
||||
"switch_progress_stopping": "Parando o nó atual",
|
||||
"switch_progress_title": "Trocando de carteira",
|
||||
"switch_stopnode_body": "Trocar de carteira reinicia o nó com a carteira selecionada. O nó em execução será parado e reiniciado com a nova carteira — se você o deixou em execução de propósito, ele volta automaticamente.",
|
||||
"switch_stopnode_confirm": "Parar nó e trocar",
|
||||
"switch_stopnode_title": "Parar o nó em execução?",
|
||||
"switch_stopnode_warn": "Já há um nó em execução que esta carteira não iniciou.",
|
||||
"syncing": "Sincronizando...",
|
||||
"t_address": "Endereço T",
|
||||
"t_addresses": "Endereços T",
|
||||
@@ -1472,6 +1514,8 @@
|
||||
"wallets_add_folder_toggle": "+ Procurar carteiras noutra pasta…",
|
||||
"wallets_badge_encrypted": "Encriptada (protegida por senha)",
|
||||
"wallets_badge_encrypted_short": "Encriptada",
|
||||
"wallets_badge_hd": "Carteira HD — não é possível confirmar a frase-semente sem abri-la",
|
||||
"wallets_badge_hd_short": "Carteira HD",
|
||||
"wallets_badge_legacy": "Carteira legada (sem frase semente)",
|
||||
"wallets_badge_legacy_short": "Legada",
|
||||
"wallets_badge_seed": "Carteira com frase semente (HD)",
|
||||
|
||||
@@ -135,25 +135,48 @@
|
||||
"change_pass_title": "Сменить пароль",
|
||||
"characters": "символов",
|
||||
"chat": "Чат",
|
||||
"chat_add_contact": "Добавить контакт",
|
||||
"chat_cancel": "Отмена",
|
||||
"chat_contact_added": "Контакт добавлен — переименуйте его в Контактах",
|
||||
"chat_contact_request": "запрос контакта",
|
||||
"chat_empty_hint": "Пока нет переписок. Полученные сообщения появятся здесь.",
|
||||
"chat_empty_start": "Начните новый с помощью «Новый разговор».",
|
||||
"chat_empty_title": "Пока нет разговоров",
|
||||
"chat_export": "Экспорт чата…",
|
||||
"chat_export_done": "Разговор экспортирован",
|
||||
"chat_export_failed": "Не удалось записать файл экспорта.",
|
||||
"chat_export_warn": "Сохраняет расшифрованные сообщения в виде обычного текста. Храните файл в надёжном месте.",
|
||||
"chat_hidden_toast": "Разговор скрыт — новое сообщение вернёт его",
|
||||
"chat_hide": "Скрыть",
|
||||
"chat_jump_latest": "Новые",
|
||||
"chat_len_over": "Сообщение слишком длинное",
|
||||
"chat_locked_hint": "Разблокируйте кошелёк, чтобы загрузить переписку.",
|
||||
"chat_mute": "Отключить уведомления",
|
||||
"chat_new_button": "Новая переписка",
|
||||
"chat_new_message": "Сообщение",
|
||||
"chat_new_message_toast": "Новое зашифрованное сообщение",
|
||||
"chat_new_send": "Отправить запрос",
|
||||
"chat_new_title": "Новая переписка",
|
||||
"chat_new_zaddr": "Z-адрес получателя",
|
||||
"chat_no_matches": "Нет разговоров, соответствующих запросу.",
|
||||
"chat_no_z_contacts": "Пока нет контактов с защищённым адресом",
|
||||
"chat_pick_contact": "Выбрать из контактов…",
|
||||
"chat_retry": "Повторить",
|
||||
"chat_search": "Поиск разговоров",
|
||||
"chat_select_hint": "Выберите переписку для просмотра.",
|
||||
"chat_send": "Отправить",
|
||||
"chat_send_failed": "не отправлено",
|
||||
"chat_sending": "отправка…",
|
||||
"chat_time_now": "сейчас",
|
||||
"chat_toast_compose_failed": "Не удалось составить сообщение (слишком длинное?).",
|
||||
"chat_toast_lite_busy": "Отправка уже выполняется, или кошелёк не открыт.",
|
||||
"chat_toast_need_funds": "Для отправки сообщений нужен небольшой экранированный баланс (для оплаты комиссии).",
|
||||
"chat_toast_no_zaddr": "Нет доступного Z-адреса для отправки сообщений.",
|
||||
"chat_toast_not_connected": "Нет подключения — сообщение не отправлено.",
|
||||
"chat_toast_request_compose_failed": "Не удалось составить запрос контакта (неверный адрес / текст?).",
|
||||
"chat_toast_request_queued": "Запрос контакта поставлен в очередь.",
|
||||
"chat_toast_waiting_reply": "Ожидание ответа от контакта — вы сможете писать ему только после этого.",
|
||||
"chat_unmute": "Включить уведомления",
|
||||
"chat_waiting_reply": "Ожидание ответа от контакта — вы сможете писать ему, как только он ответит.",
|
||||
"chat_you": "Вы",
|
||||
"choose_icon": "Выбрать иконку",
|
||||
@@ -305,6 +328,7 @@
|
||||
"contact_global_tt": "Вкл.: этот контакт остаётся видимым, какой бы кошелёк вы ни загрузили. Выкл.: он принадлежит только текущему кошельку.",
|
||||
"contact_preview_addr": "Здесь появится адрес",
|
||||
"contact_preview_name": "Имя контакта",
|
||||
"contact_wallet_loading": "Кошелёк ещё загружается — отметьте «Показывать во всех кошельках» или повторите чуть позже.",
|
||||
"contacts": "Контакты",
|
||||
"contacts_search_no_match": "Совпадающих контактов нет",
|
||||
"contacts_search_placeholder": "Поиск контактов...",
|
||||
@@ -977,6 +1001,7 @@
|
||||
"portfolio_style_featured": "Избранные строки",
|
||||
"portfolio_style_label": "Стиль портфеля",
|
||||
"portfolio_untitled": "Без названия",
|
||||
"portfolio_wallet_loading": "Дождитесь загрузки кошелька, чтобы добавить группу.",
|
||||
"price_chart": "График цен",
|
||||
"privacy_great": "Отличная конфиденциальность!",
|
||||
"privacy_low": "Низкая конфиденциальность — экранируйте средства",
|
||||
@@ -1292,6 +1317,23 @@
|
||||
"sweep_to": "Переведено на:",
|
||||
"sweep_toggle": "Перевести в мой кошелёк (не сохранять ключ)",
|
||||
"sweep_tx": "Транзакция:",
|
||||
"switch_corrupt_body": "Похоже, этот кошелёк повреждён — узел не смог его открыть. Восстановите из резервной копии, создайте заново или попробуйте восстановить.",
|
||||
"switch_corrupt_repair": "Попробовать восстановить (salvage)",
|
||||
"switch_progress_background": "Продолжить в фоне",
|
||||
"switch_progress_default_wallet": "Кошелёк по умолчанию",
|
||||
"switch_progress_elapsed": "Прошло",
|
||||
"switch_progress_external_wallet": "Внешний кошелёк",
|
||||
"switch_progress_failed_title": "Не удалось переключить кошелёк",
|
||||
"switch_progress_from_label": "из",
|
||||
"switch_progress_hint": "Корректное завершение работы может занять до минуты.",
|
||||
"switch_progress_reconnecting": "Переподключение",
|
||||
"switch_progress_starting": "Запуск узла с новым кошельком",
|
||||
"switch_progress_stopping": "Остановка текущего узла",
|
||||
"switch_progress_title": "Переключение кошелька",
|
||||
"switch_stopnode_body": "Смена кошелька перезапускает узел с выбранным кошельком. Запущенный узел будет остановлен и перезапущен с новым кошельком — если вы намеренно оставили его работать, он запустится снова автоматически.",
|
||||
"switch_stopnode_confirm": "Остановить узел и переключить",
|
||||
"switch_stopnode_title": "Остановить запущенный узел?",
|
||||
"switch_stopnode_warn": "Уже запущен узел, который не был запущен этим кошельком.",
|
||||
"syncing": "Синхронизация...",
|
||||
"t_address": "T-адрес",
|
||||
"t_addresses": "T-адреса",
|
||||
@@ -1472,6 +1514,8 @@
|
||||
"wallets_add_folder_toggle": "+ Искать кошельки в другой папке…",
|
||||
"wallets_badge_encrypted": "Зашифрован (защищён паролем)",
|
||||
"wallets_badge_encrypted_short": "Зашифрован",
|
||||
"wallets_badge_hd": "HD-кошелёк — невозможно подтвердить seed-фразу без открытия",
|
||||
"wallets_badge_hd_short": "HD-кошелёк",
|
||||
"wallets_badge_legacy": "Устаревший кошелёк (без seed-фразы)",
|
||||
"wallets_badge_legacy_short": "Устаревший",
|
||||
"wallets_badge_seed": "Кошелёк с seed-фразой (HD)",
|
||||
|
||||
@@ -135,25 +135,48 @@
|
||||
"change_pass_title": "更改密码短语",
|
||||
"characters": "字符",
|
||||
"chat": "聊天",
|
||||
"chat_add_contact": "添加联系人",
|
||||
"chat_cancel": "取消",
|
||||
"chat_contact_added": "已添加联系人——可在联系人中重命名",
|
||||
"chat_contact_request": "联系人请求",
|
||||
"chat_empty_hint": "暂无对话。您收到的消息将显示在此处。",
|
||||
"chat_empty_start": "点击\"新建会话\"开始。",
|
||||
"chat_empty_title": "还没有会话",
|
||||
"chat_export": "导出聊天…",
|
||||
"chat_export_done": "会话已导出",
|
||||
"chat_export_failed": "无法写入导出文件。",
|
||||
"chat_export_warn": "将解密后的消息保存为纯文本。请妥善保管该文件。",
|
||||
"chat_hidden_toast": "会话已隐藏——收到新消息后会重新显示",
|
||||
"chat_hide": "隐藏",
|
||||
"chat_jump_latest": "最新",
|
||||
"chat_len_over": "消息过长",
|
||||
"chat_locked_hint": "解锁钱包以加载您的聊天记录。",
|
||||
"chat_mute": "静音",
|
||||
"chat_new_button": "新建对话",
|
||||
"chat_new_message": "消息",
|
||||
"chat_new_message_toast": "新的加密聊天消息",
|
||||
"chat_new_send": "发送请求",
|
||||
"chat_new_title": "新建对话",
|
||||
"chat_new_zaddr": "收款方 z 地址",
|
||||
"chat_no_matches": "没有与搜索匹配的会话。",
|
||||
"chat_no_z_contacts": "暂无使用隐私地址的联系人",
|
||||
"chat_pick_contact": "从联系人中选择…",
|
||||
"chat_retry": "重试",
|
||||
"chat_search": "搜索会话",
|
||||
"chat_select_hint": "选择一个对话以查看。",
|
||||
"chat_send": "发送",
|
||||
"chat_send_failed": "未发送",
|
||||
"chat_sending": "发送中…",
|
||||
"chat_time_now": "刚刚",
|
||||
"chat_toast_compose_failed": "无法编写该消息(内容过长?)。",
|
||||
"chat_toast_lite_busy": "已有发送正在进行中,或未打开任何钱包。",
|
||||
"chat_toast_need_funds": "发送聊天需要少量屏蔽余额(用于支付手续费)。",
|
||||
"chat_toast_no_zaddr": "没有可用于发送聊天的 z 地址。",
|
||||
"chat_toast_not_connected": "未连接——聊天消息未发送。",
|
||||
"chat_toast_request_compose_failed": "无法编写联系人请求(地址或文本无效?)。",
|
||||
"chat_toast_request_queued": "联系人请求已排队。",
|
||||
"chat_toast_waiting_reply": "等待该联系人回复——对方回复后您即可向其发送消息。",
|
||||
"chat_unmute": "取消静音",
|
||||
"chat_waiting_reply": "等待该联系人回复——对方回复后您即可向其发送消息。",
|
||||
"chat_you": "我",
|
||||
"choose_icon": "选择图标",
|
||||
@@ -305,6 +328,7 @@
|
||||
"contact_global_tt": "开启:无论您加载哪个钱包,此联系人都保持可见。关闭:它仅属于当前钱包。",
|
||||
"contact_preview_addr": "地址将显示在此处",
|
||||
"contact_preview_name": "联系人名称",
|
||||
"contact_wallet_loading": "钱包仍在加载——请选中“在每个钱包中显示”,或稍后再试。",
|
||||
"contacts": "联系人",
|
||||
"contacts_search_no_match": "没有匹配的联系人",
|
||||
"contacts_search_placeholder": "搜索联系人...",
|
||||
@@ -977,6 +1001,7 @@
|
||||
"portfolio_style_featured": "特色行",
|
||||
"portfolio_style_label": "投资组合样式",
|
||||
"portfolio_untitled": "未命名",
|
||||
"portfolio_wallet_loading": "请等待钱包加载完成后再添加分组。",
|
||||
"price_chart": "价格图表",
|
||||
"privacy_great": "隐私性极佳!",
|
||||
"privacy_low": "隐私性低——请屏蔽资金",
|
||||
@@ -1292,6 +1317,23 @@
|
||||
"sweep_to": "归集到:",
|
||||
"sweep_toggle": "归集到我的钱包(不保留密钥)",
|
||||
"sweep_tx": "交易:",
|
||||
"switch_corrupt_body": "此钱包似乎已损坏——节点无法打开它。请从备份恢复、重新创建,或尝试修复。",
|
||||
"switch_corrupt_repair": "尝试修复(salvage)",
|
||||
"switch_progress_background": "在后台继续",
|
||||
"switch_progress_default_wallet": "默认钱包",
|
||||
"switch_progress_elapsed": "已用时",
|
||||
"switch_progress_external_wallet": "外部钱包",
|
||||
"switch_progress_failed_title": "切换钱包失败",
|
||||
"switch_progress_from_label": "来自",
|
||||
"switch_progress_hint": "正常关闭最多可能需要一分钟。",
|
||||
"switch_progress_reconnecting": "正在重新连接",
|
||||
"switch_progress_starting": "正在以新钱包启动节点",
|
||||
"switch_progress_stopping": "正在停止当前节点",
|
||||
"switch_progress_title": "正在切换钱包",
|
||||
"switch_stopnode_body": "切换钱包会以所选钱包重启节点。正在运行的节点将被停止并以新钱包重新启动——如果你是特意让它保持运行的,它会自动重新启动。",
|
||||
"switch_stopnode_confirm": "停止节点并切换",
|
||||
"switch_stopnode_title": "停止正在运行的节点?",
|
||||
"switch_stopnode_warn": "已有一个并非由此钱包启动的节点正在运行。",
|
||||
"syncing": "同步中...",
|
||||
"t_address": "T 地址",
|
||||
"t_addresses": "T 地址",
|
||||
@@ -1472,6 +1514,8 @@
|
||||
"wallets_add_folder_toggle": "+ 扫描其他文件夹中的钱包…",
|
||||
"wallets_badge_encrypted": "已加密(密码保护)",
|
||||
"wallets_badge_encrypted_short": "已加密",
|
||||
"wallets_badge_hd": "HD 钱包 — 打开钱包才能确认助记词",
|
||||
"wallets_badge_hd_short": "HD 钱包",
|
||||
"wallets_badge_legacy": "旧版钱包(无助记词)",
|
||||
"wallets_badge_legacy_short": "旧版",
|
||||
"wallets_badge_seed": "助记词钱包 (HD)",
|
||||
|
||||
87
scripts/build_emoji_subset.py
Normal file
87
scripts/build_emoji_subset.py
Normal file
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Build a monochrome Noto Emoji subset for the chat/message UI.
|
||||
|
||||
Dear ImGui rasterizes fonts with stb_truetype, which handles only monochrome
|
||||
(outline `glyf`) fonts — NOT color emoji (CBDT/sbix/COLR). So we use Google's
|
||||
*monochrome* Noto Emoji (github.com/google/fonts, ofl/notoemoji, OFL-licensed)
|
||||
and merge it into the text fonts (see Typography::loadFont, the block after the
|
||||
CJK merge). ImGui renders one glyph per codepoint with no shaping, so ZWJ
|
||||
sequences / regional-indicator flags won't compose — single-codepoint emoji
|
||||
(😀 🎉 ❤ 🔥 👍 …) render fine, which covers the overwhelming majority of use.
|
||||
|
||||
Source is the variable font pinned to wght=400 → static, then subset to the
|
||||
emoji planes plus the higher symbol/star ranges the base UI font doesn't cover.
|
||||
The base Ubuntu font already owns U+2600–26FF etc.; ImGui's MergeMode gives the
|
||||
first-loaded glyph precedence, so those stay text-styled and only the codepoints
|
||||
the base lacks fall through to this font.
|
||||
|
||||
Get the source once (OFL, redistributable):
|
||||
curl -fsSL -o /tmp/NotoEmoji-VF.ttf \
|
||||
'https://github.com/google/fonts/raw/main/ofl/notoemoji/NotoEmoji%5Bwght%5D.ttf'
|
||||
|
||||
Then: python3 scripts/build_emoji_subset.py
|
||||
Output: res/fonts/NotoEmoji-Subset.ttf (committed; embedded via INCBIN)
|
||||
"""
|
||||
import os
|
||||
from fontTools import ttLib, subset
|
||||
from fontTools.varLib.instancer import instantiateVariableFont
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
SOURCE_VF = '/tmp/NotoEmoji-VF.ttf'
|
||||
STATIC = '/tmp/NotoEmoji-Static.ttf'
|
||||
OUTPUT = os.path.join(ROOT, 'res', 'fonts', 'NotoEmoji-Subset.ttf')
|
||||
|
||||
# Emoji codepoints to keep. Ranges are inclusive.
|
||||
RANGES = [
|
||||
(0x1F000, 0x1FAFF), # all the main emoji planes (emoticons, pictographs, transport, supplement, extended)
|
||||
(0x2600, 0x27BF), # Miscellaneous Symbols + Dingbats
|
||||
(0x2B00, 0x2BFF), # stars (⭐ 2B50) and misc arrows
|
||||
(0xFE00, 0xFE0F), # variation selectors (VS16 emoji-style)
|
||||
(0x2194, 0x21AA), # arrows used as emoji
|
||||
(0x231A, 0x231B), # ⌚ ⌛
|
||||
(0x23E9, 0x23FA), # media-control emoji
|
||||
(0x25AA, 0x25FE), # small squares
|
||||
]
|
||||
SINGLES = [0x200D, 0x2934, 0x2935, 0x3030, 0x303D, 0x3297, 0x3299,
|
||||
0x00A9, 0x00AE, 0x2122, 0x2139, 0x24C2]
|
||||
|
||||
|
||||
def main():
|
||||
if not os.path.exists(SOURCE_VF):
|
||||
raise SystemExit(f"missing source font {SOURCE_VF} — see the header for the curl command")
|
||||
|
||||
# 1. Pin the weight axis so stb_truetype rasterizes a clean static instance.
|
||||
f = ttLib.TTFont(SOURCE_VF)
|
||||
if 'fvar' in f:
|
||||
instantiateVariableFont(f, {'wght': 400}, inplace=True)
|
||||
f.save(STATIC)
|
||||
|
||||
unicodes = list(SINGLES)
|
||||
for lo, hi in RANGES:
|
||||
unicodes.extend(range(lo, hi + 1))
|
||||
|
||||
opts = subset.Options()
|
||||
opts.layout_features = [] # ImGui does no shaping — drop GSUB/GPOS
|
||||
opts.name_IDs = []
|
||||
opts.notdef_outline = True
|
||||
opts.glyph_names = False
|
||||
opts.drop_tables = ['GSUB', 'GPOS', 'GDEF', 'morx', 'kern']
|
||||
|
||||
font = subset.load_font(STATIC, opts)
|
||||
ss = subset.Subsetter(options=opts)
|
||||
ss.populate(unicodes=unicodes)
|
||||
ss.subset(font)
|
||||
subset.save_font(font, OUTPUT, opts)
|
||||
|
||||
out = ttLib.TTFont(OUTPUT)
|
||||
cmap = out.getBestCmap()
|
||||
color = any(t in out.reader.keys() for t in ('CBDT', 'sbix', 'COLR'))
|
||||
print(f"Output: {OUTPUT}")
|
||||
print(f"Size: {os.path.getsize(OUTPUT)//1024} KB | glyphs: {len(cmap)} | color tables: {color}")
|
||||
if color:
|
||||
raise SystemExit("ERROR: subset has color tables — stb_truetype cannot render it")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
207
src/app.cpp
207
src/app.cpp
@@ -334,8 +334,9 @@ bool App::init()
|
||||
// Ensure ObsidianDragon config directory and template files exist
|
||||
util::Platform::ensureObsidianDragonSetup();
|
||||
|
||||
// Initialize PIN vault
|
||||
vault_ = std::make_unique<util::SecureVault>();
|
||||
// Initialize PIN vault, scoped to the active wallet so one wallet's stored passphrase is never
|
||||
// offered for another (the default wallet keeps the legacy vault.dat).
|
||||
vault_ = std::make_unique<util::SecureVault>(settings_ ? settings_->getActiveWalletFile() : "");
|
||||
|
||||
// Theme is now applied via SkinManager below after UISchema loads.
|
||||
// The old SetThemeById() C++ fallback is no longer needed at startup
|
||||
@@ -634,12 +635,7 @@ void App::rebuildLiteWallet(bool force)
|
||||
// A rebuilt controller may back a different wallet (server switch / re-open); drop any chat
|
||||
// identity + decrypted messages, lock the DB, and re-arm provisioning so it re-derives (and
|
||||
// reloads the right wallet's history) from the newly opened wallet's seed.
|
||||
chat_service_.clearIdentity();
|
||||
chat_service_.store().clear();
|
||||
chat_db_.lock();
|
||||
chat_identity_provisioned_ = false;
|
||||
chat_identity_fetch_in_flight_ = false;
|
||||
chat_identity_unavailable_ = false;
|
||||
resetChatSession();
|
||||
}
|
||||
|
||||
void App::update()
|
||||
@@ -653,6 +649,9 @@ void App::update()
|
||||
// capture_mode_ is only set for the offline full sweep, so the live tab-only sweep is unaffected.
|
||||
if (capture_mode_) return;
|
||||
|
||||
// If a wallet switch's daemon failed to start, revert to the previous wallet (main-thread-safe).
|
||||
processWalletSwitchRevert();
|
||||
|
||||
// Track user interaction for auto-lock
|
||||
if (io.MouseDelta.x != 0 || io.MouseDelta.y != 0 ||
|
||||
io.MouseClicked[0] || io.MouseClicked[1] ||
|
||||
@@ -1169,6 +1168,9 @@ void App::update()
|
||||
} else if (walletDataPage && shouldRefreshRecentTransactions()) {
|
||||
refreshRecentTransactionData();
|
||||
}
|
||||
// 0-conf chat: the normal harvest above only re-scans on a new block, so also do a light
|
||||
// mempool scan of the chat address every cycle to surface messages before confirmation.
|
||||
fastScanChatMemos();
|
||||
}
|
||||
if (network_refresh_.consumeDue(RefreshTimer::Addresses)) {
|
||||
if (walletDataPage || addresses_dirty_ || hasTransactionSendProgress()) {
|
||||
@@ -1504,6 +1506,7 @@ void App::render()
|
||||
ui::SidebarStatus sbStatus;
|
||||
sbStatus.peerCount = static_cast<int>(state_.peers.size());
|
||||
sbStatus.miningActive = state_.mining.generate || state_.pool_mining.xmrig_running;
|
||||
sbStatus.chatUnreadCount = chatUnreadCount(); // unread badge on the Chat nav item (Q1)
|
||||
|
||||
// Load logo texture lazily on first frame (or after theme change)
|
||||
// Also reload when dark↔light mode changes so the correct variant shows
|
||||
@@ -2035,6 +2038,7 @@ void App::render()
|
||||
renderEncryptWalletDialog();
|
||||
renderDecryptWalletDialog();
|
||||
renderPinDialogs();
|
||||
renderSwitchStopDaemonDialog();
|
||||
|
||||
// Render notifications (toast messages)
|
||||
ui::Notifications::instance().render();
|
||||
@@ -3861,6 +3865,125 @@ void App::renderAntivirusHelpDialog()
|
||||
#endif
|
||||
}
|
||||
|
||||
void App::renderSwitchStopDaemonDialog()
|
||||
{
|
||||
const bool confirm = show_switch_stop_daemon_confirm_;
|
||||
const bool progress = wallet_switch_dialog_open_.load();
|
||||
if (!confirm && !progress) return;
|
||||
|
||||
const auto phase = static_cast<WalletSwitchPhase>(wallet_switch_phase_.load());
|
||||
const bool failed = progress && phase == WalletSwitchPhase::Failed;
|
||||
|
||||
// Prettify a wallet filename for display (wallet.dat → "Default wallet", wallet-savings.dat → "savings").
|
||||
auto pretty = [this](const std::string& f) -> std::string {
|
||||
if (f.empty() || f == "wallet.dat") return TR("switch_progress_default_wallet");
|
||||
std::string s = f;
|
||||
if (s.size() > 4 && s.substr(s.size() - 4) == ".dat") s.resize(s.size() - 4);
|
||||
if (s.rfind("wallet-ip-", 0) == 0) return TR("switch_progress_external_wallet");
|
||||
if (s.rfind("wallet-", 0) == 0) s = s.substr(7);
|
||||
return s;
|
||||
};
|
||||
const std::string toName = pretty(settings_ ? settings_->getActiveWalletFile() : std::string());
|
||||
|
||||
// Progress title carries the target wallet ("Switching wallet — savings"); keep the string alive.
|
||||
std::string titleStr = confirm ? std::string(TR("switch_stopnode_title"))
|
||||
: failed ? std::string(TR("switch_progress_failed_title"))
|
||||
: std::string(TR("switch_progress_title")) + " — " + toName;
|
||||
|
||||
ui::material::OverlayDialogSpec ov;
|
||||
ov.title = titleStr.c_str();
|
||||
// A confirm can be dismissed via X/backdrop (= cancel); a running switch cannot (stay until it's up).
|
||||
ov.p_open = confirm ? &show_switch_stop_daemon_confirm_ : nullptr;
|
||||
ov.style = ui::material::OverlayStyle::BlurFloat;
|
||||
ov.cardWidth = 520.0f; ov.idSuffix = "switchstopnode";
|
||||
if (!ui::material::BeginOverlayDialog(ov)) {
|
||||
if (confirm && !show_switch_stop_daemon_confirm_) pending_switch_wallet_file_.clear(); // X = cancel
|
||||
return;
|
||||
}
|
||||
const float dp = ui::Layout::dpiScale();
|
||||
|
||||
if (confirm) {
|
||||
ui::material::DialogWarningHeader(TR("switch_stopnode_warn"));
|
||||
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
|
||||
ImGui::TextWrapped("%s", TR("switch_stopnode_body"));
|
||||
ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
|
||||
if (ui::material::TactileButton(TR("switch_stopnode_confirm"), ImVec2(200.0f * dp, 0))) {
|
||||
const std::string w = pending_switch_wallet_file_;
|
||||
pending_switch_wallet_file_.clear();
|
||||
show_switch_stop_daemon_confirm_ = false;
|
||||
switchToWallet(w, /*stopDaemonConfirmed=*/true); // opens the live-progress phase below
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (ui::material::TactileButton(TR("cancel"), ImVec2(110.0f * dp, 0))) {
|
||||
pending_switch_wallet_file_.clear();
|
||||
show_switch_stop_daemon_confirm_ = false;
|
||||
}
|
||||
} else if (failed) {
|
||||
// The title already says "Wallet switch failed" — put the actual reason in the warning header
|
||||
// rather than repeating the title.
|
||||
ui::material::DialogWarningHeader(wallet_switch_error_.c_str());
|
||||
ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
|
||||
// If the node reported the target wallet as corrupt, offer a one-click -salvagewallet repair
|
||||
// (retries the switch to that wallet with recovery enabled).
|
||||
if (switch_wallet_corrupt_.load()) {
|
||||
if (ui::material::TactileButton(TR("switch_corrupt_repair"), ImVec2(240.0f * dp, 0))) {
|
||||
const std::string target = wallet_switch_target_file_;
|
||||
switchToWallet(target, /*stopDaemonConfirmed=*/true, /*salvage=*/true); // reopens the progress modal
|
||||
}
|
||||
ImGui::SameLine();
|
||||
}
|
||||
if (ui::material::TactileButton(TR("close"), ImVec2(110.0f * dp, 0))) {
|
||||
wallet_switch_dialog_open_.store(false);
|
||||
wallet_switch_phase_.store(static_cast<int>(WalletSwitchPhase::None));
|
||||
wallet_switch_error_.clear();
|
||||
}
|
||||
} else {
|
||||
// Live progress, kept redrawing by isWalletSwitchInProgress().
|
||||
// Source context (the target is already in the title).
|
||||
ImGui::PushFont(ui::material::Type().caption());
|
||||
ImGui::TextDisabled("%s %s", TR("switch_progress_from_label"), pretty(wallet_switch_prev_file_).c_str());
|
||||
ImGui::PopFont();
|
||||
ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
|
||||
|
||||
// Primary status + detail. During Reconnecting the node is loading the block index / verifying /
|
||||
// rescanning — surface its live init stage (state_.warmup_status/description) instead of a bare
|
||||
// "Reconnecting…", so the ~30-60s wait shows real progress.
|
||||
std::string primary, detail;
|
||||
if (phase == WalletSwitchPhase::Stopping) {
|
||||
primary = TR("switch_progress_stopping");
|
||||
detail = TR("switch_progress_hint");
|
||||
} else if (phase == WalletSwitchPhase::Starting) {
|
||||
primary = TR("switch_progress_starting");
|
||||
} else { // Reconnecting
|
||||
if (!state_.warmup_status.empty()) { primary = state_.warmup_status; detail = state_.warmup_description; }
|
||||
else primary = TR("switch_progress_reconnecting");
|
||||
}
|
||||
ui::material::Type().text(ui::material::TypeStyle::Subtitle1,
|
||||
(primary + ui::material::LoadingDots()).c_str());
|
||||
if (!detail.empty()) {
|
||||
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
|
||||
ImGui::PushFont(ui::material::Type().body2());
|
||||
ImGui::TextWrapped("%s", detail.c_str());
|
||||
ImGui::PopFont();
|
||||
}
|
||||
|
||||
// Elapsed timer — captured on the first progress frame (reset per switch in switchToWallet).
|
||||
if (wallet_switch_started_time_ <= 0.0) wallet_switch_started_time_ = ImGui::GetTime();
|
||||
const int el = static_cast<int>(ImGui::GetTime() - wallet_switch_started_time_);
|
||||
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
|
||||
ImGui::PushFont(ui::material::Type().caption());
|
||||
ImGui::TextDisabled("%s %d:%02d", TR("switch_progress_elapsed"), el / 60, el % 60);
|
||||
ImGui::PopFont();
|
||||
|
||||
ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
|
||||
// Escape hatch — the switch keeps running in the background (a toast reports the result).
|
||||
if (ui::material::TactileButton(TR("switch_progress_background"), ImVec2(220.0f * dp, 0)))
|
||||
wallet_switch_dialog_open_.store(false);
|
||||
}
|
||||
|
||||
ui::material::EndOverlayDialog();
|
||||
}
|
||||
|
||||
void App::refreshNow()
|
||||
{
|
||||
// Trigger immediate refresh on all categories
|
||||
@@ -4133,6 +4256,56 @@ bool App::isEmbeddedDaemonRunning() const
|
||||
return daemon_controller_ && daemon_controller_->isRunning();
|
||||
}
|
||||
|
||||
bool App::stopDaemonForWalletSwitch()
|
||||
{
|
||||
// "Owned" means we spawned the node this session and hold a live process handle. That is the ONLY
|
||||
// reliable ownership signal here: externalDaemonDetected() is latched only inside EmbeddedDaemon::start(),
|
||||
// so when the app just direct-connects to an already-running, config-provided node (the common
|
||||
// keep-node-running case) start() is never called and that flag stays false even though we clearly did
|
||||
// not start the node. Gate on the handle instead.
|
||||
const bool owned = daemon_controller_ && isEmbeddedDaemonRunning();
|
||||
if (owned) {
|
||||
// We spawned it — stopEmbeddedDaemon() BLOCKS for full process exit (handle wait + SIGTERM/SIGKILL
|
||||
// escalation), so the datadir lock + RPC port are released by the time it returns; a short port
|
||||
// check just confirms.
|
||||
stopEmbeddedDaemon();
|
||||
for (int i = 0; i < 200 && daemon::EmbeddedDaemon::isRpcPortInUse() && !shutting_down_; ++i)
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
return shutting_down_ || !daemon::EmbeddedDaemon::isRpcPortInUse();
|
||||
}
|
||||
|
||||
// Adopted OR direct-connected: no process handle, so RPC "stop" is the only lever, and
|
||||
// stopEmbeddedDaemon()'s temp connection (autoDetectConfig) can't be trusted here. Send a graceful RPC
|
||||
// "stop" over the exact creds we're connected with (saved_config_) — guaranteed to reach our node, and
|
||||
// only OUR node accepts them, so a foreign dragonxd (different rpcpassword) is a safe no-op. switchToWallet
|
||||
// disconnects rpc_ before this worker runs, so build a fresh temporary connection.
|
||||
bool sent = false;
|
||||
{
|
||||
auto tmp = std::make_unique<rpc::RPCClient>();
|
||||
if (tmp->connect(saved_config_.host, saved_config_.port, saved_config_.rpcuser,
|
||||
saved_config_.rpcpassword, saved_config_.use_tls)) {
|
||||
sent = sendStopCommandSafely(*tmp, "wallet-switch stop");
|
||||
tmp->disconnect();
|
||||
}
|
||||
}
|
||||
DEBUG_LOGF("[App] wallet-switch stop of unowned node (saved_config_): %s\n", sent ? "sent" : "FAILED");
|
||||
|
||||
// CRITICAL: wait for the node to FULLY EXIT before the caller starts the replacement — NOT just for the
|
||||
// RPC port. On Windows isRpcPortInUse() is a connect() probe that reads "free" the moment the daemon
|
||||
// stops accepting RPC (early in shutdown), but the process keeps the DATADIR LOCK until it exits, and a
|
||||
// graceful shutdown can take 60-90s when the node's network threads block on peer timeouts. Starting a
|
||||
// replacement into a still-locked datadir fails ("Cannot obtain a lock…") and wedges the switch. So gate
|
||||
// on the PROCESS being gone (isDaemonProcessRunning) AND the port free. Bounded ~120s while stopping; if
|
||||
// we couldn't even send the stop (foreign node / bad creds → its port never frees), only a brief grace.
|
||||
const int maxTicks = sent ? 1200 : 50;
|
||||
auto stillUp = []() {
|
||||
return daemon::EmbeddedDaemon::isRpcPortInUse() || daemon::EmbeddedDaemon::isDaemonProcessRunning();
|
||||
};
|
||||
for (int i = 0; i < maxTicks && stillUp() && !shutting_down_; ++i)
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
return shutting_down_ || !stillUp();
|
||||
}
|
||||
|
||||
void App::rescanBlockchain()
|
||||
{
|
||||
if (!supportsFullNodeLifecycleActions()) {
|
||||
@@ -4154,6 +4327,11 @@ void App::rescanBlockchain()
|
||||
ui::Notifications::instance().warning("A blockchain maintenance operation is already in progress.");
|
||||
return;
|
||||
}
|
||||
// Don't race a wallet switch / seed-adopt / encryption restart, which drive their own daemon stop/start.
|
||||
if (daemon_restarting_) {
|
||||
ui::Notifications::instance().warning("The node is busy restarting — try again in a moment.");
|
||||
return;
|
||||
}
|
||||
|
||||
DEBUG_LOGF("[App] Starting blockchain rescan - stopping daemon first\n");
|
||||
ui::Notifications::instance().info("Restarting daemon with -rescan flag...");
|
||||
@@ -4201,6 +4379,10 @@ void App::repairWallet()
|
||||
ui::Notifications::instance().warning("A blockchain maintenance operation is already in progress.");
|
||||
return;
|
||||
}
|
||||
if (daemon_restarting_) {
|
||||
ui::Notifications::instance().warning("The node is busy restarting — try again in a moment.");
|
||||
return;
|
||||
}
|
||||
|
||||
DEBUG_LOGF("[App] Starting wallet repair (-zapwallettxes=2) - stopping daemon first\n");
|
||||
ui::Notifications::instance().info("Restarting daemon with -zapwallettxes=2 (wallet repair)...");
|
||||
@@ -4374,6 +4556,10 @@ void App::beginShutdown()
|
||||
// the adopt task will NOT restart the daemon (it checks shutting_down_ before startEmbeddedDaemon).
|
||||
if (async_tasks_.isRunning("Adopt seed wallet"))
|
||||
async_tasks_.join("Adopt seed wallet");
|
||||
// The wallet-switch task also drives daemon stop/start — let it finish before shutdown touches the
|
||||
// daemon so it can't orphan a freshly-started dragonxd (it checks shutting_down_ before starting).
|
||||
if (async_tasks_.isRunning("Switch wallet"))
|
||||
async_tasks_.join("Switch wallet");
|
||||
|
||||
// Signal the RPC worker to stop accepting new tasks (non-blocking), and abort any call
|
||||
// already in flight so the later join() doesn't wait out a request timeout.
|
||||
@@ -5114,7 +5300,10 @@ bool App::isFirstRun() const {
|
||||
}
|
||||
|
||||
bool App::hasPinVault() const {
|
||||
return vault_ && vault_->hasVault() && settings_ && settings_->getPinEnabled();
|
||||
// Gate purely on the (now per-wallet) vault presence — a vault exists iff a PIN was set for THIS
|
||||
// wallet. The old extra `getPinEnabled()` gate was a GLOBAL flag, so disabling PIN on one wallet
|
||||
// wrongly suppressed another wallet's PIN quick-unlock after a switch.
|
||||
return vault_ && vault_->hasVault();
|
||||
}
|
||||
|
||||
bool App::debugGateRequiresAuth() const {
|
||||
|
||||
101
src/app.h
101
src/app.h
@@ -139,6 +139,9 @@ public:
|
||||
* @brief Whether we are in the shutdown phase
|
||||
*/
|
||||
bool isShuttingDown() const { return shutting_down_; }
|
||||
// True while the wallet-switch progress modal is open — keeps the frame loop redrawing so its live
|
||||
// phase/spinner update in real time even when the app is otherwise idle.
|
||||
bool isWalletSwitchInProgress() const { return wallet_switch_dialog_open_.load(); }
|
||||
wallet::WalletCapabilities walletCapabilities() const { return wallet::currentWalletCapabilities(); }
|
||||
bool isLiteBuild() const { return wallet::isLiteBuild(walletCapabilities()); }
|
||||
bool supportsEmbeddedDaemon() const { return wallet::supportsEmbeddedDaemon(walletCapabilities()); }
|
||||
@@ -176,6 +179,9 @@ public:
|
||||
// message (to a conversation whose peer key we know) / a new-conversation contact request.
|
||||
void sendChatMessage(const std::string& conversationId, const std::string& text);
|
||||
void startChatConversation(const std::string& peerZaddr, const std::string& text);
|
||||
// Send a contact request into an existing conversation (used to retry a failed request in place).
|
||||
void sendContactRequestForCid(const std::string& cid, const std::string& peerZaddr,
|
||||
const std::string& text);
|
||||
// Debug/sweep convenience: give the Chat tab a demo identity + a few sample conversations
|
||||
// (in-memory only, not persisted) so the screenshot sweep captures the populated UI. No-op when
|
||||
// the chat feature is off.
|
||||
@@ -198,10 +204,16 @@ public:
|
||||
data::AddressBook& addressBook() { return address_book_; }
|
||||
const data::AddressBook& addressBook() const { return address_book_; }
|
||||
|
||||
// Hash of the active wallet's identity (derived from its address list), used to scope
|
||||
// per-wallet data (e.g. address-book contacts). Empty until addresses are known (pre-connect).
|
||||
// Hash of the active wallet's identity (derived from its address list). This is the tx-history
|
||||
// cache key — it changes when the address set changes, so DON'T scope persistent user data on it.
|
||||
std::string activeWalletIdentityHash() const;
|
||||
|
||||
// Stable per-wallet id ("w:"+hex) for scoping persistent per-wallet data (address-book contacts).
|
||||
// Generated once and persisted in the wallet index (keyed by wallet file), never recomputed from
|
||||
// the mutable address set — so creating addresses / locking / disconnecting never changes it.
|
||||
// Empty only when there is no active wallet file. Establishes + persists the id on first call.
|
||||
std::string activeWalletScopeId();
|
||||
|
||||
data::WalletIndex& walletIndex() { return wallet_index_; }
|
||||
const data::WalletIndex& walletIndex() const { return wallet_index_; }
|
||||
|
||||
@@ -389,6 +401,14 @@ public:
|
||||
// True when the current full-node wallet is a legacy, pre-seed-phrase wallet (no BIP39 mnemonic)
|
||||
// that a capable daemon could migrate — the Migrate-to-seed button glows to nudge the user.
|
||||
bool isPreSeedWallet() const { return wallet_seed_status_ == WalletSeedStatus::NoMnemonic; }
|
||||
// Authoritative BIP39-mnemonic status of the ACTIVE wallet, decided at runtime by z_exportmnemonic
|
||||
// (not the offline file probe, which can't tell an HD-but-no-mnemonic wallet from a seed-phrase one).
|
||||
// 0 = unknown/undecidable, 1 = has a seed phrase, 2 = legacy (no mnemonic).
|
||||
int activeWalletSeedBadge() const {
|
||||
if (wallet_seed_status_ == WalletSeedStatus::HasMnemonic) return 1;
|
||||
if (wallet_seed_status_ == WalletSeedStatus::NoMnemonic) return 2;
|
||||
return 0;
|
||||
}
|
||||
void showAboutDialog() { show_about_ = true; }
|
||||
|
||||
// Legacy tab compat — maps int to NavPage
|
||||
@@ -407,6 +427,12 @@ public:
|
||||
// Embedded daemon control
|
||||
bool startEmbeddedDaemon();
|
||||
void stopEmbeddedDaemon();
|
||||
// Stop the node specifically for a wallet switch, which MUST free the RPC port to relaunch on
|
||||
// -wallet=<name>. Unlike stopEmbeddedDaemon() (whose DisconnectOnly policy leaves an adopted/external
|
||||
// daemon running), this sends a graceful RPC "stop" — using our own connection creds, so only our
|
||||
// daemon obeys it — even to an adopted daemon, then waits (bounded) for the port to actually release.
|
||||
// Returns true once the RPC port is free (or we're shutting down).
|
||||
bool stopDaemonForWalletSwitch();
|
||||
bool isEmbeddedDaemonRunning() const;
|
||||
bool isUsingEmbeddedDaemon() const { return supportsEmbeddedDaemon() && use_embedded_daemon_; }
|
||||
void setUseEmbeddedDaemon(bool use) { use_embedded_daemon_ = use && supportsEmbeddedDaemon(); }
|
||||
@@ -474,7 +500,11 @@ public:
|
||||
// Switch the active wallet: persist the new -wallet=<name>, stop the node, restart on it
|
||||
// (rescan only if it was never synced in this datadir). Per-wallet data follows automatically
|
||||
// via the identity-scoped caches (P1). No-op if that wallet is already active.
|
||||
void switchToWallet(const std::string& walletFile);
|
||||
// stopDaemonConfirmed: skip the "stop the running node?" confirmation (set true when re-entered from
|
||||
// that dialog). salvage: start the target node with -salvagewallet (repair a corrupt wallet).
|
||||
void switchToWallet(const std::string& walletFile, bool stopDaemonConfirmed = false, bool salvage = false);
|
||||
// Main-thread continuation: if a wallet switch's daemon failed to start, revert active_wallet_file.
|
||||
void processWalletSwitchRevert();
|
||||
|
||||
// Wallet encryption helpers
|
||||
void encryptWalletWithPassphrase(const std::string& passphrase);
|
||||
@@ -653,11 +683,30 @@ private:
|
||||
chat::ChatDatabase chat_db_; // persistent backing (seed-derived encryption at rest)
|
||||
bool chat_identity_provisioned_ = false; // identity set on the service this session
|
||||
bool chat_identity_fetch_in_flight_ = false; // a z_exportmnemonic worker job is pending
|
||||
// Bumped by resetChatSession() on every wallet change; an in-flight identity fetch captures the
|
||||
// value at post time and its completion callback discards its (previous-wallet) secret if the id
|
||||
// no longer matches — so wallet A's seed can't be provisioned under wallet B via a stale job.
|
||||
int chat_session_generation_ = 0;
|
||||
bool chat_identity_unavailable_ = false; // provisioning failed definitively (e.g. non-mnemonic wallet)
|
||||
// Per-conversation "last seen" watermark (message timestamp) for unread tracking (Q1). Updated when a
|
||||
// thread is viewed (markChatConversationSeen); wiped in resetChatSession so unread doesn't leak across
|
||||
// wallets. In-memory only (resets on app restart).
|
||||
std::map<std::string, std::int64_t> chat_seen_watermark_;
|
||||
public:
|
||||
// Total unread incoming chat messages across all conversations (for the sidebar badge). 0 when the
|
||||
// feature is off / no identity.
|
||||
int chatUnreadCount() const;
|
||||
// Mark a conversation read up to latestTs (called by the Chat tab while a thread is displayed).
|
||||
void markChatConversationSeen(const std::string& cid, std::int64_t latestTs);
|
||||
private:
|
||||
// Provision the chat identity once the wallet seed is reachable+unlocked (per-tick, both
|
||||
// variants); derives via deriveChatIdentityFromSecret and wipes the secret. No-op when the
|
||||
// feature is off, already provisioned, in flight, or unavailable.
|
||||
void maybeProvisionChatIdentity();
|
||||
// Drop the in-memory chat identity + decrypted message store, lock the chat DB, and re-arm
|
||||
// provisioning. MUST be called whenever the loaded wallet changes (switch / seed-migration adopt)
|
||||
// so wallet A's private chat can't surface — or be signed with A's keys — under wallet B.
|
||||
void resetChatSession();
|
||||
// One-time nudge: on a full-node wallet that has a mnemonic, remind the user (once per
|
||||
// install) to back up their seed phrase. Cheap early-outs keep it idle until it can act.
|
||||
void maybeRemindSeedBackup();
|
||||
@@ -671,11 +720,21 @@ private:
|
||||
void pollSweepStatus(); // Confirming step: poll sweep confirmations + legacy balance
|
||||
void beginAdoptSeedWallet(); // stop daemon -> swap wallet.dat -> restart with -rescan
|
||||
void provisionChatIdentityFromSecret(std::string secret);
|
||||
std::string chatReplyZaddr(); // a stable (persisted) wallet z-addr for chat
|
||||
std::string chatReplyZaddr(); // a stable (persisted) wallet z-addr — chat IDENTITY (reply-to)
|
||||
// A spendable z-address that can actually PAY the fee (balance >= fee), preferring the identity
|
||||
// reply address. The reply-to in the memo stays the identity address, so paying from a different
|
||||
// funded note is transport-transparent. Empty if no z-address can cover the fee.
|
||||
std::string chatPayFromZaddr(double fee) const;
|
||||
std::string generateChatLocalId(const char* prefix, int numBytes) const; // unique echo id / cid
|
||||
bool broadcastChatMemos(const chat::OutgoingChatMemos& memos); // returns true if submitted
|
||||
// Broadcast the memos and, when the async op resolves, flip the echo (echoLocalId) to Sent/Failed.
|
||||
bool broadcastChatMemos(const chat::OutgoingChatMemos& memos, const std::string& echoLocalId); // true if submitted
|
||||
bool broadcastChatMemosLite(const chat::OutgoingChatMemos& memos); // lite two-recipient send
|
||||
void ingestLiteChatMemos(const wallet::LiteWalletAppRefreshModel& model); // lite chat receive harvest
|
||||
// Full-node 0-conf fast path: re-scan just the chat reply address at minconf=0 every refresh cycle
|
||||
// so incoming messages surface at mempool speed (before a block). Hidden conversations are skipped
|
||||
// (they still un-hide via the normal confirmed harvest). Self-gated; no-op without a chat identity.
|
||||
void fastScanChatMemos();
|
||||
bool chat_fast_scan_in_flight_ = false; // guard against overlapping fast-scan RPCs
|
||||
// Lite first-run welcome prompt: dismissed for the session once the user picks an action.
|
||||
bool lite_firstrun_dismissed_ = false;
|
||||
// Lite send-time unlock: set to show the unlock modal when a spend is attempted while locked.
|
||||
@@ -725,6 +784,37 @@ private:
|
||||
// Daemon restart (e.g. after changing debug log categories)
|
||||
std::atomic<bool> daemon_restarting_{false};
|
||||
|
||||
// Wallet-switch failure recovery: the switch worker sets wallet_switch_failed_ when the new
|
||||
// wallet's daemon won't start/stay up; the main loop then reverts active_wallet_file to
|
||||
// wallet_switch_prev_file_ (settings writes stay on the main thread) so a broken wallet isn't
|
||||
// persisted across restarts. daemon_restarting_ stays set until the revert re-arms the reconnect.
|
||||
std::atomic<bool> wallet_switch_failed_{false};
|
||||
// Distinguishes the failure reason for the revert message: true when the switch failed because the
|
||||
// running node wouldn't release the RPC port in time (not because the target wallet is bad).
|
||||
std::atomic<bool> switch_stop_failed_{false};
|
||||
// True from a switch until the new wallet's daemon actually connects (onConnected). If the daemon
|
||||
// instead crash-wedges (a wallet that fails LATE in init, past the fast start grace), the connect
|
||||
// loop flags a revert so a broken wallet still can't stick.
|
||||
std::atomic<bool> wallet_switch_pending_confirm_{false};
|
||||
std::string wallet_switch_prev_file_;
|
||||
// Confirm-before-stop for a switch that would stop an ADOPTED (externally-running) node. switchToWallet
|
||||
// sets these and defers to renderSwitchStopDaemonDialog; confirming re-calls switchToWallet(w, true).
|
||||
bool show_switch_stop_daemon_confirm_ = false;
|
||||
std::string pending_switch_wallet_file_;
|
||||
// Live progress for the switch modal: it stays open from confirm through stop → wait-for-exit → start →
|
||||
// reconnect and auto-closes when the new node connects (onConnected). Phase is worker-updated;
|
||||
// dialog_open_ gates rendering — both atomic since onConnected/the worker may run off the main thread.
|
||||
// error_ is written only on the main thread (processWalletSwitchRevert) and read by the (main-thread) UI.
|
||||
enum class WalletSwitchPhase : int { None = 0, Stopping, Starting, Reconnecting, Failed };
|
||||
std::atomic<int> wallet_switch_phase_{0};
|
||||
std::atomic<bool> wallet_switch_dialog_open_{false};
|
||||
std::string wallet_switch_error_;
|
||||
double wallet_switch_started_time_ = 0.0; // ImGui::GetTime() captured on first progress frame (elapsed)
|
||||
// Set when a failed switch's node output indicates the target wallet is CORRUPT — the Failed modal then
|
||||
// offers a one-click "-salvagewallet" repair, retrying the switch to wallet_switch_target_file_.
|
||||
std::atomic<bool> switch_wallet_corrupt_{false};
|
||||
std::string wallet_switch_target_file_; // the wallet we were switching TO (for a salvage retry)
|
||||
|
||||
// Set by the deleteBlockchainData worker (item count); the main loop surfaces a completion toast
|
||||
// and resets it to -1. Atomic because the worker thread writes it and the UI thread reads/clears it.
|
||||
std::atomic<int> pending_delete_result_{-1};
|
||||
@@ -1125,6 +1215,7 @@ private:
|
||||
void renderDecryptWalletDialog();
|
||||
void renderPinDialogs();
|
||||
void renderAntivirusHelpDialog();
|
||||
void renderSwitchStopDaemonDialog(); // confirm before stopping an adopted node to switch wallets
|
||||
void processDeferredEncryption();
|
||||
|
||||
// Private methods - connection
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
#include "rpc/rpc_worker.h"
|
||||
#include "rpc/connection.h"
|
||||
#include "chat/chat_identity.h" // deriveChatIdentityFromSecret for HushChat identity provisioning
|
||||
#include "ui/windows/chat_tab.h" // ui::ResetChatTab — wipe chat UI plaintext on a wallet switch
|
||||
#include <sodium.h> // sodium_memzero for wiping the fetched mnemonic
|
||||
#include <cctype>
|
||||
#include "config/settings.h"
|
||||
@@ -189,6 +190,17 @@ static WarmupText translateWarmup(const std::string& raw)
|
||||
return {raw.c_str(), ""};
|
||||
}
|
||||
|
||||
// A node told to open a wallet it can't prints one of these to its console: the BDB corrupt-wallet
|
||||
// recovery ("Failed to rename … .bak", "wallet.dat corrupt, salvage failed") or a generic load error.
|
||||
// Used to offer a -salvagewallet repair when a switch fails because the target wallet is corrupt.
|
||||
static bool walletOutputLooksCorrupt(const std::string& out)
|
||||
{
|
||||
return out.find("Failed to rename") != std::string::npos
|
||||
|| out.find("salvage failed") != std::string::npos
|
||||
|| out.find("wallet.dat corrupt") != std::string::npos
|
||||
|| out.find("Error loading wallet") != std::string::npos;
|
||||
}
|
||||
|
||||
// Phrases dragonxd prints to its console while initializing, in the order translateWarmup()
|
||||
// understands them. The most recent matching console line tells us which stage the node is in
|
||||
// even when the RPC probe just times out (no -28 reply to read).
|
||||
@@ -463,6 +475,14 @@ void App::tryConnect()
|
||||
if (isUsingEmbeddedDaemon() && !isEmbeddedDaemonRunning()) {
|
||||
// Prevent infinite crash-restart loop
|
||||
if (daemon_controller_ && daemon_controller_->crashCount() >= 3) {
|
||||
if (wallet_switch_pending_confirm_.load()) {
|
||||
// The just-switched-to wallet's daemon keeps crashing (e.g. a wallet that
|
||||
// fails LATE in init, past the fast start grace) — revert to the previous
|
||||
// wallet rather than wedging on a broken one. The main thread does the
|
||||
// settings revert (processWalletSwitchRevert) + resets the crash count.
|
||||
wallet_switch_pending_confirm_.store(false);
|
||||
wallet_switch_failed_.store(true);
|
||||
}
|
||||
{ char buf[128]; snprintf(buf, sizeof(buf), TR("sb_daemon_crashed"), daemon_controller_->crashCount());
|
||||
connection_status_ = buf; }
|
||||
VERBOSE_LOGF("[connect #%d] Daemon crashed %d times — not restarting (use Settings > Restart Daemon to retry)\n",
|
||||
@@ -496,6 +516,13 @@ void App::tryConnect()
|
||||
void App::onConnected()
|
||||
{
|
||||
state_.connected = true;
|
||||
const bool completedSwitch = wallet_switch_pending_confirm_.exchange(false); // this connect confirms a switch
|
||||
// A successful connect completes a switch — close/clear the progress modal (kept open through the
|
||||
// stop → start → reconnect sequence, or already hidden via "continue in background").
|
||||
if (completedSwitch || wallet_switch_dialog_open_.load()) {
|
||||
wallet_switch_dialog_open_.store(false);
|
||||
wallet_switch_phase_.store(static_cast<int>(WalletSwitchPhase::None));
|
||||
}
|
||||
state_.daemon_initializing = false; // RPC is answering now; clear the "initializing" overlay
|
||||
daemon_wait_attempts_ = 0; // re-arm the port-busy / start-failure notifications
|
||||
daemon_start_error_shown_ = false;
|
||||
@@ -977,6 +1004,34 @@ std::string App::activeWalletIdentityHash() const
|
||||
return data::TransactionHistoryCache::walletIdentityHash(identity);
|
||||
}
|
||||
|
||||
std::string App::activeWalletScopeId()
|
||||
{
|
||||
if (!settings_) return std::string();
|
||||
const std::string file = settings_->getActiveWalletFile();
|
||||
if (file.empty()) return std::string();
|
||||
|
||||
// Reuse the persisted id if we have one for this wallet file.
|
||||
if (const auto* existing = wallet_index_.find(file)) {
|
||||
if (!existing->scopeId.empty()) return existing->scopeId;
|
||||
}
|
||||
|
||||
// Establish one now: 16 random bytes, "w:"-prefixed so it's distinguishable from the legacy
|
||||
// 64-hex address-hash scopes we migrate away from.
|
||||
unsigned char rnd[16];
|
||||
randombytes_buf(rnd, sizeof(rnd));
|
||||
static const char* kHex = "0123456789abcdef";
|
||||
std::string id = "w:";
|
||||
for (unsigned char b : rnd) { id.push_back(kHex[b >> 4]); id.push_back(kHex[b & 0x0F]); }
|
||||
|
||||
data::WalletIndexEntry e;
|
||||
if (const auto* existing = wallet_index_.find(file)) e = *existing;
|
||||
e.fileName = file;
|
||||
if (e.displayName.empty()) e.displayName = file;
|
||||
e.scopeId = id;
|
||||
if (wallet_index_.upsert(e)) wallet_index_.save();
|
||||
return id;
|
||||
}
|
||||
|
||||
void App::updateWalletIndexForActiveWallet(bool markOpened)
|
||||
{
|
||||
if (!settings_) return;
|
||||
@@ -1014,7 +1069,7 @@ void App::updateWalletIndexForActiveWallet(bool markOpened)
|
||||
if (wallet_index_.upsert(e)) wallet_index_.save();
|
||||
}
|
||||
|
||||
void App::switchToWallet(const std::string& walletFile)
|
||||
void App::switchToWallet(const std::string& walletFile, bool stopDaemonConfirmed, bool salvage)
|
||||
{
|
||||
if (!supportsFullNodeLifecycleActions()) {
|
||||
ui::Notifications::instance().warning("Full-node lifecycle actions are unavailable in lite build");
|
||||
@@ -1029,6 +1084,16 @@ void App::switchToWallet(const std::string& walletFile)
|
||||
ui::Notifications::instance().warning("The node is busy restarting — try again in a moment.");
|
||||
return;
|
||||
}
|
||||
// Don't race the other daemon-lifecycle operations. rescan/repair set state_.sync.rescanning; the
|
||||
// seed-migration flow drives its own daemon stop/start; an encryption restart sets daemon_restarting_.
|
||||
if (state_.sync.rescanning) {
|
||||
ui::Notifications::instance().warning("A rescan or repair is in progress — try again once it finishes.");
|
||||
return;
|
||||
}
|
||||
if (show_seed_migration_) {
|
||||
ui::Notifications::instance().warning("Finish or cancel the seed migration before switching wallets.");
|
||||
return;
|
||||
}
|
||||
if (!isUsingEmbeddedDaemon()) {
|
||||
ui::Notifications::instance().warning("Switching wallets needs the embedded daemon — stop any external dragonxd first.");
|
||||
return;
|
||||
@@ -1037,14 +1102,44 @@ void App::switchToWallet(const std::string& walletFile)
|
||||
ui::Notifications::instance().warning("Finish or cancel the pending send before switching wallets.");
|
||||
return;
|
||||
}
|
||||
// If we're connected to a node this session did NOT spawn (no live process handle — it was left
|
||||
// running by "keep node running", started by the user, or we just direct-connected to a config-provided
|
||||
// one), confirm before stopping it: switching must stop+restart it on the new wallet, but the user may
|
||||
// have kept it running on purpose. A node we started ourselves (handle held) restarts silently.
|
||||
if (!stopDaemonConfirmed && state_.connected && !isEmbeddedDaemonRunning()) {
|
||||
pending_switch_wallet_file_ = walletFile;
|
||||
show_switch_stop_daemon_confirm_ = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Rescan only if this wallet was never synced in this datadir (freshly imported/new). One we've
|
||||
// loaded here before just catches up from its recorded block on start — fast.
|
||||
bool needRescan = true;
|
||||
if (const auto* e = wallet_index_.find(walletFile)) needRescan = !e->syncedHere;
|
||||
|
||||
const std::string prevWallet = settings_->getActiveWalletFile(); // for revert if the new one fails
|
||||
wallet_switch_prev_file_ = prevWallet;
|
||||
wallet_switch_target_file_ = walletFile; // remembered for a "salvage" retry from the failure modal
|
||||
wallet_switch_failed_.store(false);
|
||||
switch_stop_failed_.store(false); // reason latch for the revert message; a stale one must not leak
|
||||
switch_wallet_corrupt_.store(false);
|
||||
wallet_switch_pending_confirm_.store(true); // cleared on a successful connect (onConnected)
|
||||
settings_->setActiveWalletFile(walletFile);
|
||||
settings_->save();
|
||||
// A prior crash-wedge shouldn't block reconnecting to the freshly-launched wallet daemon.
|
||||
if (daemon_controller_) daemon_controller_->resetCrashCount();
|
||||
|
||||
// Re-scope the PIN vault to the new wallet (its own vault-<scope>.dat), and clear any carried-over
|
||||
// lock-screen attempt/lockout state + wipe the passphrase/PIN entry buffers — the previous wallet's
|
||||
// failed-unlock counter and typed secrets must not apply to the new wallet.
|
||||
if (vault_) vault_->setWalletScope(walletFile);
|
||||
lock_attempts_ = 0;
|
||||
lock_lockout_timer_ = 0.0f;
|
||||
lock_error_msg_.clear();
|
||||
lock_error_timer_ = 0.0f;
|
||||
lock_unlock_in_progress_ = false;
|
||||
util::SecureVault::secureZero(lock_passphrase_buf_, sizeof(lock_passphrase_buf_));
|
||||
util::SecureVault::secureZero(lock_pin_buf_, sizeof(lock_pin_buf_));
|
||||
|
||||
// Same restart coordination as the seed-adopt flow: gate the main-loop reconnect, disconnect,
|
||||
// then stop + restart on a background thread. syncSettings() re-reads active_wallet_file on
|
||||
@@ -1053,19 +1148,135 @@ void App::switchToWallet(const std::string& walletFile)
|
||||
daemon_restarting_ = true;
|
||||
if (rpc_ && rpc_->isConnected()) rpc_->disconnect();
|
||||
onDisconnected("Switching wallet");
|
||||
ui::Notifications::instance().info("Switching wallet — the node will restart…");
|
||||
// The loaded wallet is changing — drop the old wallet's chat identity + decrypted store so it
|
||||
// can't surface (or sign outgoing chat) under the new wallet. It re-provisions from the new
|
||||
// wallet's seed once it connects. onDisconnected alone doesn't cover chat.
|
||||
resetChatSession();
|
||||
// Open the switch progress modal — it stays up through stop → wait-for-exit → start → reconnect and
|
||||
// auto-closes when the new node connects (onConnected). The worker advances the phase from here.
|
||||
wallet_switch_error_.clear();
|
||||
wallet_switch_started_time_ = 0.0; // re-captured on the first progress frame → elapsed starts fresh
|
||||
wallet_switch_phase_.store(static_cast<int>(WalletSwitchPhase::Stopping));
|
||||
wallet_switch_dialog_open_.store(true);
|
||||
|
||||
async_tasks_.submit("Switch wallet", [this, needRescan](const util::AsyncTaskManager::Token&) {
|
||||
// Stop the node fully so it releases the datadir .lock + RPC port before we relaunch.
|
||||
stopEmbeddedDaemon();
|
||||
for (int i = 0; i < 60 && isEmbeddedDaemonRunning(); ++i)
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(500));
|
||||
if (needRescan && daemon_controller_) daemon_controller_->setRescanOnNextStart(true);
|
||||
if (!shutting_down_) startEmbeddedDaemon();
|
||||
daemon_restarting_ = false; // re-arm reconnect once the new daemon has been launched
|
||||
async_tasks_.submit("Switch wallet", [this, needRescan, salvage](const util::AsyncTaskManager::Token&) {
|
||||
bool ok = false;
|
||||
// Watermark the node's captured console output so we scan only THIS start's lines for a
|
||||
// corruption signature (below), not stale output from a previous daemon.
|
||||
std::size_t out_off = 0;
|
||||
if (daemon_controller_) daemon_controller_->outputSince(out_off);
|
||||
try {
|
||||
// Stop the node so it releases the datadir .lock + RPC port before we relaunch. For an adopted
|
||||
// (external) daemon this sends a graceful RPC "stop" — stopEmbeddedDaemon()'s policy would leave
|
||||
// it running — and then waits for the RPC PORT to actually free, the correct readiness signal
|
||||
// (isEmbeddedDaemonRunning() is process-handle-only and reads false immediately for an adopted
|
||||
// daemon). The wait breaks promptly on shutdown so beginShutdown's join() doesn't freeze the UI.
|
||||
const bool port_free = stopDaemonForWalletSwitch();
|
||||
if (!port_free) {
|
||||
// The old node wouldn't release the port in time — don't start() into a busy port (that
|
||||
// fast-fails on the isPortInUse check and gets misread as a bad wallet). Revert with an
|
||||
// accurate, distinct reason instead.
|
||||
switch_stop_failed_.store(true);
|
||||
ok = false;
|
||||
} else {
|
||||
// The relaunch is ours — clear the adopted-external latch so the fresh process is treated
|
||||
// as owned (stop/isRunning/exit behave normally for the next switch and app exit).
|
||||
wallet_switch_phase_.store(static_cast<int>(WalletSwitchPhase::Starting));
|
||||
if (daemon_controller_) daemon_controller_->clearExternalDaemonDetected();
|
||||
if (salvage && daemon_controller_) daemon_controller_->setSalvageOnNextStart(true); // repair a corrupt wallet
|
||||
else if (needRescan && daemon_controller_) daemon_controller_->setRescanOnNextStart(true); // (salvage implies rescan)
|
||||
// Start ONCE. Do NOT retry-spawn: a second start while the first is still shutting down leaves
|
||||
// two dragonxd holding wallet.dat against each other (BDB "Failed to rename … Error"). The
|
||||
// stopDaemonForWalletSwitch() wait already ensured the old node's process is gone, so a valid
|
||||
// wallet opens cleanly; a bad/corrupt wallet exits during init and we revert.
|
||||
ok = shutting_down_ ? true : startEmbeddedDaemon();
|
||||
// A missing/corrupt wallet makes dragonxd exit during init — confirm the process survived a
|
||||
// moment; if not, the wallet is bad. (A valid launch keeps the process alive while it syncs.)
|
||||
if (ok && !shutting_down_) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(1500));
|
||||
if (!isEmbeddedDaemonRunning()) ok = false;
|
||||
}
|
||||
// If it died in init, scan this start's captured output for a corrupt-wallet signature so the
|
||||
// failure modal can offer a repair. Give the dying node a moment to flush its final lines.
|
||||
if (!ok && !shutting_down_ && daemon_controller_) {
|
||||
for (int i = 0; i < 12 && !shutting_down_; ++i) std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
if (walletOutputLooksCorrupt(daemon_controller_->outputSince(out_off)))
|
||||
switch_wallet_corrupt_.store(true);
|
||||
}
|
||||
// Process is up and staying up — now waiting for RPC to answer (onConnected closes the modal).
|
||||
if (ok) wallet_switch_phase_.store(static_cast<int>(WalletSwitchPhase::Reconnecting));
|
||||
}
|
||||
} catch (...) {
|
||||
ok = false; // a throw during stop/start is a failed switch — revert rather than wedge
|
||||
}
|
||||
if (ok) {
|
||||
daemon_restarting_ = false; // re-arm reconnect once the new daemon is up
|
||||
} else {
|
||||
// Leave the reconnect GATED (daemon_restarting_ stays true) and hand the revert to the main
|
||||
// thread — settings writes + vault re-scope must not run on this worker thread. The revert
|
||||
// re-arms the gate, so it can never get stuck true.
|
||||
wallet_switch_failed_.store(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Main-thread continuation for a failed wallet switch: restore the previous wallet file so a broken
|
||||
// active_wallet_file isn't persisted, re-scope the vault back, and re-arm the reconnect so the connect
|
||||
// loop brings the (good) previous wallet's daemon back up. Called each tick from update().
|
||||
void App::processWalletSwitchRevert()
|
||||
{
|
||||
if (!wallet_switch_failed_.exchange(false)) return;
|
||||
wallet_switch_pending_confirm_.store(false);
|
||||
if (settings_ && !wallet_switch_prev_file_.empty() &&
|
||||
settings_->getActiveWalletFile() != wallet_switch_prev_file_) {
|
||||
settings_->setActiveWalletFile(wallet_switch_prev_file_);
|
||||
settings_->save();
|
||||
if (vault_) vault_->setWalletScope(wallet_switch_prev_file_);
|
||||
}
|
||||
// Clear the crash-wedge so the (good) previous wallet's daemon is allowed to start again.
|
||||
if (daemon_controller_) daemon_controller_->resetCrashCount();
|
||||
// Accurate reason: "port wouldn't free" (the running node kept the connection); "wallet is corrupt"
|
||||
// (the node's recovery couldn't open it); else a generic bad-wallet failure.
|
||||
const std::string reason =
|
||||
switch_stop_failed_.exchange(false)
|
||||
? std::string("Couldn't switch wallets — the running node didn't release its connection in time. "
|
||||
"It's still on the previous wallet.")
|
||||
: switch_wallet_corrupt_.load()
|
||||
? std::string(TR("switch_corrupt_body"))
|
||||
: std::string("Couldn't open that wallet — reverted to the previous one.");
|
||||
// Surface it in the progress modal if it's still up (keep it open on Failed until the user closes);
|
||||
// otherwise (the user chose "continue in background") fall back to a toast.
|
||||
if (wallet_switch_dialog_open_.load()) {
|
||||
wallet_switch_error_ = reason;
|
||||
wallet_switch_phase_.store(static_cast<int>(WalletSwitchPhase::Failed));
|
||||
} else {
|
||||
ui::Notifications::instance().error(reason);
|
||||
}
|
||||
daemon_restarting_ = false; // the connect loop now restarts the previous wallet's daemon
|
||||
}
|
||||
|
||||
int App::chatUnreadCount() const
|
||||
{
|
||||
if (!chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return 0;
|
||||
int unread = 0;
|
||||
const auto& store = chat_service_.store();
|
||||
for (const auto& cid : store.conversationIds()) {
|
||||
if (settings_ && settings_->isChatMuted(cid)) continue; // muted conversations don't badge (Q10)
|
||||
if (settings_ && settings_->isChatHidden(cid)) continue; // hidden conversations don't badge
|
||||
std::int64_t seen = 0;
|
||||
const auto it = chat_seen_watermark_.find(cid);
|
||||
if (it != chat_seen_watermark_.end()) seen = it->second;
|
||||
for (const auto& m : store.conversation(cid))
|
||||
if (m.direction == chat::ChatDirection::Incoming && m.timestamp > seen) ++unread;
|
||||
}
|
||||
return unread;
|
||||
}
|
||||
|
||||
void App::markChatConversationSeen(const std::string& cid, std::int64_t latestTs)
|
||||
{
|
||||
if (latestTs > 0) chat_seen_watermark_[cid] = latestTs;
|
||||
}
|
||||
|
||||
void App::wipePendingTransactionHistoryCachePassphrase()
|
||||
{
|
||||
if (!pending_transaction_history_cache_passphrase_.empty()) {
|
||||
@@ -1507,7 +1718,22 @@ void App::refreshTransactionData()
|
||||
!result.hushChatMetadata.empty()) {
|
||||
std::unordered_map<std::string, std::int64_t> chatTxTimes;
|
||||
for (const auto& tx : result.transactions) chatTxTimes[tx.txid] = tx.timestamp;
|
||||
chat_service_.ingest(result.hushChatMetadata, chatTxTimes, std::time(nullptr));
|
||||
std::vector<std::string> newChatCids;
|
||||
chat_service_.ingest(result.hushChatMetadata, chatTxTimes, std::time(nullptr), &newChatCids);
|
||||
// A new message un-hides a hidden conversation (you can't un-receive), so nothing is lost.
|
||||
if (settings_) {
|
||||
bool unhid = false;
|
||||
for (const auto& cid : newChatCids)
|
||||
if (settings_->isChatHidden(cid)) { settings_->setChatHidden(cid, false); unhid = true; }
|
||||
if (unhid) settings_->save();
|
||||
}
|
||||
// Toast when a NON-muted conversation received a new message and we're off the Chat tab.
|
||||
// Gate on the ingest-reported cids, not a seen-watermark delta — the latter can be swallowed
|
||||
// by block-time vs wall-clock (echo) skew (Q10 + Q4).
|
||||
if (current_page_ != ui::NavPage::Chat &&
|
||||
std::any_of(newChatCids.begin(), newChatCids.end(),
|
||||
[this](const std::string& cid){ return !(settings_ && settings_->isChatMuted(cid)); }))
|
||||
ui::Notifications::instance().info(TR("chat_new_message_toast"));
|
||||
}
|
||||
NetworkRefreshService::applyTransactionRefreshResult(
|
||||
state_, cacheUpdate, std::move(result), std::time(nullptr));
|
||||
@@ -1565,7 +1791,22 @@ void App::refreshRecentTransactionData()
|
||||
!result.hushChatMetadata.empty()) {
|
||||
std::unordered_map<std::string, std::int64_t> chatTxTimes;
|
||||
for (const auto& tx : result.transactions) chatTxTimes[tx.txid] = tx.timestamp;
|
||||
chat_service_.ingest(result.hushChatMetadata, chatTxTimes, std::time(nullptr));
|
||||
std::vector<std::string> newChatCids;
|
||||
chat_service_.ingest(result.hushChatMetadata, chatTxTimes, std::time(nullptr), &newChatCids);
|
||||
// A new message un-hides a hidden conversation (you can't un-receive), so nothing is lost.
|
||||
if (settings_) {
|
||||
bool unhid = false;
|
||||
for (const auto& cid : newChatCids)
|
||||
if (settings_->isChatHidden(cid)) { settings_->setChatHidden(cid, false); unhid = true; }
|
||||
if (unhid) settings_->save();
|
||||
}
|
||||
// Toast when a NON-muted conversation received a new message and we're off the Chat tab.
|
||||
// Gate on the ingest-reported cids, not a seen-watermark delta — the latter can be swallowed
|
||||
// by block-time vs wall-clock (echo) skew (Q10 + Q4).
|
||||
if (current_page_ != ui::NavPage::Chat &&
|
||||
std::any_of(newChatCids.begin(), newChatCids.end(),
|
||||
[this](const std::string& cid){ return !(settings_ && settings_->isChatMuted(cid)); }))
|
||||
ui::Notifications::instance().info(TR("chat_new_message_toast"));
|
||||
}
|
||||
NetworkRefreshService::applyTransactionRefreshResult(
|
||||
state_, cacheUpdate, std::move(result), std::time(nullptr));
|
||||
@@ -2447,7 +2688,7 @@ void App::exportPrivateKey(const std::string& address, std::function<void(const
|
||||
std::string err;
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Settings / Export private key");
|
||||
key = rpc_->call(method, {address}).get<std::string>();
|
||||
key = rpc_->callSecretString(method, {address}); // scrubs raw body + json node (B7)
|
||||
} catch (const std::exception& e) {
|
||||
err = e.what();
|
||||
}
|
||||
@@ -2493,6 +2734,13 @@ void App::provisionChatIdentityFromSecret(std::string secret)
|
||||
chat_service_.setPersistence(&chat_db_);
|
||||
if (chat_db_.unlockWithSecret(trimmed)) {
|
||||
chat_service_.loadFromDatabase();
|
||||
// Baseline unread: treat everything already in the store at load as read, so only messages
|
||||
// that arrive while the app is open badge as unread (Q1).
|
||||
const auto& store = chat_service_.store();
|
||||
for (const auto& cid : store.conversationIds()) {
|
||||
const auto msgs = store.conversation(cid);
|
||||
if (!msgs.empty()) chat_seen_watermark_[cid] = msgs.back().timestamp;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
chat_identity_unavailable_ = true;
|
||||
@@ -2506,6 +2754,24 @@ void App::provisionChatIdentityFromSecret(std::string secret)
|
||||
// tick; cheap early-outs keep it idle until it can act. Both variants derive the SAME identity
|
||||
// because they feed the SAME SDXLite-compatible mnemonic into the KDF (identity derivation is
|
||||
// local — peers only ever exchange public keys).
|
||||
void App::resetChatSession()
|
||||
{
|
||||
if (!chat::hushChatFeatureEnabledAtBuild()) return; // constexpr — folds away in OFF builds
|
||||
// Drop identity + decrypted plaintext in RAM, lock the seed-encrypted DB, re-arm provisioning.
|
||||
chat_service_.clearIdentity();
|
||||
chat_service_.store().clear();
|
||||
chat_seen_watermark_.clear(); // unread state is per-wallet — don't leak it across a switch
|
||||
ui::ResetChatTab(); // wipe the chat UI's typed plaintext + selection so it can't leak into the next wallet
|
||||
chat_db_.lock();
|
||||
chat_identity_provisioned_ = false;
|
||||
chat_identity_fetch_in_flight_ = false;
|
||||
chat_fast_scan_in_flight_ = false; // a stale in-flight fast-scan is dropped by its session guard
|
||||
chat_identity_unavailable_ = false;
|
||||
// Invalidate any in-flight identity fetch that captured the PREVIOUS wallet's secret, so its
|
||||
// completion callback can't provision that secret under the new wallet.
|
||||
++chat_session_generation_;
|
||||
}
|
||||
|
||||
void App::maybeProvisionChatIdentity()
|
||||
{
|
||||
if (!chat::hushChatFeatureEnabledAtBuild()) return; // constexpr — folds away in OFF builds
|
||||
@@ -2514,13 +2780,7 @@ void App::maybeProvisionChatIdentity()
|
||||
// in-memory identity and re-arm so it re-derives on the next unlock. Unencrypted wallets report
|
||||
// isLocked()==false and fall through.
|
||||
if (state_.isLocked()) {
|
||||
if (chat_identity_provisioned_ || chat_service_.hasIdentity()) {
|
||||
chat_service_.clearIdentity();
|
||||
chat_service_.store().clear(); // decrypted plaintext in RAM — drop it; DB reloads on unlock
|
||||
chat_db_.lock();
|
||||
chat_identity_provisioned_ = false;
|
||||
chat_identity_unavailable_ = false;
|
||||
}
|
||||
if (chat_identity_provisioned_ || chat_service_.hasIdentity()) resetChatSession();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2547,14 +2807,15 @@ void App::maybeProvisionChatIdentity()
|
||||
if (!state_.connected || !rpc_ || !worker_) return;
|
||||
if (!state_.encryption_state_known) return; // don't fetch before we know the lock state
|
||||
const std::string fallbackZaddr = chatReplyZaddr(); // main thread: stable identity source
|
||||
const int fetchGen = chat_session_generation_; // wallet epoch this secret belongs to
|
||||
chat_identity_fetch_in_flight_ = true;
|
||||
worker_->post([this, fallbackZaddr]() -> rpc::RPCWorker::MainCb {
|
||||
worker_->post([this, fallbackZaddr, fetchGen]() -> rpc::RPCWorker::MainCb {
|
||||
std::string secret; // the mnemonic (portable) OR a spending key (legacy fallback)
|
||||
bool transientFail = false; // connection blip — allow a later retry
|
||||
bool unavailable = false; // no usable secret — stop retrying this session
|
||||
rpc::RPCClient::TraceScope trace("HushChat / identity");
|
||||
try {
|
||||
auto response = rpc_->call("z_exportmnemonic");
|
||||
auto response = rpc_->callSecret("z_exportmnemonic"); // zero the raw body too (B7)
|
||||
if (response.contains("mnemonic") && response["mnemonic"].is_string()) {
|
||||
// Scrub the json's own copy of the seed after taking ours (the rest of the RPC
|
||||
// response chain is unmanaged — a fuller fix belongs in the RPC layer).
|
||||
@@ -2568,7 +2829,14 @@ void App::maybeProvisionChatIdentity()
|
||||
unavailable = true;
|
||||
} else {
|
||||
try {
|
||||
secret = rpc_->call("z_exportkey", {fallbackZaddr}).get<std::string>();
|
||||
// Mirror the mnemonic path: take our copy, then scrub the json's own copy of the
|
||||
// spending key so it isn't left in freed heap (B4). z_exportkey returns a bare string.
|
||||
auto keyResp = rpc_->callSecret("z_exportkey", {fallbackZaddr}); // zero the raw body too (B7)
|
||||
if (keyResp.is_string()) {
|
||||
auto& key = keyResp.get_ref<std::string&>();
|
||||
secret = key;
|
||||
if (!key.empty()) sodium_memzero(&key[0], key.size());
|
||||
}
|
||||
} catch (const rpc::RpcError&) {
|
||||
unavailable = true; // no spending key either (view-only?) — give up
|
||||
} catch (const std::exception&) {
|
||||
@@ -2578,7 +2846,14 @@ void App::maybeProvisionChatIdentity()
|
||||
} catch (const std::exception&) {
|
||||
transientFail = true;
|
||||
}
|
||||
return [this, secret = std::move(secret), transientFail, unavailable]() mutable {
|
||||
return [this, secret = std::move(secret), transientFail, unavailable, fetchGen]() mutable {
|
||||
// The wallet changed while this fetch ran (resetChatSession bumped the generation): this
|
||||
// secret belongs to the PREVIOUS wallet — wipe it and do nothing so it can't provision an
|
||||
// identity under the new wallet. Don't touch the flags either (they belong to the new epoch).
|
||||
if (fetchGen != chat_session_generation_) {
|
||||
if (!secret.empty()) sodium_memzero(&secret[0], secret.size());
|
||||
return;
|
||||
}
|
||||
chat_identity_fetch_in_flight_ = false;
|
||||
if (unavailable) chat_identity_unavailable_ = true;
|
||||
// Provision only on success AND while still unlocked: a lock (e.g. auto-lock) can
|
||||
@@ -2626,6 +2901,27 @@ std::string App::chatReplyZaddr()
|
||||
return best;
|
||||
}
|
||||
|
||||
std::string App::chatPayFromZaddr(double fee) const
|
||||
{
|
||||
// z_sendmany spends from ONE z-address, so the "from" must itself hold >= fee. Prefer the identity
|
||||
// reply address (keeps from == reply-to, the simplest case); otherwise pick the highest-balance
|
||||
// spendable z-address that can cover the fee. The memo still advertises the identity address as
|
||||
// reply-to, so paying from a different note doesn't change who the peer replies to.
|
||||
std::string reply;
|
||||
if (settings_) reply = settings_->getChatReplyZaddr();
|
||||
for (const auto& a : state_.z_addresses)
|
||||
if (a.address == reply && a.has_spending_key && a.balance >= fee) return reply;
|
||||
|
||||
std::string best;
|
||||
double bestBal = -1.0;
|
||||
for (const auto& a : state_.z_addresses)
|
||||
if (a.has_spending_key && !a.address.empty() && a.balance >= fee && a.balance > bestBal) {
|
||||
best = a.address;
|
||||
bestBal = a.balance;
|
||||
}
|
||||
return best; // empty → no z-address can cover the fee
|
||||
}
|
||||
|
||||
// A unique opaque id (hex), used for the local echo id (we never harvest our own sends) and for
|
||||
// new conversation ids. Random — collisions are astronomically unlikely.
|
||||
std::string App::generateChatLocalId(const char* prefix, int numBytes) const
|
||||
@@ -2645,21 +2941,43 @@ std::string App::generateChatLocalId(const char* prefix, int numBytes) const
|
||||
// LIVE-VERIFY (cannot be proven from source): that dragonxd returns the memo under `memoStr` verbatim
|
||||
// on receive, that recipient-array order maps to note position (the receive pairing needs the header
|
||||
// at a lower position), that a 0-value memo-only tx relays, and full SDXL<->DragonX interop.
|
||||
bool App::broadcastChatMemos(const chat::OutgoingChatMemos& memos)
|
||||
// A chat send moves 0 value, so the fee is the ONLY thing that forces a real shielded input — a
|
||||
// 0-value + 0-fee tx builds a degenerate, unrelayable tx (see z_sendmany targetAmount). Never let the
|
||||
// global default-fee setting drop chat below a working minimum (well above the node's min relay fee).
|
||||
static constexpr double kChatMinFeeDrgx = 0.0001;
|
||||
|
||||
bool App::broadcastChatMemos(const chat::OutgoingChatMemos& memos, const std::string& echoLocalId)
|
||||
{
|
||||
if (memos.recipientZaddr.empty()) return false;
|
||||
|
||||
// Guard the async resolve against a wallet lock/switch between submit and callback: if the chat
|
||||
// session was reset in between, this echo belongs to a stale session — don't touch the new store.
|
||||
const auto sessionGen = chat_session_generation_;
|
||||
|
||||
if (lite_wallet_) {
|
||||
return broadcastChatMemosLite(memos);
|
||||
const bool ok = broadcastChatMemosLite(memos);
|
||||
// The lite backend has no per-send completion callback here; resolve optimistically on a
|
||||
// successful queue (its own broadcast log surfaces a later failure).
|
||||
if (ok && sessionGen == chat_session_generation_)
|
||||
chat_service_.resolveOutgoing(echoLocalId, chat::ChatDelivery::Sent);
|
||||
return ok;
|
||||
}
|
||||
|
||||
if (!state_.connected || !rpc_ || !worker_) {
|
||||
ui::Notifications::instance().error(TR("chat_toast_not_connected"));
|
||||
return false;
|
||||
}
|
||||
const std::string from = chatReplyZaddr(); // spendable + advertised as the reply-to address
|
||||
|
||||
// Chat moves 0 value, and dragonxd REJECTS a 0-value tx whose fee exceeds the default miners fee
|
||||
// (0.0001) — so pin chat to exactly kChatMinFeeDrgx, independent of the user's default-fee setting
|
||||
// (which may be higher). It's also well above the node's min relay fee.
|
||||
const double fee = kChatMinFeeDrgx;
|
||||
|
||||
// Pay from a z-address that can actually cover the fee. The memo still advertises the identity
|
||||
// reply address, so the peer replies to the right place regardless of which note paid.
|
||||
const std::string from = chatPayFromZaddr(fee);
|
||||
if (from.empty()) {
|
||||
ui::Notifications::instance().error(TR("chat_toast_no_zaddr"));
|
||||
ui::Notifications::instance().error(TR("chat_toast_need_funds"));
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -2674,12 +2992,17 @@ bool App::broadcastChatMemos(const chat::OutgoingChatMemos& memos)
|
||||
recipients.push_back(std::move(recipient));
|
||||
}
|
||||
|
||||
const double fee = settings_ ? settings_->getDefaultFee() : 0.0001;
|
||||
// markFeeGapRetry=true is deliberate: it suppresses the fee-gap auto-retry, which rebuilds a
|
||||
// SINGLE-recipient tx from the scalar to/amount/memo and would drop the second (payload) output.
|
||||
// The callback flips the echo to Sent/Failed once the async op resolves — real delivery status.
|
||||
submitZSendMany(from, memos.recipientZaddr, 0.0, fee, /*memo*/"", recipients,
|
||||
"HushChat / broadcast", /*markFeeGapRetry*/ true, /*callback*/{});
|
||||
return true; // submitted (async build/broadcast; a later failure surfaces via the opid poller)
|
||||
"HushChat / broadcast", /*markFeeGapRetry*/ true,
|
||||
[this, echoLocalId, sessionGen](bool ok, const std::string& /*result*/) {
|
||||
if (sessionGen != chat_session_generation_) return; // wallet locked/switched — stale
|
||||
chat_service_.resolveOutgoing(echoLocalId,
|
||||
ok ? chat::ChatDelivery::Sent : chat::ChatDelivery::Failed);
|
||||
});
|
||||
return true; // submitted (async build/broadcast; the callback resolves the final status)
|
||||
}
|
||||
|
||||
// Lite variant: two 0-value recipients to the same z-address, RAW memos (the backend does
|
||||
@@ -2710,12 +3033,17 @@ void App::sendChatMessage(const std::string& conversationId, const std::string&
|
||||
if (!chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return;
|
||||
if (text.empty() || conversationId.empty()) return;
|
||||
|
||||
// The peer's z-address + public key come from a message we already have in this conversation.
|
||||
// The peer's z-address + public key come from a message we already have in this conversation. Pin to
|
||||
// the EARLIEST (establishing) values, not the latest: the memo header's `z`/`cid` ride outside the
|
||||
// AEAD, so trusting the newest would let a later message redirect our replies to an attacker-chosen
|
||||
// address / splice threads (B2). A full fix binds z+cid into the secretstream additional-data, but
|
||||
// that's a coordinated HushChat/SDXLite wire-format change; pinning hardens the reply target without it.
|
||||
std::string peerZaddr;
|
||||
std::string peerPubKey;
|
||||
for (const auto& m : chat_service_.store().conversation(conversationId)) {
|
||||
if (!m.peer_zaddr.empty()) peerZaddr = m.peer_zaddr;
|
||||
if (!m.peer_public_key_hex.empty()) peerPubKey = m.peer_public_key_hex;
|
||||
if (peerZaddr.empty() && !m.peer_zaddr.empty()) peerZaddr = m.peer_zaddr;
|
||||
if (peerPubKey.empty() && !m.peer_public_key_hex.empty()) peerPubKey = m.peer_public_key_hex;
|
||||
if (!peerZaddr.empty() && !peerPubKey.empty()) break;
|
||||
}
|
||||
if (peerPubKey.empty()) {
|
||||
ui::Notifications::instance().info(TR("chat_toast_waiting_reply"));
|
||||
@@ -2733,8 +3061,6 @@ void App::sendChatMessage(const std::string& conversationId, const std::string&
|
||||
ui::Notifications::instance().error(TR("chat_toast_compose_failed"));
|
||||
return;
|
||||
}
|
||||
const bool submitted = broadcastChatMemos(memos);
|
||||
|
||||
chat::ChatMessage echo;
|
||||
echo.direction = chat::ChatDirection::Outgoing;
|
||||
echo.kind = chat::ChatMessageKind::Message;
|
||||
@@ -2745,29 +3071,40 @@ void App::sendChatMessage(const std::string& conversationId, const std::string&
|
||||
echo.timestamp = std::time(nullptr);
|
||||
echo.txid = generateChatLocalId("out:", 8);
|
||||
echo.payload_position = 0;
|
||||
echo.delivery = submitted ? chat::ChatDelivery::Sent : chat::ChatDelivery::Failed;
|
||||
chat_service_.recordOutgoing(echo);
|
||||
echo.delivery = chat::ChatDelivery::Sending; // shows immediately; the broadcast callback resolves it
|
||||
chat_service_.recordOutgoingPending(echo); // in-memory now; persisted with the final status
|
||||
// A synchronous refusal (not connected / no funds) resolves to Failed right away so the Retry
|
||||
// affordance appears; otherwise the async callback flips it to Sent/Failed.
|
||||
if (!broadcastChatMemos(memos, echo.txid))
|
||||
chat_service_.resolveOutgoing(echo.txid, chat::ChatDelivery::Failed);
|
||||
}
|
||||
|
||||
void App::startChatConversation(const std::string& peerZaddr, const std::string& text)
|
||||
{
|
||||
if (!chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return;
|
||||
if (peerZaddr.empty() || text.empty()) return;
|
||||
sendContactRequestForCid(generateChatLocalId("", 16), peerZaddr, text); // new opaque cid
|
||||
}
|
||||
|
||||
// Compose + broadcast a contact request into a SPECIFIC conversation. startChatConversation() mints a
|
||||
// fresh cid; a Retry of a failed request reuses its existing cid so it stays in the same thread.
|
||||
void App::sendContactRequestForCid(const std::string& cid, const std::string& peerZaddr,
|
||||
const std::string& text)
|
||||
{
|
||||
if (!chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return;
|
||||
if (cid.empty() || peerZaddr.empty() || text.empty()) return;
|
||||
const std::string myReply = chatReplyZaddr();
|
||||
if (myReply.empty()) {
|
||||
ui::Notifications::instance().error(TR("chat_toast_no_zaddr"));
|
||||
return;
|
||||
}
|
||||
|
||||
const std::string cid = generateChatLocalId("", 16); // opaque per-conversation id
|
||||
chat::OutgoingChatMemos memos;
|
||||
if (chat_service_.composeContactRequest(myReply, peerZaddr, cid, text, memos)
|
||||
!= chat::ChatComposeStatus::Ok) {
|
||||
ui::Notifications::instance().error(TR("chat_toast_request_compose_failed"));
|
||||
return;
|
||||
}
|
||||
const bool submitted = broadcastChatMemos(memos);
|
||||
|
||||
chat::ChatMessage echo;
|
||||
echo.direction = chat::ChatDirection::Outgoing;
|
||||
echo.kind = chat::ChatMessageKind::ContactRequest;
|
||||
@@ -2777,9 +3114,12 @@ void App::startChatConversation(const std::string& peerZaddr, const std::string&
|
||||
echo.timestamp = std::time(nullptr);
|
||||
echo.txid = generateChatLocalId("out:", 8);
|
||||
echo.payload_position = 0;
|
||||
echo.delivery = submitted ? chat::ChatDelivery::Sent : chat::ChatDelivery::Failed;
|
||||
chat_service_.recordOutgoing(echo);
|
||||
if (submitted) ui::Notifications::instance().success(TR("chat_toast_request_queued"));
|
||||
echo.delivery = chat::ChatDelivery::Sending;
|
||||
chat_service_.recordOutgoingPending(echo);
|
||||
if (broadcastChatMemos(memos, echo.txid))
|
||||
ui::Notifications::instance().success(TR("chat_toast_request_queued"));
|
||||
else
|
||||
chat_service_.resolveOutgoing(echo.txid, chat::ChatDelivery::Failed);
|
||||
}
|
||||
|
||||
// HushChat (lite variant): the full-node harvest works off z_viewtransaction, but the lite wallet's
|
||||
@@ -2808,7 +3148,93 @@ void App::ingestLiteChatMemos(const wallet::LiteWalletAppRefreshModel& model)
|
||||
auto extracted = chat::extractHushChatTransactionMetadata(entry.second, true);
|
||||
for (auto& meta : extracted.metadata) metadata.push_back(std::move(meta));
|
||||
}
|
||||
if (!metadata.empty()) chat_service_.ingest(metadata, txTimestamps, std::time(nullptr));
|
||||
if (!metadata.empty()) {
|
||||
std::vector<std::string> newChatCids;
|
||||
chat_service_.ingest(metadata, txTimestamps, std::time(nullptr), &newChatCids);
|
||||
// Mirror the full-node paths: a new incoming message un-hides a hidden conversation (you can't
|
||||
// un-receive), and a non-muted new message toasts when we're off the Chat tab.
|
||||
if (settings_) {
|
||||
bool unhid = false;
|
||||
for (const auto& cid : newChatCids)
|
||||
if (settings_->isChatHidden(cid)) { settings_->setChatHidden(cid, false); unhid = true; }
|
||||
if (unhid) settings_->save();
|
||||
}
|
||||
if (current_page_ != ui::NavPage::Chat &&
|
||||
std::any_of(newChatCids.begin(), newChatCids.end(),
|
||||
[this](const std::string& cid){ return !(settings_ && settings_->isChatMuted(cid)); }))
|
||||
ui::Notifications::instance().info(TR("chat_new_message_toast"));
|
||||
}
|
||||
}
|
||||
|
||||
void App::fastScanChatMemos()
|
||||
{
|
||||
// Full-node only (lite has its own harvest). Re-scan JUST the chat reply address at minconf=0 every
|
||||
// cycle so peers' messages surface at mempool speed. The normal, block-tip-gated harvest still
|
||||
// ingests everything on confirmation (the store dedups on txid+position, so no double-insert).
|
||||
if (lite_wallet_) return;
|
||||
if (!chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return;
|
||||
if (!state_.connected || !rpc_ || !worker_) return;
|
||||
if (chat_fast_scan_in_flight_) return; // don't stack RPCs if a previous scan is still running
|
||||
const std::string addr = chatReplyZaddr();
|
||||
if (addr.empty()) return;
|
||||
|
||||
chat_fast_scan_in_flight_ = true;
|
||||
const int scanGen = chat_session_generation_; // guard: drop the result if the wallet switches/locks
|
||||
worker_->post([this, addr, scanGen]() -> rpc::RPCWorker::MainCb {
|
||||
std::vector<chat::HushChatTransactionMetadata> metadata;
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("HushChat / 0-conf fast scan");
|
||||
nlohmann::json received = rpc_->call("z_listreceivedbyaddress", {addr, 0}); // 0 = include mempool
|
||||
if (received.is_array()) {
|
||||
std::unordered_map<std::string, chat::HushChatTransactionInput> byTxid;
|
||||
std::size_t noteIndex = 0;
|
||||
for (const auto& note : received) {
|
||||
const std::size_t fallbackPos = noteIndex++;
|
||||
if (!note.is_object()) continue;
|
||||
const std::string txid = note.value("txid", std::string());
|
||||
const std::string memo = note.value("memoStr", std::string());
|
||||
if (txid.empty() || memo.empty()) continue;
|
||||
std::size_t pos = fallbackPos;
|
||||
for (const char* key : {"position", "outputIndex", "outindex"})
|
||||
if (note.contains(key) && note[key].is_number_integer() && note[key].get<int>() >= 0) {
|
||||
pos = static_cast<std::size_t>(note[key].get<int>());
|
||||
break;
|
||||
}
|
||||
auto& in = byTxid[txid];
|
||||
in.txid = txid;
|
||||
in.outputs.push_back(chat::HushChatMemoOutput{pos, memo});
|
||||
}
|
||||
for (auto& entry : byTxid) {
|
||||
auto extracted = chat::extractHushChatTransactionMetadata(entry.second);
|
||||
for (auto& m : extracted.metadata) metadata.push_back(std::move(m));
|
||||
}
|
||||
}
|
||||
} catch (const std::exception&) {}
|
||||
return [this, scanGen, metadata = std::move(metadata)]() mutable {
|
||||
// The wallet was switched/locked between post and now — this metadata belongs to the previous
|
||||
// session. resetChatSession already reset the in-flight flag, so just drop; clearing it here
|
||||
// would clobber a new session's own in-flight scan (mirrors the broadcast/identity guards).
|
||||
if (scanGen != chat_session_generation_) return;
|
||||
chat_fast_scan_in_flight_ = false;
|
||||
if (metadata.empty()) return;
|
||||
// Skip HIDDEN conversations — the mempool fast path deliberately doesn't surface them; they
|
||||
// still come back through the normal confirmed harvest (which un-hides on a new message).
|
||||
std::vector<chat::HushChatTransactionMetadata> visible;
|
||||
visible.reserve(metadata.size());
|
||||
for (auto& m : metadata)
|
||||
if (!(settings_ && settings_->isChatHidden(m.conversation_id)))
|
||||
visible.push_back(std::move(m));
|
||||
if (visible.empty()) return;
|
||||
|
||||
std::unordered_map<std::string, std::int64_t> noTimes; // mempool: no block time → ingest uses now
|
||||
std::vector<std::string> newChatCids;
|
||||
chat_service_.ingest(visible, noTimes, std::time(nullptr), &newChatCids);
|
||||
if (current_page_ != ui::NavPage::Chat &&
|
||||
std::any_of(newChatCids.begin(), newChatCids.end(),
|
||||
[this](const std::string& cid){ return !(settings_ && settings_->isChatMuted(cid)); }))
|
||||
ui::Notifications::instance().info(TR("chat_new_message_toast"));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
bool App::lockLiteWallet()
|
||||
@@ -2836,12 +3262,22 @@ void App::seedChatDemoData()
|
||||
|
||||
// Demo identity so the tab renders the populated UI (hasIdentity()==true). Injected messages go
|
||||
// straight into the in-memory store (no DB attached here → not persisted, gone on restart).
|
||||
chat::ChatKeyPair keys;
|
||||
if (chat::deriveChatIdentityFromSecret("obsidian-dragon-demo-chat", keys).status
|
||||
== chat::ChatIdentityStatus::Ready) {
|
||||
chat_service_.setIdentity(keys);
|
||||
//
|
||||
// Two hard rules, both learned the hard way:
|
||||
// 1. NEVER clobber a real provisioned identity. Overwriting the wallet's seed-derived identity with
|
||||
// a demo one made two different-seed wallets share an identity — so their own sends looped back
|
||||
// and DECRYPTED as incoming. Only fabricate an identity when there is no real one.
|
||||
// 2. Use a RANDOM secret, never a fixed string. A constant demo secret is the SAME public key on
|
||||
// every install/wallet, i.e. everyone who ran the demo shared one cryptographic identity.
|
||||
if (!chat_service_.hasIdentity()) {
|
||||
chat::ChatKeyPair keys;
|
||||
const std::string demoSecret = generateChatLocalId("demo-chat:", 24); // random, per-run
|
||||
if (chat::deriveChatIdentityFromSecret(demoSecret, keys).status
|
||||
== chat::ChatIdentityStatus::Ready) {
|
||||
chat_service_.setIdentity(keys);
|
||||
}
|
||||
chat::wipeChatKeyPair(keys);
|
||||
}
|
||||
chat::wipeChatKeyPair(keys);
|
||||
|
||||
auto& store = chat_service_.store();
|
||||
const std::string zAlice = "zs1demoalice6xh2n8fchrz23thcgqqd2353v8ev2pr7p7lq4p3elsyrfkuenq";
|
||||
@@ -3151,7 +3587,7 @@ void App::exportSeedPhrase(std::function<void(bool, bool, const std::string&, co
|
||||
bool noMnemonic = false;
|
||||
rpc::RPCClient::TraceScope trace("Settings / Export seed phrase");
|
||||
try {
|
||||
auto response = rpc_->call("z_exportmnemonic");
|
||||
auto response = rpc_->callSecret("z_exportmnemonic"); // zero the raw body too (B7)
|
||||
if (response.contains("mnemonic") && response["mnemonic"].is_string()) {
|
||||
auto& m = response["mnemonic"].get_ref<std::string&>();
|
||||
phrase = m;
|
||||
@@ -3429,6 +3865,13 @@ void App::beginAdoptSeedWallet()
|
||||
daemon_restarting_ = true;
|
||||
if (rpc_ && rpc_->isConnected()) rpc_->disconnect();
|
||||
onDisconnected("Adopting seed wallet");
|
||||
// Adopting swaps in a brand-new seed wallet — drop the legacy wallet's chat identity + store so
|
||||
// it re-derives from the new seed (the file name is unchanged, so nothing else detects the swap).
|
||||
resetChatSession();
|
||||
// The PIN vault is name-scoped, and the file name is unchanged, so the legacy wallet's stored
|
||||
// passphrase would otherwise stay associated with the new seed wallet — remove it (the new wallet
|
||||
// has its own passphrase; the user can re-enable PIN quick-unlock for it).
|
||||
if (vault_) vault_->removeVault();
|
||||
const std::string base = seed_migration_temp_dir_;
|
||||
async_tasks_.submit("Adopt seed wallet", [this, base](const util::AsyncTaskManager::Token&) {
|
||||
namespace fs = std::filesystem;
|
||||
@@ -3437,11 +3880,14 @@ void App::beginAdoptSeedWallet()
|
||||
bool swapDone = false;
|
||||
try {
|
||||
std::error_code ec;
|
||||
// 1. Stop the main daemon and wait for it to fully exit.
|
||||
stopEmbeddedDaemon();
|
||||
for (int i = 0; i < 60 && isEmbeddedDaemonRunning(); ++i)
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(500));
|
||||
if (isEmbeddedDaemonRunning()) {
|
||||
// 1. Stop the main daemon and wait for it to FULLY release wallet.dat + the RPC port before we
|
||||
// swap the file. Use the wallet-switch stop path: for an ADOPTED (external) daemon it sends a
|
||||
// graceful RPC "stop" and gates on the port actually freeing — isEmbeddedDaemonRunning() is
|
||||
// process-handle-only and would falsely read "stopped" for an adopted daemon, letting the swap
|
||||
// run against a live daemon still holding wallet.dat. (For an owned daemon stopEmbeddedDaemon()
|
||||
// already blocks for full process exit, so the file is closed before the swap.)
|
||||
const bool port_free = stopDaemonForWalletSwitch();
|
||||
if (!port_free) {
|
||||
err = "The daemon did not stop in time; your wallet was not changed.";
|
||||
} else {
|
||||
// 2. Swap wallet.dat. Move the legacy one aside to a timestamped backup (NEVER
|
||||
@@ -3483,6 +3929,11 @@ void App::beginAdoptSeedWallet()
|
||||
// unless we're quitting, in which case don't resurrect it.
|
||||
if (swapDone && daemon_controller_) daemon_controller_->setRescanOnNextStart(true);
|
||||
if (!shutting_down_) {
|
||||
// We stopped the daemon ourselves (port_free) — clear the adopted-external latch so the
|
||||
// relaunched process is treated as owned (stop/isRunning/exit behave normally afterward).
|
||||
// Skip when the stop failed: the old adopted daemon is still up, and we must not mark a
|
||||
// process we can't control as owned.
|
||||
if (port_free && daemon_controller_) daemon_controller_->clearExternalDaemonDetected();
|
||||
if (!startEmbeddedDaemon() && swapDone)
|
||||
warn = "Your wallet was swapped, but the daemon did not restart — start it from Settings.";
|
||||
}
|
||||
|
||||
@@ -241,16 +241,23 @@ void App::restartDaemonAfterEncryption(const char* taskName, bool announceRestar
|
||||
// the daemon is restarting.
|
||||
connection_status_ = TR("restarting_after_encryption");
|
||||
}
|
||||
// Gate the main-loop reconnect while the daemon is down (so tryConnect can't hit the stopped
|
||||
// node or start a duplicate) and block a concurrent wallet switch/rescan, which check this flag.
|
||||
daemon_restarting_ = true;
|
||||
// Give daemon a moment to shut down, then restart
|
||||
// (do this off the main thread to avoid stalling the UI)
|
||||
async_tasks_.submit(taskName, [this](const util::AsyncTaskManager::Token& token) {
|
||||
for (int i = 0; i < 20 && !token.cancelled() && !shutting_down_; ++i)
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
if (token.cancelled() || shutting_down_) return;
|
||||
stopEmbeddedDaemon();
|
||||
if (token.cancelled() || shutting_down_) return;
|
||||
startEmbeddedDaemon();
|
||||
// tryConnect will be called by the update loop
|
||||
// daemon_restarting_ MUST be cleared on every exit (incl. an early-out or a throw), else it
|
||||
// stays stuck true and wedges reconnect + all future switch/rescan/encryption operations.
|
||||
try {
|
||||
for (int i = 0; i < 20 && !token.cancelled() && !shutting_down_; ++i)
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
if (!token.cancelled() && !shutting_down_) {
|
||||
stopEmbeddedDaemon();
|
||||
if (!token.cancelled() && !shutting_down_) startEmbeddedDaemon();
|
||||
}
|
||||
} catch (...) {}
|
||||
daemon_restarting_ = false; // re-arm reconnect (tryConnect runs from the update loop)
|
||||
});
|
||||
} else {
|
||||
ui::Notifications::instance().warning(
|
||||
@@ -892,8 +899,8 @@ void App::renderLockScreen() {
|
||||
cy += captionFont->LegacySize + 12.0f * dp;
|
||||
}
|
||||
|
||||
// Check if PIN vault is available
|
||||
bool hasPinVault = vault_ && vault_->hasVault() && settings_ && settings_->getPinEnabled();
|
||||
// Check if PIN vault is available (per-wallet vault presence; not the global getPinEnabled flag).
|
||||
bool hasPinVault = vault_ && vault_->hasVault();
|
||||
|
||||
// Mode toggle (PIN / Passphrase) — only show if PIN vault exists
|
||||
if (hasPinVault) {
|
||||
|
||||
@@ -376,6 +376,14 @@ void App::buildSweepCatalog()
|
||||
add("modal-antivirus", ui::NavPage::Mining,
|
||||
[](App& a) { a.pending_antivirus_dialog_ = true; },
|
||||
[](App& a) { a.pending_antivirus_dialog_ = false; });
|
||||
add("modal-switch-stopnode", ui::NavPage::Settings,
|
||||
[](App& a) { a.pending_switch_wallet_file_ = "wallet-savings.dat"; a.show_switch_stop_daemon_confirm_ = true; },
|
||||
[](App& a) { a.show_switch_stop_daemon_confirm_ = false; a.pending_switch_wallet_file_.clear(); });
|
||||
add("modal-switch-progress", ui::NavPage::Settings,
|
||||
[](App& a) { a.wallet_switch_phase_.store(static_cast<int>(App::WalletSwitchPhase::Stopping));
|
||||
a.wallet_switch_dialog_open_.store(true); },
|
||||
[](App& a) { a.wallet_switch_dialog_open_.store(false);
|
||||
a.wallet_switch_phase_.store(static_cast<int>(App::WalletSwitchPhase::None)); });
|
||||
// Wave-2 fund/secret dialogs (setup never fires the async RPC — no button is clicked).
|
||||
add("modal-shield", ui::NavPage::Send,
|
||||
[](App&) { ui::ShieldDialog::showShieldCoinbase(); },
|
||||
|
||||
@@ -129,6 +129,35 @@ bool ChatDatabase::append(const ChatMessage& message)
|
||||
return sqlite3_changes(db_) > 0;
|
||||
}
|
||||
|
||||
bool ChatDatabase::upsert(const ChatMessage& message)
|
||||
{
|
||||
if (!key_ready_ || !ensureOpen()) return false;
|
||||
|
||||
std::vector<unsigned char> nonce;
|
||||
std::vector<unsigned char> cipher;
|
||||
std::string plain = serialize(message);
|
||||
const bool encrypted = encrypt(plain, nonce, cipher);
|
||||
if (!plain.empty()) sodium_memzero(&plain[0], plain.size());
|
||||
if (!encrypted) return false;
|
||||
|
||||
const std::string dedup = dedupHash(message.txid, message.payload_position);
|
||||
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(db_,
|
||||
"INSERT INTO chat_messages (wallet_tag, dedup_hash, nonce, payload) VALUES (?, ?, ?, ?) "
|
||||
"ON CONFLICT(wallet_tag, dedup_hash) DO UPDATE SET nonce=excluded.nonce, payload=excluded.payload",
|
||||
-1, &stmt, nullptr) != SQLITE_OK) {
|
||||
return false;
|
||||
}
|
||||
sqlite3_bind_text(stmt, 1, wallet_tag_.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(stmt, 2, dedup.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_blob(stmt, 3, nonce.data(), static_cast<int>(nonce.size()), SQLITE_TRANSIENT);
|
||||
sqlite3_bind_blob(stmt, 4, cipher.data(), static_cast<int>(cipher.size()), SQLITE_TRANSIENT);
|
||||
const bool done = sqlite3_step(stmt) == SQLITE_DONE;
|
||||
sqlite3_finalize(stmt);
|
||||
return done;
|
||||
}
|
||||
|
||||
std::vector<ChatMessage> ChatDatabase::load()
|
||||
{
|
||||
std::vector<ChatMessage> out;
|
||||
@@ -267,7 +296,10 @@ bool ChatDatabase::deserialize(const std::string& json, ChatMessage& out) const
|
||||
out.body = parsed.value("b", std::string());
|
||||
out.timestamp = parsed.value("ts", static_cast<std::int64_t>(0));
|
||||
out.payload_position = static_cast<std::size_t>(parsed.value("pos", static_cast<std::uint64_t>(0)));
|
||||
out.delivery = static_cast<ChatDelivery>(parsed.value("dl", 0)); // old rows → Sent
|
||||
out.delivery = static_cast<ChatDelivery>(parsed.value("dl", 0)); // old rows → Sent (0)
|
||||
// A persisted "Sending" means we crashed mid-broadcast; the outcome is unknown. Resolve it
|
||||
// optimistically to Sent on load so it can't show a stuck spinner forever.
|
||||
if (out.delivery == ChatDelivery::Sending) out.delivery = ChatDelivery::Sent;
|
||||
return true;
|
||||
} catch (const std::exception&) {
|
||||
return false;
|
||||
|
||||
@@ -43,6 +43,11 @@ public:
|
||||
// Returns true if newly inserted; false on duplicate or while locked.
|
||||
bool append(const ChatMessage& message);
|
||||
|
||||
// Persist-or-overwrite one message by its (txid+position) dedup key. Unlike append(), this
|
||||
// updates an existing row's payload — used for outgoing echoes whose delivery status changes
|
||||
// (Sending → Sent/Failed). Returns true on success; false while locked / on error.
|
||||
bool upsert(const ChatMessage& message);
|
||||
|
||||
// Decrypt and return every stored message for the unlocked wallet, in insertion order. Empty
|
||||
// while locked or if none. Rows that fail to decrypt/parse are skipped.
|
||||
std::vector<ChatMessage> load();
|
||||
|
||||
@@ -10,9 +10,11 @@ namespace dragonx::chat {
|
||||
|
||||
enum class ChatDirection { Incoming, Outgoing };
|
||||
enum class ChatMessageKind { Message, ContactRequest };
|
||||
// Outgoing delivery: Sent = the broadcast was submitted; Failed = it wasn't (not connected, no
|
||||
// spendable address, a send already in progress). Always Sent for incoming.
|
||||
enum class ChatDelivery { Sent, Failed };
|
||||
// Outgoing delivery status. Sending = broadcast in flight (async op not yet resolved); Sent = the
|
||||
// daemon accepted + broadcast the tx; Failed = it didn't (not connected, no funded address, rejected).
|
||||
// Always Sent for incoming. NB: the values are persisted (chat DB serializes the int), so Sent MUST
|
||||
// stay 0 and new states are APPENDED — never reordered.
|
||||
enum class ChatDelivery { Sent, Failed, Sending };
|
||||
|
||||
struct ChatMessage {
|
||||
ChatDirection direction = ChatDirection::Incoming;
|
||||
|
||||
@@ -25,11 +25,20 @@ void ChatService::clearIdentity() {
|
||||
|
||||
int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata,
|
||||
const std::unordered_map<std::string, std::int64_t>& txTimestamps,
|
||||
std::int64_t fallbackTimestamp) {
|
||||
std::int64_t fallbackTimestamp,
|
||||
std::vector<std::string>* newIncomingCids) {
|
||||
if (!has_identity_) return 0;
|
||||
|
||||
const std::string myPubKey = chatIdentityPublicKeyHex(identity_);
|
||||
|
||||
int added = 0;
|
||||
for (const auto& meta : metadata) {
|
||||
// A memo whose sender is our OWN identity is something we sent (only we hold our key). The local
|
||||
// echo already records it as outgoing — ingesting it as incoming would duplicate it as a phantom
|
||||
// "from peer" message. (This also collapses same-seed self-chat, where the "peer" wallet shares
|
||||
// our identity, so every message would otherwise loop back.)
|
||||
if (!myPubKey.empty() && meta.sender_public_key_hex == myPubKey) continue;
|
||||
|
||||
ChatMessage message;
|
||||
message.direction = ChatDirection::Incoming;
|
||||
message.txid = meta.txid;
|
||||
@@ -59,6 +68,9 @@ int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata
|
||||
if (store_.append(message)) {
|
||||
if (db_) db_->append(message);
|
||||
++added;
|
||||
// Every ingested message is incoming — report its cid so the caller can notify without
|
||||
// relying on a seen-watermark delta (which block-time vs wall-clock skew can swallow).
|
||||
if (newIncomingCids) newIncomingCids->push_back(message.conversation_id);
|
||||
}
|
||||
}
|
||||
return added;
|
||||
@@ -105,4 +117,18 @@ bool ChatService::recordOutgoing(const ChatMessage& message) {
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ChatService::recordOutgoingPending(const ChatMessage& message) {
|
||||
// Persist immediately (as Sending) so a send survives an app quit before the broadcast resolves;
|
||||
// resolveOutgoing() then UPSERTS the row to the final status. A stray persisted Sending (crash mid-
|
||||
// broadcast) loads as Sent (see ChatDatabase::deserialize).
|
||||
const bool appended = store_.append(message);
|
||||
if (appended && db_) db_->upsert(message);
|
||||
return appended;
|
||||
}
|
||||
|
||||
void ChatService::resolveOutgoing(const std::string& txid, ChatDelivery delivery) {
|
||||
const ChatMessage* updated = store_.updateDelivery(txid, delivery);
|
||||
if (updated && db_) db_->upsert(*updated); // overwrite the Sending row with the final status
|
||||
}
|
||||
|
||||
} // namespace dragonx::chat
|
||||
|
||||
@@ -40,9 +40,13 @@ public:
|
||||
// when the txid isn't present. Newly-added messages are also persisted (if a database is
|
||||
// attached). Returns the number of NEW messages added; 0 with no identity. Undecryptable
|
||||
// Messages are dropped silently (no logging of memo/plaintext).
|
||||
// When `newIncomingCids` is non-null it is filled with the conversation ids of the genuinely-new
|
||||
// incoming (non-request) messages appended this call — a reliable "a new message arrived here"
|
||||
// signal for notifications that doesn't depend on any timestamp/seen-watermark comparison.
|
||||
int ingest(const std::vector<HushChatTransactionMetadata>& metadata,
|
||||
const std::unordered_map<std::string, std::int64_t>& txTimestamps,
|
||||
std::int64_t fallbackTimestamp = 0);
|
||||
std::int64_t fallbackTimestamp = 0,
|
||||
std::vector<std::string>* newIncomingCids = nullptr);
|
||||
|
||||
// Attach a persistent backing store (Phase 2). Not owned. Pass nullptr to detach. New messages
|
||||
// from ingest() are written through; loadFromDatabase() rehydrates the in-memory store from it.
|
||||
@@ -72,6 +76,12 @@ public:
|
||||
// only local record of what we sent.)
|
||||
bool recordOutgoing(const ChatMessage& message);
|
||||
|
||||
// Two-phase echo for delivery tracking: record + persist immediately as Sending (survives an app
|
||||
// quit), then resolveOutgoing() upserts the final status once the broadcast completes. A stray
|
||||
// persisted Sending (crash mid-broadcast) loads back as Sent.
|
||||
bool recordOutgoingPending(const ChatMessage& message);
|
||||
void resolveOutgoing(const std::string& txid, ChatDelivery delivery);
|
||||
|
||||
const ChatStore& store() const { return store_; }
|
||||
ChatStore& store() { return store_; }
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
#include "chat_store.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace dragonx::chat {
|
||||
|
||||
std::string ChatStore::dedupKey(const ChatMessage& message) {
|
||||
@@ -19,9 +21,29 @@ std::vector<ChatMessage> ChatStore::conversation(const std::string& conversation
|
||||
for (const auto& message : messages_) {
|
||||
if (message.conversation_id == conversationId) out.push_back(message);
|
||||
}
|
||||
// Return chronological (oldest→newest). messages_ is in scan/insertion order, which is NOT
|
||||
// time-ordered (a full scan harvests txids in std::set/unordered_map order) — that would mis-order the
|
||||
// rendered thread AND let the reply-target pin (first-seen peer address) latch onto a non-establishing
|
||||
// message (B2). timestamp is the block/tx time (peer can't set it); tie-break txid + payload_position
|
||||
// for determinism.
|
||||
std::stable_sort(out.begin(), out.end(), [](const ChatMessage& a, const ChatMessage& b) {
|
||||
if (a.timestamp != b.timestamp) return a.timestamp < b.timestamp;
|
||||
if (a.txid != b.txid) return a.txid < b.txid;
|
||||
return a.payload_position < b.payload_position;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
const ChatMessage* ChatStore::updateDelivery(const std::string& txid, ChatDelivery delivery) {
|
||||
for (auto& message : messages_) {
|
||||
if (message.txid == txid) {
|
||||
message.delivery = delivery;
|
||||
return &message;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
std::vector<std::string> ChatStore::conversationIds() const {
|
||||
std::vector<std::string> ids;
|
||||
std::unordered_set<std::string> seenIds;
|
||||
|
||||
@@ -21,6 +21,10 @@ public:
|
||||
// Messages in a conversation, in insertion order.
|
||||
std::vector<ChatMessage> conversation(const std::string& conversationId) const;
|
||||
|
||||
// Update an outgoing echo's delivery status by its local txid. Returns a pointer to the updated
|
||||
// message (for the caller to persist), or nullptr if no message has that txid.
|
||||
const ChatMessage* updateDelivery(const std::string& txid, ChatDelivery delivery);
|
||||
|
||||
// Distinct conversation ids, in first-seen order.
|
||||
std::vector<std::string> conversationIds() const;
|
||||
|
||||
|
||||
@@ -147,6 +147,16 @@ bool Settings::load(const std::string& path)
|
||||
loadScalar(j, "language", language_);
|
||||
loadScalar(j, "skin_id", skin_id_);
|
||||
loadScalar(j, "chat_reply_zaddr", chat_reply_zaddr_);
|
||||
if (j.contains("muted_chat_cids") && j["muted_chat_cids"].is_array()) {
|
||||
muted_chat_cids_.clear();
|
||||
for (const auto& c : j["muted_chat_cids"])
|
||||
if (c.is_string()) muted_chat_cids_.push_back(c.get<std::string>());
|
||||
}
|
||||
if (j.contains("hidden_chat_cids") && j["hidden_chat_cids"].is_array()) {
|
||||
hidden_chat_cids_.clear();
|
||||
for (const auto& c : j["hidden_chat_cids"])
|
||||
if (c.is_string()) hidden_chat_cids_.push_back(c.get<std::string>());
|
||||
}
|
||||
loadScalar(j, "acrylic_enabled", acrylic_enabled_);
|
||||
loadScalar(j, "acrylic_quality", acrylic_quality_);
|
||||
loadScalar(j, "blur_multiplier", blur_multiplier_);
|
||||
@@ -422,6 +432,12 @@ bool Settings::save(const std::string& path)
|
||||
j["language"] = language_;
|
||||
j["skin_id"] = skin_id_;
|
||||
j["chat_reply_zaddr"] = chat_reply_zaddr_;
|
||||
j["muted_chat_cids"] = json::array();
|
||||
for (const auto& c : muted_chat_cids_)
|
||||
j["muted_chat_cids"].push_back(c);
|
||||
j["hidden_chat_cids"] = json::array();
|
||||
for (const auto& c : hidden_chat_cids_)
|
||||
j["hidden_chat_cids"].push_back(c);
|
||||
j["acrylic_enabled"] = acrylic_enabled_;
|
||||
j["acrylic_quality"] = acrylic_quality_;
|
||||
j["blur_multiplier"] = blur_multiplier_;
|
||||
|
||||
@@ -118,6 +118,31 @@ public:
|
||||
std::string getChatReplyZaddr() const { return chat_reply_zaddr_; }
|
||||
void setChatReplyZaddr(const std::string& z) { chat_reply_zaddr_ = z; }
|
||||
|
||||
// Muted chat conversations (by cid) — muted conversations don't badge or raise a toast (Q10).
|
||||
bool isChatMuted(const std::string& cid) const {
|
||||
return std::find(muted_chat_cids_.begin(), muted_chat_cids_.end(), cid) != muted_chat_cids_.end();
|
||||
}
|
||||
void setChatMuted(const std::string& cid, bool muted) {
|
||||
const bool already = isChatMuted(cid);
|
||||
if (muted && !already) muted_chat_cids_.push_back(cid);
|
||||
else if (!muted && already)
|
||||
muted_chat_cids_.erase(std::remove(muted_chat_cids_.begin(), muted_chat_cids_.end(), cid),
|
||||
muted_chat_cids_.end());
|
||||
}
|
||||
|
||||
// Hidden chat conversations (by cid) — hidden ones are filtered out of the list; a new incoming
|
||||
// message un-hides them (you can't un-receive) so nothing is silently lost.
|
||||
bool isChatHidden(const std::string& cid) const {
|
||||
return std::find(hidden_chat_cids_.begin(), hidden_chat_cids_.end(), cid) != hidden_chat_cids_.end();
|
||||
}
|
||||
void setChatHidden(const std::string& cid, bool hidden) {
|
||||
const bool already = isChatHidden(cid);
|
||||
if (hidden && !already) hidden_chat_cids_.push_back(cid);
|
||||
else if (!hidden && already)
|
||||
hidden_chat_cids_.erase(std::remove(hidden_chat_cids_.begin(), hidden_chat_cids_.end(), cid),
|
||||
hidden_chat_cids_.end());
|
||||
}
|
||||
|
||||
// Privacy
|
||||
bool getSaveZtxs() const { return save_ztxs_; }
|
||||
void setSaveZtxs(bool save) { save_ztxs_ = save; }
|
||||
@@ -473,6 +498,8 @@ private:
|
||||
std::string theme_ = "dragonx";
|
||||
std::string skin_id_ = "dragonx";
|
||||
std::string chat_reply_zaddr_;
|
||||
std::vector<std::string> muted_chat_cids_; // muted chat conversations by cid (Q10)
|
||||
std::vector<std::string> hidden_chat_cids_; // hidden chat conversations by cid
|
||||
bool save_ztxs_ = true;
|
||||
bool auto_shield_ = true;
|
||||
bool use_tor_ = false;
|
||||
|
||||
@@ -61,6 +61,11 @@ bool DaemonController::externalDaemonDetected() const
|
||||
return daemon_->externalDaemonDetected();
|
||||
}
|
||||
|
||||
void DaemonController::clearExternalDaemonDetected()
|
||||
{
|
||||
daemon_->clearExternalDaemonDetected();
|
||||
}
|
||||
|
||||
DaemonController::State DaemonController::state() const
|
||||
{
|
||||
return daemon_->getState();
|
||||
@@ -116,6 +121,11 @@ void DaemonController::setZapOnNextStart(bool enabled)
|
||||
daemon_->setZapOnNextStart(enabled);
|
||||
}
|
||||
|
||||
void DaemonController::setSalvageOnNextStart(bool enabled)
|
||||
{
|
||||
daemon_->setSalvageOnNextStart(enabled);
|
||||
}
|
||||
|
||||
bool DaemonController::zapOnNextStart() const
|
||||
{
|
||||
return daemon_->zapOnNextStart();
|
||||
|
||||
@@ -93,6 +93,7 @@ public:
|
||||
|
||||
bool isRunning() const;
|
||||
bool externalDaemonDetected() const;
|
||||
void clearExternalDaemonDetected();
|
||||
State state() const;
|
||||
const std::string& lastError() const;
|
||||
int crashCount() const;
|
||||
@@ -106,6 +107,7 @@ public:
|
||||
bool rescanOnNextStart() const;
|
||||
void setZapOnNextStart(bool enabled);
|
||||
bool zapOnNextStart() const;
|
||||
void setSalvageOnNextStart(bool enabled);
|
||||
|
||||
static ShutdownDecision evaluateShutdownPolicy(bool hasDaemon,
|
||||
bool externalDaemonDetected,
|
||||
|
||||
@@ -386,52 +386,80 @@ static std::string getPortOwnerInfo(int port)
|
||||
#endif
|
||||
}
|
||||
|
||||
// Check if a TCP port is already in use (something is LISTENING)
|
||||
// Check if a TCP port is already in use (something is LISTENING). The daemon binds BOTH 127.0.0.1 (IPv4)
|
||||
// and ::1 (IPv6); during shutdown one can linger after the other releases (and a "Binding RPC on ::1 …
|
||||
// failed" is fatal to a fresh start), so we treat the port as in use if EITHER localhost family has it.
|
||||
static bool isPortInUse(int port)
|
||||
{
|
||||
#ifdef _WIN32
|
||||
WSADATA wsa;
|
||||
if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0) return false;
|
||||
SOCKET sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
|
||||
if (sock == INVALID_SOCKET) { WSACleanup(); return false; }
|
||||
struct sockaddr_in addr;
|
||||
addr.sin_family = AF_INET;
|
||||
addr.sin_port = htons(static_cast<u_short>(port));
|
||||
addr.sin_addr.s_addr = inet_addr("127.0.0.1");
|
||||
int result = connect(sock, (struct sockaddr*)&addr, sizeof(addr));
|
||||
closesocket(sock);
|
||||
bool inUse = false;
|
||||
{ // IPv4 127.0.0.1
|
||||
SOCKET sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
|
||||
if (sock != INVALID_SOCKET) {
|
||||
struct sockaddr_in addr; memset(&addr, 0, sizeof(addr));
|
||||
addr.sin_family = AF_INET;
|
||||
addr.sin_port = htons(static_cast<u_short>(port));
|
||||
addr.sin_addr.s_addr = inet_addr("127.0.0.1");
|
||||
if (connect(sock, (struct sockaddr*)&addr, sizeof(addr)) == 0) inUse = true;
|
||||
closesocket(sock);
|
||||
}
|
||||
}
|
||||
if (!inUse) { // IPv6 ::1
|
||||
SOCKET sock = socket(AF_INET6, SOCK_STREAM, IPPROTO_TCP);
|
||||
if (sock != INVALID_SOCKET) {
|
||||
struct sockaddr_in6 addr; memset(&addr, 0, sizeof(addr));
|
||||
addr.sin6_family = AF_INET6;
|
||||
addr.sin6_port = htons(static_cast<u_short>(port));
|
||||
addr.sin6_addr = in6addr_loopback; // ::1 — avoids inet_pton's _WIN32_WINNT gating on mingw
|
||||
if (connect(sock, (struct sockaddr*)&addr, sizeof(addr)) == 0) inUse = true;
|
||||
closesocket(sock);
|
||||
}
|
||||
}
|
||||
WSACleanup();
|
||||
return (result == 0);
|
||||
return inUse;
|
||||
#else
|
||||
// On macOS /proc doesn't exist; on Linux prefer /proc/net/tcp to avoid
|
||||
// creating sockets. Fall back to connect() if /proc is unavailable.
|
||||
FILE* fp = fopen("/proc/net/tcp", "r");
|
||||
if (fp) {
|
||||
char line[256];
|
||||
// On macOS /proc doesn't exist; on Linux prefer /proc/net/tcp{,6} to avoid creating sockets. The
|
||||
// parse is family-agnostic: %*X skips the local IP (8 hex for v4, 32 for v6), %X grabs the port.
|
||||
auto scanProc = [port](const char* path) -> bool {
|
||||
FILE* fp = fopen(path, "r");
|
||||
if (!fp) return false;
|
||||
char line[512];
|
||||
unsigned int localPort, state;
|
||||
bool found = false;
|
||||
while (fgets(line, sizeof(line), fp)) {
|
||||
if (sscanf(line, " %*d: %*X:%X %*X:%*X %X", &localPort, &state) == 2) {
|
||||
if (localPort == static_cast<unsigned int>(port) && state == 0x0A) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
if (localPort == static_cast<unsigned int>(port) && state == 0x0A) { found = true; break; }
|
||||
}
|
||||
}
|
||||
fclose(fp);
|
||||
return found;
|
||||
};
|
||||
if (FILE* probe = fopen("/proc/net/tcp", "r")) { // /proc available → authoritative LISTEN check
|
||||
fclose(probe);
|
||||
return scanProc("/proc/net/tcp") || scanProc("/proc/net/tcp6");
|
||||
}
|
||||
// Fallback (macOS): try to connect
|
||||
int sock = socket(AF_INET, SOCK_STREAM, 0);
|
||||
if (sock < 0) return false;
|
||||
struct sockaddr_in addr;
|
||||
memset(&addr, 0, sizeof(addr));
|
||||
addr.sin_family = AF_INET;
|
||||
addr.sin_port = htons(static_cast<uint16_t>(port));
|
||||
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||
int result = connect(sock, (struct sockaddr*)&addr, sizeof(addr));
|
||||
close(sock);
|
||||
return (result == 0);
|
||||
// Fallback (macOS): connect() probe on both loopback families.
|
||||
auto connProbe = [port](int family, const char* addr) -> bool {
|
||||
int sock = socket(family, SOCK_STREAM, 0);
|
||||
if (sock < 0) return false;
|
||||
bool ok = false;
|
||||
if (family == AF_INET) {
|
||||
struct sockaddr_in a; memset(&a, 0, sizeof(a));
|
||||
a.sin_family = AF_INET; a.sin_port = htons(static_cast<uint16_t>(port));
|
||||
a.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||
ok = (connect(sock, (struct sockaddr*)&a, sizeof(a)) == 0);
|
||||
} else {
|
||||
struct sockaddr_in6 a; memset(&a, 0, sizeof(a));
|
||||
a.sin6_family = AF_INET6; a.sin6_port = htons(static_cast<uint16_t>(port));
|
||||
inet_pton(AF_INET6, addr, &a.sin6_addr);
|
||||
ok = (connect(sock, (struct sockaddr*)&a, sizeof(a)) == 0);
|
||||
}
|
||||
close(sock);
|
||||
return ok;
|
||||
};
|
||||
return connProbe(AF_INET, "127.0.0.1") || connProbe(AF_INET6, "::1");
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -496,9 +524,16 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
|
||||
args.push_back("-wallet=" + wallet_file_);
|
||||
}
|
||||
|
||||
// Add wallet-repair flag if requested (one-shot). -zapwallettxes=2 wipes all wallet tx/note
|
||||
// records and rebuilds them from the chain; it implies -rescan, so don't also pass -rescan.
|
||||
if (zap_on_next_start_.exchange(false)) {
|
||||
// Add wallet-repair flag if requested (one-shot). Precedence: salvage > zap > rescan; each implies a
|
||||
// rescan in the daemon, so we don't stack them.
|
||||
if (salvage_on_next_start_.exchange(false)) {
|
||||
// -salvagewallet recovers readable keypairs from a corrupt wallet.dat; the daemon then implies -rescan.
|
||||
DEBUG_LOGF("[INFO] Adding -salvagewallet flag to recover a corrupt wallet\n");
|
||||
args.push_back("-salvagewallet");
|
||||
zap_on_next_start_.store(false);
|
||||
rescan_on_next_start_.store(false);
|
||||
} else if (zap_on_next_start_.exchange(false)) {
|
||||
// -zapwallettxes=2 wipes all wallet tx/note records and rebuilds them from the chain (implies -rescan).
|
||||
DEBUG_LOGF("[INFO] Adding -zapwallettxes=2 flag for wallet repair (zap & rebuild)\n");
|
||||
args.push_back("-zapwallettxes=2");
|
||||
rescan_on_next_start_.store(false); // implied by zap; avoid redundant -rescan
|
||||
@@ -655,18 +690,25 @@ static DWORD findProcessByName(const char* name)
|
||||
{
|
||||
HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
|
||||
if (snap == INVALID_HANDLE_VALUE) return 0;
|
||||
|
||||
PROCESSENTRY32 entry;
|
||||
|
||||
// Use the explicit WIDE Toolhelp API + a wide compare so this is correct regardless of the UNICODE
|
||||
// macro. (The non-suffixed PROCESSENTRY32/Process32First map to the wide variants when UNICODE is
|
||||
// defined, in which case szExeFile is WCHAR[] and an ANSI _stricmp would compare garbage and NEVER
|
||||
// match — silently making findProcessByName a no-op that returns 0 for a running process.)
|
||||
wchar_t wname[MAX_PATH];
|
||||
if (MultiByteToWideChar(CP_ACP, 0, name, -1, wname, MAX_PATH) == 0) { CloseHandle(snap); return 0; }
|
||||
|
||||
PROCESSENTRY32W entry;
|
||||
entry.dwSize = sizeof(entry);
|
||||
|
||||
|
||||
DWORD pid = 0;
|
||||
if (Process32First(snap, &entry)) {
|
||||
if (Process32FirstW(snap, &entry)) {
|
||||
do {
|
||||
if (_stricmp(entry.szExeFile, name) == 0) {
|
||||
if (lstrcmpiW(entry.szExeFile, wname) == 0) { // Win32 case-insensitive wide compare
|
||||
pid = entry.th32ProcessID;
|
||||
break;
|
||||
}
|
||||
} while (Process32Next(snap, &entry));
|
||||
} while (Process32NextW(snap, &entry));
|
||||
}
|
||||
CloseHandle(snap);
|
||||
return pid;
|
||||
@@ -1247,5 +1289,28 @@ bool EmbeddedDaemon::tcpPortInUse(int port)
|
||||
return isPortInUse(port);
|
||||
}
|
||||
|
||||
bool EmbeddedDaemon::isDaemonProcessRunning()
|
||||
{
|
||||
#ifdef _WIN32
|
||||
return findProcessByName("dragonxd.exe") != 0;
|
||||
#elif defined(__linux__)
|
||||
// Scan /proc for a process whose comm is exactly "dragonxd". Iterate with an error_code so a proc
|
||||
// entry vanishing mid-scan (a process exiting) can't throw.
|
||||
std::error_code ec;
|
||||
fs::directory_iterator it("/proc", ec), end;
|
||||
for (; !ec && it != end; it.increment(ec)) {
|
||||
const std::string pid = it->path().filename().string();
|
||||
if (pid.empty() || pid[0] < '0' || pid[0] > '9') continue; // numeric pid dirs only
|
||||
std::ifstream f((it->path() / "comm").string());
|
||||
std::string comm;
|
||||
if (f && std::getline(f, comm) && comm == "dragonxd") return true;
|
||||
}
|
||||
return false;
|
||||
#else
|
||||
// macOS has no /proc; fall back to the RPC-port probe (best-effort).
|
||||
return isPortInUse(std::atoi(DRAGONX_DEFAULT_RPC_PORT));
|
||||
#endif
|
||||
}
|
||||
|
||||
} // namespace daemon
|
||||
} // namespace dragonx
|
||||
|
||||
@@ -142,6 +142,10 @@ public:
|
||||
* When true the wallet should connect to it instead of showing an error.
|
||||
*/
|
||||
bool externalDaemonDetected() const { return external_daemon_detected_; }
|
||||
// Clear the adopted-external latch before relaunching our OWN process (e.g. a wallet switch that
|
||||
// stopped the adopted daemon), so the freshly spawned daemon is treated as owned. start() also
|
||||
// clears it on the port-free fall-through, but only if not short-circuited by an early guard.
|
||||
void clearExternalDaemonDetected() { external_daemon_detected_ = false; }
|
||||
|
||||
/**
|
||||
* @brief Set callback for state changes
|
||||
@@ -197,6 +201,11 @@ public:
|
||||
void setZapOnNextStart(bool v) { zap_on_next_start_ = v; }
|
||||
bool zapOnNextStart() const { return zap_on_next_start_.load(); }
|
||||
|
||||
// -salvagewallet: attempt to recover keys from a corrupt wallet.dat on startup (implies -rescan in
|
||||
// the daemon). One-shot, consumed on the next start. Used to repair a wallet a switch flagged corrupt.
|
||||
void setSalvageOnNextStart(bool v) { salvage_on_next_start_ = v; }
|
||||
bool salvageOnNextStart() const { return salvage_on_next_start_.load(); }
|
||||
|
||||
/**
|
||||
* @brief One-shot isolated-datadir override for the NEXT start(): run the daemon against a
|
||||
* different datadir (with its own DRAGONX.conf) plus the given extra args. Used by the
|
||||
@@ -217,6 +226,15 @@ public:
|
||||
*/
|
||||
void setSkipPortCheck(bool v) { skip_port_check_ = v; }
|
||||
|
||||
/**
|
||||
* @brief True while ANY dragonxd process is running (by process name), regardless of who started it.
|
||||
* Unlike isRpcPortInUse()/isRunning(), this reflects the actual PROCESS still being alive — a
|
||||
* graceful shutdown stops accepting RPC (port reads "free") but keeps the datadir lock until the
|
||||
* process exits, which can take up to ~90s. Use this to know a stopped node has FULLY released
|
||||
* the datadir before starting a replacement. Matches the daemon binary name on all platforms.
|
||||
*/
|
||||
static bool isDaemonProcessRunning();
|
||||
|
||||
/** @brief Is an arbitrary TCP port currently in use on localhost? (used to pick a free port) */
|
||||
static bool tcpPortInUse(int port);
|
||||
|
||||
@@ -261,6 +279,7 @@ private:
|
||||
std::atomic<int> crash_count_{0}; // consecutive crash counter
|
||||
std::atomic<bool> rescan_on_next_start_{false}; // -rescan flag for next start
|
||||
std::atomic<bool> zap_on_next_start_{false}; // -zapwallettxes=2 flag for next start
|
||||
std::atomic<bool> salvage_on_next_start_{false}; // -salvagewallet flag for next start
|
||||
std::string override_datadir_; // one-shot: -datadir for the next start
|
||||
std::vector<std::string> override_extra_args_; // one-shot: extra args for the next start
|
||||
bool skip_port_check_ = false; // isolated instance on a non-default port
|
||||
|
||||
@@ -110,9 +110,13 @@ SeedWalletResult SeedWalletCreator::create(bool keepDatadir,
|
||||
|
||||
// 6. Export the new seed phrase + a fresh shielded receive address (the future sweep target).
|
||||
try {
|
||||
auto m = cli.call("z_exportmnemonic");
|
||||
if (m.contains("mnemonic") && m["mnemonic"].is_string())
|
||||
r.seedPhrase = m["mnemonic"].get<std::string>();
|
||||
auto m = cli.callSecret("z_exportmnemonic"); // zero the raw body too (B7)
|
||||
if (m.contains("mnemonic") && m["mnemonic"].is_string()) {
|
||||
// Take our copy, then scrub the json node's own copy so it isn't freed in the clear (B7).
|
||||
auto& mn = m["mnemonic"].get_ref<std::string&>();
|
||||
r.seedPhrase = mn;
|
||||
if (!mn.empty()) sodium_memzero(&mn[0], mn.size());
|
||||
}
|
||||
r.destAddress = cli.call("z_getnewaddress").get<std::string>();
|
||||
r.ok = !r.seedPhrase.empty() && !r.destAddress.empty();
|
||||
if (!r.ok) r.error = "The isolated node returned an empty seed or address.";
|
||||
|
||||
@@ -144,6 +144,20 @@ bool AddressBook::removeEntry(size_t index)
|
||||
return save();
|
||||
}
|
||||
|
||||
int AddressBook::reattachLegacyScopes(const std::string& scopeId)
|
||||
{
|
||||
if (scopeId.empty()) return 0;
|
||||
int rescoped = 0;
|
||||
for (auto& e : entries_) {
|
||||
if (e.isGlobal()) continue; // global stays global
|
||||
if (e.scope.rfind("w:", 0) == 0) continue; // already a stable scope
|
||||
e.scope = scopeId;
|
||||
++rescoped;
|
||||
}
|
||||
if (rescoped > 0) save();
|
||||
return rescoped;
|
||||
}
|
||||
|
||||
int AddressBook::findByAddress(const std::string& address) const
|
||||
{
|
||||
for (size_t i = 0; i < entries_.size(); i++) {
|
||||
|
||||
@@ -86,6 +86,15 @@ public:
|
||||
*/
|
||||
bool removeEntry(size_t index);
|
||||
|
||||
/**
|
||||
* @brief Re-attach contacts stuck on a legacy (drifting address-hash) scope to a stable wallet id.
|
||||
* Rewrites every non-global entry whose scope is NOT already a stable "w:"-prefixed id to
|
||||
* `scopeId`, and saves once if anything changed. Recovery for contacts orphaned when the
|
||||
* old address-set-hash scope shifted (e.g. after creating a new address).
|
||||
* @return number of entries re-scoped.
|
||||
*/
|
||||
int reattachLegacyScopes(const std::string& scopeId);
|
||||
|
||||
/**
|
||||
* @brief Find entry by address (any scope). Used for contact-label lookups.
|
||||
* @param address Address to search for
|
||||
|
||||
@@ -25,6 +25,7 @@ bool sameEntry(const WalletIndexEntry& a, const WalletIndexEntry& b)
|
||||
return a.fileName == b.fileName
|
||||
&& a.displayName == b.displayName
|
||||
&& a.walletIdentityHash == b.walletIdentityHash
|
||||
&& a.scopeId == b.scopeId
|
||||
&& a.cachedBalance == b.cachedBalance
|
||||
&& a.cachedAddressCount == b.cachedAddressCount
|
||||
&& a.lastOpenedEpoch == b.lastOpenedEpoch
|
||||
@@ -61,6 +62,7 @@ bool WalletIndex::load()
|
||||
if (w.fileName.empty()) continue;
|
||||
w.displayName = e.value("name", w.fileName);
|
||||
w.walletIdentityHash = e.value("identity", "");
|
||||
w.scopeId = e.value("scopeId", "");
|
||||
w.cachedBalance = e.value("balance", -1.0);
|
||||
w.cachedAddressCount = e.value("addresses", (long long)-1);
|
||||
w.lastOpenedEpoch = e.value("lastOpened", (long long)0);
|
||||
@@ -96,6 +98,7 @@ bool WalletIndex::save()
|
||||
e["file"] = w.fileName;
|
||||
e["name"] = w.displayName;
|
||||
e["identity"] = w.walletIdentityHash;
|
||||
e["scopeId"] = w.scopeId;
|
||||
e["balance"] = w.cachedBalance;
|
||||
e["addresses"] = w.cachedAddressCount;
|
||||
e["lastOpened"] = w.lastOpenedEpoch;
|
||||
|
||||
@@ -22,6 +22,8 @@ struct WalletIndexEntry {
|
||||
std::string fileName; // plain wallet filename in the datadir (e.g. "wallet.dat")
|
||||
std::string displayName; // user-facing name (defaults to fileName)
|
||||
std::string walletIdentityHash; // address-derived identity of the last load; "" = unknown
|
||||
std::string scopeId; // stable per-wallet id ("w:"+hex) for scoping contacts etc.;
|
||||
// generated once, never recomputed from the (mutable) address set
|
||||
double cachedBalance = -1.0; // last-known total balance; < 0 = unknown (never opened)
|
||||
long long cachedAddressCount = -1;// < 0 = unknown
|
||||
long long lastOpenedEpoch = 0; // unix seconds of last open; 0 = never opened
|
||||
|
||||
@@ -15,3 +15,4 @@ INCBIN(ubuntu_mono, "@CMAKE_SOURCE_DIR@/res/fonts/UbuntuMono-R.ttf");
|
||||
INCBIN(material_icons, "@CMAKE_SOURCE_DIR@/res/fonts/MaterialIcons-Regular.ttf");
|
||||
INCBIN(mdi_pickaxe_subset, "@CMAKE_SOURCE_DIR@/res/fonts/MaterialDesignIcons-Pickaxe-Subset.ttf");
|
||||
INCBIN(noto_cjk_subset, "@CMAKE_SOURCE_DIR@/res/fonts/NotoSansCJK-Subset.ttf");
|
||||
INCBIN(noto_emoji_subset, "@CMAKE_SOURCE_DIR@/res/fonts/NotoEmoji-Subset.ttf");
|
||||
|
||||
@@ -35,4 +35,7 @@ extern "C" {
|
||||
|
||||
extern const unsigned char g_noto_cjk_subset_data[];
|
||||
extern const unsigned int g_noto_cjk_subset_size;
|
||||
|
||||
extern const unsigned char g_noto_emoji_subset_data[];
|
||||
extern const unsigned int g_noto_emoji_subset_size;
|
||||
}
|
||||
|
||||
18
src/main.cpp
18
src/main.cpp
@@ -439,8 +439,16 @@ static bool InitImGui(SDL_Window* window, SDL_GLContext gl_context);
|
||||
static void Shutdown(SDL_Window* window, SDL_GLContext gl_context);
|
||||
#endif
|
||||
|
||||
// Global single instance lock
|
||||
// Global single instance lock. Keyed PER VARIANT so the full node and Lite can run side by side —
|
||||
// their config dirs are already separate (DRAGONX_APP_NAME), so only this lock kept them apart.
|
||||
// NB: DRAGONX_LITE_BUILD is ALWAYS defined (0 for the full node, 1 for Lite) via $<BOOL:...>, so this
|
||||
// must be #if (value), not #ifdef (existence) — #ifdef is true for both and made the full node grab
|
||||
// the Lite lock.
|
||||
#if DRAGONX_LITE_BUILD
|
||||
static dragonx::util::SingleInstance g_single_instance("obsidiandragonlite");
|
||||
#else
|
||||
static dragonx::util::SingleInstance g_single_instance("obsidiandragon");
|
||||
#endif
|
||||
|
||||
// Check for payment URI in command line args
|
||||
static std::string findPaymentURI(int argc, char* argv[])
|
||||
@@ -764,11 +772,12 @@ int main(int argc, char* argv[])
|
||||
|
||||
// Check for existing instance
|
||||
if (!g_single_instance.tryLock()) {
|
||||
fprintf(stderr, "Another instance of ObsidianDragon is already running.\n");
|
||||
fprintf(stderr, "Another instance of %s is already running.\n", DRAGONX_APP_NAME);
|
||||
DEBUG_LOGF("Please close the existing instance first.\n");
|
||||
#ifdef _WIN32
|
||||
MessageBoxW(nullptr, L"Another instance of ObsidianDragon is already running.\nPlease close it first.",
|
||||
L"ObsidianDragon", MB_OK | MB_ICONINFORMATION);
|
||||
const std::string msg = std::string("Another instance of ") + DRAGONX_APP_NAME +
|
||||
" is already running.\nPlease close it first.";
|
||||
MessageBoxA(nullptr, msg.c_str(), DRAGONX_APP_NAME, MB_OK | MB_ICONINFORMATION);
|
||||
#endif
|
||||
return 1;
|
||||
}
|
||||
@@ -1966,6 +1975,7 @@ int main(int argc, char* argv[])
|
||||
bool backdropNeedsFrames = (backdrop_active || app.getGradientTexture() != 0)
|
||||
&& !opaqueBackground;
|
||||
bool animating = app.isShuttingDown()
|
||||
|| app.isWalletSwitchInProgress()
|
||||
|| backdropNeedsFrames
|
||||
|| app.hasTransactionSendProgress()
|
||||
|| app.isTransactionRefreshInProgress()
|
||||
|
||||
@@ -23,6 +23,17 @@ namespace rpc {
|
||||
|
||||
namespace {
|
||||
|
||||
// Recursively zero every string value in a JSON tree in place — used to wipe a discarded parse tree
|
||||
// that held a secret (B7). Operates on the underlying std::string buffers via get_ref.
|
||||
void scrubJsonSecrets(nlohmann::json& j) {
|
||||
if (j.is_string()) {
|
||||
auto& s = j.get_ref<std::string&>();
|
||||
if (!s.empty()) sodium_memzero(&s[0], s.size());
|
||||
} else if (j.is_object() || j.is_array()) {
|
||||
for (auto& el : j) scrubJsonSecrets(el);
|
||||
}
|
||||
}
|
||||
|
||||
std::mutex g_trace_mutex;
|
||||
RPCClient::TraceCallback g_trace_callback;
|
||||
std::atomic_bool g_trace_enabled{false};
|
||||
@@ -317,7 +328,7 @@ std::string RPCClient::performCall(const std::string& method, const json& params
|
||||
return response_data;
|
||||
}
|
||||
|
||||
json RPCClient::parseRpcResult(long httpCode, const std::string& body)
|
||||
json RPCClient::parseRpcResult(long httpCode, const std::string& body, bool scrubSource)
|
||||
{
|
||||
// Bitcoin/Hush RPC returns HTTP 500 for application-level errors
|
||||
// (insufficient funds, bad params, etc.) with a valid JSON body.
|
||||
@@ -355,7 +366,9 @@ json RPCClient::parseRpcResult(long httpCode, const std::string& body)
|
||||
throw RpcError(errCode, "RPC error: " + err_msg);
|
||||
}
|
||||
|
||||
return response["result"];
|
||||
json result = response["result"]; // a COPY of the subobject (operator[] yields an lvalue ref)
|
||||
if (scrubSource) scrubJsonSecrets(response); // zero the discarded tree's secret before it frees (B7)
|
||||
return result;
|
||||
}
|
||||
|
||||
json RPCClient::call(const std::string& method, const json& params)
|
||||
@@ -370,6 +383,56 @@ json RPCClient::call(const std::string& method, const json& params)
|
||||
return parseRpcResult(http_code, response_data);
|
||||
}
|
||||
|
||||
json RPCClient::callSecret(const std::string& method, const json& params)
|
||||
{
|
||||
std::lock_guard<std::recursive_mutex> lk(curl_mutex_);
|
||||
if (!impl_->curl) {
|
||||
throw std::runtime_error("Not connected");
|
||||
}
|
||||
|
||||
// Zero the raw response body whether parsing succeeds or throws — it holds the secret in the
|
||||
// clear (the curl write buffer performCall returns), and would otherwise be freed un-wiped (B7).
|
||||
long http_code = 0;
|
||||
std::string response_data = performCall(method, params, http_code);
|
||||
try {
|
||||
json result = parseRpcResult(http_code, response_data, /*scrubSource=*/true);
|
||||
if (!response_data.empty()) sodium_memzero(&response_data[0], response_data.size());
|
||||
return result;
|
||||
} catch (...) {
|
||||
if (!response_data.empty()) sodium_memzero(&response_data[0], response_data.size());
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
std::string RPCClient::callSecretString(const std::string& method, const json& params)
|
||||
{
|
||||
std::lock_guard<std::recursive_mutex> lk(curl_mutex_);
|
||||
if (!impl_->curl) {
|
||||
throw std::runtime_error("Not connected");
|
||||
}
|
||||
|
||||
long http_code = 0;
|
||||
std::string response_data = performCall(method, params, http_code);
|
||||
try {
|
||||
json result = parseRpcResult(http_code, response_data, /*scrubSource=*/true);
|
||||
std::string out;
|
||||
if (result.is_string()) {
|
||||
// Copy the secret out, then zero the json node's OWN heap buffer — parseRpcResult's
|
||||
// json::parse allocates this independently of response_data, so scrubbing the raw body
|
||||
// alone would leave it behind (B7). `out` is now the only live copy; the caller wipes it.
|
||||
auto& s = result.get_ref<std::string&>();
|
||||
out = s;
|
||||
if (!s.empty()) sodium_memzero(&s[0], s.size());
|
||||
}
|
||||
if (!response_data.empty()) sodium_memzero(&response_data[0], response_data.size());
|
||||
if (!result.is_string()) throw std::runtime_error("RPC result is not a string");
|
||||
return out;
|
||||
} catch (...) {
|
||||
if (!response_data.empty()) sodium_memzero(&response_data[0], response_data.size());
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
json RPCClient::call(const std::string& method, const json& params, long timeoutSec)
|
||||
{
|
||||
std::lock_guard<std::recursive_mutex> lk(curl_mutex_);
|
||||
|
||||
@@ -133,6 +133,26 @@ public:
|
||||
*/
|
||||
json call(const std::string& method, const json& params = json::array());
|
||||
|
||||
/**
|
||||
* @brief Like call(), but zeroes the raw HTTP response body after parsing (B7).
|
||||
*
|
||||
* For RPCs whose response carries a secret (z_exportmnemonic / z_exportkey), the parsed
|
||||
* value is scrubbed by the caller — but the raw response string this method builds also
|
||||
* holds that secret and is otherwise freed without zeroing. Use this variant so the largest,
|
||||
* longest-lived plaintext copy doesn't linger in freed heap. Slightly slower (an extra wipe);
|
||||
* only worth it for secret-bearing calls.
|
||||
*/
|
||||
json callSecret(const std::string& method, const json& params = json::array());
|
||||
|
||||
/**
|
||||
* @brief callSecret() for RPCs whose result is a bare secret string (z_exportkey / dumpprivkey /
|
||||
* z_exportviewingkey). Returns the result string with BOTH the raw response body AND the
|
||||
* parsed json's own copy of the secret zeroed — so no un-scrubbed heap copy survives the
|
||||
* call. The returned std::string is the only remaining copy; the caller owns it and must
|
||||
* wipe it (sodium_memzero) when done. Throws if the result is not a JSON string.
|
||||
*/
|
||||
std::string callSecretString(const std::string& method, const json& params = json::array());
|
||||
|
||||
/**
|
||||
* @brief Make a raw RPC call with a custom timeout
|
||||
* @param method RPC method name
|
||||
@@ -250,8 +270,10 @@ private:
|
||||
// hold curl_mutex_ and have verified impl_->curl.
|
||||
std::string performCall(const std::string& method, const json& params, long& httpCodeOut);
|
||||
// Centralizes the HTTP-code check and JSON error->RpcError extraction, returning
|
||||
// response["result"] on success.
|
||||
static json parseRpcResult(long httpCode, const std::string& body);
|
||||
// response["result"] on success. When scrubSource is true (secret-bearing calls), the intermediate
|
||||
// parse tree's string values are zeroed before it is discarded — parseRpcResult returns a COPY of
|
||||
// the result node, so its own tree would otherwise free the secret un-wiped (B7).
|
||||
static json parseRpcResult(long httpCode, const std::string& body, bool scrubSource = false);
|
||||
|
||||
// Splits a UnifiedCallback into the (Callback, ErrorCallback) pair used by doRPC.
|
||||
static std::pair<Callback, ErrorCallback> splitUnified(UnifiedCallback cb);
|
||||
|
||||
@@ -168,20 +168,6 @@ void appendExtractedHushChatMetadata(std::vector<chat::HushChatTransactionMetada
|
||||
}
|
||||
}
|
||||
|
||||
void appendExtractedHushChatMetadata(std::vector<chat::HushChatTransactionMetadata>& destination,
|
||||
const std::string& txid,
|
||||
const NetworkRefreshService::TransactionViewCacheEntry& entry)
|
||||
{
|
||||
if (!chat::hushChatFeatureEnabledAtBuild()) return;
|
||||
|
||||
std::vector<chat::HushChatMemoOutput> outputs;
|
||||
outputs.reserve(entry.outgoing_outputs.size());
|
||||
for (const auto& output : entry.outgoing_outputs) {
|
||||
if (!output.memo.empty()) outputs.push_back(chat::HushChatMemoOutput{output.position, output.memo});
|
||||
}
|
||||
appendExtractedHushChatMetadata(destination, txid, outputs);
|
||||
}
|
||||
|
||||
void appendExtractedHushChatMetadata(std::vector<chat::HushChatTransactionMetadata>& destination,
|
||||
const HushChatMemoOutputMap& outputsByTxid)
|
||||
{
|
||||
@@ -920,7 +906,10 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
|
||||
if (cached != snapshot.viewTxCache.end()) {
|
||||
if (!trackedSend || !cached->second.outgoing_outputs.empty()) {
|
||||
appendViewTransactionOutputs(result.transactions, txid, cached->second);
|
||||
appendExtractedHushChatMetadata(result.hushChatMetadata, txid, cached->second);
|
||||
// NB: do NOT extract chat metadata from our OWN outgoing memos here — ingest marks
|
||||
// everything Incoming, so that duplicates every sent message as a phantom "from peer"
|
||||
// entry. Genuine incoming comes from the z_listreceivedbyaddress path; our sends are
|
||||
// recorded by the local echo. (The recent-refresh variant already omits this.)
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -945,7 +934,8 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
|
||||
|
||||
auto entry = parseViewTransactionCacheEntry(viewTransaction);
|
||||
appendViewTransactionOutputs(result.transactions, txid, entry);
|
||||
appendExtractedHushChatMetadata(result.hushChatMetadata, txid, entry);
|
||||
// (Chat metadata is harvested only from RECEIVED notes, not our own outgoing memos — see
|
||||
// the cached-view branch above.)
|
||||
|
||||
json rawTransaction;
|
||||
bool hasRawTransaction = false;
|
||||
|
||||
@@ -325,11 +325,50 @@ ImFont* Typography::loadFont(ImGuiIO& io, int weight, float size, const char* na
|
||||
name, g_noto_cjk_subset_size);
|
||||
}
|
||||
}
|
||||
|
||||
// Merge monochrome emoji for chat + user text (Q12). Only into the small text fonts — baking
|
||||
// ~1400 emoji at heading sizes would bloat the atlas for glyphs no heading needs. ImGui does no
|
||||
// shaping, so single-codepoint emoji render but ZWJ sequences / flags won't compose. The
|
||||
// >0xFFFF ranges require IMGUI_USE_WCHAR32 (imconfig.h).
|
||||
static const char* const kEmojiFonts[] = {
|
||||
"Body1", "Body2", "Subtitle1", "Subtitle2", "Caption", "Overline", "Button", "ButtonSm"
|
||||
};
|
||||
bool wantEmoji = false;
|
||||
for (const char* n : kEmojiFonts) if (strcmp(name, n) == 0) { wantEmoji = true; break; }
|
||||
if (wantEmoji && g_noto_emoji_subset_size > 0) {
|
||||
void* emojiCopy = IM_ALLOC(g_noto_emoji_subset_size);
|
||||
memcpy(emojiCopy, g_noto_emoji_subset_data, g_noto_emoji_subset_size);
|
||||
|
||||
ImFontConfig emojiCfg;
|
||||
emojiCfg.FontDataOwnedByAtlas = true;
|
||||
emojiCfg.MergeMode = true; // merge into the text font just loaded
|
||||
emojiCfg.OversampleH = 1;
|
||||
emojiCfg.OversampleV = 1;
|
||||
emojiCfg.PixelSnapH = true;
|
||||
emojiCfg.GlyphMinAdvanceX = 0;
|
||||
// The base Ubuntu font already owns U+2600–26FF etc.; MergeMode keeps the first-loaded glyph,
|
||||
// so its text-style symbols win and only the codepoints it lacks fall through to emoji.
|
||||
static const ImWchar emojiRanges[] = {
|
||||
0x2600, 0x27BF, // Misc Symbols + Dingbats
|
||||
0x2B00, 0x2BFF, // stars (⭐) + arrows
|
||||
0x1F000, 0x1FAFF, // emoji planes (emoticons, pictographs, transport, supplement, extended)
|
||||
0,
|
||||
};
|
||||
emojiCfg.GlyphRanges = emojiRanges;
|
||||
snprintf(emojiCfg.Name, sizeof(emojiCfg.Name), "NotoEmoji %.0fpx (merge)", size);
|
||||
|
||||
ImFont* emojiMerge = io.Fonts->AddFontFromMemoryTTF(emojiCopy, g_noto_emoji_subset_size, size, &emojiCfg);
|
||||
if (emojiMerge) {
|
||||
DEBUG_LOGF("Typography: Merged emoji (%u bytes) into %s OK\n", g_noto_emoji_subset_size, name);
|
||||
} else {
|
||||
DEBUG_LOGF("Typography: WARNING — emoji merge FAILED for %s (size=%u)\n", name, size);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
DEBUG_LOGF("Typography: Failed to load %s\n", name);
|
||||
IM_FREE(fontDataCopy);
|
||||
}
|
||||
|
||||
|
||||
return font;
|
||||
}
|
||||
|
||||
|
||||
@@ -149,6 +149,7 @@ struct SidebarStatus {
|
||||
int unconfirmedTxCount = 0; // badge on History
|
||||
bool miningActive = false; // green dot on Mining
|
||||
int peerCount = 0; // badge on Peers
|
||||
int chatUnreadCount = 0; // badge on Chat (unread incoming messages)
|
||||
// Exit
|
||||
bool exitClicked = false;
|
||||
// Branding logo (optional — loaded at startup)
|
||||
@@ -715,6 +716,8 @@ inline bool RenderSidebar(NavPage& current, float sidebarWidth, float contentHei
|
||||
dotOnly = true; badgeCol = Success();
|
||||
} else if (item.page == NavPage::Peers && status.peerCount > 0) {
|
||||
badgeCount = status.peerCount;
|
||||
} else if (item.page == NavPage::Chat && status.chatUnreadCount > 0) {
|
||||
badgeCount = status.chatUnreadCount;
|
||||
}
|
||||
|
||||
if (badgeCount > 0 || dotOnly) {
|
||||
|
||||
@@ -2,21 +2,32 @@
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
//
|
||||
// chat_tab.cpp — read-only HushChat view (Phase 3): a conversation list + the
|
||||
// selected thread, both read from the App-owned ChatService store.
|
||||
// chat_tab.cpp — HushChat view: a conversation list + the selected thread (both read
|
||||
// from the App-owned ChatService store), with a composer and a new-conversation flow.
|
||||
|
||||
#include "chat_tab.h"
|
||||
#include "../../app.h"
|
||||
#include "../../data/address_book.h"
|
||||
#include "../../chat/chat_service.h"
|
||||
#include "../../util/i18n.h"
|
||||
#include "../../util/platform.h" // getConfigDir + writeFileAtomically — conversation export (Q11)
|
||||
#include "../../config/settings.h" // per-conversation mute (Q10)
|
||||
#include "../material/colors.h"
|
||||
#include "../material/color_theme.h" // WithAlpha
|
||||
#include "../material/type.h"
|
||||
#include "../material/draw_helpers.h" // TactileButton / LabeledInput / BeginOverlayDialog
|
||||
#include "../material/project_icons.h" // ICON_MD_*
|
||||
#include "../layout.h" // Layout::dpiScale()
|
||||
#include "../notifications.h" // Notifications — add-to-contacts confirmation
|
||||
#include "imgui.h"
|
||||
|
||||
#include <sodium.h> // sodium_memzero — wipe typed plaintext on a wallet switch
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cfloat>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <ctime>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
@@ -32,13 +43,25 @@ std::string s_scroll_to_cid; // when set, scroll the thread to the bottom next
|
||||
|
||||
// Composer + new-conversation UI state.
|
||||
char s_compose[512] = "";
|
||||
std::string s_compose_cid; // the conversation s_compose is a draft for; draft is wiped when it changes
|
||||
bool s_show_new_convo = false;
|
||||
char s_new_zaddr[128] = "";
|
||||
char s_new_msg[256] = "";
|
||||
char s_search[80] = ""; // conversation-list filter (Q8)
|
||||
|
||||
// Effective draw-list font size for a material font (mirrors sidebar's ScaledFontSize).
|
||||
float scaledSize(ImFont* f) { return f->LegacySize * ImGui::GetStyle().FontScaleMain; }
|
||||
|
||||
// Case-insensitive substring match (ASCII) for the conversation search (Q8).
|
||||
bool containsCI(const std::string& hay, const std::string& needle) {
|
||||
if (needle.empty()) return true;
|
||||
const auto it = std::search(hay.begin(), hay.end(), needle.begin(), needle.end(),
|
||||
[](char a, char b) {
|
||||
return std::tolower(static_cast<unsigned char>(a)) == std::tolower(static_cast<unsigned char>(b));
|
||||
});
|
||||
return it != hay.end();
|
||||
}
|
||||
|
||||
std::string shorten(const std::string& s, std::size_t head = 12, std::size_t tail = 6) {
|
||||
if (s.size() <= head + tail + 3) return s;
|
||||
return s.substr(0, head) + "..." + s.substr(s.size() - tail);
|
||||
@@ -54,6 +77,23 @@ std::string formatTime(std::int64_t ts) {
|
||||
return buf;
|
||||
}
|
||||
|
||||
// Compact relative time for the list ("now", "5m", "3h", "2d", then "Mon DD"). UI thread only.
|
||||
std::string relativeTime(std::int64_t ts) {
|
||||
if (ts <= 0) return "";
|
||||
std::int64_t d = static_cast<std::int64_t>(std::time(nullptr)) - ts;
|
||||
if (d < 0) d = 0;
|
||||
if (d < 45) return TR("chat_time_now");
|
||||
if (d < 3600) return std::to_string(d / 60) + "m";
|
||||
if (d < 86400) return std::to_string(d / 3600) + "h";
|
||||
if (d < 7 * 86400) return std::to_string(d / 86400) + "d";
|
||||
std::time_t t = static_cast<std::time_t>(ts);
|
||||
std::tm* tm = std::localtime(&t);
|
||||
if (!tm) return "";
|
||||
char buf[16];
|
||||
std::strftime(buf, sizeof(buf), "%b %d", tm);
|
||||
return buf;
|
||||
}
|
||||
|
||||
// One line of collapsed body text for the list preview (newlines flattened).
|
||||
std::string previewOf(const std::string& body) {
|
||||
std::string out = body;
|
||||
@@ -62,6 +102,32 @@ std::string previewOf(const std::string& body) {
|
||||
return out;
|
||||
}
|
||||
|
||||
// A stable, legible avatar color for a conversation (FNV-1a of the cid → a fixed material palette).
|
||||
ImU32 avatarColor(const std::string& seed) {
|
||||
std::uint32_t h = 2166136261u;
|
||||
for (unsigned char ch : seed) { h ^= ch; h *= 16777619u; }
|
||||
static const ImU32 kPalette[] = {
|
||||
IM_COL32(0xEF,0x53,0x50,255), IM_COL32(0xAB,0x47,0xBC,255), IM_COL32(0x5C,0x6B,0xC0,255),
|
||||
IM_COL32(0x29,0xB6,0xF6,255), IM_COL32(0x26,0xA6,0x9A,255), IM_COL32(0x66,0xBB,0x6A,255),
|
||||
IM_COL32(0xFF,0xA7,0x26,255), IM_COL32(0x8D,0x6E,0x63,255),
|
||||
};
|
||||
return kPalette[h % (sizeof(kPalette) / sizeof(kPalette[0]))];
|
||||
}
|
||||
|
||||
// Uppercase first glyph of a display name (UTF-8 aware) for a letter-avatar.
|
||||
std::string initialOf(const std::string& name) {
|
||||
std::size_t i = 0;
|
||||
while (i < name.size() && static_cast<unsigned char>(name[i]) <= ' ') ++i;
|
||||
if (i >= name.size()) return "?";
|
||||
const unsigned char c = name[i];
|
||||
if (c < 0x80) {
|
||||
const char u = (c >= 'a' && c <= 'z') ? static_cast<char>(c - 32) : static_cast<char>(c);
|
||||
return std::string(1, u);
|
||||
}
|
||||
const std::size_t len = (c >= 0xF0) ? 4 : (c >= 0xE0) ? 3 : 2; // UTF-8 lead byte → sequence length
|
||||
return name.substr(i, std::min(len, name.size() - i));
|
||||
}
|
||||
|
||||
struct ConvSummary {
|
||||
std::string cid;
|
||||
std::string peerZaddr;
|
||||
@@ -88,6 +154,51 @@ void centeredHint(const char* text) {
|
||||
ImGui::PopFont();
|
||||
}
|
||||
|
||||
// Centered empty state: big muted icon + title + optional wrapped hint (V4).
|
||||
void centeredEmptyState(const char* icon, const char* title, const char* hint) {
|
||||
const ImVec2 avail = ImGui::GetContentRegionAvail();
|
||||
const ImVec2 origin = ImGui::GetCursorPos();
|
||||
ImFont* iconF = material::Type().iconXL();
|
||||
ImFont* titleF = material::Type().subtitle1();
|
||||
ImFont* hintF = material::Type().body2();
|
||||
const float gap = 8.0f * Layout::dpiScale();
|
||||
const float wrap = std::min(avail.x - 40.0f, 360.0f);
|
||||
const float iconSz = iconF ? scaledSize(iconF) : 40.0f;
|
||||
const float iconH = iconF ? iconF->CalcTextSizeA(iconSz, FLT_MAX, 0.0f, icon).y : 0.0f;
|
||||
const float titleH = titleF->CalcTextSizeA(scaledSize(titleF), FLT_MAX, 0.0f, title).y;
|
||||
const float hintH = hint ? hintF->CalcTextSizeA(scaledSize(hintF), wrap, wrap, hint).y : 0.0f;
|
||||
const float totalH = iconH + gap + titleH + (hint ? gap + hintH : 0.0f);
|
||||
float y = origin.y + std::max(0.0f, (avail.y - totalH) * 0.5f);
|
||||
|
||||
if (iconF) {
|
||||
const float iw = iconF->CalcTextSizeA(iconSz, FLT_MAX, 0.0f, icon).x;
|
||||
ImGui::SetCursorPos(ImVec2(origin.x + (avail.x - iw) * 0.5f, y));
|
||||
ImGui::PushFont(iconF);
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, material::WithAlpha(material::OnSurface(), 70));
|
||||
ImGui::TextUnformatted(icon);
|
||||
ImGui::PopStyleColor(); ImGui::PopFont();
|
||||
y += iconH + gap;
|
||||
}
|
||||
{
|
||||
const float tw = titleF->CalcTextSizeA(scaledSize(titleF), FLT_MAX, 0.0f, title).x;
|
||||
ImGui::SetCursorPos(ImVec2(origin.x + (avail.x - tw) * 0.5f, y));
|
||||
ImGui::PushFont(titleF);
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
|
||||
ImGui::TextUnformatted(title);
|
||||
ImGui::PopStyleColor(); ImGui::PopFont();
|
||||
y += titleH + gap;
|
||||
}
|
||||
if (hint) {
|
||||
ImGui::SetCursorPos(ImVec2(origin.x + (avail.x - wrap) * 0.5f, y));
|
||||
ImGui::PushFont(hintF);
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, material::WithAlpha(material::OnSurface(), 120));
|
||||
ImGui::PushTextWrapPos(ImGui::GetCursorPos().x + wrap);
|
||||
ImGui::TextUnformatted(hint);
|
||||
ImGui::PopTextWrapPos();
|
||||
ImGui::PopStyleColor(); ImGui::PopFont();
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void RenderChatTab(App* app)
|
||||
@@ -98,21 +209,22 @@ void RenderChatTab(App* app)
|
||||
|
||||
// Not unlocked / identity not derived yet → nothing to show.
|
||||
if (!service.hasIdentity()) {
|
||||
centeredHint(TR("chat_locked_hint"));
|
||||
centeredEmptyState(ICON_MD_LOCK, TR("chat_locked_hint"), nullptr);
|
||||
return;
|
||||
}
|
||||
|
||||
// Build conversation summaries (single scan per conversation), sorted by most-recent activity.
|
||||
std::vector<ConvSummary> convs;
|
||||
for (const auto& cid : store.conversationIds()) {
|
||||
if (app->settings() && app->settings()->isChatHidden(cid)) continue; // hidden — a new message un-hides it
|
||||
const auto messages = store.conversation(cid);
|
||||
if (messages.empty()) continue;
|
||||
ConvSummary c;
|
||||
c.cid = cid;
|
||||
c.count = static_cast<int>(messages.size());
|
||||
for (const auto& m : messages) { // last non-empty peer z-addr / key across the thread
|
||||
if (!m.peer_zaddr.empty()) c.peerZaddr = m.peer_zaddr;
|
||||
if (!m.peer_public_key_hex.empty()) c.peerPubKey = m.peer_public_key_hex;
|
||||
for (const auto& m : messages) { // pin to the EARLIEST (establishing) peer z-addr / key (B2 — the
|
||||
if (c.peerZaddr.empty() && !m.peer_zaddr.empty()) c.peerZaddr = m.peer_zaddr; // memo header rides
|
||||
if (c.peerPubKey.empty() && !m.peer_public_key_hex.empty()) c.peerPubKey = m.peer_public_key_hex; // outside the AEAD)
|
||||
}
|
||||
const auto& last = messages.back();
|
||||
c.lastBody = last.body;
|
||||
@@ -132,6 +244,12 @@ void RenderChatTab(App* app)
|
||||
s_selected_cid = convs.front().cid;
|
||||
s_scroll_to_cid = s_selected_cid;
|
||||
}
|
||||
// The composer holds a single draft — wipe it when the active conversation changes so text typed
|
||||
// for one contact can't be sent to another (B5).
|
||||
if (s_selected_cid != s_compose_cid) {
|
||||
sodium_memzero(s_compose, sizeof(s_compose));
|
||||
s_compose_cid = s_selected_cid;
|
||||
}
|
||||
|
||||
const ImVec2 avail = ImGui::GetContentRegionAvail();
|
||||
const float listW = std::clamp(avail.x * 0.32f, 220.0f, 360.0f);
|
||||
@@ -139,7 +257,7 @@ void RenderChatTab(App* app)
|
||||
// fonts. Left raw, at higher DPI the row was too short for the enlarged text and the preview's
|
||||
// right margin (rowW - pad) shrank to ~zero, clipping the last glyph mid-word.
|
||||
const float pad = 10.0f * Layout::dpiScale();
|
||||
const float rowH = 52.0f * Layout::dpiScale();
|
||||
const float rowH = 58.0f * Layout::dpiScale();
|
||||
|
||||
ImFont* nameFont = material::Type().body2();
|
||||
ImFont* metaFont = material::Type().caption();
|
||||
@@ -147,13 +265,27 @@ void RenderChatTab(App* app)
|
||||
const float metaSz = scaledSize(metaFont);
|
||||
|
||||
// ---- Left: new-conversation button + conversation list ----
|
||||
// Faint sidebar tint so the list reads as distinct from the thread pane (V6).
|
||||
ImGui::PushStyleColor(ImGuiCol_ChildBg, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::OnSurface(), 10)));
|
||||
ImGui::BeginChild("##ChatList", ImVec2(listW, avail.y), true);
|
||||
{
|
||||
if (ImGui::Button(TR("chat_new_button"), ImVec2(-FLT_MIN, 0.0f))) {
|
||||
// Accented "New conversation" action (house tactile style).
|
||||
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 205)));
|
||||
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(material::Primary()));
|
||||
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 235)));
|
||||
const bool newClicked = material::TactileButton(TR("chat_new_button"), ImVec2(-FLT_MIN, 0.0f));
|
||||
ImGui::PopStyleColor(3);
|
||||
if (newClicked) {
|
||||
s_show_new_convo = true;
|
||||
s_new_zaddr[0] = '\0';
|
||||
s_new_msg[0] = '\0';
|
||||
}
|
||||
// Search filter (Q8) — only worth showing once there's more than one conversation.
|
||||
if (convs.size() > 1) {
|
||||
ImGui::SetNextItemWidth(-FLT_MIN);
|
||||
ImGui::InputTextWithHint("##chatsearch", TR("chat_search"), s_search, sizeof(s_search));
|
||||
}
|
||||
const std::string search = s_search;
|
||||
ImGui::Separator();
|
||||
if (convs.empty()) {
|
||||
ImGui::PushFont(metaFont);
|
||||
@@ -166,42 +298,75 @@ void RenderChatTab(App* app)
|
||||
}
|
||||
|
||||
ImDrawList* dl = ImGui::GetWindowDrawList(); // the list child's draw list (correct clip/z-order)
|
||||
const ImU32 selBg = ImGui::GetColorU32(ImGuiCol_Header);
|
||||
const ImU32 hoverBg = ImGui::GetColorU32(ImGuiCol_HeaderHovered);
|
||||
const float dp = Layout::dpiScale();
|
||||
const float round = 6.0f * dp;
|
||||
const float avR = rowH * 0.30f; // letter-avatar radius
|
||||
int shown = 0;
|
||||
for (std::size_t i = 0; i < convs.size(); ++i) {
|
||||
const ConvSummary& c = convs[i];
|
||||
ImGui::PushID(static_cast<int>(i));
|
||||
if (!search.empty() && !containsCI(c.peerName, search) && !containsCI(c.lastBody, search))
|
||||
continue; // filtered out by search (Q8) — thread pane still keeps it open
|
||||
++shown;
|
||||
ImGui::PushID(c.cid.c_str()); // stable id — the list re-sorts by lastTs each frame (B6)
|
||||
const ImVec2 p = ImGui::GetCursorScreenPos();
|
||||
const float rowW = ImGui::GetContentRegionAvail().x;
|
||||
const bool clicked = ImGui::InvisibleButton("##row", ImVec2(rowW, rowH));
|
||||
const bool hovered = ImGui::IsItemHovered();
|
||||
const bool selected = (c.cid == s_selected_cid);
|
||||
if (clicked) { s_selected_cid = c.cid; s_scroll_to_cid = c.cid; }
|
||||
if (selected || hovered)
|
||||
dl->AddRectFilled(p, ImVec2(p.x + rowW, p.y + rowH), selected ? selBg : hoverBg, 6.0f);
|
||||
if (hovered && !selected) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||
|
||||
// Name (top-left).
|
||||
dl->AddText(nameFont, nameSz, ImVec2(p.x + pad, p.y + pad), material::OnSurface(),
|
||||
c.peerName.c_str());
|
||||
// Time (top-right, muted).
|
||||
const std::string when = formatTime(c.lastTs);
|
||||
// Row background — house tactile card style (matches contacts_tab).
|
||||
const ImVec2 mn = p, mx(p.x + rowW, p.y + rowH);
|
||||
const ImU32 fill = selected ? material::WithAlpha(material::Primary(), 42)
|
||||
: hovered ? material::WithAlpha(material::OnSurface(), 26)
|
||||
: material::WithAlpha(material::OnSurface(), 10);
|
||||
dl->AddRectFilled(mn, mx, fill, round);
|
||||
if (selected) dl->AddRect(mn, mx, material::WithAlpha(material::Primary(), 150), round, 0, 1.6f * dp);
|
||||
|
||||
// Leading letter-avatar (deterministic color circle + the peer's initial).
|
||||
const ImVec2 avC(mn.x + pad + avR, mn.y + rowH * 0.5f);
|
||||
dl->AddCircleFilled(avC, avR, avatarColor(c.cid), 24);
|
||||
{
|
||||
const std::string init = initialOf(c.peerName);
|
||||
const ImVec2 isz = nameFont->CalcTextSizeA(nameSz, FLT_MAX, 0.0f, init.c_str());
|
||||
dl->AddText(nameFont, nameSz, ImVec2(avC.x - isz.x * 0.5f, avC.y - isz.y * 0.5f),
|
||||
IM_COL32(255, 255, 255, 235), init.c_str());
|
||||
}
|
||||
const float textX = avC.x + avR + pad;
|
||||
|
||||
// Name (top).
|
||||
dl->AddText(nameFont, nameSz, ImVec2(textX, p.y + pad), material::OnSurface(), c.peerName.c_str());
|
||||
// Time (top-right, muted) — compact relative form (Q5).
|
||||
const std::string when = relativeTime(c.lastTs);
|
||||
if (!when.empty()) {
|
||||
const ImVec2 wsz = metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, when.c_str());
|
||||
dl->AddText(metaFont, metaSz, ImVec2(p.x + rowW - pad - wsz.x, p.y + pad + 1.0f),
|
||||
dl->AddText(metaFont, metaSz, ImVec2(mx.x - pad - wsz.x, p.y + pad + 1.0f),
|
||||
material::OnSurfaceMedium(), when.c_str());
|
||||
}
|
||||
// Preview (bottom, clipped, muted).
|
||||
// Preview (bottom, clipped to the text column, muted).
|
||||
const std::string preview = previewOf(c.lastBody);
|
||||
dl->PushClipRect(p, ImVec2(p.x + rowW - pad, p.y + rowH), true);
|
||||
dl->AddText(metaFont, metaSz, ImVec2(p.x + pad, p.y + rowH - pad - metaSz),
|
||||
dl->PushClipRect(ImVec2(textX, p.y), ImVec2(mx.x - pad, mx.y), true);
|
||||
dl->AddText(metaFont, metaSz, ImVec2(textX, p.y + rowH - pad - metaSz),
|
||||
material::OnSurfaceMedium(), preview.c_str());
|
||||
dl->PopClipRect();
|
||||
ImGui::PopID();
|
||||
ImGui::Dummy(ImVec2(0.0f, 3.0f * dp)); // small gap between cards
|
||||
}
|
||||
if (!search.empty() && shown == 0) {
|
||||
ImGui::PushFont(metaFont);
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
|
||||
ImGui::PushTextWrapPos(0.0f);
|
||||
ImGui::TextUnformatted(TR("chat_no_matches"));
|
||||
ImGui::PopTextWrapPos();
|
||||
ImGui::PopStyleColor();
|
||||
ImGui::PopFont();
|
||||
}
|
||||
}
|
||||
ImGui::EndChild();
|
||||
ImGui::PopStyleColor(); // list ChildBg tint (V6)
|
||||
|
||||
ImGui::SameLine();
|
||||
ImGui::SameLine(0.0f, 1.0f * Layout::dpiScale()); // tight single-line seam between the panes
|
||||
|
||||
// ---- Right: selected conversation thread ----
|
||||
ImGui::BeginChild("##ChatThread", ImVec2(0, avail.y), true);
|
||||
@@ -210,8 +375,22 @@ void RenderChatTab(App* app)
|
||||
for (const auto& c : convs) if (c.cid == s_selected_cid) { sel = &c; break; }
|
||||
|
||||
if (sel) {
|
||||
// Header: peer name + z-address.
|
||||
app->markChatConversationSeen(sel->cid, sel->lastTs); // viewing the thread clears its unread (Q1)
|
||||
// Header: peer avatar + name (V7).
|
||||
ImGui::PushFont(material::Type().subtitle1());
|
||||
{
|
||||
const float nameH = ImGui::GetTextLineHeight();
|
||||
const float avR = nameH * 0.6f;
|
||||
const ImVec2 hp = ImGui::GetCursorScreenPos();
|
||||
ImDrawList* hdl = ImGui::GetWindowDrawList();
|
||||
const ImVec2 avC(hp.x + avR, hp.y + nameH * 0.5f);
|
||||
hdl->AddCircleFilled(avC, avR, avatarColor(sel->cid), 24);
|
||||
const std::string init = initialOf(sel->peerName);
|
||||
const ImVec2 isz = ImGui::CalcTextSize(init.c_str());
|
||||
hdl->AddText(ImVec2(avC.x - isz.x * 0.5f, avC.y - isz.y * 0.5f),
|
||||
IM_COL32(255, 255, 255, 235), init.c_str());
|
||||
ImGui::SetCursorScreenPos(ImVec2(hp.x + 2.0f * avR + 8.0f * Layout::dpiScale(), hp.y));
|
||||
}
|
||||
ImGui::TextUnformatted(sel->peerName.c_str());
|
||||
ImGui::PopFont();
|
||||
if (!sel->peerZaddr.empty()) {
|
||||
@@ -220,43 +399,173 @@ void RenderChatTab(App* app)
|
||||
ImGui::TextUnformatted(shorten(sel->peerZaddr, 20, 12).c_str());
|
||||
ImGui::PopStyleColor();
|
||||
ImGui::PopFont();
|
||||
// Header actions: copy the peer z-address (Q3), and add them to contacts when unknown (Q2).
|
||||
if (ImGui::SmallButton(TR("copy_address"))) ImGui::SetClipboardText(sel->peerZaddr.c_str());
|
||||
if (book.findByAddress(sel->peerZaddr) < 0) {
|
||||
ImGui::SameLine();
|
||||
if (ImGui::SmallButton(TR("chat_add_contact"))) {
|
||||
data::AddressBookEntry e(sel->peerName, sel->peerZaddr); // label = shortened addr; rename in Contacts
|
||||
if (book.addEntry(e)) Notifications::instance().success(TR("chat_contact_added"));
|
||||
else Notifications::instance().error(TR("address_book_exists"));
|
||||
}
|
||||
}
|
||||
// Export the decrypted conversation to a plain-text file (Q11). Written with restricted
|
||||
// permissions; the tooltip warns it's plaintext.
|
||||
ImGui::SameLine();
|
||||
const bool exportClicked = ImGui::SmallButton(TR("chat_export"));
|
||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("chat_export_warn"));
|
||||
if (exportClicked) {
|
||||
std::string content = "DragonX chat export\n";
|
||||
content += sel->peerName + " <" + sel->peerZaddr + ">\n";
|
||||
content += "cid: " + sel->cid + "\n\n";
|
||||
for (const auto& m : store.conversation(sel->cid)) {
|
||||
const bool out = (m.direction == chat::ChatDirection::Outgoing);
|
||||
content += "[" + formatTime(m.timestamp) + "] " +
|
||||
(out ? std::string(TR("chat_you")) : sel->peerName) + ": " + m.body + "\n";
|
||||
}
|
||||
std::string safe;
|
||||
for (char ch : sel->peerName)
|
||||
safe += (std::isalnum(static_cast<unsigned char>(ch)) ? ch : '_');
|
||||
if (safe.empty()) safe = "chat";
|
||||
const std::string path = util::Platform::getConfigDir() + "/dragonx-chat-" + safe + ".txt";
|
||||
if (util::Platform::writeFileAtomically(path, content, /*restrictPermissions=*/true))
|
||||
Notifications::instance().success(std::string(TR("chat_export_done")) + ": " + path);
|
||||
else
|
||||
Notifications::instance().error(TR("chat_export_failed"));
|
||||
}
|
||||
// Mute toggle — muted conversations don't badge or toast (Q10). Persisted to settings.
|
||||
ImGui::SameLine();
|
||||
const bool muted = app->settings() && app->settings()->isChatMuted(sel->cid);
|
||||
if (ImGui::SmallButton(muted ? TR("chat_unmute") : TR("chat_mute")) && app->settings()) {
|
||||
app->settings()->setChatMuted(sel->cid, !muted);
|
||||
app->settings()->save();
|
||||
}
|
||||
// Hide conversation — drops it from the list (messages stay in the encrypted store; a
|
||||
// new incoming message brings it back). Deselect so the thread resets to the next one.
|
||||
ImGui::SameLine();
|
||||
if (ImGui::SmallButton(TR("chat_hide")) && app->settings()) {
|
||||
app->markChatConversationSeen(sel->cid, sel->lastTs); // don't leave a phantom unread
|
||||
app->settings()->setChatHidden(sel->cid, true);
|
||||
app->settings()->save();
|
||||
s_selected_cid.clear();
|
||||
Notifications::instance().info(TR("chat_hidden_toast"));
|
||||
}
|
||||
}
|
||||
ImGui::Separator();
|
||||
|
||||
const float footerH = ImGui::GetTextLineHeightWithSpacing() + 12.0f;
|
||||
const float dp = Layout::dpiScale();
|
||||
const float composerBoxH = ImGui::GetTextLineHeight() * 2.6f; // multi-line composer (Q6)
|
||||
const float footerH = composerBoxH + ImGui::GetTextLineHeight() + 20.0f * dp;
|
||||
ImGui::BeginChild("##ChatMessages", ImVec2(0, ImGui::GetContentRegionAvail().y - footerH), false);
|
||||
bool atBottom = true;
|
||||
const ImVec2 msgWinMin = ImGui::GetWindowPos();
|
||||
const ImVec2 msgWinSize = ImGui::GetWindowSize();
|
||||
{
|
||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||
const float bpad = 9.0f * dp, bround = 9.0f * dp;
|
||||
const auto messages = store.conversation(s_selected_cid);
|
||||
for (const auto& m : messages) {
|
||||
std::int64_t prevMin = -1; int prevDir = -1;
|
||||
for (std::size_t mi = 0; mi < messages.size(); ++mi) {
|
||||
const auto& m = messages[mi];
|
||||
ImGui::PushID(static_cast<int>(mi));
|
||||
const bool outgoing = (m.direction == chat::ChatDirection::Outgoing);
|
||||
const bool request = (m.kind == chat::ChatMessageKind::ContactRequest);
|
||||
const bool failed = outgoing && m.delivery == chat::ChatDelivery::Failed;
|
||||
// Meta line: who + time (+ request tag / failed marker).
|
||||
std::string who = outgoing ? std::string(TR("chat_you")) : sel->peerName;
|
||||
const std::string when = formatTime(m.timestamp);
|
||||
if (!when.empty()) who += " " + when;
|
||||
if (request) who += " [" + std::string(TR("chat_contact_request")) + "]";
|
||||
if (failed) who += " · " + std::string(TR("chat_send_failed"));
|
||||
ImGui::PushFont(metaFont);
|
||||
ImGui::PushStyleColor(ImGuiCol_Text,
|
||||
failed ? material::Error() : (outgoing ? material::Primary() : material::OnSurfaceMedium()));
|
||||
ImGui::TextUnformatted(who.c_str());
|
||||
ImGui::PopStyleColor();
|
||||
ImGui::PopFont();
|
||||
// Body (wrapped).
|
||||
ImGui::PushFont(nameFont);
|
||||
ImGui::PushTextWrapPos(0.0f);
|
||||
ImGui::TextWrapped("%s", m.body.c_str());
|
||||
ImGui::PopTextWrapPos();
|
||||
ImGui::PopFont();
|
||||
ImGui::Dummy(ImVec2(0.0f, 6.0f));
|
||||
const bool request = (m.kind == chat::ChatMessageKind::ContactRequest);
|
||||
const bool failed = outgoing && m.delivery == chat::ChatDelivery::Failed;
|
||||
const bool sending = outgoing && m.delivery == chat::ChatDelivery::Sending;
|
||||
const std::int64_t minute = m.timestamp / 60;
|
||||
const bool startGroup = (prevDir != static_cast<int>(outgoing)) || (minute != prevMin);
|
||||
|
||||
const float availW = ImGui::GetContentRegionAvail().x;
|
||||
const float maxBubbleW = std::max(140.0f * dp, availW * 0.72f);
|
||||
const float innerW = maxBubbleW - 2.0f * bpad;
|
||||
|
||||
// Grouped meta line (sender + time), once per sender/minute run, aligned to the sender's side.
|
||||
if (startGroup) {
|
||||
std::string meta = outgoing ? std::string(TR("chat_you")) : sel->peerName;
|
||||
const std::string when = formatTime(m.timestamp);
|
||||
if (!when.empty()) meta += " " + when;
|
||||
if (request) meta += " [" + std::string(TR("chat_contact_request")) + "]";
|
||||
const ImVec2 msz = metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, meta.c_str());
|
||||
const ImVec2 mp = ImGui::GetCursorScreenPos();
|
||||
dl->AddText(metaFont, metaSz, ImVec2(outgoing ? mp.x + availW - msz.x : mp.x, mp.y),
|
||||
material::OnSurfaceMedium(), meta.c_str());
|
||||
ImGui::Dummy(ImVec2(availW, metaSz + 3.0f * dp));
|
||||
}
|
||||
|
||||
// Direction-aligned rounded bubble.
|
||||
const ImVec2 tsz = nameFont->CalcTextSizeA(nameSz, innerW, innerW, m.body.c_str());
|
||||
const float bw = std::min(maxBubbleW, tsz.x + 2.0f * bpad);
|
||||
const float bh = tsz.y + 2.0f * bpad;
|
||||
const ImVec2 cur = ImGui::GetCursorScreenPos();
|
||||
const float bx = outgoing ? (cur.x + availW - bw) : cur.x;
|
||||
const ImVec2 bmin(bx, cur.y), bmax(bx + bw, cur.y + bh);
|
||||
const ImU32 bubCol = failed ? material::WithAlpha(material::Error(), 40)
|
||||
: outgoing ? material::WithAlpha(material::Primary(), 46)
|
||||
: material::WithAlpha(material::OnSurface(), 22);
|
||||
dl->AddRectFilled(bmin, bmax, bubCol, bround);
|
||||
dl->AddText(nameFont, nameSz, ImVec2(bx + bpad, cur.y + bpad), material::OnSurface(),
|
||||
m.body.c_str(), nullptr, innerW);
|
||||
ImGui::Dummy(ImVec2(availW, bh));
|
||||
if (ImGui::IsMouseHoveringRect(bmin, bmax)) {
|
||||
material::Tooltip("%s", formatTime(m.timestamp).c_str());
|
||||
if (ImGui::IsMouseClicked(ImGuiMouseButton_Right)) ImGui::OpenPopup("##msgmenu");
|
||||
}
|
||||
if (ImGui::BeginPopup("##msgmenu")) {
|
||||
if (ImGui::MenuItem(TR("copy"))) ImGui::SetClipboardText(m.body.c_str());
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
// Failed send → right-aligned "not sent" + Retry (Q7).
|
||||
if (failed) {
|
||||
ImGui::Dummy(ImVec2(0.0f, 1.0f * dp));
|
||||
const float ftw = metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, TR("chat_send_failed")).x;
|
||||
const float rtw = ImGui::CalcTextSize(TR("chat_retry")).x + ImGui::GetStyle().FramePadding.x * 2.0f;
|
||||
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + std::max(0.0f, availW - ftw - rtw - 8.0f * dp));
|
||||
ImGui::PushFont(metaFont); ImGui::PushStyleColor(ImGuiCol_Text, material::Error());
|
||||
ImGui::TextUnformatted(TR("chat_send_failed"));
|
||||
ImGui::PopStyleColor(); ImGui::PopFont();
|
||||
ImGui::SameLine();
|
||||
if (ImGui::SmallButton(TR("chat_retry"))) {
|
||||
// A failed contact request must be re-sent as a request (it has no peer key
|
||||
// yet) into the SAME conversation — not routed through sendChatMessage,
|
||||
// which would just say "waiting for reply".
|
||||
if (request) app->sendContactRequestForCid(sel->cid, m.peer_zaddr, m.body);
|
||||
else app->sendChatMessage(sel->cid, m.body);
|
||||
s_scroll_to_cid = sel->cid;
|
||||
}
|
||||
}
|
||||
// In-flight send → subtle right-aligned "sending…"; the op callback resolves it.
|
||||
if (sending) {
|
||||
ImGui::Dummy(ImVec2(0.0f, 1.0f * dp));
|
||||
const float sw = metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, TR("chat_sending")).x;
|
||||
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + std::max(0.0f, availW - sw));
|
||||
ImGui::PushFont(metaFont); ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
|
||||
ImGui::TextUnformatted(TR("chat_sending"));
|
||||
ImGui::PopStyleColor(); ImGui::PopFont();
|
||||
}
|
||||
ImGui::Dummy(ImVec2(0.0f, 5.0f * dp));
|
||||
ImGui::PopID();
|
||||
prevMin = minute; prevDir = static_cast<int>(outgoing);
|
||||
}
|
||||
if (s_scroll_to_cid == s_selected_cid) {
|
||||
ImGui::SetScrollHereY(1.0f);
|
||||
s_scroll_to_cid.clear();
|
||||
}
|
||||
atBottom = ImGui::GetScrollY() >= ImGui::GetScrollMaxY() - 4.0f;
|
||||
}
|
||||
ImGui::EndChild();
|
||||
// Jump-to-latest pill when scrolled up (Q9). SetCursorScreenPos moves the parent cursor to
|
||||
// the child's bottom edge; save + restore it so the composer footer below stays anchored
|
||||
// (otherwise it renders ~8px too high only while the pill is shown).
|
||||
if (!atBottom) {
|
||||
const ImVec2 savedCursor = ImGui::GetCursorScreenPos();
|
||||
const ImVec2 pill(msgWinMin.x + msgWinSize.x - 84.0f * dp, msgWinMin.y + msgWinSize.y - 34.0f * dp);
|
||||
ImGui::SetCursorScreenPos(pill);
|
||||
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 220)));
|
||||
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(material::Primary()));
|
||||
if (ImGui::Button(TR("chat_jump_latest"), ImVec2(74.0f * dp, 26.0f * dp))) s_scroll_to_cid = sel->cid;
|
||||
ImGui::PopStyleColor(2);
|
||||
ImGui::SetCursorScreenPos(savedCursor);
|
||||
}
|
||||
|
||||
// Composer footer: message input + send (only once we know the peer's key), else a hint.
|
||||
ImGui::Separator();
|
||||
@@ -269,54 +578,130 @@ void RenderChatTab(App* app)
|
||||
ImGui::PopStyleColor();
|
||||
ImGui::PopFont();
|
||||
} else {
|
||||
const float sendW = 74.0f;
|
||||
ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x - sendW - ImGui::GetStyle().ItemSpacing.x);
|
||||
bool submit = ImGui::InputText("##compose", s_compose, sizeof(s_compose),
|
||||
ImGuiInputTextFlags_EnterReturnsTrue);
|
||||
// Multi-line composer (Q6): Enter sends, Ctrl+Enter inserts a newline. A live byte counter
|
||||
// tracks the effective on-chain body cap = (512 − len("utf8:"))/2 − secretstream ABYTES.
|
||||
static constexpr int kBodyMaxBytes = (512 - 5) / 2 - 17; // = 236 (see chat_outgoing.cpp)
|
||||
const float sendW = 80.0f * Layout::dpiScale(); // scaled so the translated label never clips (B3)
|
||||
const float inputW = ImGui::GetContentRegionAvail().x - sendW - ImGui::GetStyle().ItemSpacing.x;
|
||||
const int used = static_cast<int>(std::strlen(s_compose));
|
||||
const bool overCap = used > kBodyMaxBytes;
|
||||
bool submit = ImGui::InputTextMultiline("##compose", s_compose, sizeof(s_compose),
|
||||
ImVec2(inputW, composerBoxH),
|
||||
ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_CtrlEnterForNewLine);
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button(TR("chat_send"), ImVec2(sendW, 0.0f))) submit = true;
|
||||
if (submit && s_compose[0] != '\0') {
|
||||
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 205)));
|
||||
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(material::Primary()));
|
||||
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 235)));
|
||||
ImGui::BeginDisabled(overCap || s_compose[0] == '\0');
|
||||
if (material::TactileButton(TR("chat_send"), ImVec2(sendW, composerBoxH))) submit = true;
|
||||
ImGui::EndDisabled();
|
||||
ImGui::PopStyleColor(3);
|
||||
// Byte counter (Error tint over cap) with the over-cap label.
|
||||
ImGui::PushFont(metaFont);
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, overCap ? material::Error() : material::OnSurfaceMedium());
|
||||
if (overCap) { ImGui::TextUnformatted(TR("chat_len_over")); ImGui::SameLine(); }
|
||||
const std::string counter = std::to_string(used) + " / " + std::to_string(kBodyMaxBytes);
|
||||
const float cw = metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, counter.c_str()).x;
|
||||
ImGui::SetCursorPosX(ImGui::GetCursorPosX() +
|
||||
std::max(0.0f, ImGui::GetContentRegionAvail().x - cw));
|
||||
ImGui::TextUnformatted(counter.c_str());
|
||||
ImGui::PopStyleColor();
|
||||
ImGui::PopFont();
|
||||
if (submit && s_compose[0] != '\0' && !overCap) {
|
||||
app->sendChatMessage(sel->cid, s_compose);
|
||||
s_compose[0] = '\0';
|
||||
sodium_memzero(s_compose, sizeof(s_compose));
|
||||
s_scroll_to_cid = sel->cid;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
centeredHint(convs.empty() ? TR("chat_empty_hint") : TR("chat_select_hint"));
|
||||
if (convs.empty()) centeredEmptyState(ICON_MD_FORUM, TR("chat_empty_title"), TR("chat_empty_start"));
|
||||
else centeredEmptyState(ICON_MD_CHAT_BUBBLE_OUTLINE, TR("chat_select_hint"), nullptr);
|
||||
}
|
||||
}
|
||||
ImGui::EndChild();
|
||||
|
||||
// ---- New-conversation popup (send a contact request to a z-address) ----
|
||||
// ---- New-conversation dialog (send a contact request to a z-address) — house BlurFloat overlay ----
|
||||
if (s_show_new_convo) {
|
||||
ImGui::OpenPopup("##NewConversation");
|
||||
s_show_new_convo = false;
|
||||
}
|
||||
ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
|
||||
if (ImGui::BeginPopupModal("##NewConversation", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) {
|
||||
ImGui::PushFont(material::Type().subtitle1());
|
||||
ImGui::TextUnformatted(TR("chat_new_title"));
|
||||
ImGui::PopFont();
|
||||
ImGui::Spacing();
|
||||
ImGui::TextUnformatted(TR("chat_new_zaddr"));
|
||||
ImGui::SetNextItemWidth(440.0f);
|
||||
ImGui::InputText("##newz", s_new_zaddr, sizeof(s_new_zaddr));
|
||||
ImGui::TextUnformatted(TR("chat_new_message"));
|
||||
ImGui::SetNextItemWidth(440.0f);
|
||||
ImGui::InputText("##newm", s_new_msg, sizeof(s_new_msg));
|
||||
ImGui::Spacing();
|
||||
const bool canSend = s_new_zaddr[0] != '\0' && s_new_msg[0] != '\0';
|
||||
if (ImGui::Button(TR("chat_new_send"), ImVec2(150.0f, 0.0f)) && canSend) {
|
||||
app->startChatConversation(s_new_zaddr, s_new_msg);
|
||||
s_new_zaddr[0] = '\0';
|
||||
s_new_msg[0] = '\0';
|
||||
ImGui::CloseCurrentPopup();
|
||||
const float dp = Layout::dpiScale();
|
||||
material::OverlayDialogSpec ov;
|
||||
ov.title = TR("chat_new_title");
|
||||
ov.p_open = &s_show_new_convo; // X / backdrop closes it
|
||||
ov.style = material::OverlayStyle::BlurFloat;
|
||||
ov.cardWidth = 520.0f; ov.idSuffix = "chatnewconvo";
|
||||
if (material::BeginOverlayDialog(ov)) {
|
||||
const float fieldW = ImGui::GetContentRegionAvail().x;
|
||||
material::LabeledInput(TR("chat_new_zaddr"), "##newz", s_new_zaddr, sizeof(s_new_zaddr), fieldW);
|
||||
// Or pick from contacts — chat needs a shielded z-address, so only z-addr contacts are listed.
|
||||
// Selecting one fills the field above (manual paste still works).
|
||||
ImGui::SetNextItemWidth(fieldW);
|
||||
if (ImGui::BeginCombo("##newzpick", TR("chat_pick_contact"), ImGuiComboFlags_HeightLarge)) {
|
||||
const std::string activeHash = app->activeWalletScopeId(); // per-wallet contact scope
|
||||
int shown = 0;
|
||||
for (const auto& e : book.entries()) {
|
||||
if (e.address.empty() || e.address[0] != 'z') continue; // chat requires a z-address
|
||||
// Respect the same per-wallet scope the Contacts tab enforces — don't leak another
|
||||
// wallet's scoped contact into this wallet's picker (global + legacy fail open).
|
||||
const bool visible = e.isGlobal() ||
|
||||
(e.scope.rfind("w:", 0) == 0 ? (!activeHash.empty() && e.scope == activeHash) : true);
|
||||
if (!visible) continue;
|
||||
++shown;
|
||||
const std::string item = e.label + " " + shorten(e.address, 16, 8);
|
||||
if (ImGui::Selectable(item.c_str())) {
|
||||
std::strncpy(s_new_zaddr, e.address.c_str(), sizeof(s_new_zaddr) - 1);
|
||||
s_new_zaddr[sizeof(s_new_zaddr) - 1] = '\0';
|
||||
}
|
||||
}
|
||||
if (shown == 0) {
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
|
||||
ImGui::TextUnformatted(TR("chat_no_z_contacts"));
|
||||
ImGui::PopStyleColor();
|
||||
}
|
||||
ImGui::EndCombo();
|
||||
}
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||
material::LabeledInput(TR("chat_new_message"), "##newm", s_new_msg, sizeof(s_new_msg), fieldW);
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
|
||||
|
||||
const bool canSend = s_new_zaddr[0] != '\0' && s_new_msg[0] != '\0';
|
||||
const float actionW = std::max(130.0f * dp,
|
||||
ImGui::CalcTextSize(TR("chat_new_send")).x + ImGui::GetStyle().FramePadding.x * 2.0f + 24.0f * dp);
|
||||
const float actionGap = Layout::spacingSm();
|
||||
material::BeginOverlayDialogFooter(actionW * 2.0f + actionGap, /*drawSeparator=*/false);
|
||||
|
||||
if (!canSend) ImGui::BeginDisabled();
|
||||
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 205)));
|
||||
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(material::Primary()));
|
||||
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 235)));
|
||||
const bool doSend = material::TactileButton(TR("chat_new_send"), ImVec2(actionW, 0));
|
||||
ImGui::PopStyleColor(3);
|
||||
if (doSend) {
|
||||
app->startChatConversation(s_new_zaddr, s_new_msg);
|
||||
sodium_memzero(s_new_zaddr, sizeof(s_new_zaddr)); // wipe the just-sent plaintext
|
||||
sodium_memzero(s_new_msg, sizeof(s_new_msg));
|
||||
s_show_new_convo = false;
|
||||
}
|
||||
if (!canSend) ImGui::EndDisabled();
|
||||
|
||||
ImGui::SameLine(0, actionGap);
|
||||
if (material::TactileButton(TR("chat_cancel"), ImVec2(actionW, 0))) s_show_new_convo = false;
|
||||
|
||||
material::EndOverlayDialog();
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button(TR("chat_cancel"), ImVec2(100.0f, 0.0f))) ImGui::CloseCurrentPopup();
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
}
|
||||
|
||||
void ResetChatTab()
|
||||
{
|
||||
// Securely wipe typed plaintext (a private message / recipient z-addr) so it can't resurface under
|
||||
// the next wallet after a switch/lock, and drop the selection ids.
|
||||
sodium_memzero(s_compose, sizeof(s_compose));
|
||||
sodium_memzero(s_new_zaddr, sizeof(s_new_zaddr));
|
||||
sodium_memzero(s_new_msg, sizeof(s_new_msg));
|
||||
s_selected_cid.clear();
|
||||
s_scroll_to_cid.clear();
|
||||
s_compose_cid.clear();
|
||||
s_show_new_convo = false;
|
||||
}
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
|
||||
@@ -11,17 +11,26 @@ class App;
|
||||
namespace ui {
|
||||
|
||||
/**
|
||||
* @brief Render the Chat tab (read-only HushChat conversation view — Phase 3).
|
||||
* @brief Render the Chat tab (HushChat conversation view).
|
||||
*
|
||||
* A two-pane view: a conversation list on the left and the selected thread on
|
||||
* the right, both read from the App-owned ChatService store (App::chatService()).
|
||||
* Peer z-addresses are resolved to contact names via the address book when known.
|
||||
* Read-only for now — composing/sending arrives in a later phase. The tab is only
|
||||
* reachable when built with DRAGONX_ENABLE_CHAT (gated via WalletUiSurface::Chat).
|
||||
* A two-pane view: a conversation list on the left and the selected thread on the
|
||||
* right, both read from the App-owned ChatService store (App::chatService()). Peer
|
||||
* z-addresses are resolved to contact names via the address book when known.
|
||||
* Composing/sending and starting new conversations are wired (App::sendChatMessage /
|
||||
* startChatConversation). Reachable when built with DRAGONX_ENABLE_CHAT
|
||||
* (gated via WalletUiSurface::Chat).
|
||||
*
|
||||
* @param app Pointer to the app instance.
|
||||
*/
|
||||
void RenderChatTab(App* app);
|
||||
|
||||
/**
|
||||
* @brief Securely wipe the Chat tab's UI-local state (composer / new-conversation
|
||||
* plaintext buffers + the selected-conversation ids). Called by
|
||||
* App::resetChatSession() on a wallet switch/lock so one wallet's typed
|
||||
* plaintext can't resurface (or linger unwiped in RAM) under the next wallet.
|
||||
*/
|
||||
void ResetChatTab();
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
|
||||
@@ -511,8 +511,7 @@ void ConsoleTab::renderToolbar(ConsoleCommandExecutor& exec)
|
||||
|
||||
// Clear button
|
||||
if (TactileButton(TR("console_clear"), ImVec2(0, 0), schema::UI().resolveFont("button"))) {
|
||||
clear();
|
||||
selection_.clear();
|
||||
clear(); // also drops the stale visible_indices_ + selection (see ConsoleTab::clear)
|
||||
}
|
||||
|
||||
ImGui::SameLine();
|
||||
@@ -1183,9 +1182,9 @@ void ConsoleTab::drawOutputContextMenu()
|
||||
}
|
||||
ImGui::Separator();
|
||||
if (ImGui::MenuItem(TR("console_clear_console"))) {
|
||||
// View-only clear (main thread) — drop the visible lines and the selection.
|
||||
model_.clear();
|
||||
selection_.clear();
|
||||
// View-only clear — route through clear() so the stale visible_indices_/selection are dropped too
|
||||
// (a bare model_.clear() mid-frame would leave renderOutput() indexing the emptied model_ → crash).
|
||||
clear();
|
||||
}
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
@@ -2004,6 +2003,12 @@ void ConsoleTab::clear()
|
||||
// View-only clear (main thread). The executor keeps its own log cursors, so new output
|
||||
// still appends. The "cleared" line is ingested and appears on the next frame's drain.
|
||||
model_.clear();
|
||||
// visible_indices_ was computed at the top of render() (line 311), BEFORE the toolbar's Clear button
|
||||
// ran; those indices now point past the emptied model_. renderOutput() (this same frame, after the
|
||||
// toolbar) indexes model_[visible_indices_[vi]] — so drop them (and the selection, which also holds
|
||||
// line indices) here to avoid an out-of-bounds crash. computeVisibleLines() rebuilds them next frame.
|
||||
visible_indices_.clear();
|
||||
selection_.clear();
|
||||
stop_confirm_pending_ = false; // a pending 'stop' confirmation is cancelled by clearing
|
||||
addLine(TR("console_cleared"), ConsoleChannel::Info);
|
||||
}
|
||||
|
||||
@@ -277,9 +277,16 @@ void RenderContactsTab(App* app)
|
||||
|
||||
auto& book = app->addressBook();
|
||||
|
||||
// The active wallet's identity hash scopes which contacts show + tags newly-added ones.
|
||||
// Empty pre-connect (addresses unknown) — then we don't filter, to avoid hiding contacts.
|
||||
const std::string activeHash = app->activeWalletIdentityHash();
|
||||
// Stable per-wallet id that scopes which contacts show + tags newly-added ones. Unlike the old
|
||||
// address-hash identity it doesn't drift when the address set changes, and it's known even before
|
||||
// connect. Empty only when there's no active wallet file.
|
||||
const std::string activeHash = app->activeWalletScopeId();
|
||||
// One-time recovery: contacts saved under the old drifting address-hash scope were orphaned when the
|
||||
// wallet's address set changed. When there's a single known wallet (so attribution is unambiguous),
|
||||
// re-attach those legacy-scoped contacts to this wallet's stable id. Idempotent (converted contacts
|
||||
// become "w:"-scoped), so it does real work only once.
|
||||
if (!activeHash.empty() && app->walletIndex().entries().size() <= 1)
|
||||
app->addressBook().reattachLegacyScopes(activeHash);
|
||||
|
||||
auto clearEditFields = []() {
|
||||
s_edit_label[0] = '\0';
|
||||
@@ -763,10 +770,25 @@ void RenderContactsTab(App* app)
|
||||
};
|
||||
data::AddressBookEntry entry(trimAB(s_edit_label), trimAB(s_edit_address), s_edit_notes);
|
||||
entry.avatar = s_edit_avatar;
|
||||
// Global if the user asked, or as a safe fallback when we don't yet know the wallet
|
||||
// (pre-connect) — better a visible-everywhere contact than one orphaned to no wallet.
|
||||
entry.scope = (s_edit_global || activeHash.empty()) ? std::string("global") : activeHash;
|
||||
if (isEdit) {
|
||||
// Scope: global if the user asked; otherwise this wallet — but ONLY when we actually
|
||||
// know which wallet is loaded. While the identity is unknown (the switch / first-load
|
||||
// window) don't silently make a wallet-scoped contact global: preserve an edited
|
||||
// contact's existing scope, and refuse a NEW wallet-scoped one (tick global or wait).
|
||||
bool blocked = false;
|
||||
if (s_edit_global) {
|
||||
entry.scope = "global";
|
||||
} else if (!activeHash.empty()) {
|
||||
entry.scope = activeHash;
|
||||
} else if (isEdit && s_selected_index >= 0 && s_selected_index < (int)book.size()) {
|
||||
entry.scope = book.entries()[s_selected_index].scope; // keep; don't demote to global
|
||||
if (entry.scope.empty()) entry.scope = "global";
|
||||
} else {
|
||||
Notifications::instance().error(TR("contact_wallet_loading"));
|
||||
blocked = true; // don't create a wallet-scoped contact with an unknown wallet
|
||||
}
|
||||
if (blocked) {
|
||||
// leave the dialog open so the user can tick global or retry once loaded
|
||||
} else if (isEdit) {
|
||||
if (app->addressBook().updateEntry(s_selected_index, entry)) {
|
||||
Notifications::instance().success(TR("address_book_updated"));
|
||||
s_show_edit_dialog = false;
|
||||
@@ -895,9 +917,15 @@ void RenderContactsTab(App* app)
|
||||
for (size_t i = 0; i < book.size(); ++i) {
|
||||
const auto& e = book.entries()[i];
|
||||
if (!matchesSearch(e, needle)) continue;
|
||||
// Scope filter: global contacts + this wallet's contacts. Pre-connect (activeHash empty)
|
||||
// we don't know the wallet yet, so show everything rather than hide contacts.
|
||||
if (!activeHash.empty() && !e.visibleInWallet(activeHash)) continue;
|
||||
// Scope filter: show global + this wallet's contacts. A legacy (non-"w:") scope belongs to a
|
||||
// wallet whose old address-hash identity has drifted and can't be reattributed here (multi-wallet
|
||||
// case where recovery didn't run) — fail OPEN so those contacts are never hidden. Stable "w:"
|
||||
// scopes match strictly. When no wallet is active, show global + legacy only.
|
||||
bool visible;
|
||||
if (e.isGlobal()) visible = true;
|
||||
else if (e.scope.rfind("w:", 0) == 0) visible = !activeHash.empty() && e.scope == activeHash;
|
||||
else visible = true; // legacy scope → fail open (recovery)
|
||||
if (!visible) continue;
|
||||
visibleRows.push_back(i);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
#include "export_all_keys_dialog.h"
|
||||
#include "../../app.h"
|
||||
#include <sodium.h> // sodium_memzero — wipe exported key material from memory (B7)
|
||||
#include "../../rpc/rpc_client.h"
|
||||
#include "../../rpc/rpc_worker.h"
|
||||
#include "../../util/i18n.h"
|
||||
@@ -180,13 +181,12 @@ void ExportAllKeysDialog::render(App* app)
|
||||
for (const auto& addr : z_addrs) {
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Settings / Export all keys");
|
||||
auto result = rpc->call("z_exportkey", {addr});
|
||||
if (result.is_string()) {
|
||||
keys += "# Address: " + addr + "\n";
|
||||
keys += result.get<std::string>() + "\n\n";
|
||||
exported++; // count only real successes (locked/failed keys don't count)
|
||||
}
|
||||
} catch (...) {}
|
||||
std::string k = rpc->callSecretString("z_exportkey", {addr}); // scrubs raw body + json node (B7)
|
||||
keys += "# Address: " + addr + "\n";
|
||||
keys += k + "\n\n";
|
||||
exported++; // count only real successes (locked/failed keys don't count)
|
||||
if (!k.empty()) sodium_memzero(&k[0], k.size()); // wipe the transient copy
|
||||
} catch (...) {} // non-string / locked key → skip (not counted)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,13 +196,12 @@ void ExportAllKeysDialog::render(App* app)
|
||||
for (const auto& addr : t_addrs) {
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Settings / Export all keys");
|
||||
auto result = rpc->call("dumpprivkey", {addr});
|
||||
if (result.is_string()) {
|
||||
keys += "# Address: " + addr + "\n";
|
||||
keys += result.get<std::string>() + "\n\n";
|
||||
exported++; // count only real successes (locked/failed keys don't count)
|
||||
}
|
||||
} catch (...) {}
|
||||
std::string k = rpc->callSecretString("dumpprivkey", {addr}); // scrubs raw body + json node (B7)
|
||||
keys += "# Address: " + addr + "\n";
|
||||
keys += k + "\n\n";
|
||||
exported++; // count only real successes (locked/failed keys don't count)
|
||||
if (!k.empty()) sodium_memzero(&k[0], k.size()); // wipe the transient copy
|
||||
} catch (...) {} // non-string / locked key → skip (not counted)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,6 +218,7 @@ void ExportAllKeysDialog::render(App* app)
|
||||
writeOk = true;
|
||||
}
|
||||
}
|
||||
if (!keys.empty()) sodium_memzero(&keys[0], keys.size()); // don't leave every key in freed heap
|
||||
|
||||
return [exported, total, filepath, writeOk]() {
|
||||
s_exported_count = exported;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include "key_export_dialog.h"
|
||||
#include "../../app.h"
|
||||
#include "../../wallet/lite_wallet_controller.h"
|
||||
#include <sodium.h> // sodium_memzero — wipe transient secret copies (B7)
|
||||
#include <nlohmann/json.hpp>
|
||||
#include "../../rpc/rpc_client.h"
|
||||
#include "../../rpc/rpc_worker.h"
|
||||
@@ -37,7 +38,7 @@ std::string KeyExportDialog::s_error;
|
||||
void KeyExportDialog::releaseQr()
|
||||
{
|
||||
if (s_qr_tex) { FreeQRTexture(s_qr_tex); s_qr_tex = 0; }
|
||||
s_qr_cached.clear();
|
||||
wallet::secureWipeLiteSecret(s_qr_cached); // held a plaintext copy of the key for the QR
|
||||
s_show_qr = false;
|
||||
}
|
||||
|
||||
@@ -49,7 +50,7 @@ void KeyExportDialog::show(const std::string& address, KeyType type)
|
||||
releaseQr();
|
||||
s_key_type = type;
|
||||
s_address = address;
|
||||
s_key.clear();
|
||||
wallet::secureWipeLiteSecret(s_key); // zero any prior secret before reuse
|
||||
s_error.clear();
|
||||
}
|
||||
|
||||
@@ -62,7 +63,7 @@ void KeyExportDialog::hide()
|
||||
{
|
||||
s_open = false;
|
||||
s_fetching = false;
|
||||
s_key.clear();
|
||||
wallet::secureWipeLiteSecret(s_key); // zero the displayed secret, don't just drop the buffer
|
||||
s_show_key = false;
|
||||
s_error.clear();
|
||||
releaseQr();
|
||||
@@ -187,18 +188,18 @@ void KeyExportDialog::render(App* app)
|
||||
std::string error;
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Settings / Export key");
|
||||
auto result = rpc->call(method, {addr});
|
||||
key = result.get<std::string>();
|
||||
key = rpc->callSecretString(method, {addr}); // scrubs raw body + json node (B7)
|
||||
} catch (const std::exception& e) {
|
||||
error = e.what();
|
||||
}
|
||||
return [key, error]() {
|
||||
return [key = std::move(key), error]() mutable {
|
||||
if (error.empty()) {
|
||||
s_key = key;
|
||||
s_show_key = false; // Don't show by default
|
||||
} else {
|
||||
s_error = error;
|
||||
}
|
||||
if (!key.empty()) sodium_memzero(&key[0], key.size()); // wipe the last transient copy
|
||||
s_fetching = false;
|
||||
};
|
||||
});
|
||||
@@ -213,18 +214,18 @@ void KeyExportDialog::render(App* app)
|
||||
std::string error;
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Settings / Export viewing key");
|
||||
auto result = rpc->call("z_exportviewingkey", {addr});
|
||||
key = result.get<std::string>();
|
||||
key = rpc->callSecretString("z_exportviewingkey", {addr}); // scrubs raw body + json node (B7)
|
||||
} catch (const std::exception& e) {
|
||||
error = e.what();
|
||||
}
|
||||
return [key, error]() {
|
||||
return [key = std::move(key), error]() mutable {
|
||||
if (error.empty()) {
|
||||
s_key = key;
|
||||
s_show_key = true; // Viewing keys are less sensitive
|
||||
} else {
|
||||
s_error = error;
|
||||
}
|
||||
if (!key.empty()) sodium_memzero(&key[0], key.size()); // wipe the last transient copy
|
||||
s_fetching = false;
|
||||
};
|
||||
});
|
||||
@@ -317,8 +318,8 @@ void KeyExportDialog::render(App* app)
|
||||
|
||||
if (material::TactileButton(TR("close"), ImVec2(button_width, 0), S.resolveFont(closeBtn.font))) {
|
||||
s_open = false;
|
||||
// Clear sensitive data
|
||||
s_key.clear();
|
||||
// Zero the secret, don't just drop the buffer (leaves the key in freed heap otherwise).
|
||||
wallet::secureWipeLiteSecret(s_key);
|
||||
s_show_key = false;
|
||||
releaseQr();
|
||||
}
|
||||
@@ -326,9 +327,9 @@ void KeyExportDialog::render(App* app)
|
||||
material::EndOverlayDialog();
|
||||
}
|
||||
|
||||
// Dialog dismissed any other way (scrim click / Esc): drop the key + its QR texture.
|
||||
// Dialog dismissed any other way (scrim click / Esc): wipe the key + its QR texture.
|
||||
if (!s_open) {
|
||||
if (!s_key.empty()) s_key.clear();
|
||||
wallet::secureWipeLiteSecret(s_key);
|
||||
s_show_key = false;
|
||||
releaseQr();
|
||||
}
|
||||
|
||||
@@ -380,14 +380,14 @@ static bool pfIndexVisible(App* app, int i)
|
||||
const auto& es = app->settings()->getPortfolioEntries();
|
||||
if (i < 0 || i >= (int)es.size()) return false;
|
||||
const std::string h = app->activeWalletIdentityHash();
|
||||
return es[i].scope.empty() || h.empty() || es[i].scope == h;
|
||||
return es[i].scope.empty() || (!h.empty() && es[i].scope == h);
|
||||
}
|
||||
static int pfFirstVisibleIndex(App* app)
|
||||
{
|
||||
const auto& es = app->settings()->getPortfolioEntries();
|
||||
const std::string h = app->activeWalletIdentityHash();
|
||||
for (int i = 0; i < (int)es.size(); i++)
|
||||
if (es[i].scope.empty() || h.empty() || es[i].scope == h) return i;
|
||||
if (es[i].scope.empty() || (!h.empty() && es[i].scope == h)) return i;
|
||||
return -1;
|
||||
}
|
||||
|
||||
@@ -823,7 +823,9 @@ static void RenderPortfolioEditor(App* app)
|
||||
const std::string activeHash = app->activeWalletIdentityHash();
|
||||
std::vector<int> vis;
|
||||
for (int i = 0; i < (int)entries.size(); i++)
|
||||
if (entries[i].scope.empty() || activeHash.empty() || entries[i].scope == activeHash)
|
||||
// Global groups always; this wallet's groups only when the identity is known — never
|
||||
// another wallet's scoped groups during the switch/first-load window.
|
||||
if (entries[i].scope.empty() || (!activeHash.empty() && entries[i].scope == activeHash))
|
||||
vis.push_back(i);
|
||||
if (vis.empty())
|
||||
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("portfolio_no_entries"));
|
||||
@@ -905,7 +907,11 @@ static void RenderPortfolioEditor(App* app)
|
||||
}
|
||||
ImGui::EndChild();
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||
// Add immediately creates a persisted "Untitled" group and selects it for editing.
|
||||
// Add immediately creates a persisted "Untitled" group scoped to this wallet — so it's disabled
|
||||
// while the wallet identity is unknown (switch / first-load window), otherwise a new group would
|
||||
// get an empty scope and show under every wallet.
|
||||
const bool pfIdentityKnown = !app->activeWalletIdentityHash().empty();
|
||||
ImGui::BeginDisabled(!pfIdentityKnown);
|
||||
if (material::TactileButton(TR("portfolio_add_entry"), ImVec2(masterW, addH))) {
|
||||
// Auto-save the current group, then start a fresh one selected for editing.
|
||||
pfCommitIfNeeded(app);
|
||||
@@ -918,6 +924,9 @@ static void RenderPortfolioEditor(App* app)
|
||||
s_pfEdit.sel = (int)es.size() - 1;
|
||||
PortfolioBeginEdit(app, s_pfEdit.sel);
|
||||
}
|
||||
ImGui::EndDisabled();
|
||||
if (!pfIdentityKnown && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
|
||||
material::Tooltip("%s", TR("portfolio_wallet_loading"));
|
||||
}
|
||||
ImGui::EndGroup();
|
||||
|
||||
@@ -1967,7 +1976,9 @@ static void mktDrawPortfolio(const MktCtx& cx)
|
||||
std::vector<int> vis;
|
||||
for (int i = 0; i < (int)allEntries.size(); i++) {
|
||||
const auto& e = allEntries[i];
|
||||
if (e.scope.empty() || activeHash.empty() || e.scope == activeHash) vis.push_back(i);
|
||||
// Global groups always; this wallet's only when the identity is known (never another
|
||||
// wallet's scoped groups during the switch/first-load window).
|
||||
if (e.scope.empty() || (!activeHash.empty() && e.scope == activeHash)) vis.push_back(i);
|
||||
}
|
||||
|
||||
int style = app->settings() ? app->settings()->getPortfolioStyle() : 0;
|
||||
@@ -2110,7 +2121,7 @@ void RenderMarketTab(App* app)
|
||||
const std::string pfActiveHash = app->activeWalletIdentityHash();
|
||||
int pfVisN = 0;
|
||||
for (const auto& e : pfEntriesGeo)
|
||||
if (e.scope.empty() || pfActiveHash.empty() || e.scope == pfActiveHash) pfVisN++;
|
||||
if (e.scope.empty() || (!pfActiveHash.empty() && e.scope == pfActiveHash)) pfVisN++;
|
||||
int pfStyle = app->settings()->getPortfolioStyle();
|
||||
float pfRowH = (pfStyle == 0 ? 46.0f : pfStyle == 1 ? 64.0f : 84.0f) * mktDp;
|
||||
float pfRowGap = Layout::spacingSm();
|
||||
|
||||
@@ -288,14 +288,32 @@ public:
|
||||
// encryption — so absence of a lock never falsely reads as "unencrypted" on a huge wallet.
|
||||
const ProbeResult pres = probeAt(i); // from the frame-consistent snapshot above
|
||||
const bool bLock = pres.probed && pres.encrypted;
|
||||
const bool bSeed = pres.probed && pres.hdSeed;
|
||||
const bool bLegacy = pres.probed && pres.complete && !pres.hdSeed;
|
||||
const bool bUnknown = pres.probed && !pres.complete && !pres.encrypted;
|
||||
// Seed-phrase vs legacy. Runtime status (z_exportmnemonic → activeWalletSeedBadge) is
|
||||
// authoritative for the ACTIVE wallet; otherwise the offline probe reads the hdchain
|
||||
// record's fMnemonicSeed flag directly (pres.mnemonic: 1 = BIP39 seed phrase, 2 = HD/legacy
|
||||
// with no phrase, 0 = couldn't tell) — which, unlike bare HD-record presence, actually
|
||||
// distinguishes the two. seed uses the same 1/2/0 encoding.
|
||||
const int activeBadge = rowActive[i] ? app->activeWalletSeedBadge() : 0;
|
||||
int seed = activeBadge;
|
||||
if (seed == 0 && pres.probed) {
|
||||
if (pres.mnemonic != 0) seed = pres.mnemonic; // read the flag off disk
|
||||
else if (pres.complete && !pres.hdSeed) seed = 2; // no HD records at all → no phrase
|
||||
}
|
||||
const bool bSeed = (seed == 1);
|
||||
const bool bLegacy = (seed == 2);
|
||||
// seed==0 splits by what the probe DID learn: if it saw HD records we know it's an HD
|
||||
// wallet (we just couldn't read the phrase flag — e.g. the tier-1 byte-scan fallback), so
|
||||
// label it "HD wallet" rather than a bare "?"; only a scan that couldn't even establish
|
||||
// that (incomplete, no HD marker yet) stays "Unknown". Both yield to the Lock badge when
|
||||
// encrypted, so an encrypted-but-unclassified row shows just the lock.
|
||||
const bool bHd = (seed == 0) && pres.probed && pres.hdSeed && !pres.encrypted;
|
||||
const bool bUnknown = (seed == 0) && pres.probed && !pres.hdSeed && !pres.encrypted;
|
||||
struct Badge { const char* glyph; ImU32 col; const char* label; const char* tip; };
|
||||
Badge bl[4]; int nb = 0;
|
||||
Badge bl[4]; int nb = 0; // seed/legacy/hd/unknown are mutually exclusive → at most one + lock
|
||||
if (bSeed) bl[nb++] = { ICON_MD_ECO, WithAlpha(Success(), 235), TR("wallets_badge_seed_short"), TR("wallets_badge_seed") };
|
||||
if (bLock) bl[nb++] = { ICON_MD_LOCK, WithAlpha(Warning(), 240), TR("wallets_badge_encrypted_short"), TR("wallets_badge_encrypted") };
|
||||
if (bLegacy) bl[nb++] = { ICON_MD_HISTORY, WithAlpha(OnSurfaceMedium(), 220), TR("wallets_badge_legacy_short"), TR("wallets_badge_legacy") };
|
||||
if (bHd) bl[nb++] = { ICON_MD_ACCOUNT_TREE, WithAlpha(OnSurfaceMedium(), 210), TR("wallets_badge_hd_short"), TR("wallets_badge_hd") };
|
||||
if (bUnknown) bl[nb++] = { ICON_MD_HELP_OUTLINE, WithAlpha(OnSurfaceMedium(), 185), TR("wallets_badge_unknown_short"), TR("wallets_badge_unknown") };
|
||||
const float bGap = Layout::spacingSm();
|
||||
float maxLabelW = 0.0f;
|
||||
@@ -542,6 +560,7 @@ private:
|
||||
int keyCount = 0; // transparent + shielded spendable keys (≈ addresses, incl. change)
|
||||
int txCount = 0; // wallet transaction records
|
||||
long long createdEpoch = 0; // wallet birthday (earliest keymeta nCreateTime); 0 = unknown
|
||||
int mnemonic = 0; // hdchain fMnemonicSeed flag: 0 unknown, 1 seed-phrase, 2 no phrase
|
||||
};
|
||||
struct ProbeBatch {
|
||||
std::mutex mtx;
|
||||
@@ -768,6 +787,7 @@ private:
|
||||
const auto bt = util::parseWalletBtree(t.first, std::min(budget, kPerFile));
|
||||
if (bt.parsed && bt.complete) {
|
||||
res = ProbeResult{ true, true, bt.encrypted, bt.hdSeed, true, bt.addresses(), bt.txCount, bt.createdEpoch };
|
||||
res.mnemonic = bt.mnemonicSeed; // read straight off the hdchain record
|
||||
budget -= std::min(budget, bt.bytesRead);
|
||||
} else {
|
||||
const auto pr = util::probeWalletFile(t.first, std::min(budget, kPerFile));
|
||||
|
||||
@@ -226,6 +226,9 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["chat_new_message"] = "Message";
|
||||
strings_["chat_new_send"] = "Send request";
|
||||
strings_["chat_cancel"] = "Cancel";
|
||||
strings_["chat_add_contact"] = "Add contact";
|
||||
strings_["chat_contact_added"] = "Contact added — rename it in Contacts";
|
||||
strings_["chat_new_message_toast"] = "New encrypted chat message";
|
||||
strings_["chat_toast_not_connected"] = "Not connected — chat message not sent.";
|
||||
strings_["chat_toast_no_zaddr"] = "No z-address available to send chat from.";
|
||||
strings_["chat_toast_lite_busy"] = "A send is already in progress, or no wallet is open.";
|
||||
@@ -233,6 +236,26 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["chat_toast_compose_failed"] = "Could not compose the message (too long?).";
|
||||
strings_["chat_toast_request_compose_failed"] = "Could not compose the contact request (invalid address / text?).";
|
||||
strings_["chat_toast_request_queued"] = "Contact request queued.";
|
||||
strings_["chat_toast_need_funds"] = "Need a small shielded balance to send chat (to cover the fee).";
|
||||
strings_["chat_sending"] = "sending\xE2\x80\xA6";
|
||||
strings_["chat_time_now"] = "now";
|
||||
strings_["chat_retry"] = "Retry";
|
||||
strings_["chat_jump_latest"] = "Latest";
|
||||
strings_["chat_empty_title"] = "No conversations yet";
|
||||
strings_["chat_empty_start"] = "Start one with \"New conversation\".";
|
||||
strings_["chat_search"] = "Search conversations";
|
||||
strings_["chat_no_matches"] = "No conversations match your search.";
|
||||
strings_["chat_export"] = "Export chat\xE2\x80\xA6";
|
||||
strings_["chat_export_warn"] = "Saves the decrypted messages as plain text. Store the file securely.";
|
||||
strings_["chat_export_done"] = "Conversation exported";
|
||||
strings_["chat_export_failed"] = "Could not write the export file.";
|
||||
strings_["chat_len_over"] = "Message too long";
|
||||
strings_["chat_mute"] = "Mute";
|
||||
strings_["chat_unmute"] = "Unmute";
|
||||
strings_["chat_hide"] = "Hide";
|
||||
strings_["chat_hidden_toast"] = "Conversation hidden — a new message brings it back";
|
||||
strings_["chat_pick_contact"] = "Choose from contacts\xE2\x80\xA6";
|
||||
strings_["chat_no_z_contacts"] = "No shielded-address contacts yet";
|
||||
// Seed-phrase backup (full-node)
|
||||
strings_["seed_backup_button"] = "Seed phrase";
|
||||
strings_["tt_seed_backup"] = "Show and back up your wallet's 24-word recovery seed phrase";
|
||||
@@ -276,9 +299,11 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["wallets_badge_seed"] = "Seed phrase wallet (HD)";
|
||||
strings_["wallets_badge_legacy"] = "Legacy wallet (no seed phrase)";
|
||||
strings_["wallets_badge_unknown"] = "Wallet type not fully determined (large file — open to confirm)";
|
||||
strings_["wallets_badge_hd"] = "HD wallet — can't confirm a seed phrase without opening it";
|
||||
strings_["wallets_badge_seed_short"] = "Seed phrase";
|
||||
strings_["wallets_badge_encrypted_short"] = "Encrypted";
|
||||
strings_["wallets_badge_legacy_short"] = "Legacy";
|
||||
strings_["wallets_badge_hd_short"] = "HD wallet";
|
||||
strings_["wallets_badge_unknown_short"] = "Unknown";
|
||||
strings_["wallets_open"] = "Open";
|
||||
strings_["wallets_open_folder"] = "Open folder location";
|
||||
@@ -1037,7 +1062,27 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["confirm_transaction"] = "Confirm Transaction";
|
||||
strings_["confirm_and_send"] = "Confirm & Send";
|
||||
strings_["cancel"] = "Cancel";
|
||||
|
||||
|
||||
// Wallet-switch "stop the running node?" confirmation
|
||||
strings_["switch_stopnode_title"] = "Stop the running node?";
|
||||
strings_["switch_stopnode_warn"] = "A node is already running that this wallet didn't start.";
|
||||
strings_["switch_stopnode_body"] = "Switching wallets restarts the node on the wallet you selected. The running node will be stopped and relaunched on the new wallet — if you kept it running on purpose, it comes back up automatically.";
|
||||
strings_["switch_stopnode_confirm"] = "Stop node & switch";
|
||||
// Wallet-switch live progress modal
|
||||
strings_["switch_progress_title"] = "Switching wallet";
|
||||
strings_["switch_progress_failed_title"] = "Wallet switch failed";
|
||||
strings_["switch_progress_stopping"] = "Stopping the current node";
|
||||
strings_["switch_progress_starting"] = "Starting the node on the new wallet";
|
||||
strings_["switch_progress_reconnecting"] = "Reconnecting";
|
||||
strings_["switch_progress_hint"] = "A graceful shutdown can take up to a minute.";
|
||||
strings_["switch_progress_background"] = "Continue in background";
|
||||
strings_["switch_progress_from_label"] = "from";
|
||||
strings_["switch_progress_elapsed"] = "Elapsed";
|
||||
strings_["switch_progress_default_wallet"] = "Default wallet";
|
||||
strings_["switch_progress_external_wallet"] = "External wallet";
|
||||
strings_["switch_corrupt_body"] = "This wallet appears corrupt — the node couldn't open it. Restore it from a backup, re-create it, or try to repair it.";
|
||||
strings_["switch_corrupt_repair"] = "Try to repair (salvage)";
|
||||
|
||||
// Receive Tab
|
||||
strings_["receiving_addresses"] = "Your Receiving Addresses";
|
||||
strings_["new_z_shielded"] = "New z-Address (Shielded)";
|
||||
@@ -1245,6 +1290,7 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["contact_avatar_remove"] = "Remove";
|
||||
strings_["contact_avatar_image_hint"] = "The image is copied into the app so it stays available if the original moves.";
|
||||
strings_["contact_avatar_copy_failed"] = "Could not copy that image.";
|
||||
strings_["contact_wallet_loading"] = "The wallet is still loading — tick \"Show in every wallet\", or try again in a moment.";
|
||||
strings_["contact_avatar_bad_image"] = "That image couldn't be loaded.";
|
||||
strings_["contact_global_badge_tt"] = "Global contact — visible in every wallet";
|
||||
strings_["address_book_added"] = "Address added to book";
|
||||
@@ -1520,6 +1566,7 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["portfolio_no_addr_match"] = "No addresses match";
|
||||
strings_["portfolio_manage_title"] = "Manage portfolio";
|
||||
strings_["portfolio_add_entry"] = "Add entry";
|
||||
strings_["portfolio_wallet_loading"] = "Wait until the wallet finishes loading to add a group.";
|
||||
strings_["portfolio_new_entry"] = "New entry";
|
||||
strings_["portfolio_label"] = "Label";
|
||||
strings_["portfolio_addresses_sel"] = "%d selected";
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
|
||||
#include <fstream>
|
||||
#include <filesystem>
|
||||
#include <cctype>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <algorithm>
|
||||
#include "../util/logger.h"
|
||||
@@ -31,18 +33,38 @@ static constexpr uint8_t VAULT_VERSION = 0x01;
|
||||
static constexpr unsigned long long ARGON2_MEMLIMIT = crypto_pwhash_MEMLIMIT_MODERATE;
|
||||
static constexpr unsigned long long ARGON2_OPSLIMIT = crypto_pwhash_OPSLIMIT_MODERATE;
|
||||
|
||||
SecureVault::SecureVault() {
|
||||
SecureVault::SecureVault(const std::string& walletFile) {
|
||||
// Ensure libsodium is initialized
|
||||
if (sodium_init() < 0) {
|
||||
// sodium_init returns 0 on success, 1 if already initialized, -1 on failure
|
||||
// We'll proceed anyway — the functions will fail gracefully
|
||||
}
|
||||
setWalletScope(walletFile);
|
||||
}
|
||||
|
||||
SecureVault::~SecureVault() = default;
|
||||
|
||||
std::string SecureVault::getVaultPath() {
|
||||
return (fs::path(Platform::getConfigDir()) / "vault.dat").string();
|
||||
void SecureVault::setWalletScope(const std::string& walletFile) {
|
||||
// The default wallet keeps the legacy global "vault.dat" (empty scope) for backward compatibility;
|
||||
// any other wallet gets a per-wallet tag. A readable sanitized prefix PLUS an 8-hex FNV-1a of the
|
||||
// RAW filename — the hash disambiguates so two distinct files can never share a vault even if their
|
||||
// sanitized forms collide (e.g. "wallet foo.dat" vs "wallet_foo.dat").
|
||||
if (walletFile.empty() || walletFile == "wallet.dat") { scope_.clear(); return; }
|
||||
std::string s;
|
||||
s.reserve(walletFile.size());
|
||||
for (unsigned char c : walletFile)
|
||||
s += (std::isalnum(c) || c == '-' || c == '_') ? static_cast<char>(c) : '_';
|
||||
uint64_t h = 1469598103934665603ULL; // FNV-1a of the raw name
|
||||
for (unsigned char c : walletFile) { h ^= c; h *= 1099511628211ULL; }
|
||||
char suffix[9];
|
||||
std::snprintf(suffix, sizeof(suffix), "%08x", static_cast<unsigned>(h & 0xffffffffu));
|
||||
if (s.size() > 48) s.resize(48);
|
||||
scope_ = s + "-" + suffix;
|
||||
}
|
||||
|
||||
std::string SecureVault::getVaultPath() const {
|
||||
const std::string name = scope_.empty() ? std::string("vault.dat") : ("vault-" + scope_ + ".dat");
|
||||
return (fs::path(Platform::getConfigDir()) / name).string();
|
||||
}
|
||||
|
||||
bool SecureVault::hasVault() const {
|
||||
|
||||
@@ -29,9 +29,15 @@ namespace util {
|
||||
*/
|
||||
class SecureVault {
|
||||
public:
|
||||
SecureVault();
|
||||
// `walletFile` scopes the vault to a specific wallet.dat so one wallet's PIN passphrase is never
|
||||
// offered for another. The default wallet ("wallet.dat" / empty) keeps the legacy global vault.dat
|
||||
// path for backward compatibility; other wallets get their own vault-<scope>.dat.
|
||||
explicit SecureVault(const std::string& walletFile = "");
|
||||
~SecureVault();
|
||||
|
||||
// Re-scope to a different wallet (called when the active wallet changes).
|
||||
void setWalletScope(const std::string& walletFile);
|
||||
|
||||
/**
|
||||
* @brief Check if a vault file exists on disk
|
||||
*/
|
||||
@@ -78,15 +84,17 @@ public:
|
||||
static void secureZero(void* ptr, size_t len);
|
||||
|
||||
/**
|
||||
* @brief Get the vault file path
|
||||
* @brief Get this vault's file path (scoped to its wallet)
|
||||
*/
|
||||
static std::string getVaultPath();
|
||||
std::string getVaultPath() const;
|
||||
|
||||
private:
|
||||
// Derive a 32-byte key from PIN + salt using Argon2id
|
||||
bool deriveKey(const std::string& pin,
|
||||
const uint8_t* salt, size_t saltLen,
|
||||
uint8_t* key, size_t keyLen) const;
|
||||
|
||||
std::string scope_; // "" = default/legacy wallet (vault.dat); else a sanitized wallet-file tag
|
||||
};
|
||||
|
||||
} // namespace util
|
||||
|
||||
@@ -138,10 +138,33 @@ struct WalletBtreeStats {
|
||||
long long createdEpoch = 0; ///< earliest keymeta nCreateTime (wallet birthday); 0 = unknown
|
||||
bool encrypted = false; ///< saw an mkey record
|
||||
bool hdSeed = false; ///< saw hdseed/chdseed/hdchain
|
||||
// BIP39 seed-phrase detection read straight off the hdchain record's fMnemonicSeed flag (which the
|
||||
// daemon keeps PLAINTEXT even in an encrypted wallet — only the seed itself is crypted):
|
||||
// 0 = undecidable, 1 = mnemonic seed-phrase wallet (z_exportmnemonic works), 2 = HD/legacy, no phrase.
|
||||
int mnemonicSeed = 0;
|
||||
std::size_t bytesRead = 0; ///< bytes actually read (for budgeting)
|
||||
int addresses() const { return transparentKeys + shieldedKeys; }
|
||||
};
|
||||
|
||||
// Recover the BIP39-mnemonic flag from a serialized CHDChain record value (the plaintext "hdchain"
|
||||
// record). The daemon serializes CHDChain in Bitcoin/Zcash little-endian byte order as:
|
||||
// nVersion:int32 | seedFp:32B | nCreateTime:int64 | saplingAccountCounter:uint32
|
||||
// | [nVersion>=2 (VERSION_HD_TRANSPARENT)] transparentChildCounter:uint32
|
||||
// | [nVersion>=3 (VERSION_HD_MNEMONIC)] fMnemonicSeed:bool(1B)
|
||||
// so fMnemonicSeed sits at byte offset 52 (= 4+32+8+4+4) once nVersion>=3. Returns 1 = mnemonic,
|
||||
// 2 = HD but not mnemonic (raw-entropy seed, or a pre-mnemonic v1/v2 wallet), 0 = undecidable.
|
||||
inline int hdChainMnemonicFlag(const unsigned char* val, uint32_t len) {
|
||||
if (!val || len < 4) return 0;
|
||||
const uint32_t v = (uint32_t)val[0] | ((uint32_t)val[1] << 8) |
|
||||
((uint32_t)val[2] << 16) | ((uint32_t)val[3] << 24);
|
||||
const int32_t nVersion = (int32_t)v;
|
||||
if (nVersion < 1 || nVersion > 100) return 0; // implausible version → treat as unreadable, not "legacy"
|
||||
if (nVersion < 3) return 2; // VERSION_HD_MNEMONIC not reached → field absent → no phrase
|
||||
constexpr uint32_t kMnemonicOff = 52;
|
||||
if (len <= kMnemonicOff) return 0; // truncated/unexpected value → can't read the flag
|
||||
return val[kMnemonicOff] ? 1 : 2;
|
||||
}
|
||||
|
||||
inline WalletBtreeStats parseWalletBtree(const std::string& path,
|
||||
std::size_t maxBytes = 256u * 1024u * 1024u) {
|
||||
WalletBtreeStats st;
|
||||
@@ -287,7 +310,14 @@ inline WalletBtreeStats parseWalletBtree(const std::string& path,
|
||||
else if (is("name")) st.addressBook++;
|
||||
else if (is("tx")) st.txCount++;
|
||||
else if (is("mkey")) st.encrypted = true;
|
||||
else if (is("hdseed") || is("chdseed") || is("hdchain")) st.hdSeed = true;
|
||||
else if (is("hdseed") || is("chdseed")) st.hdSeed = true;
|
||||
else if (is("hdchain")) {
|
||||
st.hdSeed = true;
|
||||
// The hdchain VALUE carries fMnemonicSeed — read it to tell a BIP39 seed-phrase wallet
|
||||
// apart from a legacy/raw-entropy HD wallet (bare record presence can't). First one wins.
|
||||
if (st.mnemonicSeed == 0 && dtype == B_KEYDATA && dp)
|
||||
st.mnemonicSeed = hdChainMnemonicFlag(dp, dl);
|
||||
}
|
||||
else if (is("keymeta")) {
|
||||
// CKeyMetadata data = nVersion(int32) + nCreateTime(int64) + …, all little-endian (Bitcoin
|
||||
// serialization). The earliest non-zero nCreateTime is the wallet birthday (daemon's
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include "data/transaction_history_cache.h"
|
||||
#include "data/address_book.h"
|
||||
#include "data/wallet_index.h"
|
||||
#include "util/secure_vault.h"
|
||||
#include "daemon/lifecycle_adapters.h"
|
||||
#include "data/wallet_state.h"
|
||||
#include "rpc/connection.h"
|
||||
@@ -827,6 +828,63 @@ void testWalletFileProbe()
|
||||
EXPECT_FALSE(dragonx::util::parseWalletBtree((dir / "junk.dat").string()).parsed);
|
||||
}
|
||||
|
||||
// 8) Mnemonic-flag decode (hdChainMnemonicFlag): the fMnemonicSeed byte lives at offset 52 of the
|
||||
// CHDChain value once nVersion>=3; v1/v2 have no such field (→ "no phrase"), and a short/garbage
|
||||
// value is undecidable (0), never mislabeled "legacy".
|
||||
{
|
||||
using dragonx::util::hdChainMnemonicFlag;
|
||||
auto chdchain = [](int32_t ver, int flag /* -1 = omit the trailing byte */) {
|
||||
std::string v;
|
||||
for (int k = 0; k < 4; ++k) v += (char)(((uint32_t)ver >> (8 * k)) & 0xff); // nVersion, LE
|
||||
v.append(32 + 8 + 4 + 4, '\x07'); // seedFp+times+counters (48B)
|
||||
if (flag >= 0) v += (char)(flag ? 1 : 0); // fMnemonicSeed @ offset 52
|
||||
return v;
|
||||
};
|
||||
auto flagOf = [&](const std::string& v) {
|
||||
return hdChainMnemonicFlag(reinterpret_cast<const unsigned char*>(v.data()), (uint32_t)v.size());
|
||||
};
|
||||
EXPECT_EQ(flagOf(chdchain(3, 1)), 1); // v3, fMnemonicSeed=1 → seed phrase
|
||||
EXPECT_EQ(flagOf(chdchain(3, 0)), 2); // v3, fMnemonicSeed=0 → HD, no phrase
|
||||
EXPECT_EQ(flagOf(chdchain(2, -1)), 2); // v2 (field not serialized) → no phrase
|
||||
EXPECT_EQ(flagOf(chdchain(1, -1)), 2); // v1 → no phrase
|
||||
EXPECT_EQ(flagOf(chdchain(3, -1)), 0); // v3 but value ends before offset 52 → undecidable
|
||||
EXPECT_EQ(flagOf(chdchain(999, 1)), 0); // implausible version → undecidable, NOT legacy
|
||||
EXPECT_EQ(hdChainMnemonicFlag(nullptr, 0), 0);
|
||||
EXPECT_EQ(flagOf(std::string(3, '\0')), 0); // < 4 bytes → undecidable
|
||||
}
|
||||
|
||||
// 9) End-to-end: a minimal btree whose single record is an hdchain with a v3 CHDChain value →
|
||||
// parseWalletBtree surfaces hdSeed AND the decoded mnemonicSeed (1 for a seed-phrase wallet, 2 when
|
||||
// the flag is clear). Exercises the value-reading path inside the real walk, not just the decoder.
|
||||
{
|
||||
auto buildHdchainWallet = [](const std::string& chdValue) {
|
||||
std::string w(1024, '\0');
|
||||
auto put16 = [&](std::size_t o, unsigned v){ w[o]=(char)(v&0xff); w[o+1]=(char)((v>>8)&0xff); };
|
||||
auto put32 = [&](std::size_t o, unsigned v){ for(int k=0;k<4;k++) w[o+k]=(char)((v>>(8*k))&0xff); };
|
||||
put32(12, 0x00053162u); put32(20, 512); w[25]=(char)9; put32(88, 1); // metapage → root = page 1
|
||||
const std::size_t P = 512; // page 1: one (key,data) pair
|
||||
put16(P+20, 2); w[P+24]=(char)1; w[P+25]=(char)5; // entries=2, level 1, P_LBTREE
|
||||
put16(P+26, 490); put16(P+28, 430); // item offsets: key@490, data@430
|
||||
std::string kd; kd += (char)7; kd += "hdchain"; // key BKEYDATA: CompactSize(7)+name
|
||||
put16(P+490, (unsigned)kd.size()); w[P+490+2]=(char)1;
|
||||
for (std::size_t k=0;k<kd.size();++k) w[P+490+3+k]=kd[k];
|
||||
put16(P+430, (unsigned)chdValue.size()); w[P+430+2]=(char)1; // data BKEYDATA: the CHDChain value
|
||||
for (std::size_t k=0;k<chdValue.size();++k) w[P+430+3+k]=chdValue[k];
|
||||
return w;
|
||||
};
|
||||
std::string v3seed; // v3, fMnemonicSeed=1
|
||||
for (int k=0;k<4;k++) v3seed += (char)((3u>>(8*k))&0xff);
|
||||
v3seed.append(48, '\0'); v3seed += (char)1;
|
||||
writef(dir / "hdchain_seed.dat", buildHdchainWallet(v3seed));
|
||||
auto s1 = dragonx::util::parseWalletBtree((dir / "hdchain_seed.dat").string());
|
||||
EXPECT_TRUE(s1.parsed); EXPECT_TRUE(s1.hdSeed); EXPECT_EQ(s1.mnemonicSeed, 1);
|
||||
|
||||
std::string v3nophrase = v3seed; v3nophrase.back() = (char)0; // fMnemonicSeed=0
|
||||
writef(dir / "hdchain_legacy.dat", buildHdchainWallet(v3nophrase));
|
||||
auto s2 = dragonx::util::parseWalletBtree((dir / "hdchain_legacy.dat").string());
|
||||
EXPECT_TRUE(s2.parsed); EXPECT_TRUE(s2.hdSeed); EXPECT_EQ(s2.mnemonicSeed, 2);
|
||||
}
|
||||
|
||||
fs::remove_all(dir, ec);
|
||||
}
|
||||
|
||||
@@ -2139,6 +2197,34 @@ void testOperationStatusPollParsing()
|
||||
EXPECT_TRUE(malformed.staleOpids.empty());
|
||||
}
|
||||
|
||||
void testSecureVaultScope()
|
||||
{
|
||||
using dragonx::util::SecureVault;
|
||||
auto endsWith = [](const std::string& s, const std::string& suf) {
|
||||
return s.size() >= suf.size() && s.compare(s.size() - suf.size(), suf.size(), suf) == 0;
|
||||
};
|
||||
|
||||
// Default wallet (empty or "wallet.dat") keeps the legacy global vault.dat for back-compat.
|
||||
EXPECT_TRUE(endsWith(SecureVault("").getVaultPath(), "vault.dat"));
|
||||
EXPECT_TRUE(endsWith(SecureVault("wallet.dat").getVaultPath(), "vault.dat"));
|
||||
|
||||
// A non-default wallet gets its own, distinct vault (not the legacy path).
|
||||
SecureVault vb("wallet-savings.dat");
|
||||
const std::string pb = vb.getVaultPath();
|
||||
EXPECT_TRUE(pb.find("vault-") != std::string::npos);
|
||||
EXPECT_TRUE(pb != SecureVault("").getVaultPath());
|
||||
EXPECT_TRUE(pb != SecureVault("wallet-cold.dat").getVaultPath());
|
||||
|
||||
// The FNV suffix disambiguates sanitize-colliding names, so distinct files never share a vault.
|
||||
EXPECT_TRUE(SecureVault("wallet foo.dat").getVaultPath() != SecureVault("wallet_foo.dat").getVaultPath());
|
||||
|
||||
// Re-scoping moves the path; scoping back to the default returns to vault.dat.
|
||||
vb.setWalletScope("wallet-cold.dat");
|
||||
EXPECT_TRUE(vb.getVaultPath() == SecureVault("wallet-cold.dat").getVaultPath());
|
||||
vb.setWalletScope("wallet.dat");
|
||||
EXPECT_TRUE(endsWith(vb.getVaultPath(), "vault.dat"));
|
||||
}
|
||||
|
||||
void testWalletSecurityController()
|
||||
{
|
||||
using dragonx::services::WalletSecurityController;
|
||||
@@ -6390,6 +6476,7 @@ int main()
|
||||
testNetworkRefreshRpcCollectors();
|
||||
testNetworkRefreshResultModels();
|
||||
testOperationStatusPollParsing();
|
||||
testSecureVaultScope();
|
||||
testWalletSecurityController();
|
||||
testWalletSecurityWorkflow();
|
||||
testWalletSecurityWorkflowExecutor();
|
||||
|
||||
Reference in New Issue
Block a user