Compare commits
63
Commits
3f1838cfdd
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
743e7f8b65 | ||
|
|
6b93a8ab60 | ||
|
|
19e9b62632 | ||
|
|
076bb3ff06 | ||
|
|
f0be72ac36 | ||
|
|
6e285bd4a4 | ||
|
|
fe3d2aa935 | ||
|
|
6b9bda3ee4 | ||
|
|
84ca1a5cbe | ||
|
|
669b8b7ca5 | ||
|
|
796d639ba3 | ||
|
|
daae693d1b | ||
|
|
2f1b666c20 | ||
|
|
5aa5fa7594 | ||
|
|
f56c94bc91 | ||
|
|
7320a98505 | ||
|
|
529286777f | ||
|
|
a3b4f534d5 | ||
|
|
293b633a24 | ||
|
|
ae4012adb7 | ||
|
|
6c2a277751 | ||
|
|
a558a2a271 | ||
|
|
3ee90993ed | ||
|
|
b404bb2aff | ||
|
|
3a33e9cae9 | ||
|
|
46de4cbfee | ||
|
|
d7fee801d3 | ||
|
|
4f4c6b7a16 | ||
|
|
5563becad2 | ||
|
|
6301cd5984 | ||
|
|
9b6971095c | ||
|
|
8a6d523062 | ||
|
|
16a1ca02e8 | ||
|
|
0cb1df3bfe | ||
|
|
6f369376ec | ||
|
|
02fa1a228a | ||
|
|
099d5df3e1 | ||
|
|
df98f98669 | ||
|
|
6d58802127 | ||
|
|
165675b131 | ||
|
|
08a4427e31 | ||
|
|
f4460beddd | ||
|
|
70d5c233c0 | ||
|
|
c0d10e9664 | ||
|
|
d16bbf3695 | ||
|
|
83ce73f26b | ||
|
|
31e9c2ee2c | ||
|
|
6b0f2eda22 | ||
|
|
dc1617a2c1 | ||
|
|
f213878a7c | ||
|
|
8d8e523a26 | ||
|
|
8b07f776e5 | ||
|
|
8b46ead72a | ||
|
|
8961cbdfc9 | ||
|
|
794150b11b | ||
|
|
2e74a3ca69 | ||
|
|
e94d88e723 | ||
|
|
097b2ea014 | ||
|
|
a43ac0156f | ||
|
|
534120d3de | ||
|
|
a90aa29478 | ||
|
|
a30a083df0 | ||
|
|
89d3975eb3 |
+5
-4
@@ -15,7 +15,7 @@ if(APPLE)
|
|||||||
endif()
|
endif()
|
||||||
|
|
||||||
project(ObsidianDragon
|
project(ObsidianDragon
|
||||||
VERSION 2.0.2
|
VERSION 2.1.0
|
||||||
LANGUAGES C CXX
|
LANGUAGES C CXX
|
||||||
DESCRIPTION "DragonX Cryptocurrency Wallet"
|
DESCRIPTION "DragonX Cryptocurrency Wallet"
|
||||||
)
|
)
|
||||||
@@ -26,7 +26,7 @@ set(DRAGONX_VERSION_SUFFIX "")
|
|||||||
# ObsidianDragonLite is versioned INDEPENDENTLY of the full-node app above. The active variant's
|
# ObsidianDragonLite is versioned INDEPENDENTLY of the full-node app above. The active variant's
|
||||||
# version flows to the generated header, the Windows .rc/manifest, and build.sh's release names via
|
# version flows to the generated header, the Windows .rc/manifest, and build.sh's release names via
|
||||||
# DRAGONX_APP_VERSION* (resolved in the lite/full block below).
|
# DRAGONX_APP_VERSION* (resolved in the lite/full block below).
|
||||||
set(DRAGONX_LITE_VERSION "1.1.1")
|
set(DRAGONX_LITE_VERSION "2.0.0")
|
||||||
set(DRAGONX_LITE_VERSION_SUFFIX "")
|
set(DRAGONX_LITE_VERSION_SUFFIX "")
|
||||||
|
|
||||||
# C++17 standard
|
# C++17 standard
|
||||||
@@ -499,6 +499,7 @@ set(APP_SOURCES
|
|||||||
src/chat/chat_service.cpp
|
src/chat/chat_service.cpp
|
||||||
src/chat/chat_database.cpp
|
src/chat/chat_database.cpp
|
||||||
src/chat/chat_outgoing.cpp
|
src/chat/chat_outgoing.cpp
|
||||||
|
src/chat/chat_sensitive_vault.cpp
|
||||||
src/wallet/lite_owned_string.cpp
|
src/wallet/lite_owned_string.cpp
|
||||||
src/wallet/lite_rollout_policy.cpp
|
src/wallet/lite_rollout_policy.cpp
|
||||||
src/wallet/lite_client_bridge.cpp
|
src/wallet/lite_client_bridge.cpp
|
||||||
@@ -553,7 +554,6 @@ set(APP_SOURCES
|
|||||||
src/ui/windows/console_selection_controller.cpp
|
src/ui/windows/console_selection_controller.cpp
|
||||||
src/ui/windows/console_tab_helpers.cpp
|
src/ui/windows/console_tab_helpers.cpp
|
||||||
src/ui/windows/console_text_layout.cpp
|
src/ui/windows/console_text_layout.cpp
|
||||||
src/ui/windows/settings_window.cpp
|
|
||||||
src/ui/pages/settings_page.cpp
|
src/ui/pages/settings_page.cpp
|
||||||
src/ui/windows/about_dialog.cpp
|
src/ui/windows/about_dialog.cpp
|
||||||
src/ui/windows/faq_dialog.cpp
|
src/ui/windows/faq_dialog.cpp
|
||||||
@@ -670,6 +670,7 @@ set(APP_HEADERS
|
|||||||
src/chat/chat_service.h
|
src/chat/chat_service.h
|
||||||
src/chat/chat_database.h
|
src/chat/chat_database.h
|
||||||
src/chat/chat_outgoing.h
|
src/chat/chat_outgoing.h
|
||||||
|
src/chat/chat_sensitive_vault.h
|
||||||
src/config/version.h
|
src/config/version.h
|
||||||
src/data/wallet_state.h
|
src/data/wallet_state.h
|
||||||
src/data/transaction_history_cache.h
|
src/data/transaction_history_cache.h
|
||||||
@@ -704,7 +705,6 @@ set(APP_HEADERS
|
|||||||
src/ui/windows/console_selection_controller.h
|
src/ui/windows/console_selection_controller.h
|
||||||
src/ui/windows/console_tab.h
|
src/ui/windows/console_tab.h
|
||||||
src/ui/windows/console_tab_helpers.h
|
src/ui/windows/console_tab_helpers.h
|
||||||
src/ui/windows/settings_window.h
|
|
||||||
src/ui/windows/about_dialog.h
|
src/ui/windows/about_dialog.h
|
||||||
src/ui/windows/faq_dialog.h
|
src/ui/windows/faq_dialog.h
|
||||||
src/ui/windows/faq_content.h
|
src/ui/windows/faq_content.h
|
||||||
@@ -1226,6 +1226,7 @@ if(BUILD_TESTING)
|
|||||||
src/chat/chat_service.cpp
|
src/chat/chat_service.cpp
|
||||||
src/chat/chat_database.cpp
|
src/chat/chat_database.cpp
|
||||||
src/chat/chat_outgoing.cpp
|
src/chat/chat_outgoing.cpp
|
||||||
|
src/chat/chat_sensitive_vault.cpp
|
||||||
src/wallet/lite_owned_string.cpp
|
src/wallet/lite_owned_string.cpp
|
||||||
src/wallet/lite_rollout_policy.cpp
|
src/wallet/lite_rollout_policy.cpp
|
||||||
src/wallet/lite_client_bridge.cpp
|
src/wallet/lite_client_bridge.cpp
|
||||||
|
|||||||
Binary file not shown.
@@ -276,8 +276,12 @@
|
|||||||
"chat_delete_body_prefix": "Ihre lokale Kopie der Unterhaltung mit ",
|
"chat_delete_body_prefix": "Ihre lokale Kopie der Unterhaltung mit ",
|
||||||
"chat_delete_body_suffix": " löschen?",
|
"chat_delete_body_suffix": " löschen?",
|
||||||
"chat_delete_confirm": "Löschen",
|
"chat_delete_confirm": "Löschen",
|
||||||
|
"chat_delete_everywhere": "Überall löschen",
|
||||||
|
"chat_delete_everywhere_note": "Weist auch Ihre anderen Geräte – und eine künftige Wiederherstellung aus Ihrer Seed-Phrase – an, diese Unterhaltung auszublenden. Dabei wird ein kleines On-Chain-Signal gesendet (kostet die übliche Chat-Gebühr). Sie können sie später über „Wiederherstellen“ zurückholen.",
|
||||||
|
"chat_delete_everywhere_toast": "Wird überall gelöscht – Signal gesendet",
|
||||||
"chat_delete_failed": "Die Unterhaltung konnte nicht gelöscht werden — es wurde nichts geändert.",
|
"chat_delete_failed": "Die Unterhaltung konnte nicht gelöscht werden — es wurde nichts geändert.",
|
||||||
"chat_delete_local_note": "Dies betrifft nur dieses Gerät — die Nachrichten bleiben auf der Blockchain, und die andere Person behält ihre Kopie.",
|
"chat_delete_local_note": "Dies betrifft nur dieses Gerät — die Nachrichten bleiben auf der Blockchain, und die andere Person behält ihre Kopie.",
|
||||||
|
"chat_delete_needs_sync": "Warten Sie, bis die Wallet vollständig synchronisiert ist, um überall löschen zu können.",
|
||||||
"chat_delete_revive_note": "„Löschen“ entfernt den Verlauf auf diesem Gerät. Wenn Ihnen die Person erneut schreibt, kommt die Unterhaltung zurück.",
|
"chat_delete_revive_note": "„Löschen“ entfernt den Verlauf auf diesem Gerät. Wenn Ihnen die Person erneut schreibt, kommt die Unterhaltung zurück.",
|
||||||
"chat_delete_title": "Unterhaltung löschen?",
|
"chat_delete_title": "Unterhaltung löschen?",
|
||||||
"chat_deleted_toast": "Unterhaltung gelöscht",
|
"chat_deleted_toast": "Unterhaltung gelöscht",
|
||||||
@@ -323,6 +327,16 @@
|
|||||||
"chat_rename": "Kontakt umbenennen",
|
"chat_rename": "Kontakt umbenennen",
|
||||||
"chat_rename_hint": "Kontaktname",
|
"chat_rename_hint": "Kontaktname",
|
||||||
"chat_renamed": "Kontakt umbenannt",
|
"chat_renamed": "Kontakt umbenannt",
|
||||||
|
"chat_restore_btn": "Wiederherstellen",
|
||||||
|
"chat_restore_desc": "Unterhaltungen, die Sie überall gelöscht haben, können Sie hier zurückholen – auf diesem Gerät oder nach dem Importieren Ihrer Seed-Phrase. Beim Wiederherstellen werden die Nachrichten erneut aus der Chain importiert, und Ihre anderen Geräte werden angewiesen, sie wieder anzuzeigen.",
|
||||||
|
"chat_restore_manage": "Wiederherstellen",
|
||||||
|
"chat_restore_none": "Noch keine gelöschten Unterhaltungen gefunden. Wenn Sie eine auf einem anderen Gerät gelöscht haben, versuchen Sie den Schnellscan oder fügen Sie unten ihre Chat-Adresse ein.",
|
||||||
|
"chat_restore_paste_hint": "Chat-(z-)Adresse der Unterhaltung einfügen",
|
||||||
|
"chat_restore_paste_label": "Über Chat-Adresse wiederherstellen",
|
||||||
|
"chat_restore_scan": "Schnellscan",
|
||||||
|
"chat_restore_scanning": "Die Chain wird nach gelöschten Unterhaltungen gescannt...",
|
||||||
|
"chat_restore_title": "Unterhaltungen wiederherstellen",
|
||||||
|
"chat_restore_toast": "Unterhaltung wird wiederhergestellt – sie erscheint nach dem nächsten Scan wieder",
|
||||||
"chat_retry": "Wiederholen",
|
"chat_retry": "Wiederholen",
|
||||||
"chat_search": "Unterhaltungen durchsuchen",
|
"chat_search": "Unterhaltungen durchsuchen",
|
||||||
"chat_sec_appearance": "DARSTELLUNG",
|
"chat_sec_appearance": "DARSTELLUNG",
|
||||||
@@ -331,6 +345,35 @@
|
|||||||
"chat_send": "Senden",
|
"chat_send": "Senden",
|
||||||
"chat_send_failed": "nicht gesendet",
|
"chat_send_failed": "nicht gesendet",
|
||||||
"chat_sending": "senden…",
|
"chat_sending": "senden…",
|
||||||
|
"chat_sensitive_btn_mark": "Vertraulich machen",
|
||||||
|
"chat_sensitive_btn_setup": "Einrichten & vertraulich machen",
|
||||||
|
"chat_sensitive_btn_unlock": "Entsperren",
|
||||||
|
"chat_sensitive_btn_unlock_mark": "Entsperren & vertraulich machen",
|
||||||
|
"chat_sensitive_btn_unlock_unmark": "Entsperren & normal machen",
|
||||||
|
"chat_sensitive_btn_unmark": "Normal machen",
|
||||||
|
"chat_sensitive_err_create": "Der Tresor für vertrauliche Chats konnte nicht eingerichtet werden.",
|
||||||
|
"chat_sensitive_err_pass": "Die Passphrasen stimmen nicht überein oder sind leer.",
|
||||||
|
"chat_sensitive_err_unlock": "Falsche Passphrase.",
|
||||||
|
"chat_sensitive_lock_btn": "Vertrauliche sperren",
|
||||||
|
"chat_sensitive_mark_tip": "Vertraulich machen",
|
||||||
|
"chat_sensitive_mark_title": "Diese Unterhaltung vertraulich machen?",
|
||||||
|
"chat_sensitive_pass2_hint": "Passphrase bestätigen",
|
||||||
|
"chat_sensitive_pass_hint": "Passphrase",
|
||||||
|
"chat_sensitive_setup_label": "Erstellen Sie eine Passphrase für vertrauliche Unterhaltungen",
|
||||||
|
"chat_sensitive_toast_marked": "Unterhaltung ist jetzt vertraulich",
|
||||||
|
"chat_sensitive_toast_unlocked": "Vertrauliche Unterhaltungen entsperrt",
|
||||||
|
"chat_sensitive_toast_unmarked": "Unterhaltung ist nicht mehr vertraulich",
|
||||||
|
"chat_sensitive_unlock_body": "Geben Sie Ihre Passphrase für vertrauliche Chats ein, um Ihre vertraulichen Unterhaltungen wieder anzuzeigen.",
|
||||||
|
"chat_sensitive_unlock_btn": "Vertrauliche entsperren",
|
||||||
|
"chat_sensitive_unlock_label": "Geben Sie Ihre Passphrase für vertrauliche Chats ein",
|
||||||
|
"chat_sensitive_unlock_title": "Vertrauliche Unterhaltungen entsperren",
|
||||||
|
"chat_sensitive_unmark_body": "Diese Unterhaltung wird nicht mehr unter Ihrer Passphrase für vertrauliche Chats verschlüsselt. Neue Nachrichten werden wie bei jeder anderen Unterhaltung gespeichert.",
|
||||||
|
"chat_sensitive_unmark_tip": "Vertraulich – zum Ändern klicken",
|
||||||
|
"chat_sensitive_unmark_title": "Diese Unterhaltung normal machen?",
|
||||||
|
"chat_sensitive_warn_1": "Die Nachrichten darin werden auf diesem Gerät unter einer separaten Passphrase verschlüsselt – nicht unter Ihrer Seed-Phrase. Wer Ihre Wallet später allein aus Ihrem Seed wiederherstellt, sieht diese Unterhaltung nicht.",
|
||||||
|
"chat_sensitive_warn_dir": "Ihre hier gesendeten Nachrichten sind vollständig geschützt. Empfangene Nachrichten liegen weiterhin auf der Blockchain und könnten theoretisch von jemandem wiederhergestellt werden, der Ihren Seed und weitere Software besitzt – DragonX selbst zeigt sie ohne Ihre Passphrase niemals an.",
|
||||||
|
"chat_sensitive_warn_hist": "Nachrichten, die bereits in dieser Unterhaltung vorhanden sind, bleiben über die Chain wiederherstellbar; ab jetzt sind Nachrichten vollständig geschützt.",
|
||||||
|
"chat_sensitive_warn_lose": "Wenn Sie diese Passphrase verlieren, sind diese Nachrichten für immer verloren – sie können nicht aus Ihrem Seed wiederhergestellt werden.",
|
||||||
"chat_settings_done": "Fertig",
|
"chat_settings_done": "Fertig",
|
||||||
"chat_settings_section": "CHAT & KONTAKTE",
|
"chat_settings_section": "CHAT & KONTAKTE",
|
||||||
"chat_settings_tip": "Chat anpassen",
|
"chat_settings_tip": "Chat anpassen",
|
||||||
|
|||||||
@@ -276,8 +276,12 @@
|
|||||||
"chat_delete_body_prefix": "¿Eliminar tu copia local de la conversación con ",
|
"chat_delete_body_prefix": "¿Eliminar tu copia local de la conversación con ",
|
||||||
"chat_delete_body_suffix": "?",
|
"chat_delete_body_suffix": "?",
|
||||||
"chat_delete_confirm": "Eliminar",
|
"chat_delete_confirm": "Eliminar",
|
||||||
|
"chat_delete_everywhere": "Eliminar en todas partes",
|
||||||
|
"chat_delete_everywhere_note": "También indica a tus otros dispositivos —y a una futura restauración desde tu frase de recuperación— que oculten esta conversación. Envía una pequeña señal en la cadena (cuesta la comisión habitual del chat). Puedes recuperarla más tarde desde Restaurar.",
|
||||||
|
"chat_delete_everywhere_toast": "Eliminando en todas partes: señal enviada",
|
||||||
"chat_delete_failed": "No se pudo eliminar la conversación — no se cambió nada.",
|
"chat_delete_failed": "No se pudo eliminar la conversación — no se cambió nada.",
|
||||||
"chat_delete_local_note": "Esto solo afecta a este dispositivo — los mensajes permanecen en la cadena de bloques y la otra persona conserva su copia.",
|
"chat_delete_local_note": "Esto solo afecta a este dispositivo — los mensajes permanecen en la cadena de bloques y la otra persona conserva su copia.",
|
||||||
|
"chat_delete_needs_sync": "Espera a que la cartera se sincronice por completo para eliminar en todas partes.",
|
||||||
"chat_delete_revive_note": "«Eliminar» borra el historial en este dispositivo. Si esa persona vuelve a escribirte, la conversación reaparece.",
|
"chat_delete_revive_note": "«Eliminar» borra el historial en este dispositivo. Si esa persona vuelve a escribirte, la conversación reaparece.",
|
||||||
"chat_delete_title": "¿Eliminar conversación?",
|
"chat_delete_title": "¿Eliminar conversación?",
|
||||||
"chat_deleted_toast": "Conversación eliminada",
|
"chat_deleted_toast": "Conversación eliminada",
|
||||||
@@ -323,6 +327,16 @@
|
|||||||
"chat_rename": "Renombrar contacto",
|
"chat_rename": "Renombrar contacto",
|
||||||
"chat_rename_hint": "Nombre del contacto",
|
"chat_rename_hint": "Nombre del contacto",
|
||||||
"chat_renamed": "Contacto renombrado",
|
"chat_renamed": "Contacto renombrado",
|
||||||
|
"chat_restore_btn": "Restaurar",
|
||||||
|
"chat_restore_desc": "Las conversaciones que eliminaste en todas partes se pueden recuperar aquí, en este dispositivo o tras importar tu frase de recuperación. Restaurar reimporta los mensajes desde la cadena e indica a tus otros dispositivos que la vuelvan a mostrar.",
|
||||||
|
"chat_restore_manage": "Restaurar",
|
||||||
|
"chat_restore_none": "Aún no se han encontrado conversaciones eliminadas. Si eliminaste una en otro dispositivo, prueba con Escaneo rápido o pega su dirección de chat más abajo.",
|
||||||
|
"chat_restore_paste_hint": "Pega la dirección de chat (z) de la conversación",
|
||||||
|
"chat_restore_paste_label": "Restaurar por dirección de chat",
|
||||||
|
"chat_restore_scan": "Escaneo rápido",
|
||||||
|
"chat_restore_scanning": "Escaneando la cadena en busca de conversaciones eliminadas…",
|
||||||
|
"chat_restore_title": "Restaurar conversaciones",
|
||||||
|
"chat_restore_toast": "Restaurando conversación: reaparece tras el próximo escaneo",
|
||||||
"chat_retry": "Reintentar",
|
"chat_retry": "Reintentar",
|
||||||
"chat_search": "Buscar conversaciones",
|
"chat_search": "Buscar conversaciones",
|
||||||
"chat_sec_appearance": "APARIENCIA",
|
"chat_sec_appearance": "APARIENCIA",
|
||||||
@@ -331,6 +345,35 @@
|
|||||||
"chat_send": "Enviar",
|
"chat_send": "Enviar",
|
||||||
"chat_send_failed": "no enviado",
|
"chat_send_failed": "no enviado",
|
||||||
"chat_sending": "enviando…",
|
"chat_sending": "enviando…",
|
||||||
|
"chat_sensitive_btn_mark": "Marcar como confidencial",
|
||||||
|
"chat_sensitive_btn_setup": "Configurar y marcar como confidencial",
|
||||||
|
"chat_sensitive_btn_unlock": "Desbloquear",
|
||||||
|
"chat_sensitive_btn_unlock_mark": "Desbloquear y marcar como confidencial",
|
||||||
|
"chat_sensitive_btn_unlock_unmark": "Desbloquear y hacer normal",
|
||||||
|
"chat_sensitive_btn_unmark": "Hacer normal",
|
||||||
|
"chat_sensitive_err_create": "No se pudo crear la bóveda de chat confidencial.",
|
||||||
|
"chat_sensitive_err_pass": "Las contraseñas no coinciden o están vacías.",
|
||||||
|
"chat_sensitive_err_unlock": "Contraseña incorrecta.",
|
||||||
|
"chat_sensitive_lock_btn": "Bloquear confidenciales",
|
||||||
|
"chat_sensitive_mark_tip": "Marcar como confidencial",
|
||||||
|
"chat_sensitive_mark_title": "¿Marcar esta conversación como confidencial?",
|
||||||
|
"chat_sensitive_pass2_hint": "Confirmar contraseña",
|
||||||
|
"chat_sensitive_pass_hint": "Contraseña",
|
||||||
|
"chat_sensitive_setup_label": "Crea una contraseña para las conversaciones confidenciales",
|
||||||
|
"chat_sensitive_toast_marked": "La conversación ahora es confidencial",
|
||||||
|
"chat_sensitive_toast_unlocked": "Conversaciones confidenciales desbloqueadas",
|
||||||
|
"chat_sensitive_toast_unmarked": "La conversación ya no es confidencial",
|
||||||
|
"chat_sensitive_unlock_body": "Introduce tu contraseña de chat confidencial para volver a mostrar tus conversaciones confidenciales.",
|
||||||
|
"chat_sensitive_unlock_btn": "Desbloquear confidenciales",
|
||||||
|
"chat_sensitive_unlock_label": "Introduce tu contraseña de chat confidencial",
|
||||||
|
"chat_sensitive_unlock_title": "Desbloquear las conversaciones confidenciales",
|
||||||
|
"chat_sensitive_unmark_body": "Esta conversación dejará de estar cifrada con tu contraseña de chat confidencial. Los mensajes nuevos se guardarán como en cualquier otra conversación.",
|
||||||
|
"chat_sensitive_unmark_tip": "Confidencial: clic para cambiar",
|
||||||
|
"chat_sensitive_unmark_title": "¿Hacer normal esta conversación?",
|
||||||
|
"chat_sensitive_warn_1": "Los mensajes de esta conversación se cifrarán en este dispositivo con una contraseña aparte, no con tu frase de recuperación. Quien más adelante restaure tu cartera solo con tu frase de recuperación no verá esta conversación.",
|
||||||
|
"chat_sensitive_warn_dir": "Tus mensajes enviados aquí quedan totalmente protegidos. Los mensajes recibidos siguen en la cadena de bloques y, en teoría, alguien que tenga tu frase de recuperación y otro software podría recuperarlos; DragonX nunca los mostrará sin tu contraseña.",
|
||||||
|
"chat_sensitive_warn_hist": "Los mensajes que ya están en esta conversación siguen siendo recuperables desde la cadena; esto protege por completo los mensajes a partir de ahora.",
|
||||||
|
"chat_sensitive_warn_lose": "Si pierdes esta contraseña, estos mensajes se perderán para siempre: no se pueden recuperar con tu frase de recuperación.",
|
||||||
"chat_settings_done": "Listo",
|
"chat_settings_done": "Listo",
|
||||||
"chat_settings_section": "CHAT Y CONTACTOS",
|
"chat_settings_section": "CHAT Y CONTACTOS",
|
||||||
"chat_settings_tip": "Personalizar chat",
|
"chat_settings_tip": "Personalizar chat",
|
||||||
|
|||||||
@@ -276,8 +276,12 @@
|
|||||||
"chat_delete_body_prefix": "Supprimer votre copie locale de la conversation avec ",
|
"chat_delete_body_prefix": "Supprimer votre copie locale de la conversation avec ",
|
||||||
"chat_delete_body_suffix": " ?",
|
"chat_delete_body_suffix": " ?",
|
||||||
"chat_delete_confirm": "Supprimer",
|
"chat_delete_confirm": "Supprimer",
|
||||||
|
"chat_delete_everywhere": "Supprimer partout",
|
||||||
|
"chat_delete_everywhere_note": "Indique aussi à vos autres appareils — et à une future restauration depuis votre phrase de récupération — de masquer cette conversation. Un petit signal est envoyé sur la chaîne (il coûte les frais de chat habituels). Vous pourrez la restaurer plus tard depuis Restaurer.",
|
||||||
|
"chat_delete_everywhere_toast": "Suppression partout — signal envoyé",
|
||||||
"chat_delete_failed": "Impossible de supprimer la conversation — rien n'a été modifié.",
|
"chat_delete_failed": "Impossible de supprimer la conversation — rien n'a été modifié.",
|
||||||
"chat_delete_local_note": "Cela n'affecte que cet appareil — les messages restent sur la blockchain et l'autre personne conserve sa copie.",
|
"chat_delete_local_note": "Cela n'affecte que cet appareil — les messages restent sur la blockchain et l'autre personne conserve sa copie.",
|
||||||
|
"chat_delete_needs_sync": "Attendez que le portefeuille soit entièrement synchronisé pour supprimer partout.",
|
||||||
"chat_delete_revive_note": "« Supprimer » efface l'historique sur cet appareil. Si cette personne vous écrit à nouveau, la conversation revient.",
|
"chat_delete_revive_note": "« Supprimer » efface l'historique sur cet appareil. Si cette personne vous écrit à nouveau, la conversation revient.",
|
||||||
"chat_delete_title": "Supprimer la conversation ?",
|
"chat_delete_title": "Supprimer la conversation ?",
|
||||||
"chat_deleted_toast": "Conversation supprimée",
|
"chat_deleted_toast": "Conversation supprimée",
|
||||||
@@ -323,6 +327,16 @@
|
|||||||
"chat_rename": "Renommer le contact",
|
"chat_rename": "Renommer le contact",
|
||||||
"chat_rename_hint": "Nom du contact",
|
"chat_rename_hint": "Nom du contact",
|
||||||
"chat_renamed": "Contact renommé",
|
"chat_renamed": "Contact renommé",
|
||||||
|
"chat_restore_btn": "Restaurer",
|
||||||
|
"chat_restore_desc": "Les conversations que vous avez supprimées partout peuvent être rétablies ici — sur cet appareil ou après avoir importé votre phrase de récupération. La restauration réimporte les messages depuis la chaîne et indique à vos autres appareils de l'afficher à nouveau.",
|
||||||
|
"chat_restore_manage": "Restaurer",
|
||||||
|
"chat_restore_none": "Aucune conversation supprimée trouvée pour l'instant. Si vous en avez supprimé une sur un autre appareil, essayez l'Analyse rapide ou collez son adresse de chat ci-dessous.",
|
||||||
|
"chat_restore_paste_hint": "Collez l'adresse de chat (z) de la conversation",
|
||||||
|
"chat_restore_paste_label": "Restaurer par adresse de chat",
|
||||||
|
"chat_restore_scan": "Analyse rapide",
|
||||||
|
"chat_restore_scanning": "Analyse de la chaîne à la recherche de conversations supprimées...",
|
||||||
|
"chat_restore_title": "Restaurer des conversations",
|
||||||
|
"chat_restore_toast": "Restauration de la conversation — elle réapparaît après la prochaine analyse",
|
||||||
"chat_retry": "Réessayer",
|
"chat_retry": "Réessayer",
|
||||||
"chat_search": "Rechercher des conversations",
|
"chat_search": "Rechercher des conversations",
|
||||||
"chat_sec_appearance": "APPARENCE",
|
"chat_sec_appearance": "APPARENCE",
|
||||||
@@ -331,6 +345,35 @@
|
|||||||
"chat_send": "Envoyer",
|
"chat_send": "Envoyer",
|
||||||
"chat_send_failed": "non envoyé",
|
"chat_send_failed": "non envoyé",
|
||||||
"chat_sending": "envoi…",
|
"chat_sending": "envoi…",
|
||||||
|
"chat_sensitive_btn_mark": "Rendre confidentielle",
|
||||||
|
"chat_sensitive_btn_setup": "Configurer et rendre confidentielle",
|
||||||
|
"chat_sensitive_btn_unlock": "Déverrouiller",
|
||||||
|
"chat_sensitive_btn_unlock_mark": "Déverrouiller et rendre confidentielle",
|
||||||
|
"chat_sensitive_btn_unlock_unmark": "Déverrouiller et rendre normale",
|
||||||
|
"chat_sensitive_btn_unmark": "Rendre normale",
|
||||||
|
"chat_sensitive_err_create": "Impossible de créer le coffre des conversations confidentielles.",
|
||||||
|
"chat_sensitive_err_pass": "Les phrases secrètes ne correspondent pas, ou sont vides.",
|
||||||
|
"chat_sensitive_err_unlock": "Phrase secrète incorrecte.",
|
||||||
|
"chat_sensitive_lock_btn": "Verrouiller les confidentielles",
|
||||||
|
"chat_sensitive_mark_tip": "Rendre confidentielle",
|
||||||
|
"chat_sensitive_mark_title": "Rendre cette conversation confidentielle ?",
|
||||||
|
"chat_sensitive_pass2_hint": "Confirmez la phrase secrète",
|
||||||
|
"chat_sensitive_pass_hint": "Phrase secrète",
|
||||||
|
"chat_sensitive_setup_label": "Créez une phrase secrète pour les conversations confidentielles",
|
||||||
|
"chat_sensitive_toast_marked": "La conversation est désormais confidentielle",
|
||||||
|
"chat_sensitive_toast_unlocked": "Conversations confidentielles déverrouillées",
|
||||||
|
"chat_sensitive_toast_unmarked": "La conversation n'est plus confidentielle",
|
||||||
|
"chat_sensitive_unlock_body": "Saisissez votre phrase secrète des conversations confidentielles pour afficher à nouveau vos conversations confidentielles.",
|
||||||
|
"chat_sensitive_unlock_btn": "Déverrouiller les confidentielles",
|
||||||
|
"chat_sensitive_unlock_label": "Saisissez votre phrase secrète des conversations confidentielles",
|
||||||
|
"chat_sensitive_unlock_title": "Déverrouiller les conversations confidentielles",
|
||||||
|
"chat_sensitive_unmark_body": "Cette conversation ne sera plus chiffrée avec votre phrase secrète des conversations confidentielles. Les nouveaux messages seront stockés comme ceux de n'importe quelle autre conversation.",
|
||||||
|
"chat_sensitive_unmark_tip": "Confidentielle — cliquez pour modifier",
|
||||||
|
"chat_sensitive_unmark_title": "Rendre cette conversation normale ?",
|
||||||
|
"chat_sensitive_warn_1": "Ses messages seront chiffrés sur cet appareil avec une phrase secrète distincte — et non avec votre phrase de récupération. Quiconque restaurera plus tard votre portefeuille à partir de votre seule phrase de récupération ne verra pas cette conversation.",
|
||||||
|
"chat_sensitive_warn_dir": "Vos messages envoyés ici sont entièrement protégés. Les messages reçus restent sur la blockchain et pourraient, en théorie, être récupérés par une personne disposant de votre phrase de récupération et d'un autre logiciel — mais DragonX lui-même ne les affichera jamais sans votre phrase secrète.",
|
||||||
|
"chat_sensitive_warn_hist": "Les messages déjà présents dans cette conversation restent récupérables depuis la chaîne ; la protection s'applique pleinement aux messages à venir.",
|
||||||
|
"chat_sensitive_warn_lose": "Si vous perdez cette phrase secrète, ces messages sont perdus à jamais — ils ne pourront pas être récupérés à partir de votre phrase de récupération.",
|
||||||
"chat_settings_done": "Terminé",
|
"chat_settings_done": "Terminé",
|
||||||
"chat_settings_section": "CHAT ET CONTACTS",
|
"chat_settings_section": "CHAT ET CONTACTS",
|
||||||
"chat_settings_tip": "Personnaliser le chat",
|
"chat_settings_tip": "Personnaliser le chat",
|
||||||
|
|||||||
@@ -276,8 +276,12 @@
|
|||||||
"chat_delete_body_prefix": "次の相手との会話のローカルコピーを削除しますか:",
|
"chat_delete_body_prefix": "次の相手との会話のローカルコピーを削除しますか:",
|
||||||
"chat_delete_body_suffix": "?",
|
"chat_delete_body_suffix": "?",
|
||||||
"chat_delete_confirm": "削除",
|
"chat_delete_confirm": "削除",
|
||||||
|
"chat_delete_everywhere": "すべての端末で削除",
|
||||||
|
"chat_delete_everywhere_note": "他の端末にも、そして将来シードフレーズから復元した際にも、この会話を非表示にするよう伝えます。チェーン上に小さな信号を送信します(通常のチャット手数料がかかります)。後で「復元」から元に戻せます。",
|
||||||
|
"chat_delete_everywhere_toast": "すべての端末で削除中 — 信号を送信しました",
|
||||||
"chat_delete_failed": "会話を削除できませんでした — 変更はありません。",
|
"chat_delete_failed": "会話を削除できませんでした — 変更はありません。",
|
||||||
"chat_delete_local_note": "これはこの端末のみに影響します — メッセージはブロックチェーン上に残り、相手も自分のコピーを保持し続けます。",
|
"chat_delete_local_note": "これはこの端末のみに影響します — メッセージはブロックチェーン上に残り、相手も自分のコピーを保持し続けます。",
|
||||||
|
"chat_delete_needs_sync": "すべての端末で削除するには、ウォレットの同期が完了するまでお待ちください。",
|
||||||
"chat_delete_revive_note": "「削除」はこの端末上の履歴を消去します。相手から再度メッセージが届くと、会話は復活します。",
|
"chat_delete_revive_note": "「削除」はこの端末上の履歴を消去します。相手から再度メッセージが届くと、会話は復活します。",
|
||||||
"chat_delete_title": "会話を削除しますか?",
|
"chat_delete_title": "会話を削除しますか?",
|
||||||
"chat_deleted_toast": "会話を削除しました",
|
"chat_deleted_toast": "会話を削除しました",
|
||||||
@@ -323,6 +327,16 @@
|
|||||||
"chat_rename": "連絡先の名前を変更",
|
"chat_rename": "連絡先の名前を変更",
|
||||||
"chat_rename_hint": "連絡先名",
|
"chat_rename_hint": "連絡先名",
|
||||||
"chat_renamed": "連絡先の名前を変更しました",
|
"chat_renamed": "連絡先の名前を変更しました",
|
||||||
|
"chat_restore_btn": "復元",
|
||||||
|
"chat_restore_desc": "すべての端末で削除した会話は、ここから元に戻せます — この端末でも、シードフレーズをインポートした後でも可能です。復元すると、チェーンからメッセージが再インポートされ、他の端末にも再表示するよう伝えます。",
|
||||||
|
"chat_restore_manage": "復元",
|
||||||
|
"chat_restore_none": "削除された会話はまだ見つかりません。別の端末で削除した場合は、「クイックスキャン」を試すか、下にチャットアドレスを貼り付けてください。",
|
||||||
|
"chat_restore_paste_hint": "会話のチャット(z)アドレスを貼り付け",
|
||||||
|
"chat_restore_paste_label": "チャットアドレスで復元",
|
||||||
|
"chat_restore_scan": "クイックスキャン",
|
||||||
|
"chat_restore_scanning": "削除された会話をチェーンでスキャン中…",
|
||||||
|
"chat_restore_title": "会話を復元",
|
||||||
|
"chat_restore_toast": "会話を復元中 — 次回のスキャン後に再表示されます",
|
||||||
"chat_retry": "再送信",
|
"chat_retry": "再送信",
|
||||||
"chat_search": "会話を検索",
|
"chat_search": "会話を検索",
|
||||||
"chat_sec_appearance": "外観",
|
"chat_sec_appearance": "外観",
|
||||||
@@ -331,6 +345,35 @@
|
|||||||
"chat_send": "送信",
|
"chat_send": "送信",
|
||||||
"chat_send_failed": "未送信",
|
"chat_send_failed": "未送信",
|
||||||
"chat_sending": "送信中…",
|
"chat_sending": "送信中…",
|
||||||
|
"chat_sensitive_btn_mark": "機密にする",
|
||||||
|
"chat_sensitive_btn_setup": "設定して機密にする",
|
||||||
|
"chat_sensitive_btn_unlock": "ロック解除",
|
||||||
|
"chat_sensitive_btn_unlock_mark": "ロック解除して機密にする",
|
||||||
|
"chat_sensitive_btn_unlock_unmark": "ロック解除して通常に戻す",
|
||||||
|
"chat_sensitive_btn_unmark": "通常に戻す",
|
||||||
|
"chat_sensitive_err_create": "機密チャット用ボールトの作成に失敗しました。",
|
||||||
|
"chat_sensitive_err_pass": "パスフレーズが一致しないか、空です。",
|
||||||
|
"chat_sensitive_err_unlock": "パスフレーズが正しくありません。",
|
||||||
|
"chat_sensitive_lock_btn": "機密をロック",
|
||||||
|
"chat_sensitive_mark_tip": "機密にする",
|
||||||
|
"chat_sensitive_mark_title": "この会話を機密にしますか?",
|
||||||
|
"chat_sensitive_pass2_hint": "パスフレーズを再入力",
|
||||||
|
"chat_sensitive_pass_hint": "パスフレーズ",
|
||||||
|
"chat_sensitive_setup_label": "機密会話用のパスフレーズを作成",
|
||||||
|
"chat_sensitive_toast_marked": "会話を機密にしました",
|
||||||
|
"chat_sensitive_toast_unlocked": "機密会話のロックを解除しました",
|
||||||
|
"chat_sensitive_toast_unmarked": "会話を通常に戻しました",
|
||||||
|
"chat_sensitive_unlock_body": "機密会話を再び表示するには、機密チャット用パスフレーズを入力してください。",
|
||||||
|
"chat_sensitive_unlock_btn": "機密をロック解除",
|
||||||
|
"chat_sensitive_unlock_label": "機密チャット用パスフレーズを入力",
|
||||||
|
"chat_sensitive_unlock_title": "機密会話のロックを解除",
|
||||||
|
"chat_sensitive_unmark_body": "この会話は、機密チャット用パスフレーズで暗号化されなくなります。新しいメッセージは、他の会話と同じように保存されます。",
|
||||||
|
"chat_sensitive_unmark_tip": "機密 — タップして変更",
|
||||||
|
"chat_sensitive_unmark_title": "この会話を通常に戻しますか?",
|
||||||
|
"chat_sensitive_warn_1": "メッセージはこの端末上で、シードフレーズとは別の専用パスフレーズを使って暗号化されます。後でシードフレーズだけからウォレットを復元した人には、この会話は表示されません。",
|
||||||
|
"chat_sensitive_warn_dir": "ここで送信したメッセージは完全に保護されます。受信したメッセージはブロックチェーン上に残っているため、あなたのシードフレーズと別のソフトウェアを持つ人なら、理論上は復元できる可能性があります。ただしDragonX自体が、パスフレーズなしにそれらを表示することは決してありません。",
|
||||||
|
"chat_sensitive_warn_hist": "この会話に既にあるメッセージは、チェーンから復元可能なままです。この設定は、これ以降のメッセージを完全に保護します。",
|
||||||
|
"chat_sensitive_warn_lose": "このパスフレーズを失うと、これらのメッセージは永久に失われます。シードフレーズからは復元できません。",
|
||||||
"chat_settings_done": "完了",
|
"chat_settings_done": "完了",
|
||||||
"chat_settings_section": "チャットと連絡先",
|
"chat_settings_section": "チャットと連絡先",
|
||||||
"chat_settings_tip": "チャットのカスタマイズ",
|
"chat_settings_tip": "チャットのカスタマイズ",
|
||||||
|
|||||||
@@ -276,8 +276,12 @@
|
|||||||
"chat_delete_body_prefix": "다음 상대와의 대화 로컬 사본을 삭제할까요: ",
|
"chat_delete_body_prefix": "다음 상대와의 대화 로컬 사본을 삭제할까요: ",
|
||||||
"chat_delete_body_suffix": "?",
|
"chat_delete_body_suffix": "?",
|
||||||
"chat_delete_confirm": "삭제",
|
"chat_delete_confirm": "삭제",
|
||||||
|
"chat_delete_everywhere": "모든 기기에서 삭제",
|
||||||
|
"chat_delete_everywhere_note": "다른 기기에도, 그리고 나중에 시드 문구로 복원할 때에도 이 대화를 숨기도록 알립니다. 아주 작은 온체인 신호를 전송하며(평소 채팅 수수료가 듭니다), 나중에 '복원'에서 다시 가져올 수 있습니다.",
|
||||||
|
"chat_delete_everywhere_toast": "모든 기기에서 삭제하는 중 — 신호를 보냈습니다",
|
||||||
"chat_delete_failed": "대화를 삭제하지 못했습니다 — 변경된 내용이 없습니다.",
|
"chat_delete_failed": "대화를 삭제하지 못했습니다 — 변경된 내용이 없습니다.",
|
||||||
"chat_delete_local_note": "이 기기에만 영향을 줍니다 — 메시지는 블록체인에 남아 있고 상대방도 자신의 사본을 계속 보관합니다.",
|
"chat_delete_local_note": "이 기기에만 영향을 줍니다 — 메시지는 블록체인에 남아 있고 상대방도 자신의 사본을 계속 보관합니다.",
|
||||||
|
"chat_delete_needs_sync": "모든 기기에서 삭제하려면 지갑이 완전히 동기화될 때까지 기다리세요.",
|
||||||
"chat_delete_revive_note": "\"삭제\"는 이 기기의 기록만 지웁니다. 상대방이 다시 메시지를 보내면 대화가 되살아납니다.",
|
"chat_delete_revive_note": "\"삭제\"는 이 기기의 기록만 지웁니다. 상대방이 다시 메시지를 보내면 대화가 되살아납니다.",
|
||||||
"chat_delete_title": "대화를 삭제할까요?",
|
"chat_delete_title": "대화를 삭제할까요?",
|
||||||
"chat_deleted_toast": "대화를 삭제했습니다",
|
"chat_deleted_toast": "대화를 삭제했습니다",
|
||||||
@@ -323,6 +327,16 @@
|
|||||||
"chat_rename": "연락처 이름 변경",
|
"chat_rename": "연락처 이름 변경",
|
||||||
"chat_rename_hint": "연락처 이름",
|
"chat_rename_hint": "연락처 이름",
|
||||||
"chat_renamed": "연락처 이름이 변경되었습니다",
|
"chat_renamed": "연락처 이름이 변경되었습니다",
|
||||||
|
"chat_restore_btn": "복원",
|
||||||
|
"chat_restore_desc": "모든 기기에서 삭제한 대화를 여기서 다시 가져올 수 있습니다 — 이 기기에서든, 시드 문구를 가져온 뒤에든 가능합니다. 복원하면 체인에서 메시지를 다시 가져오고, 다른 기기에도 대화를 다시 표시하도록 알립니다.",
|
||||||
|
"chat_restore_manage": "복원",
|
||||||
|
"chat_restore_none": "삭제된 대화를 아직 찾지 못했습니다. 다른 기기에서 삭제한 대화가 있다면 '빠른 스캔'을 해보거나 아래에 채팅 주소를 붙여넣으세요.",
|
||||||
|
"chat_restore_paste_hint": "대화의 채팅 (z) 주소를 붙여넣으세요",
|
||||||
|
"chat_restore_paste_label": "채팅 주소로 복원",
|
||||||
|
"chat_restore_scan": "빠른 스캔",
|
||||||
|
"chat_restore_scanning": "체인에서 삭제된 대화를 스캔하는 중...",
|
||||||
|
"chat_restore_title": "대화 복원",
|
||||||
|
"chat_restore_toast": "대화를 복원하는 중 — 다음 스캔 후에 다시 표시됩니다",
|
||||||
"chat_retry": "다시 시도",
|
"chat_retry": "다시 시도",
|
||||||
"chat_search": "대화 검색",
|
"chat_search": "대화 검색",
|
||||||
"chat_sec_appearance": "모양",
|
"chat_sec_appearance": "모양",
|
||||||
@@ -331,6 +345,35 @@
|
|||||||
"chat_send": "전송",
|
"chat_send": "전송",
|
||||||
"chat_send_failed": "전송 안 됨",
|
"chat_send_failed": "전송 안 됨",
|
||||||
"chat_sending": "전송 중…",
|
"chat_sending": "전송 중…",
|
||||||
|
"chat_sensitive_btn_mark": "민감한 대화로 전환",
|
||||||
|
"chat_sensitive_btn_setup": "암호 설정 후 민감한 대화로 전환",
|
||||||
|
"chat_sensitive_btn_unlock": "잠금 해제",
|
||||||
|
"chat_sensitive_btn_unlock_mark": "잠금 해제 후 민감한 대화로 전환",
|
||||||
|
"chat_sensitive_btn_unlock_unmark": "잠금 해제 후 일반 대화로 전환",
|
||||||
|
"chat_sensitive_btn_unmark": "일반 대화로 전환",
|
||||||
|
"chat_sensitive_err_create": "민감한 대화 보관소를 만들지 못했습니다.",
|
||||||
|
"chat_sensitive_err_pass": "암호가 일치하지 않거나 비어 있습니다.",
|
||||||
|
"chat_sensitive_err_unlock": "잘못된 암호입니다.",
|
||||||
|
"chat_sensitive_lock_btn": "민감한 대화 잠그기",
|
||||||
|
"chat_sensitive_mark_tip": "민감한 대화로 전환",
|
||||||
|
"chat_sensitive_mark_title": "이 대화를 민감한 대화로 전환할까요?",
|
||||||
|
"chat_sensitive_pass2_hint": "암호 확인",
|
||||||
|
"chat_sensitive_pass_hint": "암호",
|
||||||
|
"chat_sensitive_setup_label": "민감한 대화를 위한 암호를 만드세요",
|
||||||
|
"chat_sensitive_toast_marked": "이제 민감한 대화입니다",
|
||||||
|
"chat_sensitive_toast_unlocked": "민감한 대화 잠금이 해제되었습니다",
|
||||||
|
"chat_sensitive_toast_unmarked": "더 이상 민감한 대화가 아닙니다",
|
||||||
|
"chat_sensitive_unlock_body": "민감한 대화를 다시 표시하려면 민감한 대화 암호를 입력하세요.",
|
||||||
|
"chat_sensitive_unlock_btn": "민감한 대화 잠금 해제",
|
||||||
|
"chat_sensitive_unlock_label": "민감한 대화 암호를 입력하세요",
|
||||||
|
"chat_sensitive_unlock_title": "민감한 대화 잠금 해제",
|
||||||
|
"chat_sensitive_unmark_body": "이 대화는 더 이상 민감한 대화 암호로 암호화되지 않습니다. 새 메시지는 다른 대화와 똑같이 저장됩니다.",
|
||||||
|
"chat_sensitive_unmark_tip": "민감한 대화 — 눌러서 변경",
|
||||||
|
"chat_sensitive_unmark_title": "이 대화를 일반 대화로 전환할까요?",
|
||||||
|
"chat_sensitive_warn_1": "이 대화의 메시지는 시드 문구가 아닌 별도의 암호로 이 기기에서 암호화됩니다. 나중에 시드만으로 지갑을 복원하는 사람은 이 대화를 볼 수 없습니다.",
|
||||||
|
"chat_sensitive_warn_dir": "여기서 보낸 메시지는 완전히 보호됩니다. 받은 메시지는 여전히 블록체인에 남아 있어, 이론적으로는 시드와 다른 소프트웨어를 가진 사람이 복구할 수도 있습니다 — 다만 DragonX 자체는 암호 없이는 그 메시지를 절대 표시하지 않습니다.",
|
||||||
|
"chat_sensitive_warn_hist": "이 대화에 이미 있는 메시지는 체인에서 계속 복구될 수 있습니다. 이렇게 하면 지금부터의 메시지는 완전히 보호됩니다.",
|
||||||
|
"chat_sensitive_warn_lose": "이 암호를 잃어버리면 이 메시지는 영원히 사라집니다 — 시드로는 복구할 수 없습니다.",
|
||||||
"chat_settings_done": "완료",
|
"chat_settings_done": "완료",
|
||||||
"chat_settings_section": "채팅 및 연락처",
|
"chat_settings_section": "채팅 및 연락처",
|
||||||
"chat_settings_tip": "채팅 사용자 지정",
|
"chat_settings_tip": "채팅 사용자 지정",
|
||||||
|
|||||||
@@ -276,8 +276,12 @@
|
|||||||
"chat_delete_body_prefix": "Excluir sua cópia local da conversa com ",
|
"chat_delete_body_prefix": "Excluir sua cópia local da conversa com ",
|
||||||
"chat_delete_body_suffix": "?",
|
"chat_delete_body_suffix": "?",
|
||||||
"chat_delete_confirm": "Excluir",
|
"chat_delete_confirm": "Excluir",
|
||||||
|
"chat_delete_everywhere": "Excluir em todo lugar",
|
||||||
|
"chat_delete_everywhere_note": "Também avisa seus outros dispositivos — e uma futura restauração a partir da sua frase de recuperação — para ocultar esta conversa. Isso envia um pequeno sinal on-chain (custa a taxa de chat habitual). Você pode trazê-la de volta depois em Restaurar.",
|
||||||
|
"chat_delete_everywhere_toast": "Excluindo em todo lugar — sinal enviado",
|
||||||
"chat_delete_failed": "Não foi possível excluir a conversa — nada foi alterado.",
|
"chat_delete_failed": "Não foi possível excluir a conversa — nada foi alterado.",
|
||||||
"chat_delete_local_note": "Isso afeta apenas este dispositivo — as mensagens permanecem na blockchain e a outra pessoa mantém sua cópia.",
|
"chat_delete_local_note": "Isso afeta apenas este dispositivo — as mensagens permanecem na blockchain e a outra pessoa mantém sua cópia.",
|
||||||
|
"chat_delete_needs_sync": "Aguarde a carteira sincronizar totalmente para excluir em todo lugar.",
|
||||||
"chat_delete_revive_note": "\"Excluir\" limpa o histórico neste dispositivo. Se essa pessoa enviar uma mensagem novamente, a conversa volta.",
|
"chat_delete_revive_note": "\"Excluir\" limpa o histórico neste dispositivo. Se essa pessoa enviar uma mensagem novamente, a conversa volta.",
|
||||||
"chat_delete_title": "Excluir conversa?",
|
"chat_delete_title": "Excluir conversa?",
|
||||||
"chat_deleted_toast": "Conversa excluída",
|
"chat_deleted_toast": "Conversa excluída",
|
||||||
@@ -323,6 +327,16 @@
|
|||||||
"chat_rename": "Renomear contato",
|
"chat_rename": "Renomear contato",
|
||||||
"chat_rename_hint": "Nome do contato",
|
"chat_rename_hint": "Nome do contato",
|
||||||
"chat_renamed": "Contato renomeado",
|
"chat_renamed": "Contato renomeado",
|
||||||
|
"chat_restore_btn": "Restaurar",
|
||||||
|
"chat_restore_desc": "Conversas que você excluiu em todo lugar podem ser trazidas de volta aqui — neste dispositivo ou após importar sua frase de recuperação. Restaurar reimporta as mensagens a partir da chain e avisa seus outros dispositivos para exibi-la novamente.",
|
||||||
|
"chat_restore_manage": "Restaurar",
|
||||||
|
"chat_restore_none": "Nenhuma conversa excluída encontrada ainda. Se você excluiu uma em outro dispositivo, tente o Escaneamento rápido ou cole o endereço de chat dela abaixo.",
|
||||||
|
"chat_restore_paste_hint": "Cole o endereço de chat (z) da conversa",
|
||||||
|
"chat_restore_paste_label": "Restaurar pelo endereço de chat",
|
||||||
|
"chat_restore_scan": "Escaneamento rápido",
|
||||||
|
"chat_restore_scanning": "Escaneando a chain em busca de conversas excluídas...",
|
||||||
|
"chat_restore_title": "Restaurar conversas",
|
||||||
|
"chat_restore_toast": "Restaurando a conversa — ela reaparece após o próximo escaneamento",
|
||||||
"chat_retry": "Tentar novamente",
|
"chat_retry": "Tentar novamente",
|
||||||
"chat_search": "Pesquisar conversas",
|
"chat_search": "Pesquisar conversas",
|
||||||
"chat_sec_appearance": "APARÊNCIA",
|
"chat_sec_appearance": "APARÊNCIA",
|
||||||
@@ -331,6 +345,35 @@
|
|||||||
"chat_send": "Enviar",
|
"chat_send": "Enviar",
|
||||||
"chat_send_failed": "não enviada",
|
"chat_send_failed": "não enviada",
|
||||||
"chat_sending": "enviando…",
|
"chat_sending": "enviando…",
|
||||||
|
"chat_sensitive_btn_mark": "Tornar confidencial",
|
||||||
|
"chat_sensitive_btn_setup": "Configurar e tornar confidencial",
|
||||||
|
"chat_sensitive_btn_unlock": "Desbloquear",
|
||||||
|
"chat_sensitive_btn_unlock_mark": "Desbloquear e tornar confidencial",
|
||||||
|
"chat_sensitive_btn_unlock_unmark": "Desbloquear e tornar normal",
|
||||||
|
"chat_sensitive_btn_unmark": "Tornar normal",
|
||||||
|
"chat_sensitive_err_create": "Não foi possível criar o cofre das conversas confidenciais.",
|
||||||
|
"chat_sensitive_err_pass": "As senhas não coincidem ou estão vazias.",
|
||||||
|
"chat_sensitive_err_unlock": "Senha incorreta.",
|
||||||
|
"chat_sensitive_lock_btn": "Bloquear confidenciais",
|
||||||
|
"chat_sensitive_mark_tip": "Tornar confidencial",
|
||||||
|
"chat_sensitive_mark_title": "Tornar esta conversa confidencial?",
|
||||||
|
"chat_sensitive_pass2_hint": "Confirmar senha",
|
||||||
|
"chat_sensitive_pass_hint": "Senha",
|
||||||
|
"chat_sensitive_setup_label": "Crie uma senha para as conversas confidenciais",
|
||||||
|
"chat_sensitive_toast_marked": "A conversa agora é confidencial",
|
||||||
|
"chat_sensitive_toast_unlocked": "Conversas confidenciais desbloqueadas",
|
||||||
|
"chat_sensitive_toast_unmarked": "A conversa não é mais confidencial",
|
||||||
|
"chat_sensitive_unlock_body": "Digite a sua senha de conversas confidenciais para exibir novamente as suas conversas confidenciais.",
|
||||||
|
"chat_sensitive_unlock_btn": "Desbloquear confidenciais",
|
||||||
|
"chat_sensitive_unlock_label": "Digite a sua senha de conversas confidenciais",
|
||||||
|
"chat_sensitive_unlock_title": "Desbloquear conversas confidenciais",
|
||||||
|
"chat_sensitive_unmark_body": "Esta conversa deixará de ser criptografada com a sua senha de conversas confidenciais. As novas mensagens serão armazenadas como em qualquer outra conversa.",
|
||||||
|
"chat_sensitive_unmark_tip": "Confidencial — toque para alterar",
|
||||||
|
"chat_sensitive_unmark_title": "Tornar esta conversa normal?",
|
||||||
|
"chat_sensitive_warn_1": "As mensagens dela serão criptografadas neste dispositivo com uma senha separada — não a sua frase de recuperação. Quem mais tarde restaurar sua carteira apenas a partir da sua semente não verá esta conversa.",
|
||||||
|
"chat_sensitive_warn_dir": "As mensagens que você envia aqui ficam totalmente protegidas. As mensagens recebidas continuam na blockchain e poderiam, em teoria, ser recuperadas por alguém que tenha a sua semente e outros softwares — o próprio DragonX nunca as exibirá sem a sua senha.",
|
||||||
|
"chat_sensitive_warn_hist": "As mensagens que já estão nesta conversa continuam recuperáveis a partir da chain; isso protege totalmente as mensagens daqui em diante.",
|
||||||
|
"chat_sensitive_warn_lose": "Se você perder esta senha, estas mensagens desaparecem para sempre — elas não podem ser recuperadas a partir da sua semente.",
|
||||||
"chat_settings_done": "Concluído",
|
"chat_settings_done": "Concluído",
|
||||||
"chat_settings_section": "CHAT E CONTATOS",
|
"chat_settings_section": "CHAT E CONTATOS",
|
||||||
"chat_settings_tip": "Personalizar chat",
|
"chat_settings_tip": "Personalizar chat",
|
||||||
|
|||||||
@@ -276,8 +276,12 @@
|
|||||||
"chat_delete_body_prefix": "Удалить локальную копию разговора с ",
|
"chat_delete_body_prefix": "Удалить локальную копию разговора с ",
|
||||||
"chat_delete_body_suffix": "?",
|
"chat_delete_body_suffix": "?",
|
||||||
"chat_delete_confirm": "Удалить",
|
"chat_delete_confirm": "Удалить",
|
||||||
|
"chat_delete_everywhere": "Удалить везде",
|
||||||
|
"chat_delete_everywhere_note": "Также сообщает другим вашим устройствам — и кошельку, который вы позже восстановите из сид-фразы, — что этот разговор нужно скрыть. Отправляется крошечный сигнал в блокчейне (взимается обычная комиссия чата). Позже вы сможете вернуть его в разделе «Восстановить».",
|
||||||
|
"chat_delete_everywhere_toast": "Удаление везде — сигнал отправлен",
|
||||||
"chat_delete_failed": "Не удалось удалить разговор — ничего не изменено.",
|
"chat_delete_failed": "Не удалось удалить разговор — ничего не изменено.",
|
||||||
"chat_delete_local_note": "Это влияет только на это устройство — сообщения остаются в блокчейне, и у собеседника сохранится своя копия.",
|
"chat_delete_local_note": "Это влияет только на это устройство — сообщения остаются в блокчейне, и у собеседника сохранится своя копия.",
|
||||||
|
"chat_delete_needs_sync": "Дождитесь полной синхронизации кошелька, чтобы удалить везде.",
|
||||||
"chat_delete_revive_note": "«Удалить» очищает историю на этом устройстве. Если вам снова напишут, разговор появится снова.",
|
"chat_delete_revive_note": "«Удалить» очищает историю на этом устройстве. Если вам снова напишут, разговор появится снова.",
|
||||||
"chat_delete_title": "Удалить разговор?",
|
"chat_delete_title": "Удалить разговор?",
|
||||||
"chat_deleted_toast": "Разговор удалён",
|
"chat_deleted_toast": "Разговор удалён",
|
||||||
@@ -323,6 +327,16 @@
|
|||||||
"chat_rename": "Переименовать контакт",
|
"chat_rename": "Переименовать контакт",
|
||||||
"chat_rename_hint": "Имя контакта",
|
"chat_rename_hint": "Имя контакта",
|
||||||
"chat_renamed": "Контакт переименован",
|
"chat_renamed": "Контакт переименован",
|
||||||
|
"chat_restore_btn": "Восстановить",
|
||||||
|
"chat_restore_desc": "Разговоры, которые вы удалили везде, можно вернуть здесь — на этом устройстве или после импорта сид-фразы. Восстановление заново импортирует сообщения из блокчейна и сообщает другим вашим устройствам, что разговор снова нужно показывать.",
|
||||||
|
"chat_restore_manage": "Восстановить",
|
||||||
|
"chat_restore_none": "Удалённых разговоров пока не найдено. Если вы удалили разговор на другом устройстве, попробуйте «Быстрое сканирование» или вставьте его адрес чата ниже.",
|
||||||
|
"chat_restore_paste_hint": "Вставьте защищённый (z) адрес этого разговора",
|
||||||
|
"chat_restore_paste_label": "Восстановить по адресу чата",
|
||||||
|
"chat_restore_scan": "Быстрое сканирование",
|
||||||
|
"chat_restore_scanning": "Сканирование блокчейна в поиске удалённых разговоров…",
|
||||||
|
"chat_restore_title": "Восстановление разговоров",
|
||||||
|
"chat_restore_toast": "Восстановление разговора — он появится снова после следующего сканирования",
|
||||||
"chat_retry": "Повторить",
|
"chat_retry": "Повторить",
|
||||||
"chat_search": "Поиск разговоров",
|
"chat_search": "Поиск разговоров",
|
||||||
"chat_sec_appearance": "ВИД",
|
"chat_sec_appearance": "ВИД",
|
||||||
@@ -331,6 +345,35 @@
|
|||||||
"chat_send": "Отправить",
|
"chat_send": "Отправить",
|
||||||
"chat_send_failed": "не отправлено",
|
"chat_send_failed": "не отправлено",
|
||||||
"chat_sending": "отправка…",
|
"chat_sending": "отправка…",
|
||||||
|
"chat_sensitive_btn_mark": "Сделать конфиденциальным",
|
||||||
|
"chat_sensitive_btn_setup": "Настроить и сделать конфиденциальным",
|
||||||
|
"chat_sensitive_btn_unlock": "Разблокировать",
|
||||||
|
"chat_sensitive_btn_unlock_mark": "Разблокировать и сделать конфиденциальным",
|
||||||
|
"chat_sensitive_btn_unlock_unmark": "Разблокировать и сделать обычным",
|
||||||
|
"chat_sensitive_btn_unmark": "Сделать обычным",
|
||||||
|
"chat_sensitive_err_create": "Не удалось создать хранилище конфиденциальных разговоров.",
|
||||||
|
"chat_sensitive_err_pass": "Пароли не совпадают или не заполнены.",
|
||||||
|
"chat_sensitive_err_unlock": "Неверный пароль.",
|
||||||
|
"chat_sensitive_lock_btn": "Заблокировать конфиденциальные",
|
||||||
|
"chat_sensitive_mark_tip": "Сделать конфиденциальным",
|
||||||
|
"chat_sensitive_mark_title": "Сделать этот разговор конфиденциальным?",
|
||||||
|
"chat_sensitive_pass2_hint": "Подтвердите пароль",
|
||||||
|
"chat_sensitive_pass_hint": "Пароль",
|
||||||
|
"chat_sensitive_setup_label": "Создайте пароль для конфиденциальных разговоров",
|
||||||
|
"chat_sensitive_toast_marked": "Разговор теперь конфиденциальный",
|
||||||
|
"chat_sensitive_toast_unlocked": "Конфиденциальные разговоры разблокированы",
|
||||||
|
"chat_sensitive_toast_unmarked": "Разговор больше не конфиденциальный",
|
||||||
|
"chat_sensitive_unlock_body": "Введите пароль для конфиденциальных разговоров, чтобы снова показать их.",
|
||||||
|
"chat_sensitive_unlock_btn": "Разблокировать конфиденциальные",
|
||||||
|
"chat_sensitive_unlock_label": "Введите пароль для конфиденциальных разговоров",
|
||||||
|
"chat_sensitive_unlock_title": "Разблокировать конфиденциальные разговоры",
|
||||||
|
"chat_sensitive_unmark_body": "Этот разговор больше не будет зашифрован вашим паролем для конфиденциальных разговоров. Новые сообщения будут храниться как в любом другом разговоре.",
|
||||||
|
"chat_sensitive_unmark_tip": "Конфиденциальный — нажмите, чтобы изменить",
|
||||||
|
"chat_sensitive_unmark_title": "Сделать этот разговор обычным?",
|
||||||
|
"chat_sensitive_warn_1": "Сообщения в нём будут зашифрованы на этом устройстве отдельным паролем, а не вашей сид-фразой. Любой, кто позже восстановит ваш кошелёк только по сид-фразе, не увидит этот разговор.",
|
||||||
|
"chat_sensitive_warn_dir": "Отправленные вами здесь сообщения защищены полностью. Полученные сообщения по-прежнему находятся в блокчейне, и теоретически их может восстановить тот, у кого есть ваша сид-фраза и другое ПО, — но сам DragonX никогда не покажет их без вашего пароля.",
|
||||||
|
"chat_sensitive_warn_hist": "Сообщения, которые уже есть в этом разговоре, по-прежнему можно восстановить из блокчейна; полная защита действует для сообщений, начиная с этого момента.",
|
||||||
|
"chat_sensitive_warn_lose": "Если вы потеряете этот пароль, эти сообщения пропадут навсегда — их нельзя восстановить по сид-фразе.",
|
||||||
"chat_settings_done": "Готово",
|
"chat_settings_done": "Готово",
|
||||||
"chat_settings_section": "ЧАТ И КОНТАКТЫ",
|
"chat_settings_section": "ЧАТ И КОНТАКТЫ",
|
||||||
"chat_settings_tip": "Настройка чата",
|
"chat_settings_tip": "Настройка чата",
|
||||||
|
|||||||
@@ -276,8 +276,12 @@
|
|||||||
"chat_delete_body_prefix": "删除与以下用户会话的本地副本:",
|
"chat_delete_body_prefix": "删除与以下用户会话的本地副本:",
|
||||||
"chat_delete_body_suffix": "?",
|
"chat_delete_body_suffix": "?",
|
||||||
"chat_delete_confirm": "删除",
|
"chat_delete_confirm": "删除",
|
||||||
|
"chat_delete_everywhere": "在所有设备删除",
|
||||||
|
"chat_delete_everywhere_note": "这还会告知您的其他设备——以及日后通过助记词恢复的钱包——隐藏此会话。此操作会发送一个微小的链上信号(需支付常规聊天手续费)。之后您可以在“恢复”中将其重新找回。",
|
||||||
|
"chat_delete_everywhere_toast": "正在所有设备上删除——信号已发送",
|
||||||
"chat_delete_failed": "无法删除会话——未做任何更改。",
|
"chat_delete_failed": "无法删除会话——未做任何更改。",
|
||||||
"chat_delete_local_note": "此操作仅影响此设备——消息仍保留在区块链上,对方也仍保有自己的副本。",
|
"chat_delete_local_note": "此操作仅影响此设备——消息仍保留在区块链上,对方也仍保有自己的副本。",
|
||||||
|
"chat_delete_needs_sync": "请等待钱包完全同步后,才能在所有设备删除。",
|
||||||
"chat_delete_revive_note": "“删除”会清除此设备上的记录。如果对方再次给您发消息,会话会重新出现。",
|
"chat_delete_revive_note": "“删除”会清除此设备上的记录。如果对方再次给您发消息,会话会重新出现。",
|
||||||
"chat_delete_title": "删除会话?",
|
"chat_delete_title": "删除会话?",
|
||||||
"chat_deleted_toast": "会话已删除",
|
"chat_deleted_toast": "会话已删除",
|
||||||
@@ -323,6 +327,16 @@
|
|||||||
"chat_rename": "重命名联系人",
|
"chat_rename": "重命名联系人",
|
||||||
"chat_rename_hint": "联系人名称",
|
"chat_rename_hint": "联系人名称",
|
||||||
"chat_renamed": "联系人已重命名",
|
"chat_renamed": "联系人已重命名",
|
||||||
|
"chat_restore_btn": "恢复",
|
||||||
|
"chat_restore_desc": "在所有设备删除的会话可以在此处找回——无论是在本设备上,还是在导入助记词之后。恢复会从链上重新导入消息,并通知您的其他设备再次显示该会话。",
|
||||||
|
"chat_restore_manage": "恢复",
|
||||||
|
"chat_restore_none": "尚未找到已删除的会话。如果您曾在其他设备上删除过某个会话,请尝试“快速扫描”,或在下方粘贴其聊天地址。",
|
||||||
|
"chat_restore_paste_hint": "粘贴该会话的聊天(z)地址",
|
||||||
|
"chat_restore_paste_label": "通过聊天地址恢复",
|
||||||
|
"chat_restore_scan": "快速扫描",
|
||||||
|
"chat_restore_scanning": "正在扫描区块链以查找已删除的会话…",
|
||||||
|
"chat_restore_title": "恢复会话",
|
||||||
|
"chat_restore_toast": "正在恢复会话——将在下次扫描后重新显示",
|
||||||
"chat_retry": "重试",
|
"chat_retry": "重试",
|
||||||
"chat_search": "搜索会话",
|
"chat_search": "搜索会话",
|
||||||
"chat_sec_appearance": "外观",
|
"chat_sec_appearance": "外观",
|
||||||
@@ -331,6 +345,35 @@
|
|||||||
"chat_send": "发送",
|
"chat_send": "发送",
|
||||||
"chat_send_failed": "未发送",
|
"chat_send_failed": "未发送",
|
||||||
"chat_sending": "发送中…",
|
"chat_sending": "发送中…",
|
||||||
|
"chat_sensitive_btn_mark": "设为私密",
|
||||||
|
"chat_sensitive_btn_setup": "设置并设为私密",
|
||||||
|
"chat_sensitive_btn_unlock": "解锁",
|
||||||
|
"chat_sensitive_btn_unlock_mark": "解锁并设为私密",
|
||||||
|
"chat_sensitive_btn_unlock_unmark": "解锁并设为普通",
|
||||||
|
"chat_sensitive_btn_unmark": "设为普通",
|
||||||
|
"chat_sensitive_err_create": "无法设置私密聊天保险库。",
|
||||||
|
"chat_sensitive_err_pass": "两次输入的密码短语不一致,或为空。",
|
||||||
|
"chat_sensitive_err_unlock": "密码短语错误。",
|
||||||
|
"chat_sensitive_lock_btn": "锁定私密会话",
|
||||||
|
"chat_sensitive_mark_tip": "设为私密",
|
||||||
|
"chat_sensitive_mark_title": "将此会话设为私密?",
|
||||||
|
"chat_sensitive_pass2_hint": "确认密码短语",
|
||||||
|
"chat_sensitive_pass_hint": "密码短语",
|
||||||
|
"chat_sensitive_setup_label": "为私密会话创建一个密码短语",
|
||||||
|
"chat_sensitive_toast_marked": "会话现已设为私密",
|
||||||
|
"chat_sensitive_toast_unlocked": "私密会话已解锁",
|
||||||
|
"chat_sensitive_toast_unmarked": "会话已设为普通",
|
||||||
|
"chat_sensitive_unlock_body": "输入您的私密聊天密码短语,以重新显示您的私密会话。",
|
||||||
|
"chat_sensitive_unlock_btn": "解锁私密会话",
|
||||||
|
"chat_sensitive_unlock_label": "输入您的私密聊天密码短语",
|
||||||
|
"chat_sensitive_unlock_title": "解锁私密会话",
|
||||||
|
"chat_sensitive_unmark_body": "此会话将不再使用您的私密聊天密码短语加密。新消息将像其他任何会话一样存储。",
|
||||||
|
"chat_sensitive_unmark_tip": "私密——点击可更改",
|
||||||
|
"chat_sensitive_unmark_title": "将此会话设为普通?",
|
||||||
|
"chat_sensitive_warn_1": "其消息将在此设备上以一个单独的密码短语加密——而非您的助记词。日后任何仅凭您的助记词恢复钱包的人,都将看不到此会话。",
|
||||||
|
"chat_sensitive_warn_dir": "您在此发送的消息受到完全保护。收到的消息仍保留在区块链上,理论上,拥有您助记词及其他软件的人有可能将其恢复——但没有您的密码短语,DragonX 本身绝不会显示它们。",
|
||||||
|
"chat_sensitive_warn_hist": "此会话中已有的消息仍可从链上恢复;此功能将完全保护此后的新消息。",
|
||||||
|
"chat_sensitive_warn_lose": "如果您丢失此密码短语,这些消息将永远消失——它们无法通过您的助记词恢复。",
|
||||||
"chat_settings_done": "完成",
|
"chat_settings_done": "完成",
|
||||||
"chat_settings_section": "聊天与联系人",
|
"chat_settings_section": "聊天与联系人",
|
||||||
"chat_settings_tip": "聊天自定义",
|
"chat_settings_tip": "聊天自定义",
|
||||||
|
|||||||
@@ -1226,6 +1226,7 @@ pill-rounding = { size = 12.0 }
|
|||||||
padding-x = { size = 12.0 }
|
padding-x = { size = 12.0 }
|
||||||
glass-fill-alpha = { size = 18.0 }
|
glass-fill-alpha = { size = 18.0 }
|
||||||
accent-fill-alpha = { size = 0.22 } # severity-tint fill opacity (fraction of the accent alpha)
|
accent-fill-alpha = { size = 0.22 } # severity-tint fill opacity (fraction of the accent alpha)
|
||||||
|
badge-fill-alpha = { size = 0.35 } # "+N more" badge fill opacity (fraction of the accent alpha)
|
||||||
glass-border-alpha = { size = 35.0 }
|
glass-border-alpha = { size = 35.0 }
|
||||||
progress-bar-height = { size = 2.0 }
|
progress-bar-height = { size = 2.0 }
|
||||||
duration-info = { size = 1.0 }
|
duration-info = { size = 1.0 }
|
||||||
|
|||||||
@@ -48,6 +48,36 @@ for f in "${files[@]}"; do
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# ── Content guards (UX-hygiene threads) ──────────────────────────────────────
|
||||||
|
# Low-false-positive regression checks for debt the UX audit cleared, so it can't
|
||||||
|
# silently regrow. Each is deliberately narrow; widen only with an allowlist.
|
||||||
|
for f in "${files[@]}"; do
|
||||||
|
[[ "$f" == *.cpp ]] || continue
|
||||||
|
[[ -f "$f" ]] || continue
|
||||||
|
# app_sweep.cpp is the dev screenshot-sweep harness: its fixture strings are never shipped UI.
|
||||||
|
[[ "$(basename "$f")" == "app_sweep.cpp" ]] && continue
|
||||||
|
|
||||||
|
# (1) Tactile button sized with a raw multi-digit pixel literal that ignores HiDPI.
|
||||||
|
# Real sizes multiply by ui::Layout::dpiScale() / dp / hScale, or come from schema.
|
||||||
|
if hits=$(grep -nE 'Tactile(Small)?Button\([^;]*ImVec2\([1-9][0-9]+(\.[0-9]+f?)?,' "$f" \
|
||||||
|
| grep -vE 'dpiScale|\* *dp\b|\* *hdpi|hScale|schema|Scale\('); then
|
||||||
|
while IFS= read -r line; do
|
||||||
|
echo "✗ unscaled button size (multiply the px by ui::Layout::dpiScale()): $f:$line" >&2
|
||||||
|
fail=1
|
||||||
|
done <<< "$hits"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# (2) User-visible migrate/sweep status assigned a bare English literal instead of TR(...).
|
||||||
|
# "__SENTINEL__" comparisons and empty strings are fine.
|
||||||
|
if hits=$(grep -nE '(sweep_status_|seed_migration_status_)[[:space:]]*=[[:space:]]*"[A-Za-z]' "$f" \
|
||||||
|
| grep -vE 'TR\(|"__'); then
|
||||||
|
while IFS= read -r line; do
|
||||||
|
echo "✗ untranslated status string (route through TR(...)): $f:$line" >&2
|
||||||
|
fail=1
|
||||||
|
done <<< "$hits"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
if (( fail )); then
|
if (( fail )); then
|
||||||
echo "" >&2
|
echo "" >&2
|
||||||
echo "Source hygiene check failed. See docs in scripts/check-source-hygiene.sh." >&2
|
echo "Source hygiene check failed. See docs in scripts/check-source-hygiene.sh." >&2
|
||||||
|
|||||||
+285
-63
@@ -36,11 +36,11 @@
|
|||||||
#include "ui/windows/explorer_tab.h"
|
#include "ui/windows/explorer_tab.h"
|
||||||
#include "ui/windows/seed_display.h"
|
#include "ui/windows/seed_display.h"
|
||||||
#include "ui/windows/market_tab.h"
|
#include "ui/windows/market_tab.h"
|
||||||
#include "ui/windows/settings_window.h"
|
|
||||||
#include "ui/windows/about_dialog.h"
|
#include "ui/windows/about_dialog.h"
|
||||||
#include "ui/windows/faq_dialog.h"
|
#include "ui/windows/faq_dialog.h"
|
||||||
#include "embedded/IconsMaterialDesign.h"
|
#include "embedded/IconsMaterialDesign.h"
|
||||||
#include "ui/windows/key_export_dialog.h"
|
#include "ui/windows/key_export_dialog.h"
|
||||||
|
#include "ui/imgui_secret_scrub.h" // scrubImGuiInputTextState — zero ImGui's retained secret buffers
|
||||||
#include "ui/windows/transaction_details_dialog.h"
|
#include "ui/windows/transaction_details_dialog.h"
|
||||||
#include "ui/windows/qr_popup_dialog.h"
|
#include "ui/windows/qr_popup_dialog.h"
|
||||||
#include "ui/windows/validate_address_dialog.h"
|
#include "ui/windows/validate_address_dialog.h"
|
||||||
@@ -144,6 +144,28 @@ void App::wipeSecrets()
|
|||||||
sodium_memzero(export_result_, sizeof(export_result_)); // exported WIF/z-key (SECRET)
|
sodium_memzero(export_result_, sizeof(export_result_)); // exported WIF/z-key (SECRET)
|
||||||
sodium_memzero(import_key_input_, sizeof(import_key_input_)); // pasted private key (SECRET)
|
sodium_memzero(import_key_input_, sizeof(import_key_input_)); // pasted private key (SECRET)
|
||||||
sodium_memzero(import_clip_preview_, sizeof(import_clip_preview_)); // clipboard preview (SECRET)
|
sodium_memzero(import_clip_preview_, sizeof(import_clip_preview_)); // clipboard preview (SECRET)
|
||||||
|
ui::scrubImGuiInputTextState(); // zero ImGui's retained copies of whatever secret was last typed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scrub + close any secret that is currently ON SCREEN (revealed seed phrase, migration seed, exported
|
||||||
|
// key) when the wallet locks — locking shouldn't leave a phrase/key visible or resident. Called on every
|
||||||
|
// lock transition (manual, auto, lite). Separate from wipeSecrets(), which is the exit-path scrub.
|
||||||
|
void App::wipeDisplayedSecrets()
|
||||||
|
{
|
||||||
|
if (!seed_backup_phrase_.empty()) {
|
||||||
|
sodium_memzero(&seed_backup_phrase_[0], seed_backup_phrase_.size());
|
||||||
|
seed_backup_phrase_.clear();
|
||||||
|
}
|
||||||
|
if (!seed_migration_seed_.empty()) {
|
||||||
|
sodium_memzero(&seed_migration_seed_[0], seed_migration_seed_.size());
|
||||||
|
seed_migration_seed_.clear();
|
||||||
|
}
|
||||||
|
seed_backup_fetch_started_ = false;
|
||||||
|
show_seed_backup_ = false;
|
||||||
|
sodium_memzero(export_result_, sizeof(export_result_));
|
||||||
|
ui::KeyExportDialog::hide(); // zeros its static s_key via secureWipeLiteSecret
|
||||||
|
console_tab_.wipeSecretResult(); // drop any masked key-dump result held in the console
|
||||||
|
ui::scrubImGuiInputTextState(); // zero ImGui's retained copy of the last-typed passphrase/PIN
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enqueue a blocking xmrig start/stop op onto the dedicated serialized control thread so the render thread
|
// Enqueue a blocking xmrig start/stop op onto the dedicated serialized control thread so the render thread
|
||||||
@@ -943,6 +965,9 @@ void App::update()
|
|||||||
lite_wallet_->pumpAsyncOpen();
|
lite_wallet_->pumpAsyncOpen();
|
||||||
// Likewise finalize any completed async lifecycle request (Settings-page create/open/restore).
|
// Likewise finalize any completed async lifecycle request (Settings-page create/open/restore).
|
||||||
lite_wallet_->pumpLifecycleResult();
|
lite_wallet_->pumpLifecycleResult();
|
||||||
|
// A create/restore whose passphrase encryption failed left the wallet UNENCRYPTED (previously log-only): say so.
|
||||||
|
if (lite_wallet_->takeEncryptionFailureNotice())
|
||||||
|
ui::Notifications::instance().error(TR("lite_encrypt_failed_unencrypted"), 15.0f);
|
||||||
// Deliver finished wallet ops (new address / import / encrypt / decrypt) to their UI callbacks.
|
// Deliver finished wallet ops (new address / import / encrypt / decrypt) to their UI callbacks.
|
||||||
lite_wallet_->pumpWalletOps();
|
lite_wallet_->pumpWalletOps();
|
||||||
// A server switch deferred behind an in-flight wallet op: retry it. This may replace lite_wallet_; the rest of
|
// A server switch deferred behind an in-flight wallet op: retry it. This may replace lite_wallet_; the rest of
|
||||||
@@ -1028,7 +1053,11 @@ void App::update()
|
|||||||
case LiteOpKind::None:
|
case LiteOpKind::None:
|
||||||
default:
|
default:
|
||||||
if (lite_send_callback_) {
|
if (lite_send_callback_) {
|
||||||
lite_send_callback_(broadcast.ok, broadcast.ok ? broadcast.txid : broadcast.error);
|
// A transport-style failure may mean the server accepted the tx but the reply was
|
||||||
|
// lost: tag it so the Send tab says "check History" instead of inviting a resend.
|
||||||
|
const std::string liteErr = (!broadcast.ok && rpc::liteSendErrorMayBeInFlight(broadcast.error))
|
||||||
|
? rpc::markSendMaybeInFlight(broadcast.error) : broadcast.error;
|
||||||
|
lite_send_callback_(broadcast.ok, broadcast.ok ? broadcast.txid : liteErr);
|
||||||
lite_send_callback_ = nullptr;
|
lite_send_callback_ = nullptr;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -1963,6 +1992,12 @@ void App::render()
|
|||||||
}
|
}
|
||||||
settings_->setWizardCompleted(true);
|
settings_->setWizardCompleted(true);
|
||||||
settings_->save();
|
settings_->save();
|
||||||
|
// Soften the abrupt handoff: tell the user the node is starting + will keep syncing in the
|
||||||
|
// background, instead of dropping them onto a busy/blank main window with no explanation.
|
||||||
|
if (!state_.connected)
|
||||||
|
ui::Notifications::instance().info(
|
||||||
|
settings_->getEncryptionPending() ? TR("wiz_handoff_encrypting") : TR("wiz_handoff_starting"),
|
||||||
|
10.0f);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process deferred encryption from wizard (runs in background)
|
// Process deferred encryption from wizard (runs in background)
|
||||||
@@ -2387,6 +2422,8 @@ void App::render()
|
|||||||
console_exec_ = std::make_unique<ui::LiteConsoleExecutor>(this);
|
console_exec_ = std::make_unique<ui::LiteConsoleExecutor>(this);
|
||||||
else
|
else
|
||||||
console_exec_ = std::make_unique<ui::FullNodeConsoleExecutor>(this);
|
console_exec_ = std::make_unique<ui::FullNodeConsoleExecutor>(this);
|
||||||
|
// One-time: route the console's "Copy secret" through the auto-clearing clipboard.
|
||||||
|
console_tab_.setCopySecretHandler([this](const std::string& s) { copySecretToClipboard(s); });
|
||||||
}
|
}
|
||||||
console_tab_.render(*console_exec_);
|
console_tab_.render(*console_exec_);
|
||||||
// Persist the console toolbar toggles (ConsoleTab has no settings pointer). Save only when
|
// Persist the console toolbar toggles (ConsoleTab has no settings pointer). Save only when
|
||||||
@@ -2528,9 +2565,6 @@ void App::render()
|
|||||||
// content area, status bar, and every other widget in this window.
|
// content area, status bar, and every other widget in this window.
|
||||||
// ====================================================================
|
// ====================================================================
|
||||||
PERF_BEGIN(_perfDialogs);
|
PERF_BEGIN(_perfDialogs);
|
||||||
if (show_settings_) {
|
|
||||||
ui::RenderSettingsWindow(this, &show_settings_);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (show_about_) {
|
if (show_about_) {
|
||||||
ui::RenderAboutDialog(this, &show_about_);
|
ui::RenderAboutDialog(this, &show_about_);
|
||||||
@@ -2548,7 +2582,11 @@ void App::render()
|
|||||||
if (show_import_key_) {
|
if (show_import_key_) {
|
||||||
renderImportKeyDialog();
|
renderImportKeyDialog();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (show_import_wallet_file_) {
|
||||||
|
renderImportWalletFileDialog();
|
||||||
|
}
|
||||||
|
|
||||||
if (show_export_key_) {
|
if (show_export_key_) {
|
||||||
renderExportKeyDialog();
|
renderExportKeyDialog();
|
||||||
}
|
}
|
||||||
@@ -2682,6 +2720,10 @@ void App::renderNodeStatusBanner()
|
|||||||
in.node_busy = busy.busy;
|
in.node_busy = busy.busy;
|
||||||
in.node_busy_detail = busy.compact; // one line; the overlay and the quit prompt explain more
|
in.node_busy_detail = busy.compact; // one line; the overlay and the quit prompt explain more
|
||||||
in.auth_failed = rpc_auth_failed_;
|
in.auth_failed = rpc_auth_failed_;
|
||||||
|
// Corrected trigger: ONLY a degraded wallet (wallet_degraded_ — z_getnewaddress fails, can't shield) that
|
||||||
|
// actually holds transparent funds. A merely-non-mnemonic but functional wallet has wallet_degraded_ ==
|
||||||
|
// false and is never flagged here. Full-node only (in.lite returns early regardless).
|
||||||
|
in.legacy_funds_stuck = !isLiteBuild() && wallet_degraded_ && state_.transparentBalance > 0.0001;
|
||||||
|
|
||||||
const ui::NodeBannerState banner = ui::evaluateNodeStatusBanner(in);
|
const ui::NodeBannerState banner = ui::evaluateNodeStatusBanner(in);
|
||||||
if (!banner.show) return;
|
if (!banner.show) return;
|
||||||
@@ -2720,6 +2762,8 @@ void App::renderNodeStatusBanner()
|
|||||||
title = busy.title.c_str(); icon = ICON_MD_HOURGLASS_EMPTY; break;
|
title = busy.title.c_str(); icon = ICON_MD_HOURGLASS_EMPTY; break;
|
||||||
case ui::NodeBannerReason::AuthFailed:
|
case ui::NodeBannerReason::AuthFailed:
|
||||||
title = TR("node_banner_auth_title"); icon = ICON_MD_LOCK; break;
|
title = TR("node_banner_auth_title"); icon = ICON_MD_LOCK; break;
|
||||||
|
case ui::NodeBannerReason::LegacyFundsStuck:
|
||||||
|
title = TR("node_banner_legacy_title"); icon = ICON_MD_KEY; break;
|
||||||
case ui::NodeBannerReason::FullNodeOffline:
|
case ui::NodeBannerReason::FullNodeOffline:
|
||||||
default:
|
default:
|
||||||
title = TR("node_banner_offline_title"); icon = ICON_MD_CLOUD_OFF; break;
|
title = TR("node_banner_offline_title"); icon = ICON_MD_CLOUD_OFF; break;
|
||||||
@@ -2729,6 +2773,7 @@ void App::renderNodeStatusBanner()
|
|||||||
if (banner.action == ui::NodeBannerAction::Reconnect) actionLabel = TR("node_banner_reconnect");
|
if (banner.action == ui::NodeBannerAction::Reconnect) actionLabel = TR("node_banner_reconnect");
|
||||||
else if (banner.action == ui::NodeBannerAction::RestartNode) actionLabel = TR("node_banner_restart");
|
else if (banner.action == ui::NodeBannerAction::RestartNode) actionLabel = TR("node_banner_restart");
|
||||||
else if (banner.action == ui::NodeBannerAction::OpenConfigFolder) actionLabel = TR("node_banner_open_config");
|
else if (banner.action == ui::NodeBannerAction::OpenConfigFolder) actionLabel = TR("node_banner_open_config");
|
||||||
|
else if (banner.action == ui::NodeBannerAction::RecoverLegacyFunds) actionLabel = TR("node_banner_recover");
|
||||||
|
|
||||||
// The NoPeers case deliberately carries no passthrough detail (connection_status is just
|
// The NoPeers case deliberately carries no passthrough detail (connection_status is just
|
||||||
// "Connected" there, which reads as a contradiction under the "no peers" headline). Show a
|
// "Connected" there, which reads as a contradiction under the "no peers" headline). Show a
|
||||||
@@ -2736,6 +2781,7 @@ void App::renderNodeStatusBanner()
|
|||||||
std::string detailText;
|
std::string detailText;
|
||||||
if (banner.reason == ui::NodeBannerReason::NoPeers) detailText = TR("node_banner_no_peers_detail");
|
if (banner.reason == ui::NodeBannerReason::NoPeers) detailText = TR("node_banner_no_peers_detail");
|
||||||
else if (banner.reason == ui::NodeBannerReason::AuthFailed) detailText = TR("node_banner_auth_detail");
|
else if (banner.reason == ui::NodeBannerReason::AuthFailed) detailText = TR("node_banner_auth_detail");
|
||||||
|
else if (banner.reason == ui::NodeBannerReason::LegacyFundsStuck) detailText = TR("node_banner_legacy_detail");
|
||||||
else detailText = banner.detail;
|
else detailText = banner.detail;
|
||||||
|
|
||||||
const float padX = ui::Layout::spacingLg();
|
const float padX = ui::Layout::spacingLg();
|
||||||
@@ -2825,6 +2871,7 @@ void App::renderNodeStatusBanner()
|
|||||||
if (m::TactileButton(actionLabel, ImVec2(btnW, btnH))) {
|
if (m::TactileButton(actionLabel, ImVec2(btnW, btnH))) {
|
||||||
if (banner.action == ui::NodeBannerAction::RestartNode) restartDaemon();
|
if (banner.action == ui::NodeBannerAction::RestartNode) restartDaemon();
|
||||||
else if (banner.action == ui::NodeBannerAction::Reconnect) tryConnect();
|
else if (banner.action == ui::NodeBannerAction::Reconnect) tryConnect();
|
||||||
|
else if (banner.action == ui::NodeBannerAction::RecoverLegacyFunds) showSeedMigrationDialog();
|
||||||
else if (banner.action == ui::NodeBannerAction::OpenConfigFolder) {
|
else if (banner.action == ui::NodeBannerAction::OpenConfigFolder) {
|
||||||
const std::string conf = rpc::Connection::getDefaultConfPath();
|
const std::string conf = rpc::Connection::getDefaultConfPath();
|
||||||
util::Platform::openFolder(std::filesystem::path(conf).parent_path().string(),
|
util::Platform::openFolder(std::filesystem::path(conf).parent_path().string(),
|
||||||
@@ -3079,6 +3126,10 @@ void App::renderStatusBar()
|
|||||||
} else if (rpc_busy_gate_->busy()) {
|
} else if (rpc_busy_gate_->busy()) {
|
||||||
// The node holds back our requests (a long operation holds its cs_main): data may be stale.
|
// The node holds back our requests (a long operation holds its cs_main): data may be stale.
|
||||||
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(ui::material::Warning()), "%s", TR("sb_node_busy"));
|
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(ui::material::Warning()), "%s", TR("sb_node_busy"));
|
||||||
|
} else if (isLiteBuild() && !state_.sync.lite_error.empty()) {
|
||||||
|
// Lite scan/server failure: show the error instead of a frozen 'Syncing 99.9%'.
|
||||||
|
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(ui::material::Error()), TR("sb_lite_sync_error"),
|
||||||
|
state_.sync.lite_error.c_str());
|
||||||
} else if (state_.sync.syncing) {
|
} else if (state_.sync.syncing) {
|
||||||
int chainTip = state_.longestchain > 0 ? state_.longestchain : state_.sync.headers;
|
int chainTip = state_.longestchain > 0 ? state_.longestchain : state_.sync.headers;
|
||||||
int blocksLeft = chainTip - state_.sync.blocks;
|
int blocksLeft = chainTip - state_.sync.blocks;
|
||||||
@@ -3315,7 +3366,7 @@ void App::renderStatusBar()
|
|||||||
ImVec2(bellMax.x - r, bellMin.y + r), r, dotCol);
|
ImVec2(bellMax.x - r, bellMin.y + r), r, dotCol);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (clicked) {
|
if (clicked || notes.consumeHistoryRequest()) { // bell click, or a click on the toast itself
|
||||||
alerts_seen_total_ = notes.totalPushed(); // mark everything currently shown as seen
|
alerts_seen_total_ = notes.totalPushed(); // mark everything currently shown as seen
|
||||||
ImGui::OpenPopup("##AlertHistoryPopup");
|
ImGui::OpenPopup("##AlertHistoryPopup");
|
||||||
}
|
}
|
||||||
@@ -3462,14 +3513,20 @@ void App::renderLiteFirstRunPrompt()
|
|||||||
ImGui::SetNextWindowPos(ImVec2(vp->Pos.x + vp->Size.x * 0.5f, vp->Pos.y + vp->Size.y * 0.5f),
|
ImGui::SetNextWindowPos(ImVec2(vp->Pos.x + vp->Size.x * 0.5f, vp->Pos.y + vp->Size.y * 0.5f),
|
||||||
ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
|
ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
|
||||||
|
|
||||||
|
// Best-effort zero of the seed-word statics (they hold the whole phrase) before they're dropped.
|
||||||
|
auto wipeStr = [](std::string& s) { if (!s.empty()) sodium_memzero(&s[0], s.size()); s.clear(); };
|
||||||
|
auto wipeWords = [&](std::vector<std::string>& v) { for (auto& w : v) wipeStr(w); v.clear(); };
|
||||||
|
auto wipeChips = [&](std::vector<std::pair<std::string, bool>>& v) {
|
||||||
|
for (auto& c : v) { wipeStr(c.first); c.second = false; } v.clear();
|
||||||
|
};
|
||||||
auto finish = [&]() {
|
auto finish = [&]() {
|
||||||
wallet::secureWipeLiteSecret(seed);
|
wallet::secureWipeLiteSecret(seed);
|
||||||
sodium_memzero(restoreSeed, sizeof(restoreSeed));
|
sodium_memzero(restoreSeed, sizeof(restoreSeed));
|
||||||
restoreBirthday = 0;
|
restoreBirthday = 0;
|
||||||
restoring = false;
|
restoring = false;
|
||||||
restoreErr.clear();
|
restoreErr.clear();
|
||||||
words.clear();
|
wipeWords(words);
|
||||||
chips.clear();
|
wipeChips(chips);
|
||||||
progress = 0;
|
progress = 0;
|
||||||
step = 0;
|
step = 0;
|
||||||
skipConfirm = false;
|
skipConfirm = false;
|
||||||
@@ -3577,7 +3634,7 @@ void App::renderLiteFirstRunPrompt()
|
|||||||
ImGui::Spacing(); ImGui::Spacing();
|
ImGui::Spacing(); ImGui::Spacing();
|
||||||
|
|
||||||
if (ui::material::TactileButton(TR("appx_ive_written_it_down"), ImVec2(btnW, 0))) {
|
if (ui::material::TactileButton(TR("appx_ive_written_it_down"), ImVec2(btnW, 0))) {
|
||||||
chips.clear();
|
wipeChips(chips);
|
||||||
for (const auto& w : words) chips.emplace_back(w, false);
|
for (const auto& w : words) chips.emplace_back(w, false);
|
||||||
std::mt19937 rng{std::random_device{}()};
|
std::mt19937 rng{std::random_device{}()};
|
||||||
std::shuffle(chips.begin(), chips.end(), rng);
|
std::shuffle(chips.begin(), chips.end(), rng);
|
||||||
@@ -3588,7 +3645,7 @@ void App::renderLiteFirstRunPrompt()
|
|||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
if (ui::material::TactileButton(TR("appx_copy"), ImVec2(80 * dp, 0))) copySecretToClipboard(seed);
|
if (ui::material::TactileButton(TR("appx_copy"), ImVec2(80 * dp, 0))) copySecretToClipboard(seed);
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
if (ui::material::TactileButton(skipConfirm ? TR("appx_skip_anyway") : TR("appx_skip"), ImVec2(120, 0))) {
|
if (ui::material::TactileButton(skipConfirm ? TR("appx_skip_anyway") : TR("appx_skip"), ImVec2(120.0f * ui::Layout::dpiScale(), 0))) {
|
||||||
if (!skipConfirm) {
|
if (!skipConfirm) {
|
||||||
skipConfirm = true; // require a second, deliberate click
|
skipConfirm = true; // require a second, deliberate click
|
||||||
} else {
|
} else {
|
||||||
@@ -3651,9 +3708,9 @@ void App::renderLiteFirstRunPrompt()
|
|||||||
}
|
}
|
||||||
if (!verified) ImGui::EndDisabled();
|
if (!verified) ImGui::EndDisabled();
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
if (ui::material::TactileButton(TR("appx_back"), ImVec2(80, 0))) { skipConfirm = false; step = 1; }
|
if (ui::material::TactileButton(TR("appx_back"), ImVec2(80.0f * ui::Layout::dpiScale(), 0))) { skipConfirm = false; step = 1; }
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
if (ui::material::TactileButton(skipConfirm ? TR("appx_skip_anyway") : TR("appx_skip"), ImVec2(120, 0))) {
|
if (ui::material::TactileButton(skipConfirm ? TR("appx_skip_anyway") : TR("appx_skip"), ImVec2(120.0f * ui::Layout::dpiScale(), 0))) {
|
||||||
if (!skipConfirm) {
|
if (!skipConfirm) {
|
||||||
skipConfirm = true; // require a second, deliberate click
|
skipConfirm = true; // require a second, deliberate click
|
||||||
} else {
|
} else {
|
||||||
@@ -3750,7 +3807,7 @@ void App::renderLiteFirstRunPrompt()
|
|||||||
}
|
}
|
||||||
ImGui::EndDisabled();
|
ImGui::EndDisabled();
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
if (ui::material::TactileButton(TR("appx_back"), ImVec2(80, 0))) {
|
if (ui::material::TactileButton(TR("appx_back"), ImVec2(80.0f * ui::Layout::dpiScale(), 0))) {
|
||||||
sodium_memzero(restoreSeed, sizeof(restoreSeed));
|
sodium_memzero(restoreSeed, sizeof(restoreSeed));
|
||||||
restoreErr.clear();
|
restoreErr.clear();
|
||||||
step = 0;
|
step = 0;
|
||||||
@@ -3788,12 +3845,13 @@ void App::renderLiteUnlockPrompt()
|
|||||||
const float btnW = 130.0f * dp;
|
const float btnW = 130.0f * dp;
|
||||||
bool doUnlock = ui::material::TactileButton(TR("lite_unlock_btn"), ImVec2(btnW, 0)) || entered;
|
bool doUnlock = ui::material::TactileButton(TR("lite_unlock_btn"), ImVec2(btnW, 0)) || entered;
|
||||||
if (doUnlock) {
|
if (doUnlock) {
|
||||||
const bool ok = lite_wallet_->unlockWallet(pass);
|
std::string unlockErr;
|
||||||
|
const bool ok = unlockLiteWallet(std::string(pass), &unlockErr);
|
||||||
sodium_memzero(pass, sizeof(pass));
|
sodium_memzero(pass, sizeof(pass));
|
||||||
if (ok) ui::Notifications::instance().success(TR("lite_unlock_ok"), 5.0f);
|
if (ok) ui::Notifications::instance().success(TR("lite_unlock_ok"), 5.0f);
|
||||||
else {
|
else {
|
||||||
wallet::liteLog(std::string("Unlock failed: ") + TR("lite_unlock_failed"));
|
wallet::liteLog(std::string("Unlock failed: ") + unlockErr);
|
||||||
ui::Notifications::instance().error(TR("lite_unlock_failed"));
|
ui::Notifications::instance().error(unlockErr);
|
||||||
}
|
}
|
||||||
lite_unlock_prompt_ = false;
|
lite_unlock_prompt_ = false;
|
||||||
ImGui::CloseCurrentPopup();
|
ImGui::CloseCurrentPopup();
|
||||||
@@ -3895,6 +3953,85 @@ static void RenderScanHeightControl(char* buf, size_t bufSize, int tipHeight, bo
|
|||||||
ImGui::EndDisabled();
|
ImGui::EndDisabled();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void App::renderImportWalletFileDialog()
|
||||||
|
{
|
||||||
|
namespace m = ui::material;
|
||||||
|
float dp = ui::Layout::dpiScale();
|
||||||
|
|
||||||
|
m::OverlayDialogSpec ov;
|
||||||
|
ov.title = TR("import_wallet_file_title");
|
||||||
|
ov.p_open = &show_import_wallet_file_;
|
||||||
|
ov.style = m::OverlayStyle::BlurFloat;
|
||||||
|
ov.cardWidth = 640.0f;
|
||||||
|
ov.idSuffix = "importwalletfile";
|
||||||
|
if (!m::BeginOverlayDialog(ov)) return;
|
||||||
|
|
||||||
|
if (ImGui::IsWindowAppearing()) {
|
||||||
|
import_wallet_file_done_ = false;
|
||||||
|
import_wallet_file_ok_ = false;
|
||||||
|
import_wallet_file_status_.clear();
|
||||||
|
}
|
||||||
|
if (ImGui::IsKeyPressed(ImGuiKey_Escape) && !import_wallet_file_busy_) show_import_wallet_file_ = false;
|
||||||
|
|
||||||
|
m::DialogWarningHeader(TR("import_wallet_file_note"));
|
||||||
|
ImGui::Spacing();
|
||||||
|
if (!state_.connected) {
|
||||||
|
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceMedium()), "%s", TR("import_key_need_node"));
|
||||||
|
ImGui::Spacing();
|
||||||
|
}
|
||||||
|
|
||||||
|
ImGui::TextUnformatted(TR("import_wallet_file_path_label"));
|
||||||
|
ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x);
|
||||||
|
ImGui::BeginDisabled(import_wallet_file_busy_);
|
||||||
|
ImGui::InputText("##importwalletpath", import_wallet_file_path_, sizeof(import_wallet_file_path_));
|
||||||
|
ImGui::EndDisabled();
|
||||||
|
|
||||||
|
if (!import_wallet_file_busy_) {
|
||||||
|
if (m::TactileButton(TR("paste"), ImVec2(104 * dp, 0))) {
|
||||||
|
const char* clip = ImGui::GetClipboardText();
|
||||||
|
if (clip) {
|
||||||
|
std::string t = dragonx::util::trimmedClipboardText(clip);
|
||||||
|
snprintf(import_wallet_file_path_, sizeof(import_wallet_file_path_), "%s", t.c_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ImGui::SameLine();
|
||||||
|
if (m::TactileButton(TR("clear"), ImVec2(104 * dp, 0))) import_wallet_file_path_[0] = '\0';
|
||||||
|
}
|
||||||
|
ImGui::Spacing();
|
||||||
|
|
||||||
|
if (import_wallet_file_busy_) {
|
||||||
|
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceMedium()), "%s%s",
|
||||||
|
TR("import_wallet_file_importing"), m::LoadingDots());
|
||||||
|
ImGui::Spacing();
|
||||||
|
} else if (import_wallet_file_done_) {
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(
|
||||||
|
import_wallet_file_ok_ ? m::Success() : m::ReadableError()));
|
||||||
|
ImGui::TextWrapped("%s", import_wallet_file_status_.c_str());
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
ImGui::Spacing();
|
||||||
|
}
|
||||||
|
|
||||||
|
ImGui::BeginDisabled(import_wallet_file_busy_ || !state_.connected || import_wallet_file_path_[0] == '\0');
|
||||||
|
if (m::TactileButton(TR("import_wallet_file_import_btn"), ImVec2(190 * dp, 0))) {
|
||||||
|
import_wallet_file_busy_ = true;
|
||||||
|
import_wallet_file_done_ = false;
|
||||||
|
import_wallet_file_status_.clear();
|
||||||
|
importWalletFile(import_wallet_file_path_, [this](bool ok, const std::string& err) {
|
||||||
|
import_wallet_file_busy_ = false;
|
||||||
|
import_wallet_file_done_ = true;
|
||||||
|
import_wallet_file_ok_ = ok;
|
||||||
|
import_wallet_file_status_ = ok ? std::string(TR("import_wallet_file_ok")) : err;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ImGui::EndDisabled();
|
||||||
|
ImGui::SameLine();
|
||||||
|
ImGui::BeginDisabled(import_wallet_file_busy_);
|
||||||
|
if (m::TactileButton(TR("close"), ImVec2(110 * dp, 0))) show_import_wallet_file_ = false;
|
||||||
|
ImGui::EndDisabled();
|
||||||
|
|
||||||
|
m::EndOverlayDialog();
|
||||||
|
}
|
||||||
|
|
||||||
void App::renderImportKeyDialog()
|
void App::renderImportKeyDialog()
|
||||||
{
|
{
|
||||||
namespace m = ui::material;
|
namespace m = ui::material;
|
||||||
@@ -4322,8 +4459,11 @@ void App::renderExportKeyDialog()
|
|||||||
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceMedium()), "%s", TR("key_export_private_key"));
|
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceMedium()), "%s", TR("key_export_private_key"));
|
||||||
ImGui::InputTextMultiline("##exportresult", export_result_, sizeof(export_result_),
|
ImGui::InputTextMultiline("##exportresult", export_result_, sizeof(export_result_),
|
||||||
ImVec2(-1, 60.0f * dp), ImGuiInputTextFlags_ReadOnly);
|
ImVec2(-1, 60.0f * dp), ImGuiInputTextFlags_ReadOnly);
|
||||||
if (m::TactileButton(TR("copy")))
|
if (m::TactileButton(TR("copy"))) {
|
||||||
ImGui::SetClipboardText(export_result_);
|
// A private key is a secret — route through the auto-clearing clipboard path (45s armed wipe
|
||||||
|
// + its own "copied, auto-clears" toast) instead of a plain, permanent clipboard write.
|
||||||
|
copySecretToClipboard(export_result_);
|
||||||
|
}
|
||||||
} else if (export_error_) {
|
} else if (export_error_) {
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::PushTextWrapPos(0.0f);
|
ImGui::PushTextWrapPos(0.0f);
|
||||||
@@ -4707,7 +4847,7 @@ void App::renderSeedBackupDialog()
|
|||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::TextWrapped("%s", TR("seed_backup_intro"));
|
ImGui::TextWrapped("%s", TR("seed_backup_intro"));
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ui::RenderSeedWordGrid(ui::SplitSeedWords(seed_backup_phrase_));
|
{ auto words = ui::SplitSeedWords(seed_backup_phrase_); ui::RenderSeedWordGrid(words); ui::WipeSeedWords(words); }
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
if (!seed_backup_status_.empty()) {
|
if (!seed_backup_status_.empty()) {
|
||||||
ImGui::PushTextWrapPos(0.0f);
|
ImGui::PushTextWrapPos(0.0f);
|
||||||
@@ -4762,6 +4902,9 @@ void App::renderSeedMigrationDialog()
|
|||||||
if (!seed_migration_seed_.empty())
|
if (!seed_migration_seed_.empty())
|
||||||
sodium_memzero(&seed_migration_seed_[0], seed_migration_seed_.size());
|
sodium_memzero(&seed_migration_seed_[0], seed_migration_seed_.size());
|
||||||
seed_migration_seed_.clear();
|
seed_migration_seed_.clear();
|
||||||
|
// Disarm the inline confirms so an armed state can never survive a close/reopen.
|
||||||
|
seed_migration_confirm_discard_ = false;
|
||||||
|
seed_migration_confirm_sweep_ = false;
|
||||||
};
|
};
|
||||||
auto close = [this, &wipeSeed]() { wipeSeed(); show_seed_migration_ = false; };
|
auto close = [this, &wipeSeed]() { wipeSeed(); show_seed_migration_ = false; };
|
||||||
|
|
||||||
@@ -4874,7 +5017,7 @@ void App::renderSeedMigrationDialog()
|
|||||||
// distinct artifact, not body copy. Purely visual — the words/derivation/logic are untouched.
|
// distinct artifact, not body copy. Purely visual — the words/derivation/logic are untouched.
|
||||||
{
|
{
|
||||||
ui::material::GlassSectionScope seedPanel;
|
ui::material::GlassSectionScope seedPanel;
|
||||||
ui::RenderSeedWordGrid(ui::SplitSeedWords(seed_migration_seed_));
|
{ auto words = ui::SplitSeedWords(seed_migration_seed_); ui::RenderSeedWordGrid(words); ui::WipeSeedWords(words); }
|
||||||
}
|
}
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::TextColored(kMedium, "%s", TR("mig_receive_addr"));
|
ImGui::TextColored(kMedium, "%s", TR("mig_receive_addr"));
|
||||||
@@ -4895,11 +5038,15 @@ void App::renderSeedMigrationDialog()
|
|||||||
}
|
}
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::Checkbox(TR("mig_backed_up"), &seed_migration_backed_up_);
|
ImGui::Checkbox(TR("mig_backed_up"), &seed_migration_backed_up_);
|
||||||
|
ImGui::Checkbox(TR("mig_understood"), &seed_migration_understood_);
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::OnSurfaceMedium());
|
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::OnSurfaceMedium());
|
||||||
ImGui::TextWrapped("%s", TR("mig_step1_done"));
|
ImGui::TextWrapped("%s", TR("mig_step1_done"));
|
||||||
ImGui::PopStyleColor();
|
ImGui::PopStyleColor();
|
||||||
if (!seed_migration_backed_up_) ImGui::BeginDisabled();
|
// Continue requires BOTH acknowledgements, and is suppressed while the Discard confirm is armed.
|
||||||
|
const bool ackOk = seed_migration_backed_up_ && seed_migration_understood_;
|
||||||
|
const bool continueDisabled = !ackOk || seed_migration_confirm_discard_;
|
||||||
|
if (continueDisabled) ImGui::BeginDisabled();
|
||||||
if (ui::material::TactileButton(TR("mig_continue_sweep"), ImVec2(170 * dp, 0))) {
|
if (ui::material::TactileButton(TR("mig_continue_sweep"), ImVec2(170 * dp, 0))) {
|
||||||
wipeSeed(); // the sweep/adopt steps only use the address, never the seed itself
|
wipeSeed(); // the sweep/adopt steps only use the address, never the seed itself
|
||||||
seed_migration_step_ = SeedMigrationStep::Sweep;
|
seed_migration_step_ = SeedMigrationStep::Sweep;
|
||||||
@@ -4907,23 +5054,40 @@ void App::renderSeedMigrationDialog()
|
|||||||
seed_migration_nofunds_confirmed_ = false;
|
seed_migration_nofunds_confirmed_ = false;
|
||||||
refreshSeedMigrationBalance();
|
refreshSeedMigrationBalance();
|
||||||
}
|
}
|
||||||
if (!seed_migration_backed_up_) ImGui::EndDisabled();
|
if (continueDisabled) ImGui::EndDisabled();
|
||||||
|
if (!ackOk && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
|
||||||
|
ui::material::Tooltip("%s", TR("mig_ack_required"));
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
if (ui::material::TactileButton(TR("mig_discard"), ImVec2(160 * dp, 0))) {
|
// Discard now takes a second click: first click arms a confirm, since it deletes the new wallet.
|
||||||
// Throw away the isolated new wallet + clear the pending state.
|
if (!seed_migration_confirm_discard_) {
|
||||||
if (!seed_migration_temp_dir_.empty()) {
|
if (ui::material::TactileButton(TR("mig_discard"), ImVec2(160 * dp, 0)))
|
||||||
std::error_code ec;
|
seed_migration_confirm_discard_ = true;
|
||||||
std::filesystem::remove_all(seed_migration_temp_dir_, ec);
|
}
|
||||||
|
if (seed_migration_confirm_discard_) {
|
||||||
|
ImGui::Spacing();
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::ReadableError()));
|
||||||
|
ImGui::TextWrapped("%s", TR("mig_discard_confirm"));
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
if (ui::material::TactileButton(TR("mig_discard_yes"), ImVec2(160 * dp, 0))) {
|
||||||
|
// Throw away the isolated new wallet + clear the pending state. (body unchanged)
|
||||||
|
if (!seed_migration_temp_dir_.empty()) {
|
||||||
|
std::error_code ec;
|
||||||
|
std::filesystem::remove_all(seed_migration_temp_dir_, ec);
|
||||||
|
}
|
||||||
|
if (settings_) {
|
||||||
|
settings_->setSeedMigrationPending(false);
|
||||||
|
settings_->setSeedMigrationDest("");
|
||||||
|
settings_->setSeedMigrationTempDir("");
|
||||||
|
settings_->save();
|
||||||
|
}
|
||||||
|
seed_migration_temp_dir_.clear();
|
||||||
|
seed_migration_dest_.clear();
|
||||||
|
seed_migration_confirm_discard_ = false;
|
||||||
|
close();
|
||||||
}
|
}
|
||||||
if (settings_) {
|
ImGui::SameLine();
|
||||||
settings_->setSeedMigrationPending(false);
|
if (ui::material::TactileButton(TR("cancel"), ImVec2(110 * dp, 0)))
|
||||||
settings_->setSeedMigrationDest("");
|
seed_migration_confirm_discard_ = false;
|
||||||
settings_->setSeedMigrationTempDir("");
|
|
||||||
settings_->save();
|
|
||||||
}
|
|
||||||
seed_migration_temp_dir_.clear();
|
|
||||||
seed_migration_dest_.clear();
|
|
||||||
close();
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -4975,7 +5139,7 @@ void App::renderSeedMigrationDialog()
|
|||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
if (!seed_migration_nofunds_confirmed_) ImGui::BeginDisabled();
|
if (!seed_migration_nofunds_confirmed_) ImGui::BeginDisabled();
|
||||||
if (ui::material::TactileButton(TR("mig_adopt_now"), ImVec2(190 * dp, 0)))
|
if (ui::material::TactileButton(TR("mig_adopt_now"), ImVec2(190 * dp, 0)))
|
||||||
beginAdoptSeedWallet();
|
verifyThenAdoptSeedWallet(); // fresh minconf-0, sync-aware balance check before the swap
|
||||||
if (!seed_migration_nofunds_confirmed_) ImGui::EndDisabled();
|
if (!seed_migration_nofunds_confirmed_) ImGui::EndDisabled();
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
if (ui::material::TactileButton(TR("refresh"), ImVec2(110 * dp, 0)))
|
if (ui::material::TactileButton(TR("refresh"), ImVec2(110 * dp, 0)))
|
||||||
@@ -4999,17 +5163,51 @@ void App::renderSeedMigrationDialog()
|
|||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
{
|
{
|
||||||
const bool canSweep = !state_.isLocked() && seed_migration_balance_ > 0.0;
|
const bool canSweep = !state_.isLocked() && seed_migration_balance_ > 0.0;
|
||||||
if (!canSweep) ImGui::BeginDisabled();
|
if (!canSweep) seed_migration_confirm_sweep_ = false; // never keep a stale armed state
|
||||||
if (ui::material::TactileButton(TR("mig_sweep_all"), ImVec2(170 * dp, 0)))
|
// The sweep (z_shieldcoinbase / z_mergetoaddress) needs an unlocked wallet, so an encrypted
|
||||||
beginSweepToSeedWallet();
|
// wallet silently disabled the button. Offer an inline unlock instead of a dead end.
|
||||||
if (!canSweep) ImGui::EndDisabled();
|
if (state_.isLocked()) {
|
||||||
|
static char s_mig_unlock_pass[128] = {0};
|
||||||
|
ImGui::TextWrapped("%s", TR("mig_unlock_prompt"));
|
||||||
|
ImGui::SetNextItemWidth(220 * dp);
|
||||||
|
const bool submit = ImGui::InputText("##mig_unlock", s_mig_unlock_pass, sizeof(s_mig_unlock_pass),
|
||||||
|
ImGuiInputTextFlags_Password | ImGuiInputTextFlags_EnterReturnsTrue);
|
||||||
|
ImGui::SameLine();
|
||||||
|
if ((ui::material::TactileButton(TR("mig_unlock_btn"), ImVec2(120 * dp, 0)) || submit)
|
||||||
|
&& s_mig_unlock_pass[0]) {
|
||||||
|
unlockWallet(s_mig_unlock_pass, 600);
|
||||||
|
sodium_memzero(s_mig_unlock_pass, sizeof(s_mig_unlock_pass)); // don't retain the secret
|
||||||
|
}
|
||||||
|
ImGui::Spacing();
|
||||||
|
}
|
||||||
|
if (!seed_migration_confirm_sweep_) {
|
||||||
|
if (!canSweep) ImGui::BeginDisabled();
|
||||||
|
if (ui::material::TactileButton(TR("mig_sweep_all"), ImVec2(170 * dp, 0)))
|
||||||
|
seed_migration_confirm_sweep_ = true; // first click arms the confirm
|
||||||
|
if (!canSweep) ImGui::EndDisabled();
|
||||||
|
ImGui::SameLine();
|
||||||
|
if (ui::material::TactileButton(TR("refresh"), ImVec2(110 * dp, 0)))
|
||||||
|
refreshSeedMigrationBalance();
|
||||||
|
ImGui::SameLine();
|
||||||
|
if (ui::material::TactileButton(TR("mig_later"), ImVec2(100 * dp, 0)))
|
||||||
|
close(); // pending state persists; resumes here next time
|
||||||
|
} else {
|
||||||
|
// Armed: state the amount being moved before the single sweep transaction fires.
|
||||||
|
char cbuf[160]; snprintf(cbuf, sizeof(cbuf), TR("mig_sweep_confirm"), seed_migration_balance_);
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::ReadableError()));
|
||||||
|
ImGui::TextWrapped("%s", cbuf);
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
if (!canSweep) ImGui::BeginDisabled();
|
||||||
|
if (ui::material::TactileButton(TR("mig_sweep_confirm_yes"), ImVec2(170 * dp, 0))) {
|
||||||
|
seed_migration_confirm_sweep_ = false;
|
||||||
|
beginSweepToSeedWallet(); // unchanged
|
||||||
|
}
|
||||||
|
if (!canSweep) ImGui::EndDisabled();
|
||||||
|
ImGui::SameLine();
|
||||||
|
if (ui::material::TactileButton(TR("cancel"), ImVec2(110 * dp, 0)))
|
||||||
|
seed_migration_confirm_sweep_ = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ImGui::SameLine();
|
|
||||||
if (ui::material::TactileButton(TR("refresh"), ImVec2(110 * dp, 0)))
|
|
||||||
refreshSeedMigrationBalance();
|
|
||||||
ImGui::SameLine();
|
|
||||||
if (ui::material::TactileButton(TR("mig_later"), ImVec2(100 * dp, 0)))
|
|
||||||
close(); // pending state persists; resumes here next time
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5021,7 +5219,8 @@ void App::renderSeedMigrationDialog()
|
|||||||
|
|
||||||
case SeedMigrationStep::Confirming: {
|
case SeedMigrationStep::Confirming: {
|
||||||
const double dust = DRAGONX_DEFAULT_FEE; // treat <= one fee as "empty"
|
const double dust = DRAGONX_DEFAULT_FEE; // treat <= one fee as "empty"
|
||||||
const bool confirmed = seed_migration_sweep_confs_ >= 1;
|
const bool mined = seed_migration_sweep_confs_ >= 1; // remainder re-sweep stays offered once mined
|
||||||
|
const bool confirmed = dragonx::sweepConfsAreReorgSafe(seed_migration_sweep_confs_); // adopt needs reorg-safe depth
|
||||||
const bool legacyEmpty = seed_migration_legacy_remaining_ >= 0.0 &&
|
const bool legacyEmpty = seed_migration_legacy_remaining_ >= 0.0 &&
|
||||||
seed_migration_legacy_remaining_ <= dust;
|
seed_migration_legacy_remaining_ <= dust;
|
||||||
ImGui::TextWrapped("%s", TR("mig_confirming"));
|
ImGui::TextWrapped("%s", TR("mig_confirming"));
|
||||||
@@ -5041,25 +5240,47 @@ void App::renderSeedMigrationDialog()
|
|||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
if (confirmed && legacyEmpty) {
|
if (confirmed && legacyEmpty) {
|
||||||
if (ui::material::TactileButton(TR("mig_adopt"), ImVec2(180 * dp, 0)))
|
if (ui::material::TactileButton(TR("mig_adopt"), ImVec2(180 * dp, 0)))
|
||||||
beginAdoptSeedWallet();
|
verifyThenAdoptSeedWallet(); // re-reads confs + balance right before the daemon stop
|
||||||
} else if (confirmed && seed_migration_legacy_remaining_ >= 0.0 && !legacyEmpty) {
|
} else if (mined && seed_migration_legacy_remaining_ >= 0.0 && !legacyEmpty) {
|
||||||
// Only claim a remainder once the legacy balance was actually measured; while a sweep is
|
// Only claim a remainder once the legacy balance was actually measured; while a sweep is
|
||||||
// mined but the balance scan is still gated (node syncing), remaining stays at the -1.0
|
// mined but the balance scan is still gated (node syncing), remaining stays at the -1.0
|
||||||
// sentinel, so fall through to the waiting state rather than offering a sweep on an unknown.
|
// sentinel, so fall through to the waiting state rather than offering a sweep on an unknown.
|
||||||
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::ReadableError());
|
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::ReadableError());
|
||||||
ImGui::TextWrapped("%s", TR("mig_remainder"));
|
ImGui::TextWrapped("%s", TR("mig_remainder"));
|
||||||
ImGui::PopStyleColor();
|
ImGui::PopStyleColor();
|
||||||
if (ui::material::TactileButton(TR("mig_sweep_remaining"), ImVec2(180 * dp, 0)))
|
if (!seed_migration_confirm_sweep_) {
|
||||||
beginSweepToSeedWallet();
|
if (ui::material::TactileButton(TR("mig_sweep_remaining"), ImVec2(180 * dp, 0)))
|
||||||
|
seed_migration_confirm_sweep_ = true; // first click arms
|
||||||
|
} else {
|
||||||
|
char rcbuf[160];
|
||||||
|
snprintf(rcbuf, sizeof(rcbuf), TR("mig_sweep_confirm"), seed_migration_legacy_remaining_);
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::ReadableError()));
|
||||||
|
ImGui::TextWrapped("%s", rcbuf);
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
if (ui::material::TactileButton(TR("mig_sweep_confirm_yes"), ImVec2(180 * dp, 0))) {
|
||||||
|
seed_migration_confirm_sweep_ = false;
|
||||||
|
beginSweepToSeedWallet(); // unchanged
|
||||||
|
}
|
||||||
|
ImGui::SameLine();
|
||||||
|
if (ui::material::TactileButton(TR("cancel"), ImVec2(110 * dp, 0)))
|
||||||
|
seed_migration_confirm_sweep_ = false;
|
||||||
|
}
|
||||||
|
} else if (mined && legacyEmpty) {
|
||||||
|
// Mined but not yet reorg-safe deep: say so instead of the misleading "waiting to be mined".
|
||||||
|
char dbuf[200]; snprintf(dbuf, sizeof(dbuf), TR("mig_waiting_depth"), dragonx::kSweepAdoptMinConfs);
|
||||||
|
ImGui::TextWrapped("%s", dbuf);
|
||||||
} else {
|
} else {
|
||||||
ImGui::TextColored(kMedium, "%s", TR("mig_waiting_mine"));
|
ImGui::TextColored(kMedium, "%s", TR("mig_waiting_mine"));
|
||||||
}
|
}
|
||||||
ImGui::SameLine();
|
// Refresh / Later are hidden while the sweep confirm is armed, to keep that row uncluttered.
|
||||||
if (ui::material::TactileButton(TR("refresh"), ImVec2(110 * dp, 0)))
|
if (!seed_migration_confirm_sweep_) {
|
||||||
pollSweepStatus(/*forceBalance=*/true);
|
ImGui::SameLine();
|
||||||
ImGui::SameLine();
|
if (ui::material::TactileButton(TR("refresh"), ImVec2(110 * dp, 0)))
|
||||||
if (ui::material::TactileButton(TR("mig_later"), ImVec2(100 * dp, 0)))
|
pollSweepStatus(/*forceBalance=*/true);
|
||||||
close(); // pending state + txid persist; resumes here next time
|
ImGui::SameLine();
|
||||||
|
if (ui::material::TactileButton(TR("mig_later"), ImVec2(100 * dp, 0)))
|
||||||
|
close(); // pending state + txid persist; resumes here next time
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5976,16 +6197,16 @@ bool App::startEmbeddedDaemon()
|
|||||||
// NB: this callback runs on EmbeddedDaemon's monitor thread — write via the locked setter.
|
// NB: this callback runs on EmbeddedDaemon's monitor thread — write via the locked setter.
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case daemon::EmbeddedDaemon::State::Starting:
|
case daemon::EmbeddedDaemon::State::Starting:
|
||||||
setDaemonStatus(TR("sb_starting_daemon"));
|
setDaemonStatus(util::trCopy("sb_starting_daemon"));
|
||||||
break;
|
break;
|
||||||
case daemon::EmbeddedDaemon::State::Running:
|
case daemon::EmbeddedDaemon::State::Running:
|
||||||
setDaemonStatus(TR("sb_dragonxd_running"));
|
setDaemonStatus(util::trCopy("sb_dragonxd_running"));
|
||||||
break;
|
break;
|
||||||
case daemon::EmbeddedDaemon::State::Stopping:
|
case daemon::EmbeddedDaemon::State::Stopping:
|
||||||
setDaemonStatus(TR("sb_dragonxd_stopping"));
|
setDaemonStatus(util::trCopy("sb_dragonxd_stopping"));
|
||||||
break;
|
break;
|
||||||
case daemon::EmbeddedDaemon::State::Stopped:
|
case daemon::EmbeddedDaemon::State::Stopped:
|
||||||
setDaemonStatus(TR("sb_dragonxd_stopped"));
|
setDaemonStatus(util::trCopy("sb_dragonxd_stopped"));
|
||||||
break;
|
break;
|
||||||
case daemon::EmbeddedDaemon::State::Error:
|
case daemon::EmbeddedDaemon::State::Error:
|
||||||
setDaemonStatus("Error: " + msg);
|
setDaemonStatus("Error: " + msg);
|
||||||
@@ -8122,6 +8343,7 @@ void App::restartDaemon()
|
|||||||
isUsingEmbeddedDaemon(), daemon_controller_ != nullptr, isEmbeddedDaemonRunning(), daemon_restarting_.load());
|
isUsingEmbeddedDaemon(), daemon_controller_ != nullptr, isEmbeddedDaemonRunning(), daemon_restarting_.load());
|
||||||
if (!decision.allowed) return;
|
if (!decision.allowed) return;
|
||||||
if (refuseIfNodeBusy()) return;
|
if (refuseIfNodeBusy()) return;
|
||||||
|
daemon_restart_pending_.store(false); // a restart is actually happening — clear the reminder
|
||||||
daemon_restarting_ = true;
|
daemon_restarting_ = true;
|
||||||
|
|
||||||
daemon_controller_->prepareLifecycleOperation(decision, settings_.get());
|
daemon_controller_->prepareLifecycleOperation(decision, settings_.get());
|
||||||
|
|||||||
@@ -49,6 +49,7 @@
|
|||||||
#include "wallet/wallet_capabilities.h"
|
#include "wallet/wallet_capabilities.h"
|
||||||
#include "chat/chat_service.h"
|
#include "chat/chat_service.h"
|
||||||
#include "chat/chat_database.h"
|
#include "chat/chat_database.h"
|
||||||
|
#include "chat/chat_sensitive_vault.h"
|
||||||
#include "ui/sidebar.h"
|
#include "ui/sidebar.h"
|
||||||
#include "ui/windows/console_tab.h"
|
#include "ui/windows/console_tab.h"
|
||||||
#include "imgui.h"
|
#include "imgui.h"
|
||||||
@@ -114,6 +115,7 @@ class App {
|
|||||||
public:
|
public:
|
||||||
App();
|
App();
|
||||||
void wipeSecrets(); // scrub all resident secret buffers; called from ~App() AND the forced-exit path (L-05)
|
void wipeSecrets(); // scrub all resident secret buffers; called from ~App() AND the forced-exit path (L-05)
|
||||||
|
void wipeDisplayedSecrets(); // scrub + close any on-screen secret (seed/key displays) on wallet lock
|
||||||
~App();
|
~App();
|
||||||
|
|
||||||
// Non-copyable
|
// Non-copyable
|
||||||
@@ -320,6 +322,25 @@ public:
|
|||||||
// Send a contact request into an existing conversation (used to retry a failed request in place).
|
// 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,
|
void sendContactRequestForCid(const std::string& cid, const std::string& peerZaddr,
|
||||||
const std::string& text);
|
const std::string& text);
|
||||||
|
// Broadcast a self-addressed Control memo (op "del"/"res") so every device and a fresh seed import
|
||||||
|
// hides/restores this conversation. `label` is a plaintext hint (peer address/name) carried on-chain.
|
||||||
|
// Reuses the chat send coordinator + the 0-value kChatMinFeeDrgx z_sendmany path; no UI echo bubble.
|
||||||
|
void sendChatControl(const std::string& conversationId, const std::string& op, const std::string& label);
|
||||||
|
|
||||||
|
// --- Sensitive conversations (per-conversation local-key encryption) ---
|
||||||
|
enum class ChatVaultState { NotConfigured, Locked, Unlocked };
|
||||||
|
ChatVaultState chatSensitiveVaultState() const;
|
||||||
|
bool chatSensitiveVaultConfigured() const; // a vault file exists (regardless of lock state)
|
||||||
|
// Create the vault (first-time setup) and unlock it. Returns false if one already exists or on error.
|
||||||
|
bool createChatSensitiveVault(const std::string& passphrase);
|
||||||
|
// Provide the vault key to the chat DB so sensitive rows decrypt; also pulls them into the store.
|
||||||
|
bool unlockChatSensitiveVault(const std::string& passphrase);
|
||||||
|
void lockChatSensitiveVault(); // drop the key; sensitive threads vanish from the view
|
||||||
|
// Mark/unmark a conversation sensitive. Requires the vault UNLOCKED (migrates its rows to/from the
|
||||||
|
// vault key) and broadcasts the on-chain "sensitive"/"unsensitive" signal. `peerAddr` labels it.
|
||||||
|
bool markChatConversationSensitive(const std::string& cid, const std::string& peerAddr);
|
||||||
|
void unmarkChatConversationSensitive(const std::string& cid);
|
||||||
|
|
||||||
// Debug/sweep convenience: give the Chat tab a demo identity + a few sample conversations
|
// 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
|
// (in-memory only, not persisted) so the screenshot sweep captures the populated UI. No-op when
|
||||||
// the chat feature is off. Returns true when it had to fabricate the (random) demo identity
|
// the chat feature is off. Returns true when it had to fabricate the (random) demo identity
|
||||||
@@ -332,6 +353,10 @@ public:
|
|||||||
// Lock the lite wallet AND immediately tear down the chat session (the lite backend `lock`
|
// Lock the lite wallet AND immediately tear down the chat session (the lite backend `lock`
|
||||||
// doesn't update state_.locked until the next poll, so chat secrets would otherwise linger).
|
// doesn't update state_.locked until the next poll, so chat secrets would otherwise linger).
|
||||||
bool lockLiteWallet();
|
bool lockLiteWallet();
|
||||||
|
// Unlock the lite wallet with the same escalating attempt-lockout as the full node (persisted across restarts)
|
||||||
|
// and set state_.locked=false right away on success (the poll lags). Takes the passphrase by value and wipes
|
||||||
|
// it. On failure *err (if given) holds a user-facing reason ("Unlock failed..." or the lockout wait).
|
||||||
|
bool unlockLiteWallet(std::string passphrase, std::string* err = nullptr);
|
||||||
// (Re)build the lite controller from current settings so a changed lite-server selection
|
// (Re)build the lite controller from current settings so a changed lite-server selection
|
||||||
// takes effect. No-op on non-lite/unlinked builds; preserves a live wallet (see app.cpp).
|
// takes effect. No-op on non-lite/unlinked builds; preserves a live wallet (see app.cpp).
|
||||||
void rebuildLiteWallet(bool force = false);
|
void rebuildLiteWallet(bool force = false);
|
||||||
@@ -448,6 +473,9 @@ public:
|
|||||||
// Address operations
|
// Address operations
|
||||||
void createNewZAddress(std::function<void(const std::string&)> callback = nullptr);
|
void createNewZAddress(std::function<void(const std::string&)> callback = nullptr);
|
||||||
void createNewTAddress(std::function<void(const std::string&)> callback = nullptr);
|
void createNewTAddress(std::function<void(const std::string&)> callback = nullptr);
|
||||||
|
// The daemon's actual reason the last new-address RPC failed (friendly-mapped), or "" on success. Lets
|
||||||
|
// the UI show e.g. "HD seed not found" (legacy/degraded wallet) instead of a generic "couldn't create".
|
||||||
|
const std::string& lastAddressError() const { return last_address_error_; }
|
||||||
|
|
||||||
// Hide/unhide addresses from the address list (persisted in settings)
|
// Hide/unhide addresses from the address list (persisted in settings)
|
||||||
void hideAddress(const std::string& addr);
|
void hideAddress(const std::string& addr);
|
||||||
@@ -557,10 +585,11 @@ public:
|
|||||||
private:
|
private:
|
||||||
App& app_;
|
App& app_;
|
||||||
};
|
};
|
||||||
// Main thread, right before rpc_->disconnect(): abort rpc_'s in-flight call if it is a maintenance
|
// Main thread, right before rpc_->disconnect(): abort rpc_'s in-flight call (any call, not just a
|
||||||
// call, so the disconnect doesn't freeze the UI for up to its 120 s, and refuse maintenance calls that
|
// maintenance one: disconnect() needs the curl lock the call holds, which would freeze the UI), and
|
||||||
// start before the next connect (onConnected() lifts that). Ordinary calls are left to finish (at most
|
// refuse maintenance calls that start before the next connect (onConnected() lifts that). The abort
|
||||||
// 30 s, as before), so a user's own send is never cut off mid-call.
|
// flag is cleared by the next tryConnect(). A user send aborted mid-flight is handled as a lost reply
|
||||||
|
// ("outcome unknown — check History"), never a double-send.
|
||||||
void abortMaintenanceCalls();
|
void abortMaintenanceCalls();
|
||||||
// Whether a whole-wallet scan the user didn't ask for may be sent now: a connected, unlocked, synced
|
// Whether a whole-wallet scan the user didn't ask for may be sent now: a connected, unlocked, synced
|
||||||
// full node that is past warmup, not rescanning and not busy.
|
// full node that is past warmup, not rescanning and not busy.
|
||||||
@@ -641,6 +670,9 @@ public:
|
|||||||
// Dialog triggers (used by settings page to open modal dialogs)
|
// Dialog triggers (used by settings page to open modal dialogs)
|
||||||
void showImportKeyDialog() { import_view_mode_ = false; show_import_key_ = true; } // spending
|
void showImportKeyDialog() { import_view_mode_ = false; show_import_key_ = true; } // spending
|
||||||
void showImportViewingKeyDialog() { import_view_mode_ = true; show_import_key_ = true; } // watch-only
|
void showImportViewingKeyDialog() { import_view_mode_ = true; show_import_key_ = true; } // watch-only
|
||||||
|
void showImportWalletFileDialog() { show_import_wallet_file_ = true; } // import a whole legacy key dump
|
||||||
|
// Import every key from an old wallet's z_exportwallet/dumpwallet dump file (z_importwallet, one rescan).
|
||||||
|
void importWalletFile(const std::string& path, std::function<void(bool, const std::string&)> callback);
|
||||||
void showExportKeyDialog() { show_export_key_ = true; }
|
void showExportKeyDialog() { show_export_key_ = true; }
|
||||||
void showBackupDialog() { show_backup_ = true; }
|
void showBackupDialog() { show_backup_ = true; }
|
||||||
void showSeedBackupDialog() { show_seed_backup_ = true; }
|
void showSeedBackupDialog() { show_seed_backup_ = true; }
|
||||||
@@ -769,6 +801,9 @@ public:
|
|||||||
* Shows "Restarting daemon..." in the loading overlay while the daemon cycles.
|
* Shows "Restarting daemon..." in the loading overlay while the daemon cycles.
|
||||||
*/
|
*/
|
||||||
void restartDaemon();
|
void restartDaemon();
|
||||||
|
// A newer daemon binary is installed but the running node is still the old one until a restart.
|
||||||
|
void markDaemonRestartPending() { daemon_restart_pending_.store(true); }
|
||||||
|
bool daemonRestartPending() const { return daemon_restart_pending_.load(); }
|
||||||
// Switch the active wallet: persist the new -wallet=<name>, stop the node, restart on it
|
// 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
|
// (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.
|
// via the identity-scoped caches (P1). No-op if that wallet is already active.
|
||||||
@@ -927,6 +962,8 @@ private:
|
|||||||
// curve on every failed path (a PIN RPC failure used to skip it).
|
// curve on every failed path (a PIN RPC failure used to skip it).
|
||||||
void applyUnlockSuccess(const std::string& passphrase, int timeout);
|
void applyUnlockSuccess(const std::string& passphrase, int timeout);
|
||||||
void applyUnlockFailure(const std::string& errorMessage);
|
void applyUnlockFailure(const std::string& errorMessage);
|
||||||
|
// Restore the persisted unlock-failure count + lockout (once per lock-screen session, per wallet).
|
||||||
|
void restoreLockoutState();
|
||||||
// Non-credential failure (busy node, unknown error): message only, no attempt counted.
|
// Non-credential failure (busy node, unknown error): message only, no attempt counted.
|
||||||
void applyUnlockNotCounted(const std::string& errorMessage);
|
void applyUnlockNotCounted(const std::string& errorMessage);
|
||||||
// Clear all rescan + witness-rebuild progress/accumulator state. Shared by the four
|
// Clear all rescan + witness-rebuild progress/accumulator state. Shared by the four
|
||||||
@@ -1003,6 +1040,8 @@ private:
|
|||||||
// z_exportmnemonic / lite exportSeed → the same KDF), so it is portable across both variants.
|
// z_exportmnemonic / lite exportSeed → the same KDF), so it is portable across both variants.
|
||||||
chat::ChatService chat_service_;
|
chat::ChatService chat_service_;
|
||||||
chat::ChatDatabase chat_db_; // persistent backing (seed-derived encryption at rest)
|
chat::ChatDatabase chat_db_; // persistent backing (seed-derived encryption at rest)
|
||||||
|
chat::ChatSensitiveVault chat_sensitive_vault_; // local key vault for "sensitive" conversations
|
||||||
|
std::string chatSensitiveVaultPath() const; // <configDir>/chat_sensitive.vault
|
||||||
bool chat_identity_provisioned_ = false; // identity set on the service this session
|
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
|
bool chat_identity_fetch_in_flight_ = false; // a z_exportmnemonic worker job is pending
|
||||||
std::chrono::steady_clock::time_point chat_identity_retry_at_{}; // after a transient failure
|
std::chrono::steady_clock::time_point chat_identity_retry_at_{}; // after a transient failure
|
||||||
@@ -1182,6 +1221,7 @@ private:
|
|||||||
void pollSweepStatus(bool forceBalance = false); // Confirming step: confs always; balance only when it can move the gate
|
void pollSweepStatus(bool forceBalance = false); // Confirming step: confs always; balance only when it can move the gate
|
||||||
void locateSweepTxid(); // recover a lost sweep txid (send to dest) -> Confirming gate
|
void locateSweepTxid(); // recover a lost sweep txid (send to dest) -> Confirming gate
|
||||||
void beginAdoptSeedWallet(); // stop daemon -> swap wallet.dat -> restart with -rescan
|
void beginAdoptSeedWallet(); // stop daemon -> swap wallet.dat -> restart with -rescan
|
||||||
|
void verifyThenAdoptSeedWallet(); // re-read sweep confs + minconf-0 balance, then beginAdoptSeedWallet() iff safe
|
||||||
void provisionChatIdentityFromSecret(std::string secret);
|
void provisionChatIdentityFromSecret(std::string secret);
|
||||||
std::string chatReplyZaddr(); // a stable (persisted) wallet z-addr — chat IDENTITY (reply-to)
|
std::string chatReplyZaddr(); // a stable (persisted) wallet z-addr — chat IDENTITY (reply-to)
|
||||||
// A spendable z-address that can actually PAY `need` (a fee, or a split's notes plus its fee), preferring
|
// A spendable z-address that can actually PAY `need` (a fee, or a split's notes plus its fee), preferring
|
||||||
@@ -1309,6 +1349,7 @@ private:
|
|||||||
|
|
||||||
// Daemon restart (e.g. after changing debug log categories)
|
// Daemon restart (e.g. after changing debug log categories)
|
||||||
std::atomic<bool> daemon_restarting_{false};
|
std::atomic<bool> daemon_restarting_{false};
|
||||||
|
std::atomic<bool> daemon_restart_pending_{false}; // updated binary installed, awaiting a restart
|
||||||
|
|
||||||
// Wallet-switch failure recovery: the switch worker sets wallet_switch_failed_ when the new
|
// 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's daemon won't start/stay up; the main loop then reverts active_wallet_file to
|
||||||
@@ -1337,6 +1378,7 @@ private:
|
|||||||
// so a possibly-incomplete salvaged wallet isn't mistaken for fund loss. Warned once per session.
|
// so a possibly-incomplete salvaged wallet isn't mistaken for fund loss. Warned once per session.
|
||||||
bool wallet_auto_recovered_ = false; // a salvage happened this session
|
bool wallet_auto_recovered_ = false; // a salvage happened this session
|
||||||
bool wallet_auto_recovered_warned_ = false; // guard: only surface it once per session
|
bool wallet_auto_recovered_warned_ = false; // guard: only surface it once per session
|
||||||
|
std::string last_address_error_; // daemon reason the last z_getnewaddress/getnewaddress failed ("" = ok)
|
||||||
bool wallet_degraded_ = false; // v1.3.0+ opened the wallet in DEGRADED mode (no new HD keys)
|
bool wallet_degraded_ = false; // v1.3.0+ opened the wallet in DEGRADED mode (no new HD keys)
|
||||||
bool wallet_degraded_warned_ = false; // guard: surface the degraded-mode notice once per session
|
bool wallet_degraded_warned_ = false; // guard: surface the degraded-mode notice once per session
|
||||||
bool show_wallet_recovered_dialog_ = false; // auto-shown warning dialog
|
bool show_wallet_recovered_dialog_ = false; // auto-shown warning dialog
|
||||||
@@ -1399,10 +1441,15 @@ private:
|
|||||||
// UI State
|
// UI State
|
||||||
bool quit_requested_ = false;
|
bool quit_requested_ = false;
|
||||||
bool show_demo_window_ = false;
|
bool show_demo_window_ = false;
|
||||||
bool show_settings_ = false;
|
|
||||||
bool show_about_ = false;
|
bool show_about_ = false;
|
||||||
bool show_faq_ = false;
|
bool show_faq_ = false;
|
||||||
bool show_import_key_ = false;
|
bool show_import_key_ = false;
|
||||||
|
bool show_import_wallet_file_ = false; // the "import keys from a backup file" modal is open
|
||||||
|
char import_wallet_file_path_[1024] = {0}; // typed path to the old wallet's key dump (not a secret)
|
||||||
|
bool import_wallet_file_busy_ = false; // a z_importwallet long call is in flight
|
||||||
|
bool import_wallet_file_done_ = false; // terminal result reached (ok/err in import_wallet_file_ok_)
|
||||||
|
bool import_wallet_file_ok_ = false;
|
||||||
|
std::string import_wallet_file_status_; // result / error text shown in the modal
|
||||||
bool show_export_key_ = false;
|
bool show_export_key_ = false;
|
||||||
bool show_backup_ = false;
|
bool show_backup_ = false;
|
||||||
bool show_seed_backup_ = false;
|
bool show_seed_backup_ = false;
|
||||||
@@ -1453,6 +1500,9 @@ private:
|
|||||||
std::string seed_migration_dest_; // new shielded z-address (Phase 2 sweep target)
|
std::string seed_migration_dest_; // new shielded z-address (Phase 2 sweep target)
|
||||||
std::string seed_migration_temp_dir_; // temp datadir root holding the new wallet (kept)
|
std::string seed_migration_temp_dir_; // temp datadir root holding the new wallet (kept)
|
||||||
bool seed_migration_backed_up_ = false; // "I've written it down" confirmation
|
bool seed_migration_backed_up_ = false; // "I've written it down" confirmation
|
||||||
|
bool seed_migration_understood_ = false; // 2nd ack: funds recoverable only via the phrase
|
||||||
|
bool seed_migration_confirm_discard_ = false; // inline "are you sure" gate for Discard (never persisted)
|
||||||
|
bool seed_migration_confirm_sweep_ = false; // inline "are you sure" gate for Sweep (never persisted)
|
||||||
std::string seed_migration_status_; // main-thread display (progress / error text)
|
std::string seed_migration_status_; // main-thread display (progress / error text)
|
||||||
double seed_migration_balance_ = 0.0; // legacy total to sweep (shown on the Sweep step)
|
double seed_migration_balance_ = 0.0; // legacy total to sweep (shown on the Sweep step)
|
||||||
std::string seed_migration_sweep_txid_; // the z_mergetoaddress sweep transaction id
|
std::string seed_migration_sweep_txid_; // the z_mergetoaddress sweep transaction id
|
||||||
@@ -1973,6 +2023,7 @@ private:
|
|||||||
int lock_attempts_ = 0;
|
int lock_attempts_ = 0;
|
||||||
float lock_lockout_timer_ = 0.0f;
|
float lock_lockout_timer_ = 0.0f;
|
||||||
std::chrono::steady_clock::time_point lock_timer_armed_at_{}; // when lock_error/lockout timers were last armed
|
std::chrono::steady_clock::time_point lock_timer_armed_at_{}; // when lock_error/lockout timers were last armed
|
||||||
|
bool lock_state_restored_ = false; // one-shot: persisted lockout restored for this lock-screen session
|
||||||
bool lock_unlock_in_progress_ = false;
|
bool lock_unlock_in_progress_ = false;
|
||||||
|
|
||||||
// Encrypt wallet dialog state
|
// Encrypt wallet dialog state
|
||||||
@@ -2040,6 +2091,7 @@ private:
|
|||||||
void renderLiteFirstRunPrompt(); // lite-only welcome modal when no wallet exists yet
|
void renderLiteFirstRunPrompt(); // lite-only welcome modal when no wallet exists yet
|
||||||
void renderLiteUnlockPrompt(); // lite-only send-time unlock modal
|
void renderLiteUnlockPrompt(); // lite-only send-time unlock modal
|
||||||
void renderImportKeyDialog();
|
void renderImportKeyDialog();
|
||||||
|
void renderImportWalletFileDialog(); // typed-path import of a legacy key dump (z_importwallet)
|
||||||
void renderExportKeyDialog();
|
void renderExportKeyDialog();
|
||||||
void renderBackupDialog();
|
void renderBackupDialog();
|
||||||
void renderSeedBackupDialog(); // full-node "Back up seed phrase" modal (z_exportmnemonic)
|
void renderSeedBackupDialog(); // full-node "Back up seed phrase" modal (z_exportmnemonic)
|
||||||
|
|||||||
+466
-54
@@ -41,6 +41,10 @@
|
|||||||
#include "ui/windows/mining_pool_panel.h" // ui::resolveMiningUserAddress
|
#include "ui/windows/mining_pool_panel.h" // ui::resolveMiningUserAddress
|
||||||
#include <sodium.h> // sodium_memzero for wiping the fetched mnemonic
|
#include <sodium.h> // sodium_memzero for wiping the fetched mnemonic
|
||||||
#include <cctype>
|
#include <cctype>
|
||||||
|
#ifndef _WIN32
|
||||||
|
#include <fcntl.h> // open/fsync: flush the staged wallet + .bak before the atomic swap
|
||||||
|
#include <unistd.h>
|
||||||
|
#endif
|
||||||
#include "config/settings.h"
|
#include "config/settings.h"
|
||||||
#include "wallet/lite_wallet_controller.h" // lite send/new-address routing
|
#include "wallet/lite_wallet_controller.h" // lite send/new-address routing
|
||||||
#include "wallet/lite_wallet_state_mapper.h" // LiteWalletAppRefreshModel for lite chat harvest
|
#include "wallet/lite_wallet_state_mapper.h" // LiteWalletAppRefreshModel for lite chat harvest
|
||||||
@@ -559,15 +563,31 @@ void App::tryConnect()
|
|||||||
attempt, isUsingEmbeddedDaemon() ? "yes" : "no");
|
attempt, isUsingEmbeddedDaemon() ? "yes" : "no");
|
||||||
}
|
}
|
||||||
|
|
||||||
worker_->post([this, config, daemonStarting, externalDetected, attempt]() -> rpc::RPCWorker::MainCb {
|
// A previous teardown leaves rpc_'s abort flag set (open() no longer clears it, so an abort issued
|
||||||
|
// while this connect is still queued is not erased). This is the reconnect entry point: clear it here
|
||||||
|
// on the main thread, after the daemon_restarting_/shutting_down_ gates above.
|
||||||
|
if (rpc_ && !shutting_down_.load()) rpc_->clearAbort();
|
||||||
|
const std::uint64_t connectSession = rpc_session_; // main thread; a teardown/onConnected bumps it
|
||||||
|
worker_->post([this, config, daemonStarting, externalDetected, attempt, connectSession]() -> rpc::RPCWorker::MainCb {
|
||||||
bool connected = rpc_->connect(config.host, config.port, config.rpcuser, config.rpcpassword, config.use_tls);
|
bool connected = rpc_->connect(config.host, config.port, config.rpcuser, config.rpcpassword, config.use_tls);
|
||||||
std::string connectErr = rpc_->getLastConnectError();
|
std::string connectErr = rpc_->getLastConnectError();
|
||||||
const rpc::RpcFailureKind connectKind = rpc_->getLastConnectFailureKind();
|
const rpc::RpcFailureKind connectKind = rpc_->getLastConnectFailureKind();
|
||||||
bool warmingUp = rpc_->isWarmingUp();
|
bool warmingUp = rpc_->isWarmingUp();
|
||||||
std::string warmupStatus = rpc_->getWarmupStatus();
|
std::string warmupStatus = rpc_->getWarmupStatus();
|
||||||
|
|
||||||
return [this, config, connected, warmingUp, warmupStatus, daemonStarting, externalDetected, attempt, connectErr,
|
return [this, config, connected, warmingUp, warmupStatus, daemonStarting, externalDetected, attempt, connectErr,
|
||||||
connectKind]() {
|
connectKind, connectSession]() {
|
||||||
|
// A probe that succeeded after a wallet switch / adopt / restore / shutdown already tore the
|
||||||
|
// connection down must not run onConnected() against a daemon that is about to be stopped.
|
||||||
|
if (connected && (connectSession != rpc_session_ || daemon_restarting_.load() || shutting_down_.load())) {
|
||||||
|
VERBOSE_LOGF("[connect #%d] Dropping stale connect result (teardown raced the probe)\n", attempt);
|
||||||
|
if (rpc_) {
|
||||||
|
rpc_->requestAbort(); // a worker call may hold curl_mutex_; don't block the UI on disconnect()
|
||||||
|
rpc_->disconnect();
|
||||||
|
}
|
||||||
|
connection_in_progress_ = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
noteDaemonRpcResult(connected, connectKind); // a busy node (timeouts / queue full) feeds the quit guard
|
noteDaemonRpcResult(connected, connectKind); // a busy node (timeouts / queue full) feeds the quit guard
|
||||||
if (connected) connect_backoff_.reset();
|
if (connected) connect_backoff_.reset();
|
||||||
if (connected) {
|
if (connected) {
|
||||||
@@ -596,12 +616,19 @@ void App::tryConnect()
|
|||||||
if (rpc::Connection::buildCookieAuthConfig(config, cookieConfig)) {
|
if (rpc::Connection::buildCookieAuthConfig(config, cookieConfig)) {
|
||||||
VERBOSE_LOGF("[connect #%d] HTTP 401 — retrying with .cookie auth from %s\n",
|
VERBOSE_LOGF("[connect #%d] HTTP 401 — retrying with .cookie auth from %s\n",
|
||||||
attempt, cookieConfig.hush_dir.c_str());
|
attempt, cookieConfig.hush_dir.c_str());
|
||||||
worker_->post([this, cookieConfig, attempt]() -> rpc::RPCWorker::MainCb {
|
worker_->post([this, cookieConfig, attempt, connectSession]() -> rpc::RPCWorker::MainCb {
|
||||||
bool ok = rpc_->connect(cookieConfig.host, cookieConfig.port,
|
bool ok = rpc_->connect(cookieConfig.host, cookieConfig.port,
|
||||||
cookieConfig.rpcuser, cookieConfig.rpcpassword,
|
cookieConfig.rpcuser, cookieConfig.rpcpassword,
|
||||||
cookieConfig.use_tls);
|
cookieConfig.use_tls);
|
||||||
return [this, cookieConfig, ok, attempt]() {
|
return [this, cookieConfig, ok, attempt, connectSession]() {
|
||||||
connection_in_progress_ = false;
|
connection_in_progress_ = false;
|
||||||
|
if (ok && (connectSession != rpc_session_ || daemon_restarting_.load() || shutting_down_.load())) {
|
||||||
|
if (rpc_) {
|
||||||
|
rpc_->requestAbort();
|
||||||
|
rpc_->disconnect();
|
||||||
|
}
|
||||||
|
return; // stale: a teardown raced the probe
|
||||||
|
}
|
||||||
if (ok) {
|
if (ok) {
|
||||||
VERBOSE_LOGF("[connect #%d] Connected via .cookie auth\n", attempt);
|
VERBOSE_LOGF("[connect #%d] Connected via .cookie auth\n", attempt);
|
||||||
saved_config_ = cookieConfig;
|
saved_config_ = cookieConfig;
|
||||||
@@ -832,6 +859,7 @@ void App::onConnected()
|
|||||||
fast_worker_->start();
|
fast_worker_->start();
|
||||||
}
|
}
|
||||||
// Connect on the fast worker's own thread (non-blocking to main)
|
// Connect on the fast worker's own thread (non-blocking to main)
|
||||||
|
if (fast_rpc_ && !shutting_down_.load()) fast_rpc_->clearAbort(); // open() no longer clears a prior teardown's abort
|
||||||
fast_worker_->post([this]() -> rpc::RPCWorker::MainCb {
|
fast_worker_->post([this]() -> rpc::RPCWorker::MainCb {
|
||||||
bool ok = fast_rpc_->connect(saved_config_.host, saved_config_.port,
|
bool ok = fast_rpc_->connect(saved_config_.host, saved_config_.port,
|
||||||
saved_config_.rpcuser, saved_config_.rpcpassword,
|
saved_config_.rpcuser, saved_config_.rpcpassword,
|
||||||
@@ -891,8 +919,10 @@ void App::onDisconnected(const std::string& reason)
|
|||||||
pending_opids_.clear();
|
pending_opids_.clear();
|
||||||
pending_send_info_.clear();
|
pending_send_info_.clear();
|
||||||
// Resolve any deferred send callbacks so their UI doesn't spin forever on disconnect.
|
// Resolve any deferred send callbacks so their UI doesn't spin forever on disconnect.
|
||||||
|
// Each callback here exists only because z_sendmany already returned an opid, so the op may still
|
||||||
|
// commit (or have committed) while we lost the connection: report "outcome unknown", not a failure.
|
||||||
for (auto& entry : pending_send_callbacks_) {
|
for (auto& entry : pending_send_callbacks_) {
|
||||||
if (entry.second) entry.second(false, reason);
|
if (entry.second) entry.second(false, rpc::markSendMaybeInFlight(reason));
|
||||||
}
|
}
|
||||||
pending_send_callbacks_.clear();
|
pending_send_callbacks_.clear();
|
||||||
consecutive_core_failures_ = 0;
|
consecutive_core_failures_ = 0;
|
||||||
@@ -950,6 +980,13 @@ void App::onDisconnected(const std::string& reason)
|
|||||||
// the fast lane dead for the rest of the session — which silently stalls the opid poll
|
// the fast lane dead for the rest of the session — which silently stalls the opid poll
|
||||||
// (its post() never runs, so a completed send spins on "Waiting for operation").
|
// (its post() never runs, so a completed send spins on "Waiting for operation").
|
||||||
fast_worker_.reset();
|
fast_worker_.reset();
|
||||||
|
// stop() discards undrained MainCbs, and these in-progress flags are cleared only inside
|
||||||
|
// those callbacks (unlock/lock/PIN/encrypt are posted on the fast lane when it is running).
|
||||||
|
// Release them here so the lock screen cannot show "Unlocking..." forever; the user can retry
|
||||||
|
// after reconnect, and the real encryption state is re-read on connect.
|
||||||
|
lock_unlock_in_progress_ = false;
|
||||||
|
pin_in_progress_ = false;
|
||||||
|
encrypt_in_progress_ = false;
|
||||||
}
|
}
|
||||||
if (fast_rpc_) {
|
if (fast_rpc_) {
|
||||||
fast_rpc_->disconnect();
|
fast_rpc_->disconnect();
|
||||||
@@ -1111,7 +1148,10 @@ void App::abortMaintenanceCalls()
|
|||||||
{
|
{
|
||||||
// Both stick until the next connect, which is fine here: every caller disconnects next.
|
// Both stick until the next connect, which is fine here: every caller disconnects next.
|
||||||
maint_abort_.store(true); // refuses maintenance calls still queued on the worker
|
maint_abort_.store(true); // refuses maintenance calls still queued on the worker
|
||||||
if (rpc_ && maint_calls_.load() > 0) rpc_->requestAbort(); // cuts off the one in flight
|
// Unconditional: an ordinary in-flight refresh/scan also holds curl_mutex_ for its whole
|
||||||
|
// curl_easy_perform, and every caller disconnect()s next. The abort is cleared by the next
|
||||||
|
// tryConnect() (rpc_) / onConnected() (fast_rpc_) before they reconnect.
|
||||||
|
if (rpc_) rpc_->requestAbort(); // cuts off whatever call is in flight
|
||||||
}
|
}
|
||||||
|
|
||||||
bool App::walletScanReady() const
|
bool App::walletScanReady() const
|
||||||
@@ -1673,6 +1713,7 @@ void App::switchToWallet(const std::string& walletFile, bool stopDaemonConfirmed
|
|||||||
pin_vault_stale_ = false; // the out-of-date mark was the previous wallet's vault
|
pin_vault_stale_ = false; // the out-of-date mark was the previous wallet's vault
|
||||||
lock_attempts_ = 0;
|
lock_attempts_ = 0;
|
||||||
lock_lockout_timer_ = 0.0f;
|
lock_lockout_timer_ = 0.0f;
|
||||||
|
lock_state_restored_ = false; // re-restore persisted lockout for the newly-active wallet
|
||||||
lock_error_msg_.clear();
|
lock_error_msg_.clear();
|
||||||
lock_error_timer_ = 0.0f;
|
lock_error_timer_ = 0.0f;
|
||||||
lock_unlock_in_progress_ = false;
|
lock_unlock_in_progress_ = false;
|
||||||
@@ -3771,7 +3812,7 @@ void App::banPeer(const std::string& ip, int duration_seconds)
|
|||||||
}
|
}
|
||||||
return [this, err]() {
|
return [this, err]() {
|
||||||
if (!err.empty()) ui::Notifications::instance().error(std::string(TR("toast_ban_failed")) + util::friendlyRpcError(err));
|
if (!err.empty()) ui::Notifications::instance().error(std::string(TR("toast_ban_failed")) + util::friendlyRpcError(err));
|
||||||
else refreshPeerInfo();
|
else { ui::Notifications::instance().success(TR("toast_peer_banned")); refreshPeerInfo(); }
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -3789,7 +3830,7 @@ void App::unbanPeer(const std::string& ip)
|
|||||||
}
|
}
|
||||||
return [this, err]() {
|
return [this, err]() {
|
||||||
if (!err.empty()) ui::Notifications::instance().error(std::string(TR("toast_unban_failed")) + util::friendlyRpcError(err));
|
if (!err.empty()) ui::Notifications::instance().error(std::string(TR("toast_unban_failed")) + util::friendlyRpcError(err));
|
||||||
else refreshPeerInfo();
|
else { ui::Notifications::instance().success(TR("toast_peer_unbanned")); refreshPeerInfo(); }
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -3808,6 +3849,7 @@ void App::clearBans()
|
|||||||
return [this, err]() {
|
return [this, err]() {
|
||||||
if (!err.empty()) { ui::Notifications::instance().error(std::string(TR("toast_clear_bans_failed")) + util::friendlyRpcError(err)); return; }
|
if (!err.empty()) { ui::Notifications::instance().error(std::string(TR("toast_clear_bans_failed")) + util::friendlyRpcError(err)); return; }
|
||||||
state_.banned_peers.clear();
|
state_.banned_peers.clear();
|
||||||
|
ui::Notifications::instance().success(TR("toast_bans_cleared"));
|
||||||
refreshPeerInfo();
|
refreshPeerInfo();
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -3915,15 +3957,32 @@ void App::createNewAddress(bool shielded, std::function<void(const std::string&)
|
|||||||
const char* traceLabel = shielded ? "Receive tab / New shielded address"
|
const char* traceLabel = shielded ? "Receive tab / New shielded address"
|
||||||
: "Receive tab / New transparent address";
|
: "Receive tab / New transparent address";
|
||||||
worker_->post([this, callback, shielded, typeStr, rpcMethod, traceLabel]() -> rpc::RPCWorker::MainCb {
|
worker_->post([this, callback, shielded, typeStr, rpcMethod, traceLabel]() -> rpc::RPCWorker::MainCb {
|
||||||
std::string addr;
|
std::string addr, genErr;
|
||||||
try {
|
try {
|
||||||
rpc::RPCClient::TraceScope trace(traceLabel);
|
rpc::RPCClient::TraceScope trace(traceLabel);
|
||||||
json result = rpc_->call(rpcMethod);
|
json result = rpc_->call(rpcMethod);
|
||||||
addr = result.get<std::string>();
|
addr = result.get<std::string>();
|
||||||
} catch (const std::exception& e) {
|
} catch (const std::exception& e) {
|
||||||
DEBUG_LOGF("%s error: %s\n", rpcMethod, e.what());
|
DEBUG_LOGF("%s error: %s\n", rpcMethod, e.what());
|
||||||
|
genErr = e.what(); // keep the real reason so the UI can show it instead of a generic toast
|
||||||
}
|
}
|
||||||
return [this, callback, addr, shielded, typeStr]() {
|
return [this, callback, addr, genErr, shielded, typeStr]() {
|
||||||
|
if (addr.empty()) {
|
||||||
|
// Surface the daemon's actual reason (friendly-mapped) rather than a generic "couldn't create".
|
||||||
|
last_address_error_ = genErr.empty() ? std::string() : util::friendlyRpcError(genErr);
|
||||||
|
// "HD seed not found" means a legacy / degraded wallet can't derive new HD keys. Flip degraded
|
||||||
|
// mode here so the UI explains it even for an EXTERNAL daemon — detectWalletDegraded() only
|
||||||
|
// reads the embedded daemon's startup log and would otherwise miss this.
|
||||||
|
if (shielded && genErr.find("HD seed not found") != std::string::npos) {
|
||||||
|
wallet_degraded_ = true;
|
||||||
|
if (!wallet_degraded_warned_) {
|
||||||
|
wallet_degraded_warned_ = true;
|
||||||
|
ui::Notifications::instance().warning(TR("wallet_degraded_notify"), 30.0f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
last_address_error_.clear();
|
||||||
|
}
|
||||||
if (!addr.empty()) {
|
if (!addr.empty()) {
|
||||||
// Inject immediately so UI can select the address next frame
|
// Inject immediately so UI can select the address next frame
|
||||||
AddressInfo info;
|
AddressInfo info;
|
||||||
@@ -4158,6 +4217,13 @@ void App::provisionChatIdentityFromSecret(std::string secret)
|
|||||||
chat_service_.setBlockedPredicate([this](const std::string& cid) {
|
chat_service_.setBlockedPredicate([this](const std::string& cid) {
|
||||||
return settings_ && settings_->isChatBlocked(cid);
|
return settings_ && settings_->isChatBlocked(cid);
|
||||||
});
|
});
|
||||||
|
// Sensitive conversations: the local mark gives instant effect; the DB routes those rows to the
|
||||||
|
// vault key. The vault itself stays LOCKED until the user enters its passphrase (the DB then
|
||||||
|
// withholds sensitive rows), so provisioning never auto-unlocks it.
|
||||||
|
chat_service_.setLocalSensitivePredicate([this](const std::string& cid) {
|
||||||
|
return settings_ && settings_->isChatSensitive(cid);
|
||||||
|
});
|
||||||
|
chat_sensitive_vault_.setPath(chatSensitiveVaultPath());
|
||||||
if (chat_db_.unlockWithSecret(trimmed)) {
|
if (chat_db_.unlockWithSecret(trimmed)) {
|
||||||
chat_service_.loadFromDatabase();
|
chat_service_.loadFromDatabase();
|
||||||
// Baseline unread: treat everything already in the store at load as read, so only messages
|
// Baseline unread: treat everything already in the store at load as read, so only messages
|
||||||
@@ -4594,9 +4660,11 @@ void App::sendChatMessage(const std::string& conversationId, const std::string&
|
|||||||
}
|
}
|
||||||
|
|
||||||
chat::OutgoingChatMemos memos;
|
chat::OutgoingChatMemos memos;
|
||||||
if (chat_service_.composeMessage(myReply, peerPubKey, peerZaddr, conversationId, text, memos)
|
const auto composeStatus =
|
||||||
!= chat::ChatComposeStatus::Ok) {
|
chat_service_.composeMessage(myReply, peerPubKey, peerZaddr, conversationId, text, memos);
|
||||||
ui::Notifications::instance().error(TR("chat_toast_compose_failed"));
|
if (composeStatus != chat::ChatComposeStatus::Ok) {
|
||||||
|
ui::Notifications::instance().error(TR(composeStatus == chat::ChatComposeStatus::TooLong
|
||||||
|
? "chat_toast_too_long" : "chat_toast_compose_failed"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
chat::ChatMessage echo;
|
chat::ChatMessage echo;
|
||||||
@@ -4616,6 +4684,109 @@ void App::sendChatMessage(const std::string& conversationId, const std::string&
|
|||||||
enqueueChatSend(LiteOpKind::ChatSend, memos, echo.txid);
|
enqueueChatSend(LiteOpKind::ChatSend, memos, echo.txid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void App::sendChatControl(const std::string& conversationId, const std::string& op, const std::string& label)
|
||||||
|
{
|
||||||
|
if (!chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return;
|
||||||
|
if (conversationId.empty() || op.empty()) return;
|
||||||
|
|
||||||
|
// A control signal is addressed to our OWN reply address — it never reaches the peer; only this
|
||||||
|
// wallet's seed (on any device, including a fresh import) harvests and reduces it.
|
||||||
|
const std::string myReply = chatReplyZaddr();
|
||||||
|
if (myReply.empty()) {
|
||||||
|
ui::Notifications::instance().error(TR("chat_toast_no_zaddr"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const std::string myPubKey = chat_service_.identityPublicKeyHex();
|
||||||
|
if (myPubKey.empty()) return;
|
||||||
|
|
||||||
|
// The label must be a non-empty, non-'{' hint; fall back to the cid if the caller has no name.
|
||||||
|
const std::string hint = label.empty() ? conversationId : label;
|
||||||
|
|
||||||
|
chat::OutgoingChatMemos memos;
|
||||||
|
if (chat::buildOutgoingControl(myPubKey, myReply, conversationId, op, hint, memos)
|
||||||
|
!= chat::ChatComposeStatus::Ok) {
|
||||||
|
return; // never block the local delete/restore on a compose edge case; it is cosmetic cross-device
|
||||||
|
}
|
||||||
|
// Reuse the chat send coordinator exactly as a message does (0-value, kChatMinFeeDrgx fee, two memo
|
||||||
|
// outputs). No echo bubble: pass an empty echoLocalId — resolveOutgoing("") is a no-op, and the memo,
|
||||||
|
// harvested back to us, is routed to the reducer (not shown as a message).
|
||||||
|
enqueueChatSend(LiteOpKind::ChatSend, memos, /*echoLocalId=*/"");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string App::chatSensitiveVaultPath() const
|
||||||
|
{
|
||||||
|
return (std::filesystem::path(util::Platform::getConfigDir()) / "chat_sensitive.vault").string();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool App::chatSensitiveVaultConfigured() const
|
||||||
|
{
|
||||||
|
chat::ChatSensitiveVault probe(chatSensitiveVaultPath());
|
||||||
|
return probe.exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
App::ChatVaultState App::chatSensitiveVaultState() const
|
||||||
|
{
|
||||||
|
if (!chatSensitiveVaultConfigured()) return ChatVaultState::NotConfigured;
|
||||||
|
return chat_db_.hasSensitiveKey() ? ChatVaultState::Unlocked : ChatVaultState::Locked;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool App::createChatSensitiveVault(const std::string& passphrase)
|
||||||
|
{
|
||||||
|
chat_sensitive_vault_.setPath(chatSensitiveVaultPath());
|
||||||
|
std::array<unsigned char, 32> key{};
|
||||||
|
const bool ok = chat_sensitive_vault_.create(passphrase, key);
|
||||||
|
if (ok) chat_db_.setSensitiveKey(key); // the DB now encrypts sensitive rows under this key
|
||||||
|
sodium_memzero(key.data(), key.size());
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool App::unlockChatSensitiveVault(const std::string& passphrase)
|
||||||
|
{
|
||||||
|
chat_sensitive_vault_.setPath(chatSensitiveVaultPath());
|
||||||
|
std::array<unsigned char, 32> key{};
|
||||||
|
const bool ok = chat_sensitive_vault_.unlock(passphrase, key);
|
||||||
|
if (ok) {
|
||||||
|
chat_db_.setSensitiveKey(key);
|
||||||
|
chat_service_.loadFromDatabase(); // sensitive rows now decrypt → pull them into the store
|
||||||
|
chat_note_rescan_requested_ = true; // re-harvest any that were withheld while the vault was locked
|
||||||
|
}
|
||||||
|
sodium_memzero(key.data(), key.size());
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
void App::lockChatSensitiveVault()
|
||||||
|
{
|
||||||
|
chat_db_.lockSensitive();
|
||||||
|
// Drop the now-undecryptable sensitive threads from the view at once: reload (the DB withholds any
|
||||||
|
// row it can't decrypt without the vault key), keeping the in-memory store == what is readable.
|
||||||
|
chat_service_.store().clear();
|
||||||
|
chat_service_.loadFromDatabase();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool App::markChatConversationSensitive(const std::string& cid, const std::string& peerAddr)
|
||||||
|
{
|
||||||
|
if (cid.empty() || chatSensitiveVaultState() != ChatVaultState::Unlocked) return false;
|
||||||
|
if (settings_) { settings_->setChatSensitive(cid, peerAddr, true); settings_->save(); }
|
||||||
|
// Migrate this conversation's EXISTING rows from the seed key to the vault key: the predicate is now
|
||||||
|
// true, so upsert() re-encrypts each under the vault key — so the WHOLE thread hides when the vault
|
||||||
|
// locks (new messages already go to the vault key). Incoming history stays re-harvestable from chain
|
||||||
|
// (the honest caveat the UI warns about); this protects the local at-rest copy.
|
||||||
|
for (const auto& m : chat_service_.store().conversation(cid)) chat_db_.upsert(m);
|
||||||
|
sendChatControl(cid, "sensitive", peerAddr.empty() ? cid : peerAddr); // signal other devices / fresh import
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void App::unmarkChatConversationSensitive(const std::string& cid)
|
||||||
|
{
|
||||||
|
if (cid.empty()) return;
|
||||||
|
if (settings_) { settings_->setChatSensitive(cid, "", false); settings_->save(); }
|
||||||
|
// Migrate rows back to the seed key (predicate now false). Needs the vault unlocked so the rows are in
|
||||||
|
// the store to re-write; if it is locked there is nothing to migrate (they re-key on the next scan).
|
||||||
|
if (chat_db_.hasSensitiveKey())
|
||||||
|
for (const auto& m : chat_service_.store().conversation(cid)) chat_db_.upsert(m);
|
||||||
|
sendChatControl(cid, "unsensitive", cid);
|
||||||
|
}
|
||||||
|
|
||||||
void App::startChatConversation(const std::string& peerZaddr, const std::string& text)
|
void App::startChatConversation(const std::string& peerZaddr, const std::string& text)
|
||||||
{
|
{
|
||||||
if (!chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return;
|
if (!chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return;
|
||||||
@@ -5397,6 +5568,7 @@ bool App::lockLiteWallet()
|
|||||||
if (!lite_wallet_) return false;
|
if (!lite_wallet_) return false;
|
||||||
const bool ok = lite_wallet_->lockWallet();
|
const bool ok = lite_wallet_->lockWallet();
|
||||||
if (ok) {
|
if (ok) {
|
||||||
|
wipeDisplayedSecrets(); // scrub any revealed seed/key display on lock (matches full-node)
|
||||||
// Full-node App::lockWallet() sets state_.locked synchronously; the lite backend `lock`
|
// Full-node App::lockWallet() sets state_.locked synchronously; the lite backend `lock`
|
||||||
// doesn't, and state_.locked only refreshes on the next ~2s poll. Mirror the full-node
|
// doesn't, and state_.locked only refreshes on the next ~2s poll. Mirror the full-node
|
||||||
// behavior here and tear down the chat session NOW so no decrypted store / unlocked DB key
|
// behavior here and tear down the chat session NOW so no decrypted store / unlocked DB key
|
||||||
@@ -5411,6 +5583,69 @@ bool App::lockLiteWallet()
|
|||||||
return ok;
|
return ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool App::unlockLiteWallet(std::string passphrase, std::string* err)
|
||||||
|
{
|
||||||
|
const auto fail = [&](const std::string& m) { if (err) *err = m; };
|
||||||
|
const auto waitMsg = [](long long secs) {
|
||||||
|
char msg[128];
|
||||||
|
snprintf(msg, sizeof(msg), TR("sec_too_many_attempts_wait"), (double)secs);
|
||||||
|
return std::string(msg);
|
||||||
|
};
|
||||||
|
if (!lite_wallet_ || !lite_wallet_->walletOpen() || passphrase.empty()) {
|
||||||
|
wallet::secureWipeLiteSecret(passphrase);
|
||||||
|
fail(TR("lite_unlock_failed"));
|
||||||
|
return false; // nothing to attempt: not a wrong-credential failure, not counted
|
||||||
|
}
|
||||||
|
// Restore the persisted attempt count once per session (the full-node lock screen does the same).
|
||||||
|
if (!lock_state_restored_) {
|
||||||
|
lock_state_restored_ = true;
|
||||||
|
restoreLockoutState();
|
||||||
|
}
|
||||||
|
// Enforce the lockout from the persisted wall-clock expiry: lite has no per-frame lockout timer.
|
||||||
|
if (settings_ && settings_->getLockStateWallet() == settings_->getActiveWalletFile()) {
|
||||||
|
const long long remaining = settings_->getLockoutUntilEpoch() - (long long)std::time(nullptr);
|
||||||
|
if (remaining > 0) {
|
||||||
|
wallet::secureWipeLiteSecret(passphrase);
|
||||||
|
fail(waitMsg(remaining));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lock_lockout_timer_ = 0.0f; // expired: don't let a stale in-memory value re-arm the curve
|
||||||
|
|
||||||
|
const bool ok = lite_wallet_->unlockWallet(std::move(passphrase)); // wipes the passphrase
|
||||||
|
if (!ok) {
|
||||||
|
const auto st = lite_wallet_->encryptionStatus();
|
||||||
|
if (st.ok && !(st.encrypted && st.locked)) {
|
||||||
|
// Not a wrong credential: already unlocked (e.g. a racing prompt) or not encrypted. Don't count it.
|
||||||
|
state_.encrypted = st.encrypted;
|
||||||
|
state_.locked = false;
|
||||||
|
if (st.encrypted) { idle_.noteActivity(); return true; }
|
||||||
|
fail(TR("lite_unlock_failed"));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
applyUnlockFailure(TR("lite_unlock_failed")); // bump counter, arm lockout curve, persist
|
||||||
|
fail(lock_lockout_timer_ > 0.0f ? waitMsg((long long)std::ceil(lock_lockout_timer_))
|
||||||
|
: std::string(TR("lite_unlock_failed")));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success: clear the attempt state (and its persisted copy) and reflect the unlock NOW — the next poll lags.
|
||||||
|
lock_unlock_in_progress_ = false;
|
||||||
|
lock_error_msg_.clear();
|
||||||
|
lock_attempts_ = 0;
|
||||||
|
lock_lockout_timer_ = 0.0f;
|
||||||
|
if (settings_ && settings_->getLockFailedAttempts() != 0) {
|
||||||
|
settings_->setLockFailedAttempts(0);
|
||||||
|
settings_->setLockoutUntilEpoch(0);
|
||||||
|
settings_->save();
|
||||||
|
}
|
||||||
|
memset(lock_passphrase_buf_, 0, sizeof(lock_passphrase_buf_));
|
||||||
|
idle_.noteActivity();
|
||||||
|
state_.encrypted = true;
|
||||||
|
state_.locked = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
bool App::seedChatDemoData()
|
bool App::seedChatDemoData()
|
||||||
{
|
{
|
||||||
if (!chat::hushChatFeatureEnabledAtBuild()) return false;
|
if (!chat::hushChatFeatureEnabledAtBuild()) return false;
|
||||||
@@ -5623,6 +5858,48 @@ void App::importPrivateKey(const std::string& rawKey, int startHeight,
|
|||||||
if (!key.empty()) sodium_memzero(&key[0], key.size()); // scrub the calling-frame copy
|
if (!key.empty()) sodium_memzero(&key[0], key.size()); // scrub the calling-frame copy
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Import a whole wallet-key dump file (the output of the OLD wallet's z_exportwallet / dumpwallet, or
|
||||||
|
// SilentDragon's "export all private keys") via z_importwallet: the daemon reads every key from the
|
||||||
|
// file and does ONE rescan — the right tool for recovering a legacy wallet.dat the new client can't open,
|
||||||
|
// and far faster than importing keys one by one. Only the PATH touches our memory, never the keys. Runs on
|
||||||
|
// the long-call lane (no client timeout: the rescan can take a long time). Full-node only.
|
||||||
|
void App::importWalletFile(const std::string& path, std::function<void(bool, const std::string&)> callback)
|
||||||
|
{
|
||||||
|
if (!state_.connected || !rpc_ || !worker_) {
|
||||||
|
if (callback) callback(false, TR("import_key_need_node"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
std::string p(path);
|
||||||
|
while (!p.empty() && (p.front()==' '||p.front()=='\t'||p.front()=='\n'||p.front()=='\r')) p.erase(p.begin());
|
||||||
|
while (!p.empty() && (p.back()==' '||p.back()=='\t'||p.back()=='\n'||p.back()=='\r')) p.pop_back();
|
||||||
|
if (p.empty()) { if (callback) callback(false, TR("import_wallet_file_need_path")); return; }
|
||||||
|
{
|
||||||
|
std::string why; const char* trKey = nullptr;
|
||||||
|
if (longWalletOpRefused(&why, &trKey)) { if (callback) callback(false, TR(trKey)); return; }
|
||||||
|
}
|
||||||
|
const bool started = runLongWalletTask([this, p, callback](rpc::RPCClient& c) mutable -> rpc::RPCWorker::MainCb {
|
||||||
|
std::string err;
|
||||||
|
try {
|
||||||
|
rpc::RPCClient::TraceScope trace("Settings / Import wallet file");
|
||||||
|
c.call("z_importwallet", {p}, 0L); // reads the dump + one rescan; 0 = no client timeout
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
err = e.what();
|
||||||
|
if (!err.empty() && err.find("Cannot open") != std::string::npos)
|
||||||
|
err = TR("import_wallet_file_not_found"); // the most common cause: wrong/missing path
|
||||||
|
} catch (...) {
|
||||||
|
err = "Import failed (unknown error)";
|
||||||
|
}
|
||||||
|
return [this, err, callback]() {
|
||||||
|
if (!err.empty()) { if (callback) callback(false, err); return; }
|
||||||
|
invalidateAddressValidationCache();
|
||||||
|
refreshAddresses();
|
||||||
|
markWalletMutated(/*forceBalance=*/true); // keys added: balance changes with no tip/mempool signal
|
||||||
|
if (callback) callback(true, "");
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (!started && callback) callback(false, TR("appx_wallet_op_running"));
|
||||||
|
}
|
||||||
|
|
||||||
// Sweep a spending key: import it (a full rescan populates its UTXOs/notes — the stock node has no
|
// Sweep a spending key: import it (a full rescan populates its UTXOs/notes — the stock node has no
|
||||||
// address index, so there is no way to enumerate them without importing) then z_sendmany ALL of the
|
// address index, so there is no way to enumerate them without importing) then z_sendmany ALL of the
|
||||||
// key's funds (balance − fee) to a destination the user already controls. The imported key is left in
|
// key's funds (balance − fee) to a destination the user already controls. The imported key is left in
|
||||||
@@ -5633,7 +5910,7 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo
|
|||||||
const std::string& destExisting)
|
const std::string& destExisting)
|
||||||
{
|
{
|
||||||
if (!state_.connected || !rpc_ || !worker_) {
|
if (!state_.connected || !rpc_ || !worker_) {
|
||||||
sweep_status_ = "Not connected to the daemon.";
|
sweep_status_ = TR("err_not_connected_daemon");
|
||||||
sweep_step_ = SweepStep::Error;
|
sweep_step_ = SweepStep::Error;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -5645,7 +5922,7 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo
|
|||||||
// Sweeping requires a SPENDING key (transparent WIF or shielded z-spending key) — a viewing key
|
// Sweeping requires a SPENDING key (transparent WIF or shielded z-spending key) — a viewing key
|
||||||
// can't sign, and this must never accidentally route to z_importviewingkey.
|
// can't sign, and this must never accidentally route to z_importviewingkey.
|
||||||
if (!services::WalletSecurityController::isRecognizedPrivateKey(key)) {
|
if (!services::WalletSecurityController::isRecognizedPrivateKey(key)) {
|
||||||
sweep_status_ = "Enter a spending key (a viewing key can't move funds).";
|
sweep_status_ = TR("sweep_err_need_spending_key");
|
||||||
sweep_step_ = SweepStep::Error;
|
sweep_step_ = SweepStep::Error;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -5663,7 +5940,7 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo
|
|||||||
}
|
}
|
||||||
|
|
||||||
sweep_step_ = SweepStep::Running;
|
sweep_step_ = SweepStep::Running;
|
||||||
sweep_status_ = "Importing key & rescanning…";
|
sweep_status_ = TR("sweep_status_importing");
|
||||||
sweep_txid_.clear();
|
sweep_txid_.clear();
|
||||||
sweep_dest_shown_.clear();
|
sweep_dest_shown_.clear();
|
||||||
|
|
||||||
@@ -5724,12 +6001,12 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo
|
|||||||
if (sourceAddr.empty()) throw std::runtime_error("Could not determine the key's address.");
|
if (sourceAddr.empty()) throw std::runtime_error("Could not determine the key's address.");
|
||||||
|
|
||||||
// 3. Confirm the key holds spendable (confirmed) funds. z_getbalance covers t- and z-addrs.
|
// 3. Confirm the key holds spendable (confirmed) funds. z_getbalance covers t- and z-addrs.
|
||||||
|
// Let an RPC failure PROPAGATE to the outer catch (→ a real error) instead of swallowing it as
|
||||||
|
// 0.0, which would misclassify a funded key as "__NOFUNDS__" after a transient timeout/warmup.
|
||||||
auto balAt = [&](int minconf) -> double {
|
auto balAt = [&](int minconf) -> double {
|
||||||
try {
|
nlohmann::json b = c.call("z_getbalance", {sourceAddr, minconf});
|
||||||
nlohmann::json b = c.call("z_getbalance", {sourceAddr, minconf});
|
return b.is_number() ? b.get<double>()
|
||||||
return b.is_number() ? b.get<double>()
|
: b.is_string() ? std::stod(b.get<std::string>()) : 0.0;
|
||||||
: b.is_string() ? std::stod(b.get<std::string>()) : 0.0;
|
|
||||||
} catch (...) { return 0.0; }
|
|
||||||
};
|
};
|
||||||
double bal = balAt(1);
|
double bal = balAt(1);
|
||||||
if (bal <= 0.0) {
|
if (bal <= 0.0) {
|
||||||
@@ -5757,7 +6034,7 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo
|
|||||||
} catch (const std::exception& e) {
|
} catch (const std::exception& e) {
|
||||||
err = e.what();
|
err = e.what();
|
||||||
} catch (...) {
|
} catch (...) {
|
||||||
err = "Sweep failed (unknown error)";
|
err = TR("sweep_err_unknown");
|
||||||
}
|
}
|
||||||
// Scrub the worker's copy of the spending key now that the request has been sent (all paths).
|
// Scrub the worker's copy of the spending key now that the request has been sent (all paths).
|
||||||
if (!key.empty()) sodium_memzero(&key[0], key.size());
|
if (!key.empty()) sodium_memzero(&key[0], key.size());
|
||||||
@@ -5765,16 +6042,16 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo
|
|||||||
invalidateAddressValidationCache();
|
invalidateAddressValidationCache();
|
||||||
refreshAddresses();
|
refreshAddresses();
|
||||||
if (!err.empty()) {
|
if (!err.empty()) {
|
||||||
if (err == "__NOFUNDS__") sweep_status_ = "This key holds no funds to sweep.";
|
if (err == "__NOFUNDS__") sweep_status_ = TR("sweep_err_nofunds");
|
||||||
else if (err == "__UNCONFIRMED__") sweep_status_ = "This key's funds are still unconfirmed — try again shortly.";
|
else if (err == "__UNCONFIRMED__") sweep_status_ = TR("sweep_err_unconfirmed");
|
||||||
else if (err == "__ALREADY__") sweep_status_ = "This key is already in your wallet — use Send to move its funds.";
|
else if (err == "__ALREADY__") sweep_status_ = TR("sweep_err_already");
|
||||||
else if (err == "__DUST__") sweep_status_ = "This key's balance is too small to cover the network fee.";
|
else if (err == "__DUST__") sweep_status_ = TR("sweep_err_dust");
|
||||||
else sweep_status_ = err;
|
else sweep_status_ = err;
|
||||||
sweep_step_ = SweepStep::Error;
|
sweep_step_ = SweepStep::Error;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
sweep_dest_shown_ = dest;
|
sweep_dest_shown_ = dest;
|
||||||
sweep_status_ = "Sweeping funds to your address…";
|
sweep_status_ = TR("sweep_status_sweeping");
|
||||||
// Send all funds from the swept address to the destination through the tested z_sendmany
|
// Send all funds from the swept address to the destination through the tested z_sendmany
|
||||||
// wrapper (fixed-decimal amount + async-op tracking). z_sendmany needs every input in one
|
// wrapper (fixed-decimal amount + async-op tracking). z_sendmany needs every input in one
|
||||||
// transaction to cover the full balance, so it fails loudly rather than partial-sweeping.
|
// transaction to cover the full balance, so it fails loudly rather than partial-sweeping.
|
||||||
@@ -6281,7 +6558,7 @@ void App::showSeedMigrationDialog()
|
|||||||
pending_send_callbacks_[sweepOpid] = makeSweepCompletionCallback(/*resumed=*/true);
|
pending_send_callbacks_[sweepOpid] = makeSweepCompletionCallback(/*resumed=*/true);
|
||||||
trackOperation(sweepOpid);
|
trackOperation(sweepOpid);
|
||||||
seed_migration_step_ = SeedMigrationStep::Sweeping;
|
seed_migration_step_ = SeedMigrationStep::Sweeping;
|
||||||
seed_migration_status_ = "Checking on the previous sweep…";
|
seed_migration_status_ = TR("migrate_status_checking_sweep");
|
||||||
break;
|
break;
|
||||||
case MigrationResume::SweepGate:
|
case MigrationResume::SweepGate:
|
||||||
// No txid, and either no opid or not connected to re-track it (a persisted opid is left in
|
// No txid, and either no opid or not connected to re-track it (a persisted opid is left in
|
||||||
@@ -6359,15 +6636,15 @@ void App::beginSweepToSeedWallet()
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!rpc_ || !worker_ || !state_.connected) {
|
if (!rpc_ || !worker_ || !state_.connected) {
|
||||||
seed_migration_status_ = "Not connected to the daemon.";
|
seed_migration_status_ = TR("err_not_connected_daemon");
|
||||||
seed_migration_step_ = SeedMigrationStep::Error; return;
|
seed_migration_step_ = SeedMigrationStep::Error; return;
|
||||||
}
|
}
|
||||||
if (seed_migration_dest_.empty()) {
|
if (seed_migration_dest_.empty()) {
|
||||||
seed_migration_status_ = "No destination address for the sweep.";
|
seed_migration_status_ = TR("migrate_err_no_dest");
|
||||||
seed_migration_step_ = SeedMigrationStep::Error; return;
|
seed_migration_step_ = SeedMigrationStep::Error; return;
|
||||||
}
|
}
|
||||||
seed_migration_step_ = SeedMigrationStep::Sweeping;
|
seed_migration_step_ = SeedMigrationStep::Sweeping;
|
||||||
seed_migration_status_ = "Building the sweep transaction…";
|
seed_migration_status_ = TR("migrate_status_building");
|
||||||
// Mark the sweep as submitted BEFORE broadcasting (persisted synchronously). If the app or daemon
|
// Mark the sweep as submitted BEFORE broadcasting (persisted synchronously). If the app or daemon
|
||||||
// then dies after the tx reaches the mempool but before its opid/txid is persisted, a resumed
|
// then dies after the tx reaches the mempool but before its opid/txid is persisted, a resumed
|
||||||
// migration still knows a sweep is in flight and won't mistake the resulting ~0 legacy balance for
|
// migration still knows a sweep is in flight and won't mistake the resulting ~0 legacy balance for
|
||||||
@@ -6383,7 +6660,27 @@ void App::beginSweepToSeedWallet()
|
|||||||
auto res = rpc_->call("z_mergetoaddress", {fromAddrs, dest, DRAGONX_DEFAULT_FEE, 0, 0});
|
auto res = rpc_->call("z_mergetoaddress", {fromAddrs, dest, DRAGONX_DEFAULT_FEE, 0, 0});
|
||||||
opid = res.value("opid", std::string());
|
opid = res.value("opid", std::string());
|
||||||
if (opid.empty()) err = "The daemon returned no operation id for the sweep.";
|
if (opid.empty()) err = "The daemon returned no operation id for the sweep.";
|
||||||
} catch (const std::exception& e) { err = e.what(); }
|
} catch (const std::exception& e) {
|
||||||
|
// z_mergetoaddress deliberately ignores coinbase UTXOs, so a miner whose legacy balance is all
|
||||||
|
// mined coinbase gets "Could not find any funds to merge". Fall back to z_shieldcoinbase, which
|
||||||
|
// DOES move coinbase, to the same shielded destination. It returns an opid exactly like the merge,
|
||||||
|
// so the adopt gate (one opid -> txid -> confirm -> balance ~0 -> adopt) is unchanged; a batch
|
||||||
|
// limit or a mixed coinbase+other wallet is drained by the existing "Sweep remaining" re-sweep.
|
||||||
|
const std::string mergeErr = e.what();
|
||||||
|
if (mergeErr.find("find any funds to merge") != std::string::npos) {
|
||||||
|
try {
|
||||||
|
// Fixed-decimal fee string (ParseFixedPoint rejects a double that serializes as "5e-05").
|
||||||
|
const std::string feeStr = util::formatAmountFixed(DRAGONX_DEFAULT_FEE);
|
||||||
|
auto sres = rpc_->call("z_shieldcoinbase", {std::string("*"), dest, feeStr, 50});
|
||||||
|
opid = sres.value("opid", std::string());
|
||||||
|
if (opid.empty()) err = "The daemon returned no operation id for the coinbase shield.";
|
||||||
|
} catch (const std::exception& e2) {
|
||||||
|
err = e2.what(); // e.g. "Could not find any coinbase funds to shield" = genuinely empty
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
err = mergeErr;
|
||||||
|
}
|
||||||
|
}
|
||||||
return [this, opid, err]() {
|
return [this, opid, err]() {
|
||||||
if (!err.empty() || opid.empty()) {
|
if (!err.empty() || opid.empty()) {
|
||||||
// Submit failed — nothing was broadcast, so drop the "sweep in flight" marker.
|
// Submit failed — nothing was broadcast, so drop the "sweep in flight" marker.
|
||||||
@@ -6407,7 +6704,7 @@ void App::beginSweepToSeedWallet()
|
|||||||
}
|
}
|
||||||
pending_send_callbacks_[opid] = makeSweepCompletionCallback(/*resumed=*/false);
|
pending_send_callbacks_[opid] = makeSweepCompletionCallback(/*resumed=*/false);
|
||||||
trackOperation(opid);
|
trackOperation(opid);
|
||||||
seed_migration_status_ = "Waiting for the sweep transaction to be accepted…";
|
seed_migration_status_ = TR("migrate_status_waiting_accept");
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -6559,6 +6856,57 @@ void App::locateSweepTxid()
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Final gate before the swap: on the RPC worker, RE-READ the sweep tx's confirmations (when a sweep txid
|
||||||
|
// exists) and z_gettotalbalance with minconf 0 (includes unconfirmed, unlike the default minconf 1), then
|
||||||
|
// - only if both are reorg-safe, the node is not syncing and the legacy balance is ~0 - call
|
||||||
|
// beginAdoptSeedWallet() on the main thread, so the disconnect + daemon stop follow these reads at once.
|
||||||
|
// Read-only RPCs; any failure blocks adopt (nothing is stopped or swapped) and the user may retry.
|
||||||
|
void App::verifyThenAdoptSeedWallet()
|
||||||
|
{
|
||||||
|
if (capture_mode_) { beginAdoptSeedWallet(); return; } // UI sweep: unchanged behaviour
|
||||||
|
if (seed_migration_confirm_in_flight_) return;
|
||||||
|
if (!rpc_ || !worker_ || !state_.connected) {
|
||||||
|
ui::Notifications::instance().warning(TR("mig_adopt_reverify_failed"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
seed_migration_confirm_in_flight_ = true;
|
||||||
|
const std::string txid = seed_migration_sweep_txid_;
|
||||||
|
const bool needTx = !txid.empty();
|
||||||
|
const bool syncing = effectivelySyncing(); // main-thread snapshot for the worker
|
||||||
|
worker_->post([this, txid, needTx, syncing]() -> rpc::RPCWorker::MainCb {
|
||||||
|
int confs = 0; double total = -1.0; bool ok = false;
|
||||||
|
rpc::RPCClient::TraceScope trace("Migrate / pre-adopt verify");
|
||||||
|
try {
|
||||||
|
if (needTx) {
|
||||||
|
auto tx = rpc_->call("gettransaction", {txid});
|
||||||
|
if (!tx.contains("confirmations") || !tx["confirmations"].is_number()) throw std::runtime_error("no confirmations");
|
||||||
|
confs = tx["confirmations"].get<int>();
|
||||||
|
}
|
||||||
|
auto bal = rpc_->call("z_gettotalbalance", nlohmann::json::array({0})); // minconf 0
|
||||||
|
if (bal.contains("total") && bal["total"].is_string()) {
|
||||||
|
total = std::stod(bal["total"].get<std::string>());
|
||||||
|
ok = true;
|
||||||
|
}
|
||||||
|
} catch (...) { ok = false; }
|
||||||
|
return [this, needTx, syncing, confs, total, ok]() {
|
||||||
|
seed_migration_confirm_in_flight_ = false;
|
||||||
|
if (ok) {
|
||||||
|
if (needTx) seed_migration_sweep_confs_ = confs;
|
||||||
|
seed_migration_legacy_remaining_ = total;
|
||||||
|
}
|
||||||
|
// Only act while still on a step that offers Adopt (the user may have closed/changed it).
|
||||||
|
const bool onAdoptStep = seed_migration_step_ == SeedMigrationStep::Confirming ||
|
||||||
|
seed_migration_step_ == SeedMigrationStep::Sweep;
|
||||||
|
if (ok && onAdoptStep &&
|
||||||
|
dragonx::adoptReverifyPasses(needTx, confs, total, syncing, DRAGONX_DEFAULT_FEE)) {
|
||||||
|
beginAdoptSeedWallet(); // unchanged: stop daemon -> swap -> restart with -rescan
|
||||||
|
} else if (onAdoptStep) {
|
||||||
|
ui::Notifications::instance().warning(TR("mig_adopt_reverify_failed"), 12.0f);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Phase 2 step 2: adopt the new seed wallet as the primary wallet. Stop the daemon, move the
|
// Phase 2 step 2: adopt the new seed wallet as the primary wallet. Stop the daemon, move the
|
||||||
// legacy wallet.dat aside to a timestamped backup (NEVER delete it), copy the new wallet in, and
|
// legacy wallet.dat aside to a timestamped backup (NEVER delete it), copy the new wallet in, and
|
||||||
// restart with -rescan so the new keys pick up the swept funds. Runs on a background thread.
|
// restart with -rescan so the new keys pick up the swept funds. Runs on a background thread.
|
||||||
@@ -6566,7 +6914,7 @@ void App::beginAdoptSeedWallet()
|
|||||||
{
|
{
|
||||||
if (refuseIfNodeBusy()) return; // stays on its current step; the user retries once the node is free
|
if (refuseIfNodeBusy()) return; // stays on its current step; the user retries once the node is free
|
||||||
seed_migration_step_ = SeedMigrationStep::Adopting;
|
seed_migration_step_ = SeedMigrationStep::Adopting;
|
||||||
seed_migration_status_ = "Restarting with your new wallet — this rescan can take a while…";
|
seed_migration_status_ = TR("migrate_status_restarting");
|
||||||
{ std::lock_guard<std::mutex> lk(seed_migration_mutex_); seed_migration_adopt_done_ = false; }
|
{ std::lock_guard<std::mutex> lk(seed_migration_mutex_); seed_migration_adopt_done_ = false; }
|
||||||
// Gate the main-loop reconnect so tryConnect can't start the daemon while we swap wallet.dat.
|
// Gate the main-loop reconnect so tryConnect can't start the daemon while we swap wallet.dat.
|
||||||
daemon_restarting_ = true;
|
daemon_restarting_ = true;
|
||||||
@@ -6620,21 +6968,53 @@ void App::beginAdoptSeedWallet()
|
|||||||
const std::string backup = legacy + ".legacy-migrated-" + ts + ".bak";
|
const std::string backup = legacy + ".legacy-migrated-" + ts + ".bak";
|
||||||
if (!fs::exists(newWallet)) {
|
if (!fs::exists(newWallet)) {
|
||||||
err = "The new wallet file is missing; your wallet was not changed.";
|
err = "The new wallet file is missing; your wallet was not changed.";
|
||||||
|
} else if (!util::probeWalletFile(newWallet).isBerkeleyDB) {
|
||||||
|
err = "The new wallet file is not a valid wallet; your wallet was not changed.";
|
||||||
} else {
|
} else {
|
||||||
bool movedLegacy = false;
|
// Crash-safe order: wallet.dat is only ever REPLACED by one atomic rename of a complete,
|
||||||
if (fs::exists(legacy)) {
|
// size-verified staged copy, and only AFTER a complete size-verified .bak of the legacy
|
||||||
fs::rename(legacy, backup, ec);
|
// wallet exists. A hard kill at any point leaves wallet.dat as either the whole old or the
|
||||||
if (ec) err = "Could not back up your current wallet; nothing was changed.";
|
// whole new wallet - never missing or half-written. Nothing of the user's is deleted.
|
||||||
else movedLegacy = true;
|
const std::string staged = legacy + ".adopt-tmp";
|
||||||
}
|
auto sameSize = [](const std::string& a, const std::string& b) {
|
||||||
if (err.empty()) {
|
std::error_code e1, e2;
|
||||||
fs::copy_file(newWallet, legacy, fs::copy_options::overwrite_existing, ec);
|
const auto sa = fs::file_size(a, e1);
|
||||||
if (ec) {
|
const auto sb = fs::file_size(b, e2);
|
||||||
if (movedLegacy) { std::error_code ec2; fs::rename(backup, legacy, ec2); }
|
return !e1 && !e2 && sa == sb && sa > 0;
|
||||||
err = "Could not install the new wallet; your original wallet was restored.";
|
};
|
||||||
} else {
|
#ifndef _WIN32
|
||||||
swapDone = true;
|
auto syncFile = [](const std::string& p) { // best-effort flush to disk
|
||||||
|
int fd = ::open(p.c_str(), O_RDONLY);
|
||||||
|
if (fd >= 0) { ::fsync(fd); ::close(fd); }
|
||||||
|
};
|
||||||
|
#else
|
||||||
|
auto syncFile = [](const std::string&) {};
|
||||||
|
#endif
|
||||||
|
bool backupCreated = false;
|
||||||
|
// (a) Stage a full copy of the new wallet beside wallet.dat (same dir => atomic rename).
|
||||||
|
fs::copy_file(newWallet, staged, fs::copy_options::overwrite_existing, ec);
|
||||||
|
if (ec || !sameSize(newWallet, staged)) {
|
||||||
|
err = "Could not stage the new wallet; nothing was changed.";
|
||||||
|
} else {
|
||||||
|
syncFile(staged);
|
||||||
|
// (b) COPY (not rename) the legacy wallet to the timestamped .bak and verify it.
|
||||||
|
if (fs::exists(legacy)) {
|
||||||
|
backupCreated = !fs::exists(backup);
|
||||||
|
fs::copy_file(legacy, backup, fs::copy_options::none, ec); // never overwrites an existing .bak
|
||||||
|
if (ec || !sameSize(legacy, backup)) err = "Could not back up your current wallet; nothing was changed.";
|
||||||
|
else syncFile(backup);
|
||||||
}
|
}
|
||||||
|
// (c) Atomically replace wallet.dat with the staged wallet.
|
||||||
|
if (err.empty()) {
|
||||||
|
fs::rename(staged, legacy, ec);
|
||||||
|
if (ec) err = "Could not install the new wallet; your original wallet was left unchanged.";
|
||||||
|
else swapDone = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!err.empty()) { // failed before the replace: original untouched; drop only OUR scratch files
|
||||||
|
std::error_code e3;
|
||||||
|
fs::remove(staged, e3);
|
||||||
|
if (backupCreated) fs::remove(backup, e3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6915,9 +7295,21 @@ void App::rebuildWalletDatabase()
|
|||||||
// 3. Run the helper (src -> tmpOut). Quote both paths; capture its JSON line. Windowless
|
// 3. Run the helper (src -> tmpOut). Quote both paths; capture its JSON line. Windowless
|
||||||
// (runHiddenCapture) so a wallet rebuild never flashes a cmd.exe console; it runs the
|
// (runHiddenCapture) so a wallet rebuild never flashes a cmd.exe console; it runs the
|
||||||
// helper via CreateProcess directly on Windows, so no cmd.exe outer-quote wrap is needed.
|
// helper via CreateProcess directly on Windows, so no cmd.exe outer-quote wrap is needed.
|
||||||
const std::string cmd = "\"" + helper + "\" \"" + src + "\" \"" + tmpOut + "\"";
|
// runHiddenCapture runs this via popen("sh -c …") on POSIX, where $( ), backticks and an
|
||||||
|
// embedded \" still expand inside the double-quotes — so a crafted wallet filename (src is
|
||||||
|
// datadir + the activeWalletName setting) could inject a shell command. Refuse any path
|
||||||
|
// carrying a shell metacharacter; rc stays -1 and the verify-before-swap below fails safe.
|
||||||
|
auto shellSafe = [](const std::string& p) {
|
||||||
|
return p.find_first_of("\"'`$;&|<>^\n\r") == std::string::npos;
|
||||||
|
};
|
||||||
int rc = -1;
|
int rc = -1;
|
||||||
const std::string jout = util::Platform::runHiddenCapture(cmd, /*mergeStderr=*/false, &rc);
|
std::string jout;
|
||||||
|
if (shellSafe(helper) && shellSafe(src) && shellSafe(tmpOut)) {
|
||||||
|
const std::string cmd = "\"" + helper + "\" \"" + src + "\" \"" + tmpOut + "\"";
|
||||||
|
jout = util::Platform::runHiddenCapture(cmd, /*mergeStderr=*/false, &rc);
|
||||||
|
} else {
|
||||||
|
DEBUG_LOGF("[App] wallet-rebuild refused: shell metacharacter in a path\n");
|
||||||
|
}
|
||||||
DEBUG_LOGF("[App] wallet-rebuild helper rc=%d out=%s\n", rc, jout.c_str());
|
DEBUG_LOGF("[App] wallet-rebuild helper rc=%d out=%s\n", rc, jout.c_str());
|
||||||
|
|
||||||
// 4. Verify-before-swap: the output must be a readable BDB with the fund-critical keys.
|
// 4. Verify-before-swap: the output must be a readable BDB with the fund-critical keys.
|
||||||
@@ -7031,6 +7423,7 @@ void App::pumpSeedMigration()
|
|||||||
seed_migration_dest_ = std::move(dest);
|
seed_migration_dest_ = std::move(dest);
|
||||||
seed_migration_temp_dir_ = std::move(tmp);
|
seed_migration_temp_dir_ = std::move(tmp);
|
||||||
seed_migration_backed_up_ = false;
|
seed_migration_backed_up_ = false;
|
||||||
|
seed_migration_understood_ = false;
|
||||||
seed_migration_step_ = SeedMigrationStep::ShowSeed;
|
seed_migration_step_ = SeedMigrationStep::ShowSeed;
|
||||||
// Persist the pending migration so a later sweep/adopt step can find the new wallet.
|
// Persist the pending migration so a later sweep/adopt step can find the new wallet.
|
||||||
if (settings_) {
|
if (settings_) {
|
||||||
@@ -7102,7 +7495,14 @@ void App::sendTransaction(const std::string& from, const std::string& to,
|
|||||||
wallet::LiteSendRequest req;
|
wallet::LiteSendRequest req;
|
||||||
wallet::LiteSendRecipient recipient;
|
wallet::LiteSendRecipient recipient;
|
||||||
recipient.address = to;
|
recipient.address = to;
|
||||||
recipient.amountZatoshis = static_cast<std::uint64_t>(std::llround(amount * 100000000.0));
|
// Guard the double→zatoshi conversion: a negative/NaN amount would wrap to a huge uint64, and a
|
||||||
|
// sub-zatoshi amount rounds to 0. Reject rather than broadcast a bogus value.
|
||||||
|
std::uint64_t zat = 0;
|
||||||
|
if (!util::coinsToZatoshis(amount, zat)) {
|
||||||
|
if (callback) callback(false, "Invalid send amount");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
recipient.amountZatoshis = zat;
|
||||||
recipient.memo = memo;
|
recipient.memo = memo;
|
||||||
req.recipients.push_back(std::move(recipient));
|
req.recipients.push_back(std::move(recipient));
|
||||||
if (!lite_wallet_->sendTransaction(req)) {
|
if (!lite_wallet_->sendTransaction(req)) {
|
||||||
@@ -7186,6 +7586,7 @@ void App::submitZSendMany(const std::string& from, const std::string& to, double
|
|||||||
worker_->post([this, from, to, amount, fee, memo, recipients, callback, traceLabel,
|
worker_->post([this, from, to, amount, fee, memo, recipients, callback, traceLabel,
|
||||||
markFeeGapRetry, background]() -> rpc::RPCWorker::MainCb {
|
markFeeGapRetry, background]() -> rpc::RPCWorker::MainCb {
|
||||||
bool ok = false;
|
bool ok = false;
|
||||||
|
bool maybeInFlight = false; // reply lost after the request left: the node may already hold the op
|
||||||
std::string result_str;
|
std::string result_str;
|
||||||
try {
|
try {
|
||||||
rpc::RPCClient::TraceScope trace(traceLabel);
|
rpc::RPCClient::TraceScope trace(traceLabel);
|
||||||
@@ -7194,12 +7595,13 @@ void App::submitZSendMany(const std::string& from, const std::string& to, double
|
|||||||
ok = true;
|
ok = true;
|
||||||
} catch (const std::exception& e) {
|
} catch (const std::exception& e) {
|
||||||
result_str = e.what();
|
result_str = e.what();
|
||||||
|
maybeInFlight = rpc::sendReplyMayBeLost(e);
|
||||||
} catch (...) {
|
} catch (...) {
|
||||||
// Non-std throw must not escape into the worker — the main-thread callback
|
// Non-std throw must not escape into the worker — the main-thread callback
|
||||||
// below would never run, leaving a stuck "Sending…" spinner.
|
// below would never run, leaving a stuck "Sending…" spinner.
|
||||||
result_str = "Send failed (unknown error)";
|
result_str = "Send failed (unknown error)";
|
||||||
}
|
}
|
||||||
return [this, callback, ok, result_str, from, to, amount, fee, memo, markFeeGapRetry, background]() {
|
return [this, callback, ok, maybeInFlight, result_str, from, to, amount, fee, memo, markFeeGapRetry, background]() {
|
||||||
if (send_submissions_in_flight_ > 0) --send_submissions_in_flight_;
|
if (send_submissions_in_flight_ > 0) --send_submissions_in_flight_;
|
||||||
if (ok) {
|
if (ok) {
|
||||||
// z_sendmany only returned an opid: the transaction is built/signed/
|
// z_sendmany only returned an opid: the transaction is built/signed/
|
||||||
@@ -7222,7 +7624,17 @@ void App::submitZSendMany(const std::string& from, const std::string& to, double
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (!background) send_progress_active_ = false;
|
if (!background) send_progress_active_ = false;
|
||||||
if (callback) callback(false, result_str);
|
if (maybeInFlight && !background) {
|
||||||
|
// Ambiguous: z_sendmany's reply never arrived, so the op may be running. Surface the
|
||||||
|
// distinct "check History before resending" outcome and re-read the wallet so a
|
||||||
|
// committed send shows up promptly. Definitive rejections stay plain failures below.
|
||||||
|
DEBUG_LOGF("[send] z_sendmany reply lost (%s): outcome unknown, not a definitive failure\n",
|
||||||
|
result_str.c_str());
|
||||||
|
markWalletMutated(/*forceBalance=*/true);
|
||||||
|
if (callback) callback(false, rpc::markSendMaybeInFlight(result_str));
|
||||||
|
} else if (callback) {
|
||||||
|
callback(false, result_str);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -7407,7 +7819,7 @@ bool App::runtimeRescan(int startHeight)
|
|||||||
rescan_confirmed_active_ = true;
|
rescan_confirmed_active_ = true;
|
||||||
user_initiated_rescan_ = true; // user clicked Rescan → completion should toast
|
user_initiated_rescan_ = true; // user clicked Rescan → completion should toast
|
||||||
state_.sync.rescan_progress = 0.0f;
|
state_.sync.rescan_progress = 0.0f;
|
||||||
state_.sync.rescan_status = "Rescanning from block " + std::to_string(startHeight) + "...";
|
{ char rb[64]; snprintf(rb, sizeof(rb), TR("rescan_status_from_block"), startHeight); state_.sync.rescan_status = rb; }
|
||||||
markTransactionsDirty();
|
markTransactionsDirty();
|
||||||
last_tx_block_height_ = -1;
|
last_tx_block_height_ = -1;
|
||||||
invalidateShieldedHistoryScanProgress(true);
|
invalidateShieldedHistoryScanProgress(true);
|
||||||
|
|||||||
+70
-19
@@ -33,6 +33,7 @@
|
|||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <ctime>
|
#include <ctime>
|
||||||
|
#include <cmath>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
@@ -476,6 +477,12 @@ void App::applyUnlockSuccess(const std::string& passphrase, int timeout) {
|
|||||||
lock_unlock_in_progress_ = false;
|
lock_unlock_in_progress_ = false;
|
||||||
lock_error_msg_.clear();
|
lock_error_msg_.clear();
|
||||||
lock_attempts_ = 0;
|
lock_attempts_ = 0;
|
||||||
|
// Clear the persisted lockout on a successful unlock (guarded so a normal unlock isn't a disk write).
|
||||||
|
if (settings_ && settings_->getLockFailedAttempts() != 0) {
|
||||||
|
settings_->setLockFailedAttempts(0);
|
||||||
|
settings_->setLockoutUntilEpoch(0);
|
||||||
|
settings_->save();
|
||||||
|
}
|
||||||
memset(lock_passphrase_buf_, 0, sizeof(lock_passphrase_buf_));
|
memset(lock_passphrase_buf_, 0, sizeof(lock_passphrase_buf_));
|
||||||
idle_.noteActivity();
|
idle_.noteActivity();
|
||||||
state_.encrypted = true;
|
state_.encrypted = true;
|
||||||
@@ -499,9 +506,37 @@ void App::applyUnlockFailure(const std::string& errorMessage) {
|
|||||||
if (lock_attempts_ >= maxAttempts) {
|
if (lock_attempts_ >= maxAttempts) {
|
||||||
lock_lockout_timer_ = baseDelay * (float)(1 << std::min(lock_attempts_ - maxAttempts, 8));
|
lock_lockout_timer_ = baseDelay * (float)(1 << std::min(lock_attempts_ - maxAttempts, 8));
|
||||||
}
|
}
|
||||||
|
// Persist the attempt count + lockout expiry (keyed to this wallet) so killing and relaunching the
|
||||||
|
// app can't reset the escalating lockout. The PIN/passphrase itself is never stored.
|
||||||
|
if (settings_) {
|
||||||
|
settings_->setLockStateWallet(settings_->getActiveWalletFile());
|
||||||
|
settings_->setLockFailedAttempts(lock_attempts_);
|
||||||
|
settings_->setLockoutUntilEpoch(lock_lockout_timer_ > 0.0f
|
||||||
|
? (long long)std::time(nullptr) + (long long)std::ceil(lock_lockout_timer_) : 0);
|
||||||
|
settings_->save();
|
||||||
|
}
|
||||||
DEBUG_LOGF("[App] Wallet unlock failed (attempt %d)\n", lock_attempts_);
|
DEBUG_LOGF("[App] Wallet unlock failed (attempt %d)\n", lock_attempts_);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Restore the persisted lockout at the start of a lock-screen session so a kill-and-relaunch can't reset
|
||||||
|
// the escalating failure curve. Only applies when the persisted state is for the active wallet.
|
||||||
|
void App::restoreLockoutState() {
|
||||||
|
if (!settings_) return;
|
||||||
|
if (settings_->getLockStateWallet() != settings_->getActiveWalletFile()) return;
|
||||||
|
lock_attempts_ = std::max(0, std::min(settings_->getLockFailedAttempts(), 64));
|
||||||
|
long long remaining = settings_->getLockoutUntilEpoch() - (long long)std::time(nullptr);
|
||||||
|
// Cap the restored countdown so a forward clock change can't create an absurd wait. The attempt count
|
||||||
|
// still persists, so rolling the clock back only buys a short window before the next failure re-arms
|
||||||
|
// the full curve.
|
||||||
|
const float baseDelay = ui::schema::UI().drawElement("security", "lockout-base-delay").sizeOr(2.0f);
|
||||||
|
const long long maxLock = (long long)(baseDelay * (float)(1 << 8));
|
||||||
|
if (remaining > maxLock) remaining = maxLock;
|
||||||
|
if (remaining > 0) {
|
||||||
|
lock_lockout_timer_ = (float)remaining;
|
||||||
|
lock_timer_armed_at_ = std::chrono::steady_clock::now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Failure that is NOT a wrong credential (busy node, not-encrypted, unknown error): surface the
|
// Failure that is NOT a wrong credential (busy node, not-encrypted, unknown error): surface the
|
||||||
// message and clear the buffer, but do not bump lock_attempts_ or arm the lockout.
|
// message and clear the buffer, but do not bump lock_attempts_ or arm the lockout.
|
||||||
void App::applyUnlockNotCounted(const std::string& msg) {
|
void App::applyUnlockNotCounted(const std::string& msg) {
|
||||||
@@ -513,6 +548,15 @@ void App::applyUnlockNotCounted(const std::string& msg) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void App::unlockWallet(const std::string& passphrase, int timeout) {
|
void App::unlockWallet(const std::string& passphrase, int timeout) {
|
||||||
|
if (lite_wallet_) { // lite: backend unlock with the shared attempt-lockout (no RPC/daemon)
|
||||||
|
std::string liteErr;
|
||||||
|
if (!unlockLiteWallet(passphrase, &liteErr)) {
|
||||||
|
lock_error_msg_ = liteErr;
|
||||||
|
lock_error_timer_ = 3.0f;
|
||||||
|
lock_timer_armed_at_ = std::chrono::steady_clock::now();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!rpc_ || !rpc_->isConnected() || !worker_) return;
|
if (!rpc_ || !rpc_->isConnected() || !worker_) return;
|
||||||
lock_unlock_in_progress_ = true;
|
lock_unlock_in_progress_ = true;
|
||||||
|
|
||||||
@@ -563,6 +607,7 @@ void App::unlockWallet(const std::string& passphrase, int timeout) {
|
|||||||
void App::lockWallet() {
|
void App::lockWallet() {
|
||||||
if (!rpc_ || !rpc_->isConnected() || !worker_) return;
|
if (!rpc_ || !rpc_->isConnected() || !worker_) return;
|
||||||
if (lock_unlock_in_progress_) return; // Prevent duplicate async calls
|
if (lock_unlock_in_progress_) return; // Prevent duplicate async calls
|
||||||
|
wipeDisplayedSecrets(); // hide any revealed seed/key immediately, before the async lock round-trip
|
||||||
lock_unlock_in_progress_ = true;
|
lock_unlock_in_progress_ = true;
|
||||||
|
|
||||||
// Use fast-lane worker to avoid blocking behind refreshData.
|
// Use fast-lane worker to avoid blocking behind refreshData.
|
||||||
@@ -755,12 +800,11 @@ void App::checkAutoLock() {
|
|||||||
if (capture_mode_) return; // don't auto-lock while a UI sweep forces the encrypted/locked state
|
if (capture_mode_) return; // don't auto-lock while a UI sweep forces the encrypted/locked state
|
||||||
if (!state_.isEncrypted() || state_.isLocked()) return;
|
if (!state_.isEncrypted() || state_.isLocked()) return;
|
||||||
|
|
||||||
// Don't auto-lock while mining — mining is a long-running intentional
|
// Don't auto-lock while SOLO mining — it runs in the daemon and locking the wallet there would stop
|
||||||
// operation and locking the wallet on the daemon side stops solo mining.
|
// it (and it shields matured rewards, which needs the wallet). Pool mining pays an external address
|
||||||
bool miningActive = state_.mining.generate
|
// and needs no unlocked wallet, so it must NOT hold the wallet open indefinitely.
|
||||||
|| (xmrig_manager_ && xmrig_manager_->isRunning());
|
if (state_.mining.generate) {
|
||||||
if (miningActive) {
|
// Keep resetting the idle timer so we don't lock the instant solo mining stops.
|
||||||
// Keep resetting the idle timer so we don't lock the instant mining stops
|
|
||||||
idle_.noteActivity();
|
idle_.noteActivity();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1040,9 +1084,15 @@ void App::tickMiningThreadCommit()
|
|||||||
void App::renderLockScreen() {
|
void App::renderLockScreen() {
|
||||||
using namespace ui::material;
|
using namespace ui::material;
|
||||||
|
|
||||||
|
// Restore any persisted lockout once per lock-screen session (re-armed on wallet switch).
|
||||||
|
if (!lock_state_restored_) {
|
||||||
|
lock_state_restored_ = true;
|
||||||
|
restoreLockoutState();
|
||||||
|
}
|
||||||
|
|
||||||
// Keep frames coming while something on the lock screen is moving without input: the unlock in
|
// Keep frames coming while something on the lock screen is moving without input: the unlock in
|
||||||
// flight (its dots), the lockout countdown, an error message about to clear.
|
// flight (its dots), the lockout countdown, an error message about to clear.
|
||||||
if (lock_unlock_in_progress_ || lock_lockout_timer_ > 0.0f || lock_error_timer_ > 0.0f) {
|
if (lock_unlock_in_progress_ || lock_lockout_timer_ > 0.0f) {
|
||||||
RequestProgressFrame();
|
RequestProgressFrame();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1264,9 +1314,9 @@ void App::renderLockScreen() {
|
|||||||
lock_screen_was_visible_ = true;
|
lock_screen_was_visible_ = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error message
|
// Error message — stays visible until the next unlock attempt / mode switch / success, instead of
|
||||||
if (!lock_error_msg_.empty() && lock_error_timer_ > 0) {
|
// fading after a few seconds (a failed-unlock reason was often gone before it could be read).
|
||||||
lock_error_timer_ -= lockDt;
|
if (!lock_error_msg_.empty()) {
|
||||||
ImVec2 es = captionFont->CalcTextSizeA(captionFont->LegacySize, FLT_MAX, 0, lock_error_msg_.c_str());
|
ImVec2 es = captionFont->CalcTextSizeA(captionFont->LegacySize, FLT_MAX, 0, lock_error_msg_.c_str());
|
||||||
dl->AddText(captionFont, captionFont->LegacySize,
|
dl->AddText(captionFont, captionFont->LegacySize,
|
||||||
ImVec2(cardX + (cardW - es.x) * 0.5f, cy), ui::material::Error(),
|
ImVec2(cardX + (cardW - es.x) * 0.5f, cy), ui::material::Error(),
|
||||||
@@ -1312,6 +1362,7 @@ void App::renderLockScreen() {
|
|||||||
|
|
||||||
// Handle submit (Enter key or button click)
|
// Handle submit (Enter key or button click)
|
||||||
if ((submitted || btnClicked) && canSubmit) {
|
if ((submitted || btnClicked) && canSubmit) {
|
||||||
|
lock_error_msg_.clear(); // the prior error clears only when a new attempt actually starts
|
||||||
// Derive daemon unlock timeout from auto-lock setting:
|
// Derive daemon unlock timeout from auto-lock setting:
|
||||||
// 2x the idle timeout so the daemon stays unlocked longer than
|
// 2x the idle timeout so the daemon stays unlocked longer than
|
||||||
// the GUI auto-lock, acting as a safety net if the GUI crashes.
|
// the GUI auto-lock, acting as a safety net if the GUI crashes.
|
||||||
@@ -1505,7 +1556,7 @@ void App::renderEncryptWalletDialog() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!encrypt_status_.empty()) {
|
if (!encrypt_status_.empty()) {
|
||||||
ImGui::TextColored(ImVec4(1, 0.7f, 0, 1), "%s", encrypt_status_.c_str());
|
ImGui::TextColored(ui::material::WarningVec4(), "%s", encrypt_status_.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
@@ -1516,7 +1567,7 @@ void App::renderEncryptWalletDialog() {
|
|||||||
if (!valid && (encPassLen > 0 || strlen(encrypt_confirm_buf_) > 0)) {
|
if (!valid && (encPassLen > 0 || strlen(encrypt_confirm_buf_) > 0)) {
|
||||||
const char* why = (encPassLen < 8) ? TR("enc_min_len")
|
const char* why = (encPassLen < 8) ? TR("enc_min_len")
|
||||||
: (!encMatch ? TR("enc_mismatch") : "");
|
: (!encMatch ? TR("enc_mismatch") : "");
|
||||||
if (*why) ImGui::TextColored(ImVec4(1.0f, 0.7f, 0.3f, 1.0f), "%s", why);
|
if (*why) ImGui::TextColored(ui::material::WarningVec4(), "%s", why);
|
||||||
}
|
}
|
||||||
|
|
||||||
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
|
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
|
||||||
@@ -1600,7 +1651,7 @@ void App::renderEncryptWalletDialog() {
|
|||||||
ImGui::PopItemWidth();
|
ImGui::PopItemWidth();
|
||||||
|
|
||||||
if (!enc_dlg_pin_status_.empty()) {
|
if (!enc_dlg_pin_status_.empty()) {
|
||||||
ImGui::TextColored(ImVec4(1, 0.7f, 0, 1), "%s", enc_dlg_pin_status_.c_str());
|
ImGui::TextColored(ui::material::WarningVec4(), "%s", enc_dlg_pin_status_.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
@@ -1718,7 +1769,7 @@ void App::renderEncryptWalletDialog() {
|
|||||||
ImGui::PopItemWidth();
|
ImGui::PopItemWidth();
|
||||||
|
|
||||||
if (!encrypt_status_.empty()) {
|
if (!encrypt_status_.empty()) {
|
||||||
ImGui::TextColored(ImVec4(1, 0.7f, 0, 1), "%s", encrypt_status_.c_str());
|
ImGui::TextColored(ui::material::WarningVec4(), "%s", encrypt_status_.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
@@ -1799,7 +1850,7 @@ void App::renderDecryptWalletDialog() {
|
|||||||
ImGui::PopItemWidth();
|
ImGui::PopItemWidth();
|
||||||
|
|
||||||
if (!decryptState.status.empty()) {
|
if (!decryptState.status.empty()) {
|
||||||
ImGui::TextColored(ImVec4(1, 0.4f, 0.4f, 1), "%s", decryptState.status.c_str());
|
ImGui::TextColored(ui::material::ErrorVec4(), "%s", decryptState.status.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
@@ -2047,7 +2098,7 @@ void App::renderDecryptWalletDialog() {
|
|||||||
if (services::WalletSecurityWorkflow::stepIsComplete(decryptState.step,
|
if (services::WalletSecurityWorkflow::stepIsComplete(decryptState.step,
|
||||||
services::WalletSecurityWorkflow::stepFromIndex(i))) {
|
services::WalletSecurityWorkflow::stepFromIndex(i))) {
|
||||||
// Completed
|
// Completed
|
||||||
ImGui::TextColored(ImVec4(0.3f, 0.9f, 0.4f, 1.0f), ICON_MD_CHECK_CIRCLE);
|
ImGui::TextColored(ui::material::SuccessVec4(), ICON_MD_CHECK_CIRCLE);
|
||||||
} else if (i == services::WalletSecurityWorkflow::stepIndex(decryptState.step)) {
|
} else if (i == services::WalletSecurityWorkflow::stepIndex(decryptState.step)) {
|
||||||
// In progress - animate
|
// In progress - animate
|
||||||
float alpha = 0.5f + 0.5f * sinf((float)ImGui::GetTime() * 4.0f);
|
float alpha = 0.5f + 0.5f * sinf((float)ImGui::GetTime() * 4.0f);
|
||||||
@@ -2206,7 +2257,7 @@ void App::renderPinDialogs() {
|
|||||||
ImGui::PopItemWidth();
|
ImGui::PopItemWidth();
|
||||||
|
|
||||||
if (!pin_status_.empty()) {
|
if (!pin_status_.empty()) {
|
||||||
ImGui::TextColored(ImVec4(1, 0.7f, 0, 1), "%s", pin_status_.c_str());
|
ImGui::TextColored(ui::material::WarningVec4(), "%s", pin_status_.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
@@ -2336,7 +2387,7 @@ void App::renderPinDialogs() {
|
|||||||
ImGui::PopItemWidth();
|
ImGui::PopItemWidth();
|
||||||
|
|
||||||
if (!pin_status_.empty()) {
|
if (!pin_status_.empty()) {
|
||||||
ImGui::TextColored(ImVec4(1, 0.7f, 0, 1), "%s", pin_status_.c_str());
|
ImGui::TextColored(ui::material::WarningVec4(), "%s", pin_status_.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
@@ -2405,7 +2456,7 @@ void App::renderPinDialogs() {
|
|||||||
ImGui::PopItemWidth();
|
ImGui::PopItemWidth();
|
||||||
|
|
||||||
if (!pin_status_.empty()) {
|
if (!pin_status_.empty()) {
|
||||||
ImGui::TextColored(ImVec4(1, 0.7f, 0, 1), "%s", pin_status_.c_str());
|
ImGui::TextColored(ui::material::WarningVec4(), "%s", pin_status_.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
|
|||||||
+4
-3
@@ -354,6 +354,9 @@ void App::clearDemoWalletData()
|
|||||||
seed_migration_precheck_ = SeedMigrationPrecheck::Pending;
|
seed_migration_precheck_ = SeedMigrationPrecheck::Pending;
|
||||||
seed_migration_precheck_started_ = false;
|
seed_migration_precheck_started_ = false;
|
||||||
seed_migration_backed_up_ = false;
|
seed_migration_backed_up_ = false;
|
||||||
|
seed_migration_understood_ = false;
|
||||||
|
seed_migration_confirm_discard_ = false;
|
||||||
|
seed_migration_confirm_sweep_ = false;
|
||||||
seed_migration_balance_ = 0.0;
|
seed_migration_balance_ = 0.0;
|
||||||
seed_migration_balance_loaded_ = false;
|
seed_migration_balance_loaded_ = false;
|
||||||
seed_migration_nofunds_confirmed_ = false;
|
seed_migration_nofunds_confirmed_ = false;
|
||||||
@@ -567,8 +570,6 @@ void App::buildSweepCatalog()
|
|||||||
[](App& a) { ui::ContactsSweepCloseDialog(); restoreSweepContacts(a); });
|
[](App& a) { ui::ContactsSweepCloseDialog(); restoreSweepContacts(a); });
|
||||||
add("modal-about", ui::NavPage::Overview,
|
add("modal-about", ui::NavPage::Overview,
|
||||||
[](App& a) { a.show_about_ = true; }, [](App& a) { a.show_about_ = false; });
|
[](App& a) { a.show_about_ = true; }, [](App& a) { a.show_about_ = false; });
|
||||||
add("modal-settings", ui::NavPage::Settings,
|
|
||||||
[](App& a) { a.show_settings_ = true; }, [](App& a) { a.show_settings_ = false; });
|
|
||||||
|
|
||||||
// Seed-backup: pre-set fetch + a demo phrase so it renders the word grid without any RPC.
|
// Seed-backup: pre-set fetch + a demo phrase so it renders the word grid without any RPC.
|
||||||
add("modal-seed-backup", ui::NavPage::Overview,
|
add("modal-seed-backup", ui::NavPage::Overview,
|
||||||
@@ -623,7 +624,7 @@ void App::buildSweepCatalog()
|
|||||||
a.seed_migration_precheck_started_ = true;
|
a.seed_migration_precheck_started_ = true;
|
||||||
});
|
});
|
||||||
mig("modal-migrate-showseed", SeedMigrationStep::ShowSeed,
|
mig("modal-migrate-showseed", SeedMigrationStep::ShowSeed,
|
||||||
[](App& a) { a.seed_migration_seed_ = kDemoMnemonic; a.seed_migration_backed_up_ = false; });
|
[](App& a) { a.seed_migration_seed_ = kDemoMnemonic; a.seed_migration_backed_up_ = false; a.seed_migration_understood_ = false; });
|
||||||
mig("modal-migrate-sweep", SeedMigrationStep::Sweep,
|
mig("modal-migrate-sweep", SeedMigrationStep::Sweep,
|
||||||
[](App& a) { a.seed_migration_balance_ = 15.75000000; a.seed_migration_balance_loaded_ = true; });
|
[](App& a) { a.seed_migration_balance_ = 15.75000000; a.seed_migration_balance_loaded_ = true; });
|
||||||
mig("modal-migrate-nofunds", SeedMigrationStep::Sweep,
|
mig("modal-migrate-nofunds", SeedMigrationStep::Sweep,
|
||||||
|
|||||||
+2
-1
@@ -1456,7 +1456,8 @@ void App::renderFirstRunWizard() {
|
|||||||
wizard_phase_ = WizardPhase::Done;
|
wizard_phase_ = WizardPhase::Done;
|
||||||
settings_->setWizardCompleted(true);
|
settings_->setWizardCompleted(true);
|
||||||
settings_->save();
|
settings_->save();
|
||||||
ui::Notifications::instance().info(TR("wiz_encrypt_bg"));
|
// The handoff toast is now emitted once at the completion point in app.cpp
|
||||||
|
// (wiz_handoff_encrypting), so don't also toast here.
|
||||||
}
|
}
|
||||||
ImGui::EndDisabled();
|
ImGui::EndDisabled();
|
||||||
ImGui::PopStyleVar();
|
ImGui::PopStyleVar();
|
||||||
|
|||||||
+73
-13
@@ -97,6 +97,7 @@ void ChatDatabase::lock()
|
|||||||
{
|
{
|
||||||
sodium_memzero(key_.data(), key_.size());
|
sodium_memzero(key_.data(), key_.size());
|
||||||
key_ready_ = false;
|
key_ready_ = false;
|
||||||
|
lockSensitive(); // also drop the sensitive-conversation vault key
|
||||||
wallet_tag_.clear();
|
wallet_tag_.clear();
|
||||||
tombstones_.clear();
|
tombstones_.clear();
|
||||||
}
|
}
|
||||||
@@ -106,10 +107,24 @@ bool ChatDatabase::append(const ChatMessage& message)
|
|||||||
if (!key_ready_ || !ensureOpen()) return false;
|
if (!key_ready_ || !ensureOpen()) return false;
|
||||||
if (isTombstoned(message)) return false; // locally deleted — don't re-persist on a chain re-scan
|
if (isTombstoned(message)) return false; // locally deleted — don't re-persist on a chain re-scan
|
||||||
|
|
||||||
|
bool skip = false;
|
||||||
|
const unsigned char* wkey = writeKeyFor(message.conversation_id, skip);
|
||||||
|
if (skip) return false; // sensitive conversation, vault locked — don't persist (re-imports when unlocked)
|
||||||
|
|
||||||
std::vector<unsigned char> nonce;
|
std::vector<unsigned char> nonce;
|
||||||
std::vector<unsigned char> cipher;
|
std::vector<unsigned char> cipher;
|
||||||
std::string plain = serialize(message); // full plaintext (decrypted body + metadata)
|
std::string plain;
|
||||||
const bool encrypted = encrypt(plain, nonce, cipher);
|
bool encrypted = false;
|
||||||
|
try {
|
||||||
|
// serialize() dumps strictly, so an invalid-UTF-8 (attacker-influenceable) body THROWS here;
|
||||||
|
// skip the row instead of letting it escape the write path and crash (appendBatch already does).
|
||||||
|
plain = serialize(message);
|
||||||
|
encrypted = encrypt(wkey, plain, nonce, cipher);
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
if (!plain.empty()) sodium_memzero(&plain[0], plain.size());
|
||||||
|
DEBUG_LOGF("[chat] append skipped a malformed message: %s\n", e.what());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (!plain.empty()) sodium_memzero(&plain[0], plain.size()); // don't leave it on the heap
|
if (!plain.empty()) sodium_memzero(&plain[0], plain.size()); // don't leave it on the heap
|
||||||
if (!encrypted) return false;
|
if (!encrypted) return false;
|
||||||
|
|
||||||
@@ -142,10 +157,13 @@ int ChatDatabase::appendBatch(const std::vector<ChatMessage>& messages)
|
|||||||
rows.reserve(messages.size());
|
rows.reserve(messages.size());
|
||||||
for (const auto& m : messages) {
|
for (const auto& m : messages) {
|
||||||
if (isTombstoned(m)) continue; // locally deleted — don't re-persist
|
if (isTombstoned(m)) continue; // locally deleted — don't re-persist
|
||||||
|
bool skip = false;
|
||||||
|
const unsigned char* wkey = writeKeyFor(m.conversation_id, skip);
|
||||||
|
if (skip) continue; // sensitive + vault locked — don't persist
|
||||||
try {
|
try {
|
||||||
std::string plain = serialize(m); // strict json.dump() may throw on non-UTF-8 body
|
std::string plain = serialize(m); // strict json.dump() may throw on non-UTF-8 body
|
||||||
std::vector<unsigned char> nonce, cipher;
|
std::vector<unsigned char> nonce, cipher;
|
||||||
const bool ok = encrypt(plain, nonce, cipher);
|
const bool ok = encrypt(wkey, plain, nonce, cipher);
|
||||||
if (!plain.empty()) sodium_memzero(&plain[0], plain.size());
|
if (!plain.empty()) sodium_memzero(&plain[0], plain.size());
|
||||||
if (!ok) continue;
|
if (!ok) continue;
|
||||||
rows.push_back({ dedupHash(m.txid, m.payload_position), std::move(nonce), std::move(cipher) });
|
rows.push_back({ dedupHash(m.txid, m.payload_position), std::move(nonce), std::move(cipher) });
|
||||||
@@ -185,10 +203,22 @@ bool ChatDatabase::upsert(const ChatMessage& message)
|
|||||||
{
|
{
|
||||||
if (!key_ready_ || !ensureOpen()) return false;
|
if (!key_ready_ || !ensureOpen()) return false;
|
||||||
|
|
||||||
|
bool skip = false;
|
||||||
|
const unsigned char* wkey = writeKeyFor(message.conversation_id, skip);
|
||||||
|
if (skip) return false; // sensitive conversation, vault locked — don't persist the echo under the seed key
|
||||||
|
|
||||||
std::vector<unsigned char> nonce;
|
std::vector<unsigned char> nonce;
|
||||||
std::vector<unsigned char> cipher;
|
std::vector<unsigned char> cipher;
|
||||||
std::string plain = serialize(message);
|
std::string plain;
|
||||||
const bool encrypted = encrypt(plain, nonce, cipher);
|
bool encrypted = false;
|
||||||
|
try {
|
||||||
|
plain = serialize(message); // strict dump → a malformed body throws; skip rather than crash
|
||||||
|
encrypted = encrypt(wkey, plain, nonce, cipher);
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
if (!plain.empty()) sodium_memzero(&plain[0], plain.size());
|
||||||
|
DEBUG_LOGF("[chat] upsert skipped a malformed message: %s\n", e.what());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (!plain.empty()) sodium_memzero(&plain[0], plain.size());
|
if (!plain.empty()) sodium_memzero(&plain[0], plain.size());
|
||||||
if (!encrypted) return false;
|
if (!encrypted) return false;
|
||||||
|
|
||||||
@@ -232,7 +262,13 @@ std::vector<ChatMessage> ChatDatabase::load()
|
|||||||
std::vector<unsigned char> nonce(noncePtr, noncePtr + nonceLen);
|
std::vector<unsigned char> nonce(noncePtr, noncePtr + nonceLen);
|
||||||
std::vector<unsigned char> cipher(cipherPtr, cipherPtr + cipherLen);
|
std::vector<unsigned char> cipher(cipherPtr, cipherPtr + cipherLen);
|
||||||
std::string plain;
|
std::string plain;
|
||||||
if (!decrypt(nonce, cipher, plain)) continue; // wrong wallet / tampered — skip
|
// Try the seed key first; a sensitive row only decrypts under the vault key (and is silently
|
||||||
|
// withheld when the vault is locked / on a fresh seed import that has no vault key). A wrong
|
||||||
|
// wallet / tampered row fails both and is skipped.
|
||||||
|
if (!decrypt(key_.data(), nonce, cipher, plain) &&
|
||||||
|
!(sensitive_ready_ && decrypt(sensitive_key_.data(), nonce, cipher, plain))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
ChatMessage message;
|
ChatMessage message;
|
||||||
if (deserialize(plain, message)) out.push_back(std::move(message));
|
if (deserialize(plain, message)) out.push_back(std::move(message));
|
||||||
@@ -416,7 +452,7 @@ std::string ChatDatabase::serialize(const ChatMessage& message) const
|
|||||||
json["ts"] = message.timestamp;
|
json["ts"] = message.timestamp;
|
||||||
json["pos"] = static_cast<std::uint64_t>(message.payload_position);
|
json["pos"] = static_cast<std::uint64_t>(message.payload_position);
|
||||||
json["dl"] = static_cast<int>(message.delivery);
|
json["dl"] = static_cast<int>(message.delivery);
|
||||||
return json.dump();
|
return json.dump(); // strict: an invalid-UTF-8 body throws and is DROPPED by the callers (contract)
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ChatDatabase::deserialize(const std::string& json, ChatMessage& out) const
|
bool ChatDatabase::deserialize(const std::string& json, ChatMessage& out) const
|
||||||
@@ -442,11 +478,34 @@ bool ChatDatabase::deserialize(const std::string& json, ChatMessage& out) const
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ChatDatabase::encrypt(const std::string& plain,
|
void ChatDatabase::setSensitiveKey(const std::array<unsigned char, 32>& key)
|
||||||
|
{
|
||||||
|
sensitive_key_ = key; // copy; the caller still owns & wipes its own copy
|
||||||
|
sensitive_ready_ = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChatDatabase::lockSensitive()
|
||||||
|
{
|
||||||
|
sodium_memzero(sensitive_key_.data(), sensitive_key_.size());
|
||||||
|
sensitive_ready_ = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsigned char* ChatDatabase::writeKeyFor(const std::string& cid, bool& skip) const
|
||||||
|
{
|
||||||
|
skip = false;
|
||||||
|
if (sensitive_pred_ && sensitive_pred_(cid)) {
|
||||||
|
if (!sensitive_ready_) { skip = true; return nullptr; } // don't persist under the seed key
|
||||||
|
return sensitive_key_.data();
|
||||||
|
}
|
||||||
|
return key_.data();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ChatDatabase::encrypt(const unsigned char* key,
|
||||||
|
const std::string& plain,
|
||||||
std::vector<unsigned char>& nonce,
|
std::vector<unsigned char>& nonce,
|
||||||
std::vector<unsigned char>& cipher) const
|
std::vector<unsigned char>& cipher) const
|
||||||
{
|
{
|
||||||
if (!key_ready_) return false;
|
if (!key) return false;
|
||||||
nonce.resize(crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);
|
nonce.resize(crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);
|
||||||
randombytes_buf(nonce.data(), nonce.size());
|
randombytes_buf(nonce.data(), nonce.size());
|
||||||
|
|
||||||
@@ -457,18 +516,19 @@ bool ChatDatabase::encrypt(const std::string& plain,
|
|||||||
cipher.data(), &cipherLen,
|
cipher.data(), &cipherLen,
|
||||||
reinterpret_cast<const unsigned char*>(plain.data()), plain.size(),
|
reinterpret_cast<const unsigned char*>(plain.data()), plain.size(),
|
||||||
reinterpret_cast<const unsigned char*>(ad.data()), ad.size(),
|
reinterpret_cast<const unsigned char*>(ad.data()), ad.size(),
|
||||||
nullptr, nonce.data(), key_.data()) != 0) {
|
nullptr, nonce.data(), key) != 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
cipher.resize(static_cast<std::size_t>(cipherLen));
|
cipher.resize(static_cast<std::size_t>(cipherLen));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ChatDatabase::decrypt(const std::vector<unsigned char>& nonce,
|
bool ChatDatabase::decrypt(const unsigned char* key,
|
||||||
|
const std::vector<unsigned char>& nonce,
|
||||||
const std::vector<unsigned char>& cipher,
|
const std::vector<unsigned char>& cipher,
|
||||||
std::string& plain) const
|
std::string& plain) const
|
||||||
{
|
{
|
||||||
if (!key_ready_) return false;
|
if (!key) return false;
|
||||||
if (nonce.size() != crypto_aead_xchacha20poly1305_ietf_NPUBBYTES) return false;
|
if (nonce.size() != crypto_aead_xchacha20poly1305_ietf_NPUBBYTES) return false;
|
||||||
if (cipher.size() < crypto_aead_xchacha20poly1305_ietf_ABYTES) return false;
|
if (cipher.size() < crypto_aead_xchacha20poly1305_ietf_ABYTES) return false;
|
||||||
|
|
||||||
@@ -479,7 +539,7 @@ bool ChatDatabase::decrypt(const std::vector<unsigned char>& nonce,
|
|||||||
out.data(), &outLen, nullptr,
|
out.data(), &outLen, nullptr,
|
||||||
cipher.data(), cipher.size(),
|
cipher.data(), cipher.size(),
|
||||||
reinterpret_cast<const unsigned char*>(ad.data()), ad.size(),
|
reinterpret_cast<const unsigned char*>(ad.data()), ad.size(),
|
||||||
nonce.data(), key_.data()) != 0) {
|
nonce.data(), key) != 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
plain.assign(reinterpret_cast<const char*>(out.data()), static_cast<std::size_t>(outLen));
|
plain.assign(reinterpret_cast<const char*>(out.data()), static_cast<std::size_t>(outLen));
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
|
|
||||||
#include <array>
|
#include <array>
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
|
#include <functional>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <unordered_set>
|
#include <unordered_set>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
@@ -40,6 +41,17 @@ public:
|
|||||||
void lock(); // wipe the key material (DB handle stays open); load()/append() then no-op
|
void lock(); // wipe the key material (DB handle stays open); load()/append() then no-op
|
||||||
bool hasKey() const { return key_ready_; }
|
bool hasKey() const { return key_ready_; }
|
||||||
|
|
||||||
|
// --- Sensitive conversations (per-conversation local-key encryption) ---
|
||||||
|
// Provide the local vault key (from ChatSensitiveVault). Rows of conversations the sensitive
|
||||||
|
// predicate marks sensitive are AEAD-encrypted under THIS key instead of the seed-derived key, so
|
||||||
|
// they can't be read without it (even by the seed). The caller still owns and should wipe `key`.
|
||||||
|
void setSensitiveKey(const std::array<unsigned char, 32>& key);
|
||||||
|
void lockSensitive(); // wipe the sensitive key; sensitive rows then withheld
|
||||||
|
bool hasSensitiveKey() const { return sensitive_ready_; }
|
||||||
|
// Predicate: true for a conversation id that should be stored under the sensitive key. Consulted on
|
||||||
|
// WRITE to pick the key; on READ both keys are tried, so a predicate isn't needed to decrypt.
|
||||||
|
void setSensitivePredicate(std::function<bool(const std::string&)> pred) { sensitive_pred_ = std::move(pred); }
|
||||||
|
|
||||||
// Persist one message (INSERT OR IGNORE, deduped by a keyed hash of txid+payload_position).
|
// Persist one message (INSERT OR IGNORE, deduped by a keyed hash of txid+payload_position).
|
||||||
// Returns true if newly inserted; false on duplicate or while locked.
|
// Returns true if newly inserted; false on duplicate or while locked.
|
||||||
bool append(const ChatMessage& message);
|
bool append(const ChatMessage& message);
|
||||||
@@ -80,12 +92,18 @@ private:
|
|||||||
std::string dedupHash(const std::string& txid, std::size_t position) const;
|
std::string dedupHash(const std::string& txid, std::size_t position) const;
|
||||||
std::string serialize(const ChatMessage& message) const;
|
std::string serialize(const ChatMessage& message) const;
|
||||||
bool deserialize(const std::string& json, ChatMessage& out) const;
|
bool deserialize(const std::string& json, ChatMessage& out) const;
|
||||||
bool encrypt(const std::string& plain,
|
bool encrypt(const unsigned char* key,
|
||||||
|
const std::string& plain,
|
||||||
std::vector<unsigned char>& nonce,
|
std::vector<unsigned char>& nonce,
|
||||||
std::vector<unsigned char>& cipher) const;
|
std::vector<unsigned char>& cipher) const;
|
||||||
bool decrypt(const std::vector<unsigned char>& nonce,
|
bool decrypt(const unsigned char* key,
|
||||||
|
const std::vector<unsigned char>& nonce,
|
||||||
const std::vector<unsigned char>& cipher,
|
const std::vector<unsigned char>& cipher,
|
||||||
std::string& plain) const;
|
std::string& plain) const;
|
||||||
|
// The key a row for `cid` is written under. Returns nullptr and sets skip=true when the conversation
|
||||||
|
// is sensitive but the vault is locked — the row must NOT be persisted (under the seed key it would be
|
||||||
|
// readable without the vault, defeating the point); it re-imports from chain once the vault is unlocked.
|
||||||
|
const unsigned char* writeKeyFor(const std::string& cid, bool& skip) const;
|
||||||
void close();
|
void close();
|
||||||
|
|
||||||
sqlite3* db_ = nullptr;
|
sqlite3* db_ = nullptr;
|
||||||
@@ -93,6 +111,9 @@ private:
|
|||||||
std::array<unsigned char, 32> key_{}; // AEAD storage key (seed-derived)
|
std::array<unsigned char, 32> key_{}; // AEAD storage key (seed-derived)
|
||||||
std::string wallet_tag_; // seed-derived row partition (a keyed hash, hex)
|
std::string wallet_tag_; // seed-derived row partition (a keyed hash, hex)
|
||||||
bool key_ready_ = false;
|
bool key_ready_ = false;
|
||||||
|
std::array<unsigned char, 32> sensitive_key_{}; // local vault key for sensitive conversations' rows
|
||||||
|
bool sensitive_ready_ = false;
|
||||||
|
std::function<bool(const std::string&)> sensitive_pred_; // true => store this cid under the sensitive key
|
||||||
std::unordered_set<std::string> tombstones_; // dedup_hash cache of locally-deleted messages
|
std::unordered_set<std::string> tombstones_; // dedup_hash cache of locally-deleted messages
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+78
-16
@@ -17,31 +17,47 @@ std::string buildHeaderMemo(const std::string& replyZaddr,
|
|||||||
const char* type,
|
const char* type,
|
||||||
const std::string& streamHeaderHex,
|
const std::string& streamHeaderHex,
|
||||||
const std::string& publicKeyHex,
|
const std::string& publicKeyHex,
|
||||||
std::int64_t sentAt)
|
std::int64_t sentAt,
|
||||||
|
const std::string& op = "",
|
||||||
|
int parts = 1)
|
||||||
{
|
{
|
||||||
nlohmann::json header;
|
nlohmann::json header;
|
||||||
header["h"] = 1; // header number (>= 1)
|
header["h"] = 1; // header number (>= 1)
|
||||||
header["v"] = kHushChatSupportedVersion; // 0
|
header["v"] = kHushChatSupportedVersion; // 0
|
||||||
header["z"] = replyZaddr; // where the peer should reply (my address)
|
header["z"] = replyZaddr; // where the peer should reply (my address)
|
||||||
header["cid"] = conversationId;
|
header["cid"] = conversationId;
|
||||||
header["t"] = type; // "Memo" or "Cont"
|
header["t"] = type; // "Memo" / "Cont" / "Ctrl"
|
||||||
header["e"] = streamHeaderHex; // 48-hex secretstream header (Memo) / "" (Cont)
|
header["e"] = streamHeaderHex; // 48-hex secretstream header (Memo) / "" (Cont, Ctrl)
|
||||||
header["p"] = publicKeyHex; // my 64-hex crypto_kx public key
|
header["p"] = publicKeyHex; // my 64-hex crypto_kx public key
|
||||||
|
if (!op.empty()) header["op"] = op; // Control only; alphabetically sorts to cid,e,h,op,p,t,v,z
|
||||||
|
if (parts > 1) header["n"] = parts; // multi-part Message only; omitted (==1) keeps 1-part byte-identical
|
||||||
if (sentAt > 0) header["ts"] = sentAt; // optional sender compose time (Unix s) — receiver shows this
|
if (sentAt > 0) header["ts"] = sentAt; // optional sender compose time (Unix s) — receiver shows this
|
||||||
return header.dump();
|
return header.dump();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The N 2-hex index prefix for a multi-part ciphertext chunk (index 0..kHushChatMaxMessageParts-1).
|
||||||
|
std::string partIndexPrefix(std::size_t i)
|
||||||
|
{
|
||||||
|
static const char* kHex = "0123456789abcdef";
|
||||||
|
std::string p;
|
||||||
|
p += kHex[(i >> 4) & 0xF];
|
||||||
|
p += kHex[i & 0xF];
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
bool present(const std::string& value) { return !value.empty(); }
|
bool present(const std::string& value) { return !value.empty(); }
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
std::array<ChatSendOutput, 2> chatSendOutputs(const OutgoingChatMemos& memos, bool utf8Prefix)
|
std::vector<ChatSendOutput> chatSendOutputs(const OutgoingChatMemos& memos, bool utf8Prefix)
|
||||||
{
|
{
|
||||||
const std::string prefix = utf8Prefix ? "utf8:" : "";
|
const std::string prefix = utf8Prefix ? "utf8:" : "";
|
||||||
return {{
|
std::vector<ChatSendOutput> outs;
|
||||||
{ memos.recipientZaddr, prefix + memos.headerMemo }, // header — the lower memo position
|
outs.reserve(1 + memos.payloadMemos.size());
|
||||||
{ memos.recipientZaddr, prefix + memos.payloadMemo },
|
outs.push_back({ memos.recipientZaddr, prefix + memos.headerMemo }); // header (position doesn't matter;
|
||||||
}};
|
for (const auto& p : memos.payloadMemos) // the receiver reassembles by index)
|
||||||
|
outs.push_back({ memos.recipientZaddr, prefix + p });
|
||||||
|
return outs;
|
||||||
}
|
}
|
||||||
|
|
||||||
ChatComposeStatus buildOutgoingMessage(const ChatKeyPair& mine,
|
ChatComposeStatus buildOutgoingMessage(const ChatKeyPair& mine,
|
||||||
@@ -67,15 +83,32 @@ ChatComposeStatus buildOutgoingMessage(const ChatKeyPair& mine,
|
|||||||
return ChatComposeStatus::EncryptFailed;
|
return ChatComposeStatus::EncryptFailed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Chunk the ciphertext hex across payload memos. A single memo → the classic 1-part message, byte-
|
||||||
|
// identical on the wire (no "n", no index prefix). Longer → N index-prefixed chunks (tier 2), capped
|
||||||
|
// at kHushChatMaxMessageParts; beyond that it is refused as TooLong (tier 3 would lift the ceiling).
|
||||||
|
std::vector<std::string> payloads;
|
||||||
|
int parts = 1;
|
||||||
|
if (ciphertextHex.size() <= kHushChatMemoByteLimit) {
|
||||||
|
payloads.push_back(ciphertextHex);
|
||||||
|
} else {
|
||||||
|
const std::size_t chunkHex = (kHushChatMemoByteLimit - kHushChatPartIndexHexLen) & ~std::size_t(1);
|
||||||
|
const std::size_t n = (ciphertextHex.size() + chunkHex - 1) / chunkHex;
|
||||||
|
if (static_cast<int>(n) > kHushChatMaxMessageParts) return ChatComposeStatus::TooLong;
|
||||||
|
parts = static_cast<int>(n);
|
||||||
|
payloads.reserve(n);
|
||||||
|
for (std::size_t i = 0; i < n; ++i) {
|
||||||
|
payloads.push_back(partIndexPrefix(i) + ciphertextHex.substr(i * chunkHex, chunkHex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
OutgoingChatMemos memos;
|
OutgoingChatMemos memos;
|
||||||
memos.recipientZaddr = peerZaddr;
|
memos.recipientZaddr = peerZaddr;
|
||||||
memos.headerMemo = buildHeaderMemo(myReplyZaddr, conversationId, "Memo", streamHeaderHex, myPublicKeyHex,
|
memos.headerMemo = buildHeaderMemo(myReplyZaddr, conversationId, "Memo", streamHeaderHex, myPublicKeyHex,
|
||||||
static_cast<std::int64_t>(std::time(nullptr)));
|
static_cast<std::int64_t>(std::time(nullptr)), "", parts);
|
||||||
memos.payloadMemo = ciphertextHex;
|
memos.payloadMemos = std::move(payloads);
|
||||||
if (memos.headerMemo.size() > kHushChatMemoByteLimit ||
|
if (memos.headerMemo.size() > kHushChatMemoByteLimit) return ChatComposeStatus::TooLong;
|
||||||
memos.payloadMemo.size() > kHushChatMemoByteLimit) {
|
for (const auto& p : memos.payloadMemos)
|
||||||
return ChatComposeStatus::TooLong;
|
if (p.size() > kHushChatMemoByteLimit) return ChatComposeStatus::TooLong;
|
||||||
}
|
|
||||||
out = std::move(memos);
|
out = std::move(memos);
|
||||||
return ChatComposeStatus::Ok;
|
return ChatComposeStatus::Ok;
|
||||||
}
|
}
|
||||||
@@ -100,9 +133,38 @@ ChatComposeStatus buildOutgoingContactRequest(const std::string& myPublicKeyHex,
|
|||||||
memos.recipientZaddr = peerZaddr;
|
memos.recipientZaddr = peerZaddr;
|
||||||
memos.headerMemo = buildHeaderMemo(myReplyZaddr, conversationId, "Cont", "", myPublicKeyHex,
|
memos.headerMemo = buildHeaderMemo(myReplyZaddr, conversationId, "Cont", "", myPublicKeyHex,
|
||||||
static_cast<std::int64_t>(std::time(nullptr)));
|
static_cast<std::int64_t>(std::time(nullptr)));
|
||||||
memos.payloadMemo = requestText;
|
memos.payloadMemos = { requestText };
|
||||||
if (memos.headerMemo.size() > kHushChatMemoByteLimit ||
|
if (memos.headerMemo.size() > kHushChatMemoByteLimit ||
|
||||||
memos.payloadMemo.size() > kHushChatMemoByteLimit) {
|
memos.payloadMemos[0].size() > kHushChatMemoByteLimit) {
|
||||||
|
return ChatComposeStatus::TooLong;
|
||||||
|
}
|
||||||
|
out = std::move(memos);
|
||||||
|
return ChatComposeStatus::Ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
ChatComposeStatus buildOutgoingControl(const std::string& myPublicKeyHex,
|
||||||
|
const std::string& selfZaddr,
|
||||||
|
const std::string& conversationId,
|
||||||
|
const std::string& op,
|
||||||
|
const std::string& label,
|
||||||
|
OutgoingChatMemos& out)
|
||||||
|
{
|
||||||
|
// A control signal is addressed to SELF: only this wallet (any device holding the seed) harvests
|
||||||
|
// it, and it never reaches the peer. op carries the action; the label is a plaintext hint (peer
|
||||||
|
// address / display name) so a fresh import can show the conversation it refers to.
|
||||||
|
if (label.empty()) return ChatComposeStatus::EmptyBody;
|
||||||
|
if (label.front() == '{') return ChatComposeStatus::BadRequestText; // would parse as a header
|
||||||
|
if (!present(op) || !present(myPublicKeyHex) || !present(selfZaddr) || !present(conversationId)) {
|
||||||
|
return ChatComposeStatus::MissingField;
|
||||||
|
}
|
||||||
|
|
||||||
|
OutgoingChatMemos memos;
|
||||||
|
memos.recipientZaddr = selfZaddr;
|
||||||
|
memos.headerMemo = buildHeaderMemo(selfZaddr, conversationId, "Ctrl", "", myPublicKeyHex,
|
||||||
|
static_cast<std::int64_t>(std::time(nullptr)), op);
|
||||||
|
memos.payloadMemos = { label };
|
||||||
|
if (memos.headerMemo.size() > kHushChatMemoByteLimit ||
|
||||||
|
memos.payloadMemos[0].size() > kHushChatMemoByteLimit) {
|
||||||
return ChatComposeStatus::TooLong;
|
return ChatComposeStatus::TooLong;
|
||||||
}
|
}
|
||||||
out = std::move(memos);
|
out = std::move(memos);
|
||||||
|
|||||||
@@ -12,13 +12,17 @@
|
|||||||
|
|
||||||
#include <array>
|
#include <array>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
namespace dragonx::chat {
|
namespace dragonx::chat {
|
||||||
|
|
||||||
struct OutgoingChatMemos {
|
struct OutgoingChatMemos {
|
||||||
std::string recipientZaddr; // the peer's z-address (recipient of both memo outputs)
|
std::string recipientZaddr; // the peer's z-address (recipient of every memo output)
|
||||||
std::string headerMemo; // JSON header — MUST occupy the lower memo position on the wire
|
std::string headerMemo; // JSON header
|
||||||
std::string payloadMemo; // ciphertext hex (Message) or plaintext (ContactRequest)
|
// One payload (ciphertext hex for a Message, plaintext for a ContactRequest/Control), OR — for a
|
||||||
|
// multi-part Message (header "n" = N > 1) — N index-prefixed ciphertext chunks (a 2-hex part index
|
||||||
|
// then that chunk's ciphertext hex), reassembled by the receiver regardless of on-chain order.
|
||||||
|
std::vector<std::string> payloadMemos;
|
||||||
};
|
};
|
||||||
|
|
||||||
// One of the two 0-value memo outputs a HushChat send produces (amount is always 0).
|
// One of the two 0-value memo outputs a HushChat send produces (amount is always 0).
|
||||||
@@ -31,7 +35,7 @@ struct ChatSendOutput {
|
|||||||
// `utf8Prefix` prepends the daemon's "utf8:" marker required by full-node z_sendmany (which then
|
// `utf8Prefix` prepends the daemon's "utf8:" marker required by full-node z_sendmany (which then
|
||||||
// UTF-8-encodes the bytes on-chain, byte-identical to SDXLite's Memo::from_str); lite backends take
|
// UTF-8-encodes the bytes on-chain, byte-identical to SDXLite's Memo::from_str); lite backends take
|
||||||
// raw UTF-8, so pass false there.
|
// raw UTF-8, so pass false there.
|
||||||
std::array<ChatSendOutput, 2> chatSendOutputs(const OutgoingChatMemos& memos, bool utf8Prefix);
|
std::vector<ChatSendOutput> chatSendOutputs(const OutgoingChatMemos& memos, bool utf8Prefix);
|
||||||
|
|
||||||
enum class ChatComposeStatus {
|
enum class ChatComposeStatus {
|
||||||
Ok,
|
Ok,
|
||||||
@@ -63,4 +67,15 @@ ChatComposeStatus buildOutgoingContactRequest(const std::string& myPublicKeyHex,
|
|||||||
const std::string& requestText,
|
const std::string& requestText,
|
||||||
OutgoingChatMemos& out);
|
OutgoingChatMemos& out);
|
||||||
|
|
||||||
|
// Build a self-addressed control signal (delete / restore / mark-sensitive). It is sent to your OWN
|
||||||
|
// z-address, so only this wallet's seed can harvest it and it never reaches the peer. `op` is the
|
||||||
|
// action ("del"/"res"/...); `label` is a plaintext hint (peer address or name) carried as the payload
|
||||||
|
// so a fresh seed import can name the conversation the signal refers to.
|
||||||
|
ChatComposeStatus buildOutgoingControl(const std::string& myPublicKeyHex,
|
||||||
|
const std::string& selfZaddr,
|
||||||
|
const std::string& conversationId,
|
||||||
|
const std::string& op,
|
||||||
|
const std::string& label,
|
||||||
|
OutgoingChatMemos& out);
|
||||||
|
|
||||||
} // namespace dragonx::chat
|
} // namespace dragonx::chat
|
||||||
|
|||||||
@@ -81,6 +81,8 @@ const char* hushChatHeaderTypeName(HushChatHeaderType type)
|
|||||||
return "Memo";
|
return "Memo";
|
||||||
case HushChatHeaderType::ContactRequest:
|
case HushChatHeaderType::ContactRequest:
|
||||||
return "Cont";
|
return "Cont";
|
||||||
|
case HushChatHeaderType::Control:
|
||||||
|
return "Ctrl";
|
||||||
}
|
}
|
||||||
return "Unknown";
|
return "Unknown";
|
||||||
}
|
}
|
||||||
@@ -138,7 +140,15 @@ HushChatHeaderParseResult parseHushChatHeaderMemo(const std::string& memo)
|
|||||||
header.type = HushChatHeaderType::Message;
|
header.type = HushChatHeaderType::Message;
|
||||||
} else if (type == "Cont") {
|
} else if (type == "Cont") {
|
||||||
header.type = HushChatHeaderType::ContactRequest;
|
header.type = HushChatHeaderType::ContactRequest;
|
||||||
|
} else if (type == "Ctrl") {
|
||||||
|
header.type = HushChatHeaderType::Control;
|
||||||
|
// A control signal carries a non-empty op ("del"/"res"/...); the op VALUE isn't restricted here so
|
||||||
|
// a future op reaches newer clients and is ignored by the reducer on older ones (forward-compat).
|
||||||
|
if (!readRequiredString(object, "op", header.op, error)) return fail(error);
|
||||||
|
if (header.op.empty()) return fail("control op is empty");
|
||||||
} else {
|
} else {
|
||||||
|
// Unknown type (older client seeing a newer memo type): fail here so it is grouped as an invalid
|
||||||
|
// header and ignored, never crashing. Keep this the catch-all.
|
||||||
return fail("unknown HushChat header type");
|
return fail("unknown HushChat header type");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,6 +161,13 @@ HushChatHeaderParseResult parseHushChatHeaderMemo(const std::string& memo)
|
|||||||
!isHexString(header.secretstream_header_hex)) {
|
!isHexString(header.secretstream_header_hex)) {
|
||||||
return fail("message header must include a 24 byte secretstream header encoded as hex");
|
return fail("message header must include a 24 byte secretstream header encoded as hex");
|
||||||
}
|
}
|
||||||
|
// Optional part count ("n"); absent/1 = classic single-payload message. Bound it so a garbage or
|
||||||
|
// future-oversized header can't make the grouper try to collect an unbounded number of payloads.
|
||||||
|
if (auto it = object.find("n"); it != object.end() && it->is_number_integer()) {
|
||||||
|
const int n = it->get<int>();
|
||||||
|
if (n < 1 || n > kHushChatMaxMessageParts) return fail("message part count out of range");
|
||||||
|
header.parts = n;
|
||||||
|
}
|
||||||
} else if (!header.secretstream_header_hex.empty()) {
|
} else if (!header.secretstream_header_hex.empty()) {
|
||||||
return fail("contact request header must not include a secretstream header");
|
return fail("contact request header must not include a secretstream header");
|
||||||
}
|
}
|
||||||
@@ -179,6 +196,76 @@ HushChatMemoGroupingResult groupHushChatMemoOutputs(const std::vector<HushChatMe
|
|||||||
});
|
});
|
||||||
|
|
||||||
HushChatMemoGroupingResult result;
|
HushChatMemoGroupingResult result;
|
||||||
|
|
||||||
|
// --- Larger-messages tier 2: one message split across N payload outputs in a SINGLE tx. ---
|
||||||
|
// A lone Message header with part count ("n") > 1 means its ciphertext was chunked across N payload
|
||||||
|
// outputs, each prefixed with a 2-hex part index so order survives the daemon's output shuffle.
|
||||||
|
// Reassemble here and emit one pair. Only a clean one-header + exactly-N-chunks tx takes this path;
|
||||||
|
// anything else falls through to the classic single-pair logic. An older client lacks this branch,
|
||||||
|
// mis-pairs the first chunk, fails to decrypt, and drops it — graceful degradation.
|
||||||
|
{
|
||||||
|
std::optional<HushChatHeader> multiHeader;
|
||||||
|
std::size_t multiHeaderPos = 0;
|
||||||
|
int validHeaders = 0;
|
||||||
|
bool anyOversize = false;
|
||||||
|
std::vector<const HushChatMemoOutput*> payloads;
|
||||||
|
for (const auto& entry : ordered) {
|
||||||
|
const auto& o = entry.output;
|
||||||
|
if (o.memo.size() > kHushChatMemoByteLimit) { anyOversize = true; break; }
|
||||||
|
if (!o.memo.empty() && o.memo.front() == '{') {
|
||||||
|
auto parsed = parseHushChatHeaderMemo(o.memo);
|
||||||
|
if (parsed.ok) {
|
||||||
|
++validHeaders;
|
||||||
|
if (parsed.header.type == HushChatHeaderType::Message && parsed.header.parts > 1) {
|
||||||
|
multiHeader = std::move(parsed.header);
|
||||||
|
multiHeaderPos = o.position;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (!o.memo.empty()) {
|
||||||
|
payloads.push_back(&o);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!anyOversize && validHeaders == 1 && multiHeader) {
|
||||||
|
const int want = multiHeader->parts;
|
||||||
|
auto hexVal = [](char c) -> int {
|
||||||
|
if (c >= '0' && c <= '9') return c - '0';
|
||||||
|
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
|
||||||
|
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
|
||||||
|
return -1;
|
||||||
|
};
|
||||||
|
std::vector<std::string> chunks(static_cast<std::size_t>(want));
|
||||||
|
std::vector<bool> seen(static_cast<std::size_t>(want), false);
|
||||||
|
bool ok = (static_cast<int>(payloads.size()) == want);
|
||||||
|
std::size_t lowPos = 0; bool haveLow = false;
|
||||||
|
for (const auto* p : payloads) {
|
||||||
|
if (!ok) break;
|
||||||
|
const std::string& m = p->memo;
|
||||||
|
if (m.size() < kHushChatPartIndexHexLen || (m.size() % 2) != 0 || !isHexString(m)) { ok = false; break; }
|
||||||
|
const int idx = hexVal(m[0]) * 16 + hexVal(m[1]);
|
||||||
|
if (idx < 0 || idx >= want || seen[static_cast<std::size_t>(idx)]) { ok = false; break; }
|
||||||
|
seen[static_cast<std::size_t>(idx)] = true;
|
||||||
|
chunks[static_cast<std::size_t>(idx)] = m.substr(kHushChatPartIndexHexLen);
|
||||||
|
if (!haveLow || p->position < lowPos) { lowPos = p->position; haveLow = true; }
|
||||||
|
}
|
||||||
|
if (ok) {
|
||||||
|
std::string full;
|
||||||
|
for (const auto& c : chunks) full += c;
|
||||||
|
if (!full.empty() && (full.size() % 2) == 0) {
|
||||||
|
HushChatMemoPair pair;
|
||||||
|
pair.header = std::move(*multiHeader);
|
||||||
|
pair.header_position = multiHeaderPos;
|
||||||
|
pair.payload_position = haveLow ? lowPos : 0;
|
||||||
|
pair.payload_memo = std::move(full); // the reassembled full ciphertext hex
|
||||||
|
result.pairs.push_back(std::move(pair));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.issues.push_back(HushChatMemoGroupingIssueInfo{
|
||||||
|
HushChatMemoGroupingIssue::MissingPayload, multiHeaderPos,
|
||||||
|
"multi-part message did not reassemble"});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
std::optional<HushChatMemoOutput> pending_header_output;
|
std::optional<HushChatMemoOutput> pending_header_output;
|
||||||
std::optional<HushChatHeader> pending_header;
|
std::optional<HushChatHeader> pending_header;
|
||||||
// A payload memo seen BEFORE its header. The full-node daemon shuffles the two 0-value memo
|
// A payload memo seen BEFORE its header. The full-node daemon shuffles the two 0-value memo
|
||||||
@@ -299,6 +386,7 @@ HushChatTransactionExtractionResult extractHushChatTransactionMetadata(
|
|||||||
HushChatTransactionMetadata metadata;
|
HushChatTransactionMetadata metadata;
|
||||||
metadata.txid = transaction.txid;
|
metadata.txid = transaction.txid;
|
||||||
metadata.type = pair.header.type;
|
metadata.type = pair.header.type;
|
||||||
|
metadata.op = pair.header.op; // Control only; empty for Message/ContactRequest
|
||||||
metadata.conversation_id = pair.header.conversation_id;
|
metadata.conversation_id = pair.header.conversation_id;
|
||||||
metadata.reply_zaddr = pair.header.reply_zaddr;
|
metadata.reply_zaddr = pair.header.reply_zaddr;
|
||||||
metadata.header_position = pair.header_position;
|
metadata.header_position = pair.header_position;
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ namespace dragonx::chat {
|
|||||||
|
|
||||||
enum class HushChatHeaderType {
|
enum class HushChatHeaderType {
|
||||||
Message,
|
Message,
|
||||||
ContactRequest
|
ContactRequest,
|
||||||
|
Control // a self-addressed control signal (delete / restore / mark sensitive); carries an `op`
|
||||||
};
|
};
|
||||||
|
|
||||||
struct HushChatHeader {
|
struct HushChatHeader {
|
||||||
@@ -21,8 +22,13 @@ struct HushChatHeader {
|
|||||||
std::string reply_zaddr;
|
std::string reply_zaddr;
|
||||||
std::string conversation_id;
|
std::string conversation_id;
|
||||||
HushChatHeaderType type = HushChatHeaderType::Message;
|
HushChatHeaderType type = HushChatHeaderType::Message;
|
||||||
|
std::string op; // Control only ("del" / "res" / "sensitive" / "unsensitive" / future ops); empty otherwise
|
||||||
std::string secretstream_header_hex;
|
std::string secretstream_header_hex;
|
||||||
std::string public_key_hex;
|
std::string public_key_hex;
|
||||||
|
// Message only: how many payload outputs this message's ciphertext is split across (header "n").
|
||||||
|
// 1 = the classic single-payload message (field omitted on the wire). >1 means the payloads are
|
||||||
|
// index-prefixed ciphertext chunks reassembled in order (larger-messages tier 2, one transaction).
|
||||||
|
int parts = 1;
|
||||||
// Optional sender-stamped compose time (header "ts", Unix seconds). 0 = absent (older sender) → the
|
// Optional sender-stamped compose time (header "ts", Unix seconds). 0 = absent (older sender) → the
|
||||||
// receiver falls back to the tx/receive time. Lets both sides show the SAME (send) time.
|
// receiver falls back to the tx/receive time. Lets both sides show the SAME (send) time.
|
||||||
std::int64_t sent_at = 0;
|
std::int64_t sent_at = 0;
|
||||||
@@ -73,6 +79,7 @@ struct HushChatTransactionInput {
|
|||||||
struct HushChatTransactionMetadata {
|
struct HushChatTransactionMetadata {
|
||||||
std::string txid;
|
std::string txid;
|
||||||
HushChatHeaderType type = HushChatHeaderType::Message;
|
HushChatHeaderType type = HushChatHeaderType::Message;
|
||||||
|
std::string op; // Control only: the control op ("del"/"res"/...); empty otherwise
|
||||||
std::string conversation_id;
|
std::string conversation_id;
|
||||||
std::string reply_zaddr;
|
std::string reply_zaddr;
|
||||||
std::size_t header_position = 0;
|
std::size_t header_position = 0;
|
||||||
@@ -95,6 +102,13 @@ struct HushChatTransactionExtractionResult {
|
|||||||
|
|
||||||
constexpr int kHushChatSupportedVersion = 0;
|
constexpr int kHushChatSupportedVersion = 0;
|
||||||
constexpr std::size_t kHushChatMemoByteLimit = 512;
|
constexpr std::size_t kHushChatMemoByteLimit = 512;
|
||||||
|
// Larger-messages tier 2: the most payload outputs a single message may be split across (one tx).
|
||||||
|
// Keeps the chat tx well under the shielded max_tx_size after Sietch decoy padding + proving cost.
|
||||||
|
// A message that would need more parts than this is refused at compose time (tier 3 would lift it).
|
||||||
|
constexpr int kHushChatMaxMessageParts = 16;
|
||||||
|
// Each multi-part payload chunk is prefixed with a 2-hex-char (1-byte) part index so the receiver can
|
||||||
|
// reorder chunks after the daemon shuffles a tx's outputs. 2 hex covers indices 0..255 (>> the cap).
|
||||||
|
constexpr std::size_t kHushChatPartIndexHexLen = 2;
|
||||||
constexpr std::size_t kHushChatPublicKeyHexLength = 64;
|
constexpr std::size_t kHushChatPublicKeyHexLength = 64;
|
||||||
constexpr std::size_t kHushChatSecretstreamHeaderHexLength = 48;
|
constexpr std::size_t kHushChatSecretstreamHeaderHexLength = 48;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
// DragonX Wallet - HushChat sensitive-conversation local key vault (implementation).
|
||||||
|
|
||||||
|
#include "chat_sensitive_vault.h"
|
||||||
|
|
||||||
|
#include <sodium.h>
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <fstream>
|
||||||
|
#include <iterator>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#if !defined(_WIN32)
|
||||||
|
#include <sys/stat.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
namespace dragonx::chat {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
constexpr unsigned char kVersion = 0x01;
|
||||||
|
constexpr std::size_t kSaltLen = 16; // crypto_pwhash_SALTBYTES
|
||||||
|
constexpr std::size_t kNonceLen = 24; // crypto_secretbox_NONCEBYTES
|
||||||
|
constexpr std::size_t kKeyLen = 32; // the vault key / crypto_secretbox_KEYBYTES
|
||||||
|
constexpr std::size_t kMacLen = 16; // crypto_secretbox_MACBYTES
|
||||||
|
constexpr std::size_t kCipherLen = kKeyLen + kMacLen; // 48
|
||||||
|
constexpr std::size_t kFileLen = 1 + kSaltLen + kNonceLen + kCipherLen; // 89
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
ChatSensitiveVault::ChatSensitiveVault(std::string vaultPath) : path_(std::move(vaultPath)) {}
|
||||||
|
|
||||||
|
void ChatSensitiveVault::setPath(std::string vaultPath) { path_ = std::move(vaultPath); }
|
||||||
|
|
||||||
|
bool ChatSensitiveVault::deriveKek(const std::string& passphrase, const unsigned char* salt,
|
||||||
|
unsigned char* kek) const {
|
||||||
|
// Fixed MODERATE limits (≈0.7s / 256 MiB) resist brute-forcing a weak passphrase; because they are
|
||||||
|
// fixed, the file carries no KDF parameters and unlock derives with exactly the same cost.
|
||||||
|
return crypto_pwhash(kek, kKeyLen, passphrase.c_str(), passphrase.size(), salt,
|
||||||
|
crypto_pwhash_OPSLIMIT_MODERATE, crypto_pwhash_MEMLIMIT_MODERATE,
|
||||||
|
crypto_pwhash_ALG_ARGON2ID13) == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ChatSensitiveVault::writeVault(const unsigned char* salt, const unsigned char* nonce,
|
||||||
|
const unsigned char* cipher, std::size_t cipherLen) const {
|
||||||
|
if (path_.empty()) return false;
|
||||||
|
const std::string tmp = path_ + ".tmp";
|
||||||
|
{
|
||||||
|
std::ofstream f(tmp, std::ios::binary | std::ios::trunc);
|
||||||
|
if (!f) return false;
|
||||||
|
f.put(static_cast<char>(kVersion));
|
||||||
|
f.write(reinterpret_cast<const char*>(salt), kSaltLen);
|
||||||
|
f.write(reinterpret_cast<const char*>(nonce), kNonceLen);
|
||||||
|
f.write(reinterpret_cast<const char*>(cipher), static_cast<std::streamsize>(cipherLen));
|
||||||
|
if (!f) { f.close(); std::remove(tmp.c_str()); return false; }
|
||||||
|
}
|
||||||
|
#if !defined(_WIN32)
|
||||||
|
::chmod(tmp.c_str(), 0600); // best-effort: the key is passphrase-wrapped, so this is defense-in-depth
|
||||||
|
#endif
|
||||||
|
if (std::rename(tmp.c_str(), path_.c_str()) != 0) { std::remove(tmp.c_str()); return false; }
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ChatSensitiveVault::exists() const {
|
||||||
|
if (path_.empty()) return false;
|
||||||
|
std::ifstream f(path_, std::ios::binary);
|
||||||
|
if (!f) return false;
|
||||||
|
char ver = 0;
|
||||||
|
if (!f.get(ver) || static_cast<unsigned char>(ver) != kVersion) return false;
|
||||||
|
f.seekg(0, std::ios::end);
|
||||||
|
return f.good() && static_cast<std::size_t>(f.tellg()) == kFileLen;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ChatSensitiveVault::create(const std::string& passphrase, std::array<unsigned char, 32>& keyOut) {
|
||||||
|
keyOut.fill(0);
|
||||||
|
if (path_.empty() || passphrase.empty()) return false;
|
||||||
|
if (exists()) return false; // never clobber an existing vault
|
||||||
|
if (sodium_init() < 0) return false;
|
||||||
|
|
||||||
|
unsigned char salt[kSaltLen], nonce[kNonceLen], kek[kKeyLen];
|
||||||
|
unsigned char cipher[kCipherLen];
|
||||||
|
randombytes_buf(salt, kSaltLen);
|
||||||
|
randombytes_buf(nonce, kNonceLen);
|
||||||
|
randombytes_buf(keyOut.data(), kKeyLen); // the random vault key
|
||||||
|
|
||||||
|
bool ok = deriveKek(passphrase, salt, kek);
|
||||||
|
if (ok) {
|
||||||
|
crypto_secretbox_easy(cipher, keyOut.data(), kKeyLen, nonce, kek);
|
||||||
|
ok = writeVault(salt, nonce, cipher, kCipherLen);
|
||||||
|
}
|
||||||
|
sodium_memzero(kek, sizeof kek);
|
||||||
|
sodium_memzero(cipher, sizeof cipher);
|
||||||
|
if (!ok) keyOut.fill(0);
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ChatSensitiveVault::unlock(const std::string& passphrase, std::array<unsigned char, 32>& keyOut) const {
|
||||||
|
keyOut.fill(0);
|
||||||
|
if (path_.empty() || passphrase.empty()) return false;
|
||||||
|
if (sodium_init() < 0) return false;
|
||||||
|
|
||||||
|
std::ifstream f(path_, std::ios::binary);
|
||||||
|
if (!f) return false;
|
||||||
|
std::vector<unsigned char> buf((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
|
||||||
|
if (buf.size() != kFileLen || buf[0] != kVersion) return false;
|
||||||
|
|
||||||
|
const unsigned char* salt = buf.data() + 1;
|
||||||
|
const unsigned char* nonce = salt + kSaltLen;
|
||||||
|
const unsigned char* cipher = nonce + kNonceLen;
|
||||||
|
|
||||||
|
unsigned char kek[kKeyLen];
|
||||||
|
if (!deriveKek(passphrase, salt, kek)) { sodium_memzero(kek, sizeof kek); return false; }
|
||||||
|
int rc = crypto_secretbox_open_easy(keyOut.data(), cipher, kCipherLen, nonce, kek);
|
||||||
|
sodium_memzero(kek, sizeof kek);
|
||||||
|
if (rc != 0) { keyOut.fill(0); return false; } // wrong passphrase or corrupt file
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ChatSensitiveVault::changePassphrase(const std::string& oldPassphrase, const std::string& newPassphrase) {
|
||||||
|
if (newPassphrase.empty()) return false;
|
||||||
|
std::array<unsigned char, 32> key{};
|
||||||
|
if (!unlock(oldPassphrase, key)) return false; // wrong old passphrase
|
||||||
|
|
||||||
|
unsigned char salt[kSaltLen], nonce[kNonceLen], kek[kKeyLen], cipher[kCipherLen];
|
||||||
|
randombytes_buf(salt, kSaltLen);
|
||||||
|
randombytes_buf(nonce, kNonceLen);
|
||||||
|
bool ok = deriveKek(newPassphrase, salt, kek);
|
||||||
|
if (ok) {
|
||||||
|
crypto_secretbox_easy(cipher, key.data(), kKeyLen, nonce, kek);
|
||||||
|
ok = writeVault(salt, nonce, cipher, kCipherLen);
|
||||||
|
}
|
||||||
|
sodium_memzero(kek, sizeof kek);
|
||||||
|
sodium_memzero(cipher, sizeof cipher);
|
||||||
|
sodium_memzero(key.data(), key.size());
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ChatSensitiveVault::remove() {
|
||||||
|
if (path_.empty()) return true;
|
||||||
|
std::error_code ec;
|
||||||
|
(void)ec;
|
||||||
|
if (std::remove(path_.c_str()) != 0) {
|
||||||
|
// Treat "already absent" as success; anything else is a real failure only if the file is still there.
|
||||||
|
std::ifstream f(path_, std::ios::binary);
|
||||||
|
return !f.good();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace dragonx::chat
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
// DragonX Wallet - HushChat "sensitive conversation" local key vault.
|
||||||
|
//
|
||||||
|
// A per-wallet random 32-byte key, wrapped under a USER PASSPHRASE (Argon2id-derived KEK +
|
||||||
|
// XSalsa20-Poly1305), stored in a small local file. It encrypts the at-rest rows of conversations the
|
||||||
|
// user marks "sensitive" in ChatDatabase, INDEPENDENTLY of the seed-derived chat key — so those rows
|
||||||
|
// cannot be read by someone who has the wallet seed but not this passphrase, and a wallet freshly
|
||||||
|
// restored from the seed alone cannot read them at all. Losing the passphrase (or the file) makes the
|
||||||
|
// sensitive messages unrecoverable BY DESIGN. Not thread-safe; drive from the main thread.
|
||||||
|
//
|
||||||
|
// File format (version 1), 89 bytes:
|
||||||
|
// [1] version (0x01)
|
||||||
|
// [16] Argon2id salt
|
||||||
|
// [24] XSalsa20-Poly1305 nonce
|
||||||
|
// [48] ciphertext = 32-byte vault key + 16-byte Poly1305 tag
|
||||||
|
// Argon2id uses MODERATE ops/mem limits (fixed), so the format needs to carry no KDF parameters.
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
#include <cstddef>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace dragonx::chat {
|
||||||
|
|
||||||
|
class ChatSensitiveVault {
|
||||||
|
public:
|
||||||
|
ChatSensitiveVault() = default;
|
||||||
|
explicit ChatSensitiveVault(std::string vaultPath);
|
||||||
|
|
||||||
|
void setPath(std::string vaultPath);
|
||||||
|
const std::string& path() const { return path_; }
|
||||||
|
|
||||||
|
// True if a well-formed vault file exists (version byte + exact size). No passphrase / Argon2.
|
||||||
|
bool exists() const;
|
||||||
|
|
||||||
|
// Create a NEW vault: generate a random 32-byte key, wrap it under `passphrase`, write the file, and
|
||||||
|
// return the key in keyOut. Refuses (returns false) if a vault already exists, the passphrase is
|
||||||
|
// empty, or on IO/crypto error. keyOut is zeroed on any failure.
|
||||||
|
bool create(const std::string& passphrase, std::array<unsigned char, 32>& keyOut);
|
||||||
|
|
||||||
|
// Unlock: derive the KEK from `passphrase`, decrypt the stored key into keyOut. Returns false on a
|
||||||
|
// wrong passphrase, a missing/corrupt file, or crypto failure; keyOut is zeroed unless it succeeds.
|
||||||
|
bool unlock(const std::string& passphrase, std::array<unsigned char, 32>& keyOut) const;
|
||||||
|
|
||||||
|
// Re-wrap the existing key under a new passphrase (the key is unchanged). False if old is wrong.
|
||||||
|
bool changePassphrase(const std::string& oldPassphrase, const std::string& newPassphrase);
|
||||||
|
|
||||||
|
// Delete the vault file. Returns true if removed or already absent.
|
||||||
|
bool remove();
|
||||||
|
|
||||||
|
private:
|
||||||
|
bool deriveKek(const std::string& passphrase, const unsigned char* salt, unsigned char* kek) const;
|
||||||
|
bool writeVault(const unsigned char* salt, const unsigned char* nonce,
|
||||||
|
const unsigned char* cipher, std::size_t cipherLen) const;
|
||||||
|
|
||||||
|
std::string path_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace dragonx::chat
|
||||||
@@ -40,6 +40,16 @@ int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata
|
|||||||
int added = 0;
|
int added = 0;
|
||||||
std::vector<ChatMessage> fresh; // persisted once after the loop (one transaction)
|
std::vector<ChatMessage> fresh; // persisted once after the loop (one transaction)
|
||||||
for (const auto& meta : metadata) {
|
for (const auto& meta : metadata) {
|
||||||
|
// A self-addressed control signal (delete / restore / ...). Reduce it and move on: it is never a
|
||||||
|
// message, and it must be processed even though WE are its sender — unlike ordinary self-sent memos,
|
||||||
|
// which the guard below skips. The label (peer hint) rides the payload.
|
||||||
|
if (meta.type == HushChatHeaderType::Control) {
|
||||||
|
const auto ctlTimeIt = txTimestamps.find(meta.txid);
|
||||||
|
const std::int64_t ctlRef = ctlTimeIt != txTimestamps.end() ? ctlTimeIt->second : fallbackTimestamp;
|
||||||
|
applyControl(meta.conversation_id, meta.op, meta.payload_memo,
|
||||||
|
meta.sent_at > 0 ? meta.sent_at : ctlRef, meta.txid);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
// Already stored: append would refuse it. Every scan re-sends the whole history, so this skips the
|
// Already stored: append would refuse it. Every scan re-sends the whole history, so this skips the
|
||||||
// decrypt for all but the new messages.
|
// decrypt for all but the new messages.
|
||||||
if (store_.contains(meta.txid, meta.payload_position)) continue;
|
if (store_.contains(meta.txid, meta.payload_position)) continue;
|
||||||
@@ -77,6 +87,11 @@ int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata
|
|||||||
// deleted with "revive on new message", so only that exact message is skipped — a new message
|
// deleted with "revive on new message", so only that exact message is skipped — a new message
|
||||||
// in the same conversation has a different txid and flows through normally.
|
// in the same conversation has a different txid and flows through normally.
|
||||||
if (blocked_pred_ && blocked_pred_(message.conversation_id)) continue;
|
if (blocked_pred_ && blocked_pred_(message.conversation_id)) continue;
|
||||||
|
if (conversationHiddenOnChain(message.conversation_id)) continue; // hidden by an on-chain delete
|
||||||
|
// Withhold a sensitive conversation's messages while its local vault key is absent (locked vault or
|
||||||
|
// a fresh seed import). Fail-closed: if we can't store it under the vault key we don't surface it.
|
||||||
|
// They re-import and persist under the vault key once it is unlocked and a scan runs.
|
||||||
|
if (isConversationSensitive(message.conversation_id) && !(db_ && db_->hasSensitiveKey())) continue;
|
||||||
if (db_ && db_->isTombstoned(message)) continue;
|
if (db_ && db_->isTombstoned(message)) continue;
|
||||||
|
|
||||||
if (meta.type == HushChatHeaderType::ContactRequest) {
|
if (meta.type == HushChatHeaderType::ContactRequest) {
|
||||||
@@ -116,6 +131,13 @@ int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (db_ && !fresh.empty()) db_->appendBatch(fresh);
|
if (db_ && !fresh.empty()) db_->appendBatch(fresh);
|
||||||
|
// Enforce on-chain deletions against the store view: erase any conversation whose latest control op is
|
||||||
|
// "del". This catches rows appended earlier in this very batch (before their delete signal was seen in
|
||||||
|
// the metadata order) and rows rehydrated from the DB at startup before any control was reduced. The DB
|
||||||
|
// rows are left intact so a later "res" — or a rescan — can restore the thread.
|
||||||
|
for (const auto& entry : control_) {
|
||||||
|
if (entry.second.op == "del") store_.eraseConversation(entry.first);
|
||||||
|
}
|
||||||
return added;
|
return added;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,6 +168,60 @@ bool ChatService::deleteConversation(const std::string& conversationId, bool blo
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ChatService::applyControl(const std::string& cid, const std::string& op, const std::string& label,
|
||||||
|
std::int64_t timestamp, const std::string& txid) {
|
||||||
|
// Route the op to its axis; ignore unknown future ops so they never pollute either reduction.
|
||||||
|
std::unordered_map<std::string, ControlState>* target = nullptr;
|
||||||
|
if (op == "del" || op == "res") target = &control_;
|
||||||
|
else if (op == "sensitive" || op == "unsensitive") target = &sensitive_control_;
|
||||||
|
else return;
|
||||||
|
|
||||||
|
auto it = target->find(cid);
|
||||||
|
if (it != target->end()) {
|
||||||
|
const ControlState& cur = it->second;
|
||||||
|
// A later signal wins; equal timestamps break by txid so the reduction is deterministic and a
|
||||||
|
// replay of an older op can never flip the resolved state.
|
||||||
|
if (timestamp < cur.timestamp || (timestamp == cur.timestamp && txid <= cur.txid)) return;
|
||||||
|
it->second = ControlState{op, label, timestamp, txid};
|
||||||
|
} else {
|
||||||
|
target->emplace(cid, ControlState{op, label, timestamp, txid});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ChatService::conversationHiddenOnChain(const std::string& conversationId) const {
|
||||||
|
auto it = control_.find(conversationId);
|
||||||
|
return it != control_.end() && it->second.op == "del";
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::pair<std::string, std::string>> ChatService::onChainDeletedConversations() const {
|
||||||
|
std::vector<std::pair<std::string, std::string>> out;
|
||||||
|
for (const auto& entry : control_) {
|
||||||
|
if (entry.second.op == "del") out.emplace_back(entry.first, entry.second.label);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
bool ChatService::conversationSensitiveOnChain(const std::string& conversationId) const {
|
||||||
|
auto it = sensitive_control_.find(conversationId);
|
||||||
|
return it != sensitive_control_.end() && it->second.op == "sensitive";
|
||||||
|
}
|
||||||
|
bool ChatService::isConversationSensitive(const std::string& conversationId) const {
|
||||||
|
if (conversationSensitiveOnChain(conversationId)) return true;
|
||||||
|
return local_sensitive_pred_ && local_sensitive_pred_(conversationId);
|
||||||
|
}
|
||||||
|
void ChatService::setPersistence(ChatDatabase* db) {
|
||||||
|
db_ = db;
|
||||||
|
// The DB picks the per-row key by this predicate: a sensitive conversation's rows are stored under the
|
||||||
|
// local vault key. Combines the on-chain reduction with the local (instant) mark.
|
||||||
|
if (db_) db_->setSensitivePredicate([this](const std::string& cid) { return isConversationSensitive(cid); });
|
||||||
|
}
|
||||||
|
std::vector<std::pair<std::string, std::string>> ChatService::onChainSensitiveConversations() const {
|
||||||
|
std::vector<std::pair<std::string, std::string>> out;
|
||||||
|
for (const auto& entry : sensitive_control_) {
|
||||||
|
if (entry.second.op == "sensitive") out.emplace_back(entry.first, entry.second.label);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
std::string ChatService::identityPublicKeyHex() const {
|
std::string ChatService::identityPublicKeyHex() const {
|
||||||
if (!has_identity_) return {};
|
if (!has_identity_) return {};
|
||||||
return chatIdentityPublicKeyHex(identity_);
|
return chatIdentityPublicKeyHex(identity_);
|
||||||
|
|||||||
+46
-1
@@ -15,6 +15,7 @@
|
|||||||
#include <string>
|
#include <string>
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
#include <unordered_set>
|
#include <unordered_set>
|
||||||
|
#include <utility>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
namespace dragonx::chat {
|
namespace dragonx::chat {
|
||||||
@@ -52,7 +53,8 @@ public:
|
|||||||
|
|
||||||
// Attach a persistent backing store (Phase 2). Not owned. Pass nullptr to detach. New messages
|
// 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.
|
// from ingest() are written through; loadFromDatabase() rehydrates the in-memory store from it.
|
||||||
void setPersistence(ChatDatabase* db) { db_ = db; }
|
// Also wires the DB's per-conversation sensitivity predicate to isConversationSensitive().
|
||||||
|
void setPersistence(ChatDatabase* db);
|
||||||
// Load previously-persisted messages (already decrypted at ingest, re-encrypted at rest under
|
// Load previously-persisted messages (already decrypted at ingest, re-encrypted at rest under
|
||||||
// the seed-derived key) into the in-memory store. No-op without an unlocked database.
|
// the seed-derived key) into the in-memory store. No-op without an unlocked database.
|
||||||
void loadFromDatabase();
|
void loadFromDatabase();
|
||||||
@@ -70,6 +72,34 @@ public:
|
|||||||
// persisted rows couldn't be removed, so the caller can avoid a store/DB divergence.
|
// persisted rows couldn't be removed, so the caller can avoid a store/DB divergence.
|
||||||
bool deleteConversation(const std::string& conversationId, bool block);
|
bool deleteConversation(const std::string& conversationId, bool block);
|
||||||
|
|
||||||
|
// On-chain control signals (self-addressed Control memos) reduced per conversation: the latest op,
|
||||||
|
// by sender compose time with a txid tiebreak, wins. A conversation whose latest op is "del" is hidden
|
||||||
|
// on-chain — ingest() drops its messages and erases it from the store view, on every device and on a
|
||||||
|
// fresh seed import — until a later "res" restores it. The state is re-derived from chain each session
|
||||||
|
// (the control memos re-harvest on every scan), so it needs no local persistence.
|
||||||
|
bool conversationHiddenOnChain(const std::string& conversationId) const;
|
||||||
|
// Conversations whose latest on-chain control op is "del" (for the restore picker), each paired with
|
||||||
|
// the label carried in its control memo (the peer address / display name at delete time).
|
||||||
|
std::vector<std::pair<std::string, std::string>> onChainDeletedConversations() const;
|
||||||
|
|
||||||
|
// Sensitive axis (independent of hide/restore): a conversation whose latest on-chain control op is
|
||||||
|
// "sensitive" is marked sensitive — its local rows are stored under the local vault key and withheld
|
||||||
|
// on a device without that key (including a fresh seed import), until "unsensitive". Re-derived from
|
||||||
|
// chain each session, like the hide axis.
|
||||||
|
bool conversationSensitiveOnChain(const std::string& conversationId) const;
|
||||||
|
// Conversations whose latest on-chain control op is "sensitive", each paired with its control label.
|
||||||
|
std::vector<std::pair<std::string, std::string>> onChainSensitiveConversations() const;
|
||||||
|
|
||||||
|
// A local "sensitive" predicate (typically Settings::isChatSensitive) giving INSTANT effect the moment
|
||||||
|
// the user marks a conversation, before the on-chain signal is harvested back. OR-combined with the
|
||||||
|
// on-chain reduction by isConversationSensitive().
|
||||||
|
void setLocalSensitivePredicate(std::function<bool(const std::string&)> pred) {
|
||||||
|
local_sensitive_pred_ = std::move(pred);
|
||||||
|
}
|
||||||
|
// True if this conversation is sensitive (locally marked OR on-chain). Its rows are stored under the
|
||||||
|
// local vault key; while that key is absent its messages are withheld from the store on ingest/load.
|
||||||
|
bool isConversationSensitive(const std::string& conversationId) const;
|
||||||
|
|
||||||
// --- Outgoing (compose) ---
|
// --- Outgoing (compose) ---
|
||||||
// My chat public key (hex), or "" without an identity — goes in an outgoing header's "p".
|
// My chat public key (hex), or "" without an identity — goes in an outgoing header's "p".
|
||||||
std::string identityPublicKeyHex() const;
|
std::string identityPublicKeyHex() const;
|
||||||
@@ -112,6 +142,21 @@ private:
|
|||||||
ChatStore store_;
|
ChatStore store_;
|
||||||
ChatDatabase* db_ = nullptr; // optional; not owned
|
ChatDatabase* db_ = nullptr; // optional; not owned
|
||||||
std::function<bool(const std::string&)> blocked_pred_; // true => cid is blocked (drop its messages)
|
std::function<bool(const std::string&)> blocked_pred_; // true => cid is blocked (drop its messages)
|
||||||
|
std::function<bool(const std::string&)> local_sensitive_pred_; // true => cid locally marked sensitive
|
||||||
|
|
||||||
|
// Reduced on-chain control state, cid -> latest signal. Not persisted (re-derived from chain).
|
||||||
|
struct ControlState {
|
||||||
|
std::string op; // "del" / "res" / future ops
|
||||||
|
std::string label; // the control memo's payload hint (peer address / display name)
|
||||||
|
std::int64_t timestamp = 0;
|
||||||
|
std::string txid;
|
||||||
|
};
|
||||||
|
std::unordered_map<std::string, ControlState> control_; // hide axis: del / res
|
||||||
|
std::unordered_map<std::string, ControlState> sensitive_control_; // sensitive axis: sensitive / unsensitive
|
||||||
|
// Monotonic reduce: route the op to its axis (hide vs sensitive), ignore unknown ops (forward-compat),
|
||||||
|
// and within an axis a later (timestamp, txid) wins so a replay of an older op never flips the state.
|
||||||
|
void applyControl(const std::string& cid, const std::string& op, const std::string& label,
|
||||||
|
std::int64_t timestamp, const std::string& txid);
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace dragonx::chat
|
} // namespace dragonx::chat
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#include "chat_store.h"
|
#include "chat_store.h"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <sodium.h>
|
||||||
|
|
||||||
namespace dragonx::chat {
|
namespace dragonx::chat {
|
||||||
|
|
||||||
@@ -112,6 +113,10 @@ std::vector<ChatMessage> ChatStore::eraseConversation(const std::string& convers
|
|||||||
}
|
}
|
||||||
|
|
||||||
void ChatStore::clear() {
|
void ChatStore::clear() {
|
||||||
|
// Zero decrypted plaintext (message bodies + peer address) before dropping them — clear() runs on
|
||||||
|
// wallet lock / switch / teardown, and the freed std::strings would otherwise linger in the heap.
|
||||||
|
auto wipe = [](std::string& s) { if (!s.empty()) sodium_memzero(&s[0], s.size()); s.clear(); };
|
||||||
|
for (auto& m : messages_) { wipe(m.body); wipe(m.fail_reason); wipe(m.peer_zaddr); }
|
||||||
messages_.clear();
|
messages_.clear();
|
||||||
seen_.clear();
|
seen_.clear();
|
||||||
txid_index_.clear(); // drop the History-badge txid copies too (revision_ below invalidates the memo)
|
txid_index_.clear(); // drop the History-badge txid copies too (revision_ below invalidates the memo)
|
||||||
|
|||||||
@@ -180,6 +180,14 @@ bool Settings::load(const std::string& path)
|
|||||||
(c.contains("name") && c["name"].is_string())
|
(c.contains("name") && c["name"].is_string())
|
||||||
? c["name"].get<std::string>() : std::string()});
|
? c["name"].get<std::string>() : std::string()});
|
||||||
}
|
}
|
||||||
|
if (j.contains("sensitive_chat_convs") && j["sensitive_chat_convs"].is_array()) {
|
||||||
|
sensitive_chat_convs_.clear();
|
||||||
|
for (const auto& c : j["sensitive_chat_convs"])
|
||||||
|
if (c.is_object() && c.contains("cid") && c["cid"].is_string())
|
||||||
|
sensitive_chat_convs_.push_back({c["cid"].get<std::string>(),
|
||||||
|
(c.contains("name") && c["name"].is_string())
|
||||||
|
? c["name"].get<std::string>() : std::string()});
|
||||||
|
}
|
||||||
// Chat-tab customization (re-clamped through the setters so hand-edited JSON stays in range).
|
// Chat-tab customization (re-clamped through the setters so hand-edited JSON stays in range).
|
||||||
loadScalar(j, "chat_emoji_color", chat_emoji_color_);
|
loadScalar(j, "chat_emoji_color", chat_emoji_color_);
|
||||||
loadScalar(j, "chat_poll_rate_sec", chat_poll_rate_sec_); setChatPollRateSec(chat_poll_rate_sec_);
|
loadScalar(j, "chat_poll_rate_sec", chat_poll_rate_sec_); setChatPollRateSec(chat_poll_rate_sec_);
|
||||||
@@ -271,6 +279,10 @@ bool Settings::load(const std::string& path)
|
|||||||
}
|
}
|
||||||
loadScalar(j, "wizard_completed", wizard_completed_);
|
loadScalar(j, "wizard_completed", wizard_completed_);
|
||||||
loadScalar(j, "seed_backup_reminded", seed_backup_reminded_);
|
loadScalar(j, "seed_backup_reminded", seed_backup_reminded_);
|
||||||
|
loadScalar(j, "chat_intro_dismissed", chat_intro_dismissed_);
|
||||||
|
loadScalar(j, "lock_failed_attempts", lock_failed_attempts_);
|
||||||
|
loadScalar(j, "lockout_until_epoch", lockout_until_epoch_);
|
||||||
|
loadScalar(j, "lock_state_wallet", lock_state_wallet_);
|
||||||
loadScalar(j, "large_wallet_warned", large_wallet_warned_);
|
loadScalar(j, "large_wallet_warned", large_wallet_warned_);
|
||||||
if (j.contains("empty_wallet_warning_acked") && j["empty_wallet_warning_acked"].is_array()) {
|
if (j.contains("empty_wallet_warning_acked") && j["empty_wallet_warning_acked"].is_array()) {
|
||||||
empty_wallet_warning_acked_.clear();
|
empty_wallet_warning_acked_.clear();
|
||||||
@@ -540,6 +552,13 @@ bool Settings::save(const std::string& path)
|
|||||||
o["name"] = b.name;
|
o["name"] = b.name;
|
||||||
j["blocked_chat_convs"].push_back(o);
|
j["blocked_chat_convs"].push_back(o);
|
||||||
}
|
}
|
||||||
|
j["sensitive_chat_convs"] = json::array();
|
||||||
|
for (const auto& s : sensitive_chat_convs_) {
|
||||||
|
json o;
|
||||||
|
o["cid"] = s.cid;
|
||||||
|
o["name"] = s.name;
|
||||||
|
j["sensitive_chat_convs"].push_back(o);
|
||||||
|
}
|
||||||
j["chat_emoji_color"] = chat_emoji_color_;
|
j["chat_emoji_color"] = chat_emoji_color_;
|
||||||
j["chat_poll_rate_sec"] = chat_poll_rate_sec_;
|
j["chat_poll_rate_sec"] = chat_poll_rate_sec_;
|
||||||
j["chat_bubble_style"] = chat_bubble_style_;
|
j["chat_bubble_style"] = chat_bubble_style_;
|
||||||
@@ -596,6 +615,10 @@ bool Settings::save(const std::string& path)
|
|||||||
}
|
}
|
||||||
j["wizard_completed"] = wizard_completed_;
|
j["wizard_completed"] = wizard_completed_;
|
||||||
j["seed_backup_reminded"] = seed_backup_reminded_;
|
j["seed_backup_reminded"] = seed_backup_reminded_;
|
||||||
|
j["chat_intro_dismissed"] = chat_intro_dismissed_;
|
||||||
|
j["lock_failed_attempts"] = lock_failed_attempts_;
|
||||||
|
j["lockout_until_epoch"] = lockout_until_epoch_;
|
||||||
|
j["lock_state_wallet"] = lock_state_wallet_;
|
||||||
j["large_wallet_warned"] = large_wallet_warned_;
|
j["large_wallet_warned"] = large_wallet_warned_;
|
||||||
j["empty_wallet_warning_acked"] = json::array();
|
j["empty_wallet_warning_acked"] = json::array();
|
||||||
for (const auto& w : empty_wallet_warning_acked_)
|
for (const auto& w : empty_wallet_warning_acked_)
|
||||||
|
|||||||
@@ -182,6 +182,26 @@ public:
|
|||||||
}
|
}
|
||||||
const std::vector<BlockedChatConv>& blockedChatConversations() const { return blocked_chat_convs_; }
|
const std::vector<BlockedChatConv>& blockedChatConversations() const { return blocked_chat_convs_; }
|
||||||
|
|
||||||
|
// Sensitive chat conversations (by cid): their local rows are stored under the local chat vault key
|
||||||
|
// (not the seed key) and are withheld without it. Kept locally for INSTANT effect the moment the user
|
||||||
|
// marks one; the mark is also signaled on-chain so other devices / a fresh seed import gate it too.
|
||||||
|
// The last-known peer name labels the sensitive-conversations list.
|
||||||
|
struct SensitiveChatConv { std::string cid; std::string name; };
|
||||||
|
bool isChatSensitive(const std::string& cid) const {
|
||||||
|
for (const auto& s : sensitive_chat_convs_) if (s.cid == cid) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
void setChatSensitive(const std::string& cid, const std::string& name, bool sensitive) {
|
||||||
|
const bool already = isChatSensitive(cid);
|
||||||
|
if (sensitive && !already) sensitive_chat_convs_.push_back({cid, name});
|
||||||
|
else if (!sensitive && already)
|
||||||
|
sensitive_chat_convs_.erase(
|
||||||
|
std::remove_if(sensitive_chat_convs_.begin(), sensitive_chat_convs_.end(),
|
||||||
|
[&](const SensitiveChatConv& s) { return s.cid == cid; }),
|
||||||
|
sensitive_chat_convs_.end());
|
||||||
|
}
|
||||||
|
const std::vector<SensitiveChatConv>& sensitiveChatConversations() const { return sensitive_chat_convs_; }
|
||||||
|
|
||||||
// ── Chat-tab customization (chat settings modal + Settings → Chat & Contacts) ──────
|
// ── Chat-tab customization (chat settings modal + Settings → Chat & Contacts) ──────
|
||||||
bool getChatEmojiColor() const { return chat_emoji_color_; }
|
bool getChatEmojiColor() const { return chat_emoji_color_; }
|
||||||
void setChatEmojiColor(bool v) { chat_emoji_color_ = v; }
|
void setChatEmojiColor(bool v) { chat_emoji_color_ = v; }
|
||||||
@@ -392,6 +412,17 @@ public:
|
|||||||
// Whether the one-time "back up your seed phrase" reminder has already been shown.
|
// Whether the one-time "back up your seed phrase" reminder has already been shown.
|
||||||
bool getSeedBackupReminded() const { return seed_backup_reminded_; }
|
bool getSeedBackupReminded() const { return seed_backup_reminded_; }
|
||||||
void setSeedBackupReminded(bool v) { seed_backup_reminded_ = v; }
|
void setSeedBackupReminded(bool v) { seed_backup_reminded_ = v; }
|
||||||
|
bool getChatIntroDismissed() const { return chat_intro_dismissed_; }
|
||||||
|
void setChatIntroDismissed(bool v) { chat_intro_dismissed_ = v; durable_next_save_ = true; }
|
||||||
|
|
||||||
|
// Unlock lockout state, persisted across restarts so the failed-attempt curve can't be reset by
|
||||||
|
// killing the app. Keyed by the wallet file it applies to; the PIN/passphrase is never stored.
|
||||||
|
int getLockFailedAttempts() const { return lock_failed_attempts_; }
|
||||||
|
void setLockFailedAttempts(int v) { lock_failed_attempts_ = v; durable_next_save_ = true; }
|
||||||
|
long long getLockoutUntilEpoch() const { return lockout_until_epoch_; }
|
||||||
|
void setLockoutUntilEpoch(long long v) { lockout_until_epoch_ = v; durable_next_save_ = true; }
|
||||||
|
std::string getLockStateWallet() const { return lock_state_wallet_; }
|
||||||
|
void setLockStateWallet(const std::string& v) { lock_state_wallet_ = v; durable_next_save_ = true; }
|
||||||
|
|
||||||
// One-time nudge when wallet.dat grows past the bloat threshold (re-armed if it shrinks back).
|
// One-time nudge when wallet.dat grows past the bloat threshold (re-armed if it shrinks back).
|
||||||
bool getLargeWalletWarned() const { return large_wallet_warned_; }
|
bool getLargeWalletWarned() const { return large_wallet_warned_; }
|
||||||
@@ -683,6 +714,7 @@ private:
|
|||||||
std::vector<std::string> muted_chat_cids_; // muted chat conversations by cid (Q10)
|
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
|
std::vector<std::string> hidden_chat_cids_; // hidden chat conversations by cid
|
||||||
std::vector<BlockedChatConv> blocked_chat_convs_; // blocked chat conversations (cid + last-known name)
|
std::vector<BlockedChatConv> blocked_chat_convs_; // blocked chat conversations (cid + last-known name)
|
||||||
|
std::vector<SensitiveChatConv> sensitive_chat_convs_; // sensitive chat conversations (cid + last-known name)
|
||||||
// Chat-tab customization (chat settings modal + Settings → Chat & Contacts).
|
// Chat-tab customization (chat settings modal + Settings → Chat & Contacts).
|
||||||
bool chat_emoji_color_ = true; // true = color (needs FreeType; falls back to mono if absent), false = monochrome
|
bool chat_emoji_color_ = true; // true = color (needs FreeType; falls back to mono if absent), false = monochrome
|
||||||
float chat_poll_rate_sec_ = 2.5f; // 0-conf chat fast-scan cadence (full node)
|
float chat_poll_rate_sec_ = 2.5f; // 0-conf chat fast-scan cadence (full node)
|
||||||
@@ -736,6 +768,10 @@ private:
|
|||||||
std::map<std::string, AddressMeta> address_meta_;
|
std::map<std::string, AddressMeta> address_meta_;
|
||||||
bool wizard_completed_ = false;
|
bool wizard_completed_ = false;
|
||||||
bool seed_backup_reminded_ = false;
|
bool seed_backup_reminded_ = false;
|
||||||
|
bool chat_intro_dismissed_ = false; // one-time chat "how it works" intro card
|
||||||
|
int lock_failed_attempts_ = 0; // persisted unlock-failure count (lockout curve)
|
||||||
|
long long lockout_until_epoch_ = 0; // unix time the current lockout expires (0 = none)
|
||||||
|
std::string lock_state_wallet_; // wallet file the above two apply to
|
||||||
bool large_wallet_warned_ = false;
|
bool large_wallet_warned_ = false;
|
||||||
std::set<std::string> empty_wallet_warning_acked_; // wallet files whose empty-wallet warning was dismissed
|
std::set<std::string> empty_wallet_warning_acked_; // wallet files whose empty-wallet warning was dismissed
|
||||||
bool encryption_pending_ = false;
|
bool encryption_pending_ = false;
|
||||||
|
|||||||
@@ -703,8 +703,8 @@ bool EmbeddedDaemon::start(const std::string& binary_path, int lockWaitPolls)
|
|||||||
// ... not executable or wrong architecture"). Surface THAT via setState — which also
|
// ... not executable or wrong architecture"). Surface THAT via setState — which also
|
||||||
// stores the Error message into last_error_ — instead of clobbering it with a generic
|
// stores the Error message into last_error_ — instead of clobbering it with a generic
|
||||||
// string that would then be all getLastError()/the UI ever sees.
|
// string that would then be all getLastError()/the UI ever sees.
|
||||||
std::string detail = last_error_.empty() ? std::string("Failed to start dragonxd process")
|
std::string detail = getLastError();
|
||||||
: last_error_;
|
if (detail.empty()) detail = "Failed to start dragonxd process";
|
||||||
DEBUG_LOGF("[ERROR] %s\n", detail.c_str());
|
DEBUG_LOGF("[ERROR] %s\n", detail.c_str());
|
||||||
setState(State::Error, detail);
|
setState(State::Error, detail);
|
||||||
return false;
|
return false;
|
||||||
@@ -822,7 +822,7 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
|
|||||||
char errBuf[256] = {0};
|
char errBuf[256] = {0};
|
||||||
FormatMessageA(FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS,
|
FormatMessageA(FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS,
|
||||||
NULL, err, 0, errBuf, sizeof(errBuf), NULL);
|
NULL, err, 0, errBuf, sizeof(errBuf), NULL);
|
||||||
last_error_ = "CreateProcess failed with error " + std::to_string(err) + ": " + errBuf;
|
setLastError("CreateProcess failed with error " + std::to_string(err) + ": " + errBuf);
|
||||||
DEBUG_LOGF("[ERROR] CreateProcess failed: error %lu - %s\nCommand: %s\n", err, errBuf, cmd.c_str());
|
DEBUG_LOGF("[ERROR] CreateProcess failed: error %lu - %s\nCommand: %s\n", err, errBuf, cmd.c_str());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -1232,7 +1232,7 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
|
|||||||
// Create pipe for stdout/stderr
|
// Create pipe for stdout/stderr
|
||||||
int pipefd[2];
|
int pipefd[2];
|
||||||
if (pipe(pipefd) == -1) {
|
if (pipe(pipefd) == -1) {
|
||||||
last_error_ = "Failed to create pipe: " + std::string(strerror(errno));
|
setLastError("Failed to create pipe: " + std::string(strerror(errno)));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1245,7 +1245,7 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
|
|||||||
// macOS, which has no pipe2().
|
// macOS, which has no pipe2().
|
||||||
int execpipe[2];
|
int execpipe[2];
|
||||||
if (pipe(execpipe) == -1) {
|
if (pipe(execpipe) == -1) {
|
||||||
last_error_ = "Failed to create exec-status pipe: " + std::string(strerror(errno));
|
setLastError("Failed to create exec-status pipe: " + std::string(strerror(errno)));
|
||||||
close(pipefd[0]);
|
close(pipefd[0]);
|
||||||
close(pipefd[1]);
|
close(pipefd[1]);
|
||||||
return false;
|
return false;
|
||||||
@@ -1255,7 +1255,7 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
|
|||||||
|
|
||||||
pid_t pid = fork();
|
pid_t pid = fork();
|
||||||
if (pid == -1) {
|
if (pid == -1) {
|
||||||
last_error_ = "Fork failed: " + std::string(strerror(errno));
|
setLastError("Fork failed: " + std::string(strerror(errno)));
|
||||||
close(pipefd[0]);
|
close(pipefd[0]);
|
||||||
close(pipefd[1]);
|
close(pipefd[1]);
|
||||||
close(execpipe[0]);
|
close(execpipe[0]);
|
||||||
@@ -1370,8 +1370,8 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
|
|||||||
close(pipefd[0]);
|
close(pipefd[0]);
|
||||||
int status;
|
int status;
|
||||||
waitpid(pid, &status, 0);
|
waitpid(pid, &status, 0);
|
||||||
last_error_ = "dragonxd could not be executed: " + std::string(strerror(child_errno)) +
|
setLastError("dragonxd could not be executed: " + std::string(strerror(child_errno)) +
|
||||||
" — not executable or wrong architecture";
|
" — not executable or wrong architecture");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -504,6 +504,8 @@ private:
|
|||||||
std::atomic<bool> external_daemon_detected_{false};
|
std::atomic<bool> external_daemon_detected_{false};
|
||||||
std::string last_error_;
|
std::string last_error_;
|
||||||
mutable std::mutex error_mutex_; // protects last_error_ (written by main + monitor threads)
|
mutable std::mutex error_mutex_; // protects last_error_ (written by main + monitor threads)
|
||||||
|
// Locked writer for last_error_ (startProcess() runs on the caller thread while the monitor may call setState()).
|
||||||
|
void setLastError(std::string message) { std::lock_guard<std::mutex> lk(error_mutex_); last_error_ = std::move(message); }
|
||||||
mutable std::mutex output_mutex_; // protects process_output_
|
mutable std::mutex output_mutex_; // protects process_output_
|
||||||
std::string process_output_;
|
std::string process_output_;
|
||||||
size_t output_total_ = 0; // bytes ever appended (the absolute end of process_output_)
|
size_t output_total_ = 0; // bytes ever appended (the absolute end of process_output_)
|
||||||
@@ -511,14 +513,14 @@ private:
|
|||||||
|
|
||||||
// Process handle
|
// Process handle
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
HANDLE process_handle_ = nullptr;
|
std::atomic<HANDLE> process_handle_{nullptr}; // written by start/stop/monitor threads, read by accessors
|
||||||
HANDLE stdout_read_ = nullptr; // pipe handle (Linux-style pipe, unused when tailing debug.log)
|
HANDLE stdout_read_ = nullptr; // pipe handle (Linux-style pipe, unused when tailing debug.log)
|
||||||
std::string launch_cmd_; // full command line used to launch daemon (for diagnostics)
|
std::string launch_cmd_; // full command line used to launch daemon (for diagnostics)
|
||||||
std::string launch_binary_; // binary path used (for diagnostics)
|
std::string launch_binary_; // binary path used (for diagnostics)
|
||||||
std::string launch_workdir_; // working directory used (for diagnostics)
|
std::string launch_workdir_; // working directory used (for diagnostics)
|
||||||
#else
|
#else
|
||||||
pid_t process_pid_ = 0;
|
std::atomic<pid_t> process_pid_{0}; // written by start/stop paths and the monitor (reap), read by accessors
|
||||||
int stdout_fd_ = -1; // the child's stdout+stderr (crash diagnostics; the log itself is debug.log)
|
std::atomic<int> stdout_fd_{-1}; // the child's stdout+stderr (crash diagnostics; the log itself is debug.log)
|
||||||
void tailDebugLog();
|
void tailDebugLog();
|
||||||
|
|
||||||
// Waits up to timeout_ms for `pid` to exit, draining its output; true once reaped. Only valid
|
// Waits up to timeout_ms for `pid` to exit, draining its output; true once reaped. Only valid
|
||||||
|
|||||||
@@ -42,4 +42,24 @@ inline MigrationResume decideSeedMigrationResume(bool pending,
|
|||||||
// wallet.dat on an unconfirmed sweep. (fund-safety: audit)
|
// wallet.dat on an unconfirmed sweep. (fund-safety: audit)
|
||||||
inline bool sweepGateMayAdoptEmptyWallet(bool sweepSubmitted) { return !sweepSubmitted; }
|
inline bool sweepGateMayAdoptEmptyWallet(bool sweepSubmitted) { return !sweepSubmitted; }
|
||||||
|
|
||||||
|
// Reorg-safe depth before the adopt swap is enabled. Matches the History "confirmed" threshold
|
||||||
|
// (>= 10, data/transaction_history_store_policy.h): a 1-block reorg must not be able to un-mine the
|
||||||
|
// sweep after wallet.dat has been moved aside, which would strand the funds in the .bak.
|
||||||
|
inline constexpr int kSweepAdoptMinConfs = 10;
|
||||||
|
inline bool sweepConfsAreReorgSafe(int confs) { return confs >= kSweepAdoptMinConfs; }
|
||||||
|
|
||||||
|
// Final pre-stop re-verification, run on fresh RPC reads immediately before the daemon is stopped.
|
||||||
|
// needSweepTx : a sweep txid exists, so its confirmations must be reorg-safe (false = genuinely
|
||||||
|
// empty wallet, no sweep ever submitted).
|
||||||
|
// confs : fresh gettransaction confirmations (negative = conflicted).
|
||||||
|
// balanceMinconf0 : fresh z_gettotalbalance with minconf 0 (INCLUDES unconfirmed), < 0 = unknown.
|
||||||
|
// syncing : the node is still catching up, so balance reads are not trustworthy.
|
||||||
|
inline bool adoptReverifyPasses(bool needSweepTx, int confs, double balanceMinconf0,
|
||||||
|
bool syncing, double dust)
|
||||||
|
{
|
||||||
|
if (syncing) return false;
|
||||||
|
if (needSweepTx && !sweepConfsAreReorgSafe(confs)) return false;
|
||||||
|
return balanceMinconf0 >= 0.0 && balanceMinconf0 <= dust;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace dragonx
|
} // namespace dragonx
|
||||||
|
|||||||
+32
-2
@@ -75,6 +75,13 @@ struct TransactionInfo {
|
|||||||
std::string getTypeDisplay() const;
|
std::string getTypeDisplay() const;
|
||||||
bool isConfirmed() const { return confirmations >= 1; }
|
bool isConfirmed() const { return confirmations >= 1; }
|
||||||
bool isMature() const { return confirmations >= 100; }
|
bool isMature() const { return confirmations >= 100; }
|
||||||
|
// gettransaction reports confirmations < 0 for a tx conflicted with the chain (double-spent / reorged out).
|
||||||
|
bool isConflicted() const { return confirmations < 0; }
|
||||||
|
// Unconfirmed for longer than maxAgeSec since the wallet first saw it: most likely dropped from the
|
||||||
|
// mempool or expired. A display heuristic only (timestamp 0 = unknown -> never flagged).
|
||||||
|
bool isStalePending(int64_t now, int64_t maxAgeSec = 3600) const {
|
||||||
|
return confirmations == 0 && timestamp > 0 && now - timestamp > maxAgeSec;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Exact field-wise equality: decides whether a refreshed list really differs. `amount` is compared
|
// Exact field-wise equality: decides whether a refreshed list really differs. `amount` is compared
|
||||||
@@ -86,6 +93,15 @@ inline bool sameTransactionInfo(const TransactionInfo& a, const TransactionInfo&
|
|||||||
a.txid == b.txid && a.type == b.type && a.address == b.address &&
|
a.txid == b.txid && a.type == b.type && a.address == b.address &&
|
||||||
a.from_address == b.from_address && a.memo == b.memo;
|
a.from_address == b.from_address && a.memo == b.memo;
|
||||||
}
|
}
|
||||||
|
// Same row except the confirmation COUNT: the pending (confirmations==0) state must still match because it drives the
|
||||||
|
// History tab's pending-first sort and shield-leg merge. A delta for which every row passes this changes nothing the
|
||||||
|
// tab's merged/sorted display list is built from, so it can patch confirmations in place instead of rebuilding.
|
||||||
|
inline bool sameTransactionLayout(const TransactionInfo& a, const TransactionInfo& b) {
|
||||||
|
return a.timestamp == b.timestamp && (a.confirmations == 0) == (b.confirmations == 0) &&
|
||||||
|
std::memcmp(&a.amount, &b.amount, sizeof(double)) == 0 &&
|
||||||
|
a.txid == b.txid && a.type == b.type && a.address == b.address &&
|
||||||
|
a.from_address == b.from_address && a.memo == b.memo;
|
||||||
|
}
|
||||||
inline bool sameTransactionList(const std::vector<TransactionInfo>& a, const std::vector<TransactionInfo>& b) {
|
inline bool sameTransactionList(const std::vector<TransactionInfo>& a, const std::vector<TransactionInfo>& b) {
|
||||||
if (a.size() != b.size()) return false;
|
if (a.size() != b.size()) return false;
|
||||||
for (std::size_t i = 0; i < a.size(); ++i)
|
for (std::size_t i = 0; i < a.size(); ++i)
|
||||||
@@ -189,6 +205,11 @@ struct SyncInfo {
|
|||||||
bool tip_known = false;
|
bool tip_known = false;
|
||||||
|
|
||||||
bool isSynced() const { return tip_known && !syncing && blocks > 0 && blocks >= headers - 2; }
|
bool isSynced() const { return tip_known && !syncing && blocks > 0 && blocks >= headers - 2; }
|
||||||
|
|
||||||
|
// Send precondition: the wallet is caught up to a KNOWN peer-derived tip. A fresh (re)connect or a
|
||||||
|
// peerless node has tip_known == false, so its local height can't be trusted as the network tip.
|
||||||
|
// The lite backend never populates tip_known, so lite is exempt (it has its own sync gate).
|
||||||
|
bool sendTipReady(bool liteBuild) const { return liteBuild || (tip_known && !syncing && blocks > 0); }
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -370,12 +391,21 @@ struct WalletState {
|
|||||||
// touchTransactions() afterwards. UI caches (History tab, ...) key on this, NOT on last_tx_update (a
|
// touchTransactions() afterwards. UI caches (History tab, ...) key on this, NOT on last_tx_update (a
|
||||||
// wall-clock refresh stamp that lite never sets) nor App::tx_dirty_generation_ (a refresh-needed flag).
|
// wall-clock refresh stamp that lite never sets) nor App::tx_dirty_generation_ (a refresh-needed flag).
|
||||||
std::uint64_t tx_revision = 1;
|
std::uint64_t tx_revision = 1;
|
||||||
void touchTransactions() { ++tx_revision; }
|
// Bumps with tx_revision EXCEPT for a pure confirmation-count change (see sameTransactionLayout): the History tab's
|
||||||
|
// O(N) merge+sort cache keys on this and just patches confirmations when only tx_revision moved. Every writer that
|
||||||
|
// is not setTransactionsIfChanged goes through touchTransactions()/replaceTransactions() and bumps both (safe default).
|
||||||
|
std::uint64_t tx_structure_revision = 1;
|
||||||
|
void touchTransactions() { ++tx_revision; ++tx_structure_revision; }
|
||||||
void replaceTransactions(std::vector<TransactionInfo>&& next) { transactions = std::move(next); touchTransactions(); }
|
void replaceTransactions(std::vector<TransactionInfo>&& next) { transactions = std::move(next); touchTransactions(); }
|
||||||
// Replace only when the content differs; returns true (and bumps) if it did. Keeps the old list when identical.
|
// Replace only when the content differs; returns true (and bumps) if it did. Keeps the old list when identical.
|
||||||
bool setTransactionsIfChanged(std::vector<TransactionInfo>&& next) {
|
bool setTransactionsIfChanged(std::vector<TransactionInfo>&& next) {
|
||||||
if (sameTransactionList(transactions, next)) return false;
|
if (sameTransactionList(transactions, next)) return false;
|
||||||
replaceTransactions(std::move(next));
|
bool layoutSame = transactions.size() == next.size();
|
||||||
|
for (std::size_t i = 0; layoutSame && i < next.size(); ++i)
|
||||||
|
layoutSame = sameTransactionLayout(transactions[i], next[i]);
|
||||||
|
transactions = std::move(next);
|
||||||
|
++tx_revision;
|
||||||
|
if (!layoutSame) ++tx_structure_revision;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
int64_t last_peer_update = 0;
|
int64_t last_peer_update = 0;
|
||||||
|
|||||||
+9
-18
@@ -460,15 +460,14 @@ bool Connection::createDefaultConfig(const std::string& path)
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::ofstream file(path);
|
// Build the content in memory, then write it 0600 from the start (writeFileAtomically with
|
||||||
if (!file.is_open()) {
|
// restrictPermissions) — the old ofstream+chmod left a brief world-readable window during which
|
||||||
DEBUG_LOGF("Failed to create config file: %s\n", path.c_str());
|
// another local user could read the freshly-generated rpcuser/rpcpassword.
|
||||||
return false;
|
std::ostringstream file;
|
||||||
}
|
|
||||||
|
|
||||||
// Get the data directory for exportdir
|
// Get the data directory for exportdir
|
||||||
std::string dataDir = getDefaultDataDir();
|
std::string dataDir = getDefaultDataDir();
|
||||||
|
|
||||||
file << "# DragonX configuration file\n";
|
file << "# DragonX configuration file\n";
|
||||||
file << "# Auto-generated by DragonX Wallet\n";
|
file << "# Auto-generated by DragonX Wallet\n";
|
||||||
file << "\n";
|
file << "\n";
|
||||||
@@ -485,18 +484,10 @@ bool Connection::createDefaultConfig(const std::string& path)
|
|||||||
file << "addnode=seed.dragonx.is\n";
|
file << "addnode=seed.dragonx.is\n";
|
||||||
file << "addnode=node1.dragonx.is\n";
|
file << "addnode=node1.dragonx.is\n";
|
||||||
file << "addnode=node5.dragonx.is\n";
|
file << "addnode=node5.dragonx.is\n";
|
||||||
|
|
||||||
file.close();
|
|
||||||
|
|
||||||
// The file holds the freshly-generated rpcuser/rpcpassword in plaintext. ofstream creates it
|
if (!util::Platform::writeFileAtomically(path, file.str(), /*restrictPermissions=*/true)) {
|
||||||
// with the process umask (typically world-readable 0644), so restrict it to owner read/write
|
DEBUG_LOGF("Failed to create config file: %s\n", path.c_str());
|
||||||
// before another local user can read the credentials.
|
return false;
|
||||||
{
|
|
||||||
namespace fs = std::filesystem;
|
|
||||||
std::error_code ec;
|
|
||||||
fs::permissions(path, fs::perms::owner_read | fs::perms::owner_write,
|
|
||||||
fs::perm_options::replace, ec);
|
|
||||||
if (ec) DEBUG_LOGF("Could not restrict config permissions on %s: %s\n", path.c_str(), ec.message().c_str());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
DEBUG_LOGF("Created default config file: %s\n", path.c_str());
|
DEBUG_LOGF("Created default config file: %s\n", path.c_str());
|
||||||
|
|||||||
+16
-1
@@ -216,7 +216,8 @@ bool RPCClient::open(const std::string& host, const std::string& port,
|
|||||||
curl_easy_setopt(impl_->curl, CURLOPT_HTTPHEADER, impl_->headers);
|
curl_easy_setopt(impl_->curl, CURLOPT_HTTPHEADER, impl_->headers);
|
||||||
curl_easy_setopt(impl_->curl, CURLOPT_WRITEFUNCTION, WriteCallback);
|
curl_easy_setopt(impl_->curl, CURLOPT_WRITEFUNCTION, WriteCallback);
|
||||||
// Progress callback so requestAbort() can unblock an in-flight curl_easy_perform.
|
// Progress callback so requestAbort() can unblock an in-flight curl_easy_perform.
|
||||||
clearAbort(); // a fresh connection must not start in the aborted state
|
// NOTE: abort is deliberately NOT cleared here: a requestAbort() issued while this connect was queued
|
||||||
|
// must still cut it short. Reconnect callers (App::tryConnect / onConnected) call clearAbort() first.
|
||||||
curl_easy_setopt(impl_->curl, CURLOPT_NOPROGRESS, 0L);
|
curl_easy_setopt(impl_->curl, CURLOPT_NOPROGRESS, 0L);
|
||||||
curl_easy_setopt(impl_->curl, CURLOPT_XFERINFOFUNCTION, xferInfoCallback);
|
curl_easy_setopt(impl_->curl, CURLOPT_XFERINFOFUNCTION, xferInfoCallback);
|
||||||
curl_easy_setopt(impl_->curl, CURLOPT_XFERINFODATA, this);
|
curl_easy_setopt(impl_->curl, CURLOPT_XFERINFODATA, this);
|
||||||
@@ -370,6 +371,20 @@ std::string RPCClient::performCall(const std::string& method, const json& params
|
|||||||
|
|
||||||
json payload = makePayload(method, params);
|
json payload = makePayload(method, params);
|
||||||
std::string body = payload.dump();
|
std::string body = payload.dump();
|
||||||
|
// Request-side secret scrub (responses are already scrubbed): the body + payload tree can carry
|
||||||
|
// walletpassphrase / encryptwallet / *_importkey args. Wipe them when this scope ends (all return
|
||||||
|
// and throw paths) and detach CURLOPT_POSTFIELDS so the reused handle holds no dangling pointer.
|
||||||
|
struct RequestScrubGuard {
|
||||||
|
CURL* curl; std::string& body; json& payload;
|
||||||
|
~RequestScrubGuard() {
|
||||||
|
if (!body.empty()) sodium_memzero(&body[0], body.size());
|
||||||
|
scrubJsonSecrets(payload);
|
||||||
|
if (curl) {
|
||||||
|
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, nullptr);
|
||||||
|
curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, 0L);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} requestScrubGuard{impl_->curl, body, payload};
|
||||||
std::string response_data;
|
std::string response_data;
|
||||||
|
|
||||||
// Set POST data
|
// Set POST data
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
#include <curl/curl.h>
|
#include <curl/curl.h>
|
||||||
|
|
||||||
|
#include <cctype>
|
||||||
#include <stdexcept>
|
#include <stdexcept>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
@@ -152,6 +153,54 @@ inline RpcFailureKind rpcFailureKindOf(const std::exception& e)
|
|||||||
return RpcFailureKind::Other;
|
return RpcFailureKind::Other;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Did a failed z_sendmany (or any submit call) possibly reach the node and take effect? True only for a
|
||||||
|
// transport failure AFTER the request left us with no HTTP status back (reply timeout, connection cut
|
||||||
|
// mid-reply, our own abort): the daemon may already hold the operation, so the caller must not invite an
|
||||||
|
// immediate resend. Definitive answers/rejections (AppError, QueueFull, Auth, Warmup, Deferred,
|
||||||
|
// NotListening, ConnectTimeout, any HTTP status) mean nothing was queued and stay plain failures.
|
||||||
|
inline bool sendReplyMayBeLost(const std::exception& e)
|
||||||
|
{
|
||||||
|
const auto* t = dynamic_cast<const RpcTransportError*>(&e);
|
||||||
|
return t && t->requestSent && t->httpStatus == 0 && t->curlCode != CURLE_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lite broadcast errors are plain backend text (tonic/gRPC). Ambiguous = a transport-style failure while
|
||||||
|
// talking to the lightwalletd server (the server may have accepted the tx before the reply was lost).
|
||||||
|
// Connect-phase failures and an explicit server rejection ("error_code") are definitive. Heuristic by
|
||||||
|
// design: a false positive only shows a more cautious message, never changes the send.
|
||||||
|
inline bool liteSendErrorMayBeInFlight(const std::string& err)
|
||||||
|
{
|
||||||
|
std::string lc = err;
|
||||||
|
for (char& c : lc) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||||
|
auto has = [&](const char* n) { return lc.find(n) != std::string::npos; };
|
||||||
|
if (has("error_code") || has("error trying to connect") || has("connection refused") ||
|
||||||
|
has("dns error") || has("insufficient") || has("locked"))
|
||||||
|
return false;
|
||||||
|
return has("timed out") || has("timeout") || has("deadline") || has("transport error") ||
|
||||||
|
has("connection reset") || has("broken pipe") || has("h2 protocol error") ||
|
||||||
|
has("unexpected eof") || has("status: unknown") || has("status: cancelled");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tag carried in the failure text of a send callback (bool, string) so the Send tab can show the distinct
|
||||||
|
// "may have been broadcast - check History before resending" outcome without changing the callback type.
|
||||||
|
// The prefix is readable English so callers that just print the text still say something accurate.
|
||||||
|
inline const char* sendMaybeInFlightPrefix()
|
||||||
|
{
|
||||||
|
return "Send status unknown - it may already have been broadcast: ";
|
||||||
|
}
|
||||||
|
inline bool isSendMaybeInFlight(const std::string& s)
|
||||||
|
{
|
||||||
|
return s.rfind(sendMaybeInFlightPrefix(), 0) == 0;
|
||||||
|
}
|
||||||
|
inline std::string markSendMaybeInFlight(const std::string& detail)
|
||||||
|
{
|
||||||
|
return isSendMaybeInFlight(detail) ? detail : std::string(sendMaybeInFlightPrefix()) + detail;
|
||||||
|
}
|
||||||
|
inline std::string stripSendMaybeInFlight(const std::string& s)
|
||||||
|
{
|
||||||
|
return isSendMaybeInFlight(s) ? s.substr(std::string(sendMaybeInFlightPrefix()).size()) : s;
|
||||||
|
}
|
||||||
|
|
||||||
// What a failed walletpassphrase call means. Only WrongPassphrase (-14) may count toward the lockout;
|
// What a failed walletpassphrase call means. Only WrongPassphrase (-14) may count toward the lockout;
|
||||||
// a busy/warming-up node, a wallet that is not encrypted, or anything unknown is never a bad guess.
|
// a busy/warming-up node, a wallet that is not encrypted, or anything unknown is never a bad guess.
|
||||||
enum class UnlockFailure { WrongPassphrase, NodeBusy, NotEncrypted, Other };
|
enum class UnlockFailure { WrongPassphrase, NodeBusy, NotEncrypted, Other };
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#include "rpc/rpc_busy_gate.h"
|
#include "rpc/rpc_busy_gate.h"
|
||||||
#include "../util/logger.h"
|
#include "../util/logger.h"
|
||||||
#include "../util/i18n.h" // util::tr for the price-fetch-failed default
|
#include "../util/i18n.h" // util::tr for the price-fetch-failed default
|
||||||
|
#include "../util/amount_format.h" // util::toSats: integer-satoshi accumulation
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cctype>
|
#include <cctype>
|
||||||
@@ -34,7 +35,20 @@ std::optional<T> readOptional(const json& source, const char* key)
|
|||||||
std::optional<double> readBalanceString(const json& source, const char* key)
|
std::optional<double> readBalanceString(const json& source, const char* key)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
if (source.contains(key)) return std::stod(source[key].get<std::string>());
|
if (source.contains(key)) {
|
||||||
|
const std::string text = source[key].get<std::string>();
|
||||||
|
// Strict: plain decimal only (digits, one '.', optional leading '-'). std::stod would accept
|
||||||
|
// '1.5abc', whitespace, '+', hex, exponents and nan/inf, silently.
|
||||||
|
if (text.empty()) return std::nullopt;
|
||||||
|
for (std::size_t i = 0; i < text.size(); ++i) {
|
||||||
|
const char c = text[i];
|
||||||
|
if (!((c >= '0' && c <= '9') || c == '.' || (c == '-' && i == 0))) return std::nullopt;
|
||||||
|
}
|
||||||
|
char* end = nullptr;
|
||||||
|
const double value = std::strtod(text.c_str(), &end);
|
||||||
|
if (end != text.c_str() + text.size() || !std::isfinite(value)) return std::nullopt;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
} catch (...) {}
|
} catch (...) {}
|
||||||
return std::nullopt;
|
return std::nullopt;
|
||||||
}
|
}
|
||||||
@@ -52,15 +66,17 @@ void applyBalancesFromUnspent(std::vector<AddressInfo>& addresses, const json& u
|
|||||||
// Partition each note/utxo by its per-entry "confirmations": `total` (minconf=0 — DISPLAY, includes
|
// Partition each note/utxo by its per-entry "confirmations": `total` (minconf=0 — DISPLAY, includes
|
||||||
// the user's own pending 0-conf change) vs `spendable` (confirmations>=1 — what z_sendmany, run at
|
// the user's own pending 0-conf change) vs `spendable` (confirmations>=1 — what z_sendmany, run at
|
||||||
// minconf=1, can actually spend). This lets a single z_listunspent(0)/listunspent(0) feed both.
|
// minconf=1, can actually spend). This lets a single z_listunspent(0)/listunspent(0) feed both.
|
||||||
std::map<std::string, double> total;
|
// Accumulated in integer satoshis (not doubles) so many notes can't drift by an ulp per addition.
|
||||||
std::map<std::string, double> spendable;
|
std::map<std::string, std::int64_t> total;
|
||||||
|
std::map<std::string, std::int64_t> spendable;
|
||||||
for (const auto& output : unspent) {
|
for (const auto& output : unspent) {
|
||||||
auto address = readOptional<std::string>(output, "address");
|
auto address = readOptional<std::string>(output, "address");
|
||||||
auto amount = readOptional<double>(output, "amount");
|
auto amount = readOptional<double>(output, "amount");
|
||||||
if (!address || !amount) continue;
|
if (!address || !amount || !std::isfinite(*amount)) continue;
|
||||||
total[*address] += *amount;
|
const std::int64_t sats = util::toSats(*amount);
|
||||||
|
total[*address] += sats;
|
||||||
auto conf = readOptional<int>(output, "confirmations");
|
auto conf = readOptional<int>(output, "confirmations");
|
||||||
if (conf && *conf >= 1) spendable[*address] += *amount;
|
if (conf && *conf >= 1) spendable[*address] += sats;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The address lists are rebuilt fresh (default 0) each refresh, so hard-set both — an address with no
|
// The address lists are rebuilt fresh (default 0) each refresh, so hard-set both — an address with no
|
||||||
@@ -68,8 +84,8 @@ void applyBalancesFromUnspent(std::vector<AddressInfo>& addresses, const json& u
|
|||||||
for (auto& info : addresses) {
|
for (auto& info : addresses) {
|
||||||
auto t = total.find(info.address);
|
auto t = total.find(info.address);
|
||||||
auto s = spendable.find(info.address);
|
auto s = spendable.find(info.address);
|
||||||
info.balance = (t != total.end()) ? t->second : 0.0;
|
info.balance = (t != total.end()) ? static_cast<double>(t->second) / 1e8 : 0.0;
|
||||||
info.spendableBalance = (s != spendable.end()) ? s->second : 0.0;
|
info.spendableBalance = (s != spendable.end()) ? static_cast<double>(s->second) / 1e8 : 0.0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1040,8 +1056,13 @@ NetworkRefreshService::TransactionViewCacheEntry NetworkRefreshService::parseVie
|
|||||||
|
|
||||||
void NetworkRefreshService::appendViewTransactionOutputs(std::vector<TransactionInfo>& transactions,
|
void NetworkRefreshService::appendViewTransactionOutputs(std::vector<TransactionInfo>& transactions,
|
||||||
const std::string& txid,
|
const std::string& txid,
|
||||||
const TransactionViewCacheEntry& entry)
|
const TransactionViewCacheEntry& entry,
|
||||||
|
int currentHeight)
|
||||||
{
|
{
|
||||||
|
// A mined entry's confirmations grow by one per block since it was sampled; never go backwards.
|
||||||
|
int entryConfirmations = entry.confirmations;
|
||||||
|
if (entry.confirmations >= 1 && entry.observed_height >= 0 && currentHeight > entry.observed_height)
|
||||||
|
entryConfirmations += currentHeight - entry.observed_height;
|
||||||
for (const auto& out : entry.outgoing_outputs) {
|
for (const auto& out : entry.outgoing_outputs) {
|
||||||
bool alreadyTracked = false;
|
bool alreadyTracked = false;
|
||||||
for (const auto& existing : transactions) {
|
for (const auto& existing : transactions) {
|
||||||
@@ -1073,7 +1094,7 @@ void NetworkRefreshService::appendViewTransactionOutputs(std::vector<Transaction
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!foundExistingMetadata) {
|
if (!foundExistingMetadata) {
|
||||||
info.confirmations = entry.confirmations;
|
info.confirmations = entryConfirmations;
|
||||||
info.timestamp = entry.timestamp;
|
info.timestamp = entry.timestamp;
|
||||||
}
|
}
|
||||||
transactions.push_back(std::move(info));
|
transactions.push_back(std::move(info));
|
||||||
@@ -1460,7 +1481,7 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
|
|||||||
auto cached = snapshot.viewTxCache.find(txid);
|
auto cached = snapshot.viewTxCache.find(txid);
|
||||||
if (cached != snapshot.viewTxCache.end()) {
|
if (cached != snapshot.viewTxCache.end()) {
|
||||||
if (!trackedSend || !cached->second.outgoing_outputs.empty()) {
|
if (!trackedSend || !cached->second.outgoing_outputs.empty()) {
|
||||||
appendViewTransactionOutputs(result.transactions, txid, cached->second);
|
appendViewTransactionOutputs(result.transactions, txid, cached->second, currentBlockHeight);
|
||||||
// NB: do NOT extract chat metadata from our OWN outgoing memos here — ingest marks
|
// 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"
|
// 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
|
// entry. Genuine incoming comes from the z_listreceivedbyaddress path; our sends are
|
||||||
@@ -1493,6 +1514,7 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
|
|||||||
}
|
}
|
||||||
|
|
||||||
auto entry = parseViewTransactionCacheEntry(viewTransaction);
|
auto entry = parseViewTransactionCacheEntry(viewTransaction);
|
||||||
|
entry.observed_height = currentBlockHeight; // confirmations below are sampled at this tip
|
||||||
const bool storedRow = rowBacklog.count(txid) > 0;
|
const bool storedRow = rowBacklog.count(txid) > 0;
|
||||||
if (storedRow && entry.timestamp == 0) {
|
if (storedRow && entry.timestamp == 0) {
|
||||||
const auto settled = settledStoredMeta.find(txid);
|
const auto settled = settledStoredMeta.find(txid);
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ public:
|
|||||||
std::string from_address;
|
std::string from_address;
|
||||||
std::int64_t timestamp = 0;
|
std::int64_t timestamp = 0;
|
||||||
int confirmations = 0;
|
int confirmations = 0;
|
||||||
|
int observed_height = -1; // chain tip when `confirmations` was read (-1 unknown): lets a cached entry advance
|
||||||
struct Output {
|
struct Output {
|
||||||
std::string address;
|
std::string address;
|
||||||
double value = 0.0;
|
double value = 0.0;
|
||||||
@@ -427,9 +428,12 @@ public:
|
|||||||
const std::set<std::string>& miningAddresses = {},
|
const std::set<std::string>& miningAddresses = {},
|
||||||
std::unordered_map<std::string, std::vector<chat::HushChatMemoOutput>>* chatMemoOutputs = nullptr);
|
std::unordered_map<std::string, std::vector<chat::HushChatMemoOutput>>* chatMemoOutputs = nullptr);
|
||||||
static TransactionViewCacheEntry parseViewTransactionCacheEntry(const nlohmann::json& viewTransaction);
|
static TransactionViewCacheEntry parseViewTransactionCacheEntry(const nlohmann::json& viewTransaction);
|
||||||
|
// currentHeight (>=0): the present chain tip; a cached entry that was already mined then reports
|
||||||
|
// confirmations advanced by the blocks added since it was observed (display only).
|
||||||
static void appendViewTransactionOutputs(std::vector<TransactionInfo>& transactions,
|
static void appendViewTransactionOutputs(std::vector<TransactionInfo>& transactions,
|
||||||
const std::string& txid,
|
const std::string& txid,
|
||||||
const TransactionViewCacheEntry& entry);
|
const TransactionViewCacheEntry& entry,
|
||||||
|
int currentHeight = -1);
|
||||||
// Build "send" rows from a z_listsentbyaddress entry's sends.transparentSends[] — the transparent
|
// Build "send" rows from a z_listsentbyaddress entry's sends.transparentSends[] — the transparent
|
||||||
// (z->t deshield) outgoing outputs that z_viewtransaction does not surface. Deduped by
|
// (z->t deshield) outgoing outputs that z_viewtransaction does not surface. Deduped by
|
||||||
// txid+address+amount against rows already present (e.g. listtransactions-derived t->t sends).
|
// txid+address+amount against rows already present (e.g. listtransactions-derived t->t sends).
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ bool ExplorerBlockCache::ensureOpen()
|
|||||||
}
|
}
|
||||||
|
|
||||||
db_ = openedDb;
|
db_ = openedDb;
|
||||||
|
++revision_;
|
||||||
// UI-thread-only handle: a short wait is enough; a miss falls back to the in-memory spill instead of
|
// UI-thread-only handle: a short wait is enough; a miss falls back to the in-memory spill instead of
|
||||||
// freezing the frame for 2 s.
|
// freezing the frame for 2 s.
|
||||||
sqlite3_busy_timeout(db_, 250);
|
sqlite3_busy_timeout(db_, 250);
|
||||||
@@ -176,6 +177,20 @@ std::map<int, ExplorerBlockSummary> ExplorerBlockCache::loadRange(int minHeight,
|
|||||||
return blocks;
|
return blocks;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const std::map<int, ExplorerBlockSummary>& ExplorerBlockCache::loadRangeCached(int minHeight, int maxHeight)
|
||||||
|
{
|
||||||
|
if (range_cache_revision_ != revision_) { range_cache_.clear(); range_cache_revision_ = revision_; }
|
||||||
|
if (range_cache_.size() >= 16) range_cache_.clear();
|
||||||
|
const std::pair<int, int> key(minHeight, maxHeight);
|
||||||
|
auto it = range_cache_.find(key);
|
||||||
|
if (it == range_cache_.end()) {
|
||||||
|
auto loaded = loadRange(minHeight, maxHeight); // may open the DB (bumps revision_)
|
||||||
|
if (range_cache_revision_ != revision_) { range_cache_.clear(); range_cache_revision_ = revision_; }
|
||||||
|
it = range_cache_.emplace(key, std::move(loaded)).first;
|
||||||
|
}
|
||||||
|
return it->second;
|
||||||
|
}
|
||||||
|
|
||||||
std::vector<ExplorerBlockSummary> ExplorerBlockCache::searchBlocks(const std::string& query, int limit)
|
std::vector<ExplorerBlockSummary> ExplorerBlockCache::searchBlocks(const std::string& query, int limit)
|
||||||
{
|
{
|
||||||
std::vector<ExplorerBlockSummary> results;
|
std::vector<ExplorerBlockSummary> results;
|
||||||
@@ -256,6 +271,7 @@ bool ExplorerBlockCache::storeBlockRow(const ExplorerBlockSummary& block)
|
|||||||
bool ExplorerBlockCache::storeBlock(const ExplorerBlockSummary& block)
|
bool ExplorerBlockCache::storeBlock(const ExplorerBlockSummary& block)
|
||||||
{
|
{
|
||||||
if (block.height < 1 || block.hash.empty()) return false;
|
if (block.height < 1 || block.hash.empty()) return false;
|
||||||
|
++revision_;
|
||||||
const bool stored = storeBlockRow(block);
|
const bool stored = storeBlockRow(block);
|
||||||
if (stored) {
|
if (stored) {
|
||||||
spill_.erase(block.height);
|
spill_.erase(block.height);
|
||||||
@@ -297,6 +313,7 @@ int ExplorerBlockCache::cachedBlockCount()
|
|||||||
|
|
||||||
void ExplorerBlockCache::clearBlocks()
|
void ExplorerBlockCache::clearBlocks()
|
||||||
{
|
{
|
||||||
|
++revision_;
|
||||||
spill_.clear();
|
spill_.clear();
|
||||||
if (!ensureOpen()) return;
|
if (!ensureOpen()) return;
|
||||||
exec("DELETE FROM explorer_blocks");
|
exec("DELETE FROM explorer_blocks");
|
||||||
@@ -305,6 +322,7 @@ void ExplorerBlockCache::clearBlocks()
|
|||||||
void ExplorerBlockCache::pruneTo(int maxRows)
|
void ExplorerBlockCache::pruneTo(int maxRows)
|
||||||
{
|
{
|
||||||
if (maxRows < 1 || !db_) return;
|
if (maxRows < 1 || !db_) return;
|
||||||
|
++revision_;
|
||||||
// Keep the maxRows highest blocks. The OFFSET row is the (maxRows+1)-th highest; when the table is smaller the
|
// Keep the maxRows highest blocks. The OFFSET row is the (maxRows+1)-th highest; when the table is smaller the
|
||||||
// subquery is NULL and nothing is deleted. Freed pages are reused, so the file stops growing at its high-water
|
// subquery is NULL and nothing is deleted. Freed pages are reused, so the file stops growing at its high-water
|
||||||
// mark (no VACUUM / auto_vacuum: changing auto_vacuum needs a rebuild of existing databases).
|
// mark (no VACUUM / auto_vacuum: changing auto_vacuum needs a rebuild of existing databases).
|
||||||
@@ -333,8 +351,10 @@ ExplorerBlockCache::SavedTipValidation ExplorerBlockCache::prepareValidation(
|
|||||||
// No DB to validate against: keep the spill honest ourselves (tip regressed, or a different block at the same height).
|
// No DB to validate against: keep the spill honest ourselves (tip regressed, or a different block at the same height).
|
||||||
if (!spill_.empty() && !currentBestHash.empty() &&
|
if (!spill_.empty() && !currentBestHash.empty() &&
|
||||||
(currentHeight < spill_tip_height_ ||
|
(currentHeight < spill_tip_height_ ||
|
||||||
(currentHeight == spill_tip_height_ && currentBestHash != spill_tip_hash_)))
|
(currentHeight == spill_tip_height_ && currentBestHash != spill_tip_hash_))) {
|
||||||
spill_.clear();
|
spill_.clear();
|
||||||
|
++revision_;
|
||||||
|
}
|
||||||
if (!currentBestHash.empty()) { spill_tip_height_ = currentHeight; spill_tip_hash_ = currentBestHash; }
|
if (!currentBestHash.empty()) { spill_tip_height_ = currentHeight; spill_tip_hash_ = currentBestHash; }
|
||||||
return validation;
|
return validation;
|
||||||
}
|
}
|
||||||
@@ -526,6 +546,7 @@ void ExplorerBlockCache::close()
|
|||||||
{
|
{
|
||||||
tip_known_ = false;
|
tip_known_ = false;
|
||||||
if (!db_) return;
|
if (!db_) return;
|
||||||
|
++revision_;
|
||||||
sqlite3_close(db_);
|
sqlite3_close(db_);
|
||||||
db_ = nullptr;
|
db_ = nullptr;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <map>
|
#include <map>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <utility>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
struct sqlite3;
|
struct sqlite3;
|
||||||
@@ -44,6 +45,11 @@ public:
|
|||||||
const std::string& databasePath() const { return database_path_; }
|
const std::string& databasePath() const { return database_path_; }
|
||||||
|
|
||||||
std::map<int, ExplorerBlockSummary> loadRange(int minHeight, int maxHeight);
|
std::map<int, ExplorerBlockSummary> loadRange(int minHeight, int maxHeight);
|
||||||
|
// loadRange memoized per (min,max) and invalidated by revision() so the per-frame Explorer list only hits SQLite
|
||||||
|
// after the cache actually changed (store / clear / prune / spill reset / open / close). The reference is valid
|
||||||
|
// until the next call; copy it if you call again before using it.
|
||||||
|
const std::map<int, ExplorerBlockSummary>& loadRangeCached(int minHeight, int maxHeight);
|
||||||
|
std::uint64_t revision() const { return revision_; }
|
||||||
// Fuzzy search over cached blocks: matches when the query is a substring of the height (as text)
|
// Fuzzy search over cached blocks: matches when the query is a substring of the height (as text)
|
||||||
// or the block hash (case-insensitive). Returns newest-first, capped at `limit`.
|
// or the block hash (case-insensitive). Returns newest-first, capped at `limit`.
|
||||||
std::vector<ExplorerBlockSummary> searchBlocks(const std::string& query, int limit);
|
std::vector<ExplorerBlockSummary> searchBlocks(const std::string& query, int limit);
|
||||||
@@ -102,6 +108,11 @@ private:
|
|||||||
int open_attempts_ = 0;
|
int open_attempts_ = 0;
|
||||||
int stores_since_prune_ = 0;
|
int stores_since_prune_ = 0;
|
||||||
|
|
||||||
|
// Bumped by every mutation that can change what loadRange returns; keys the loadRangeCached memo.
|
||||||
|
std::uint64_t revision_ = 1;
|
||||||
|
std::uint64_t range_cache_revision_ = 0;
|
||||||
|
std::map<std::pair<int, int>, std::map<int, ExplorerBlockSummary>> range_cache_;
|
||||||
|
|
||||||
// Blocks the DB refused, bounded and UI-thread only. Evicts the entry farthest from the last spilled height
|
// Blocks the DB refused, bounded and UI-thread only. Evicts the entry farthest from the last spilled height
|
||||||
// (the Explorer fetches around the visible window). Cleared with clearBlocks() and when the chain tip regresses.
|
// (the Explorer fetches around the visible window). Cleared with clearBlocks() and when the chain tip regresses.
|
||||||
static constexpr std::size_t kMaxSpillBlocks = 512;
|
static constexpr std::size_t kMaxSpillBlocks = 512;
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// DragonX Wallet - ImGui Edition
|
||||||
|
// Copyright 2024-2026 The Hush Developers
|
||||||
|
// Released under the GPLv3
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
// Best-effort scrub of ImGui's internal InputText buffers, which retain un-zeroed copies of typed
|
||||||
|
// secrets (wallet passphrase / PIN / seed / private key) after a field is edited — the app's own
|
||||||
|
// char[] buffers are memzeroed, but ImGui also copies the text into its global edit/undo/deactivated
|
||||||
|
// state, which it only frees (not zeroes).
|
||||||
|
//
|
||||||
|
// This reaches into vendored imgui_internal state, so it is PINNED to the vendored ImGui version: a
|
||||||
|
// bump trips the #error below and forces a re-audit of the member names. It is BEST-EFFORT only —
|
||||||
|
// std::-style buffer reallocation, the system clipboard, and OS/IME layers can still hold copies, so
|
||||||
|
// this shrinks the exposure window but does not guarantee absence of secret material in a core dump.
|
||||||
|
|
||||||
|
#include "imgui.h"
|
||||||
|
#include "imgui_internal.h"
|
||||||
|
#include <sodium.h>
|
||||||
|
#include <cstddef>
|
||||||
|
|
||||||
|
#if IMGUI_VERSION_NUM != 19259
|
||||||
|
#error "imgui_secret_scrub.h is pinned to ImGui 1.92.6 (IMGUI_VERSION_NUM 19259). After bumping libs/imgui, re-audit ImGuiInputTextState / ImGuiInputTextDeactivatedState / ImGuiContext member names below, then update this guard."
|
||||||
|
#endif
|
||||||
|
|
||||||
|
namespace dragonx {
|
||||||
|
namespace ui {
|
||||||
|
|
||||||
|
// Zero ImGui's retained InputText buffers. Safe to call at a lock transition / when a secret dialog
|
||||||
|
// closes (it defocuses the active field). Do NOT call while a secret field is actively being typed.
|
||||||
|
inline void scrubImGuiInputTextState() {
|
||||||
|
ImGuiContext* ctx = ImGui::GetCurrentContext();
|
||||||
|
if (!ctx) return;
|
||||||
|
ImGuiInputTextState& st = ctx->InputTextState;
|
||||||
|
// Defocus first so the next frame can't re-read the buffer we're about to zero (ClearActiveID also
|
||||||
|
// refreshes the deactivated-state copy, which we then zero below).
|
||||||
|
if (st.ID != 0 && ctx->ActiveId == st.ID) ImGui::ClearActiveID();
|
||||||
|
auto zeroVec = [](ImVector<char>& v) {
|
||||||
|
if (v.Data && v.Size > 0) sodium_memzero(v.Data, static_cast<size_t>(v.Size));
|
||||||
|
};
|
||||||
|
zeroVec(st.TextA); // the live edit buffer
|
||||||
|
zeroVec(st.TextToRevertTo); // Escape-to-revert backup
|
||||||
|
zeroVec(st.CallbackTextBackup); // callback reconcile scratch
|
||||||
|
st.TextLen = 0;
|
||||||
|
// NOTE: st.Stb (the stb_textedit undo/redo char buffers) is deliberately NOT zeroed here — its type
|
||||||
|
// ImStbTexteditState is incomplete in imgui_internal.h (fully defined only inside ImGui's .cpp), so we
|
||||||
|
// can't sizeof it from a header. The primary copies above cover the committed text; the undo scratch
|
||||||
|
// is a residual gap in this best-effort scrub.
|
||||||
|
zeroVec(ctx->InputTextDeactivatedState.TextA); // the just-deactivated copy
|
||||||
|
ctx->InputTextDeactivatedState.ID = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace ui
|
||||||
|
} // namespace dragonx
|
||||||
@@ -23,6 +23,7 @@ enum class NodeBannerAction {
|
|||||||
Reconnect, // full node: re-run the RPC connect state machine (App::tryConnect)
|
Reconnect, // full node: re-run the RPC connect state machine (App::tryConnect)
|
||||||
RestartNode, // full node: the embedded daemon crashed & auto-restart gave up (App::restartDaemon)
|
RestartNode, // full node: the embedded daemon crashed & auto-restart gave up (App::restartDaemon)
|
||||||
OpenConfigFolder,// external node: open the folder holding DRAGONX.conf so the user can fix the login
|
OpenConfigFolder,// external node: open the folder holding DRAGONX.conf so the user can fix the login
|
||||||
|
RecoverLegacyFunds, // a degraded legacy wallet with stuck transparent funds: open migrate-to-seed
|
||||||
};
|
};
|
||||||
|
|
||||||
// Why the banner is up. App maps this to a translated headline; `detail` carries the live,
|
// Why the banner is up. App maps this to a translated headline; `detail` carries the live,
|
||||||
@@ -35,6 +36,8 @@ enum class NodeBannerReason {
|
|||||||
NoPeers, // connected to the daemon but no peer-derived tip (0 peers) — stalled, stale
|
NoPeers, // connected to the daemon but no peer-derived tip (0 peers) — stalled, stale
|
||||||
NodeBusy, // the node is up but busy (rebuilding witnesses / catching up / not answering)
|
NodeBusy, // the node is up but busy (rebuilding witnesses / catching up / not answering)
|
||||||
AuthFailed, // the node rejected the wallet's RPC login (bad rpcuser/rpcpassword in the conf)
|
AuthFailed, // the node rejected the wallet's RPC login (bad rpcuser/rpcpassword in the conf)
|
||||||
|
LegacyFundsStuck, // a DEGRADED legacy wallet (can't derive a z-address) holding transparent funds it
|
||||||
|
// therefore can't shield/spend — offer the migrate-to-seed recovery
|
||||||
};
|
};
|
||||||
|
|
||||||
struct NodeBannerState {
|
struct NodeBannerState {
|
||||||
@@ -75,6 +78,11 @@ struct NodeBannerInputs {
|
|||||||
|
|
||||||
bool auth_failed = false; // the node rejected the wallet's RPC login (bad rpcuser/rpcpassword)
|
bool auth_failed = false; // the node rejected the wallet's RPC login (bad rpcuser/rpcpassword)
|
||||||
|
|
||||||
|
// The wallet opened DEGRADED (no usable HD seed, so z_getnewaddress / shielding fail) AND it holds
|
||||||
|
// transparent funds that are therefore stuck. App sets this ONLY from wallet_degraded_ + a transparent
|
||||||
|
// balance — never from merely-non-mnemonic status, which is a functional wallet and must not be flagged.
|
||||||
|
bool legacy_funds_stuck = false;
|
||||||
|
|
||||||
std::string connection_status; // human-readable status line (already translated)
|
std::string connection_status; // human-readable status line (already translated)
|
||||||
std::string daemon_last_error; // DaemonController::lastError() (may be empty)
|
std::string daemon_last_error; // DaemonController::lastError() (may be empty)
|
||||||
std::string lite_open_error; // lite: last wallet-open failure reason
|
std::string lite_open_error; // lite: last wallet-open failure reason
|
||||||
@@ -134,6 +142,16 @@ inline NodeBannerState evaluateNodeStatusBanner(const NodeBannerInputs& in) {
|
|||||||
? NodeBannerAction::RestartNode
|
? NodeBannerAction::RestartNode
|
||||||
: NodeBannerAction::None;
|
: NodeBannerAction::None;
|
||||||
s.detail.clear();
|
s.detail.clear();
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
// Connected and synced, but a degraded legacy wallet can't shield its transparent funds. Offer the
|
||||||
|
// recovery flow. Checked after NoPeers (can't recover until the node can see the chain anyway).
|
||||||
|
if (in.legacy_funds_stuck) {
|
||||||
|
s.show = true;
|
||||||
|
s.severity = NodeBannerSeverity::Warning;
|
||||||
|
s.reason = NodeBannerReason::LegacyFundsStuck;
|
||||||
|
s.action = NodeBannerAction::RecoverLegacyFunds;
|
||||||
|
s.detail.clear();
|
||||||
}
|
}
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,6 +100,15 @@ void Notifications::render()
|
|||||||
float iconGap = 4.0f * dp;
|
float iconGap = 4.0f * dp;
|
||||||
float msgW = textFont ? textFont->CalcTextSizeA(textFont->LegacySize, FLT_MAX, 0.0f, notif.message.c_str()).x : 100.0f;
|
float msgW = textFont ? textFont->CalcTextSizeA(textFont->LegacySize, FLT_MAX, 0.0f, notif.message.c_str()).x : 100.0f;
|
||||||
float pillWidth = pillPadX + iconW + iconGap + msgW + pillPadX;
|
float pillWidth = pillPadX + iconW + iconGap + msgW + pillPadX;
|
||||||
|
// "+N more" badge when several live toasts are queued (render shows only the newest).
|
||||||
|
const size_t moreCount = hiddenLiveCount();
|
||||||
|
std::string badge = moreCount ? ("+" + std::to_string(moreCount)) : std::string();
|
||||||
|
float badgeW = 0.0f, badgeGap = 0.0f;
|
||||||
|
if (!badge.empty() && textFont) {
|
||||||
|
badgeW = textFont->CalcTextSizeA(textFont->LegacySize, FLT_MAX, 0.0f, badge.c_str()).x + 10.0f * dp;
|
||||||
|
badgeGap = 6.0f * dp;
|
||||||
|
pillWidth += badgeW + badgeGap;
|
||||||
|
}
|
||||||
// Clamp to reasonable bounds
|
// Clamp to reasonable bounds
|
||||||
float maxPillW = viewport->WorkSize.x * 0.5f;
|
float maxPillW = viewport->WorkSize.x * 0.5f;
|
||||||
if (pillWidth > maxPillW) pillWidth = maxPillW;
|
if (pillWidth > maxPillW) pillWidth = maxPillW;
|
||||||
@@ -163,7 +172,7 @@ void Notifications::render()
|
|||||||
bool clipped = false;
|
bool clipped = false;
|
||||||
if (textFont) {
|
if (textFont) {
|
||||||
ImU32 textCol = ImGui::ColorConvertFloat4ToU32(text_color);
|
ImU32 textCol = ImGui::ColorConvertFloat4ToU32(text_color);
|
||||||
const float availTextW = (pMax.x - pillPadX) - cursorX;
|
const float availTextW = (pMax.x - pillPadX) - cursorX - (badge.empty() ? 0.0f : (badgeW + badgeGap));
|
||||||
std::string shown = notif.message;
|
std::string shown = notif.message;
|
||||||
if (availTextW > 0.0f &&
|
if (availTextW > 0.0f &&
|
||||||
textFont->CalcTextSizeA(textFont->LegacySize, FLT_MAX, 0.0f, shown.c_str()).x > availTextW) {
|
textFont->CalcTextSizeA(textFont->LegacySize, FLT_MAX, 0.0f, shown.c_str()).x > availTextW) {
|
||||||
@@ -183,8 +192,23 @@ void Notifications::render()
|
|||||||
dl->PopClipRect();
|
dl->PopClipRect();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hover: pause the auto-dismiss (handled next frame) and reveal the full message if it was clipped.
|
// "+N more" badge, right-aligned inside the pill.
|
||||||
|
if (!badge.empty() && textFont) {
|
||||||
|
float badgeX = pMax.x - pillPadX - badgeW;
|
||||||
|
ImVec4 bf = accent_color; bf.w *= nde("badge-fill-alpha", 0.35f);
|
||||||
|
dl->AddRectFilled(ImVec2(badgeX, pillY + 3.0f * dp), ImVec2(badgeX + badgeW, pMax.y - 3.0f * dp),
|
||||||
|
ImGui::ColorConvertFloat4ToU32(bf), (pillHeight - 6.0f * dp) * 0.5f);
|
||||||
|
dl->AddText(textFont, textFont->LegacySize, ImVec2(badgeX + 5.0f * dp, contentY),
|
||||||
|
ImGui::ColorConvertFloat4ToU32(text_color), badge.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hover: pause the auto-dismiss (next frame), reveal the full message if clipped, and allow a click to
|
||||||
|
// open the notification history (the status bar consumes the request).
|
||||||
toast_hovered_ = ImGui::IsMouseHoveringRect(pMin, pMax);
|
toast_hovered_ = ImGui::IsMouseHoveringRect(pMin, pMax);
|
||||||
|
if (toast_hovered_) {
|
||||||
|
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||||
|
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) requestHistory();
|
||||||
|
}
|
||||||
if (toast_hovered_ && clipped)
|
if (toast_hovered_ && clipped)
|
||||||
ImGui::SetTooltip("%s", notif.message.c_str());
|
ImGui::SetTooltip("%s", notif.message.c_str());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -169,8 +169,20 @@ public:
|
|||||||
|
|
||||||
void clear() {
|
void clear() {
|
||||||
notifications_.clear();
|
notifications_.clear();
|
||||||
|
history_requested_ = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Number of currently-live toasts beyond the one the pill shows (for a "+N more" badge).
|
||||||
|
size_t hiddenLiveCount() const {
|
||||||
|
const auto now = std::chrono::steady_clock::now();
|
||||||
|
size_t live = 0;
|
||||||
|
for (const auto& n : notifications_) if (!n.isExpiredAt(now)) ++live;
|
||||||
|
return live > 1 ? live - 1 : 0;
|
||||||
|
}
|
||||||
|
/// render() sets this when the toast is clicked; the status bar consumes it to open the history panel.
|
||||||
|
void requestHistory() { history_requested_ = true; }
|
||||||
|
bool consumeHistoryRequest() { bool r = history_requested_; history_requested_ = false; return r; }
|
||||||
|
|
||||||
void setMaxNotifications(size_t max) {
|
void setMaxNotifications(size_t max) {
|
||||||
max_notifications_ = max;
|
max_notifications_ = max;
|
||||||
}
|
}
|
||||||
@@ -191,6 +203,7 @@ private:
|
|||||||
Notifications& operator=(const Notifications&) = delete;
|
Notifications& operator=(const Notifications&) = delete;
|
||||||
|
|
||||||
std::deque<Notification> notifications_;
|
std::deque<Notification> notifications_;
|
||||||
|
bool history_requested_ = false; // set on toast click, consumed by the status bar to open history
|
||||||
size_t max_notifications_ = 5;
|
size_t max_notifications_ = 5;
|
||||||
bool toast_hovered_ = false; // pointer was over the toast last frame → pause its auto-dismiss
|
bool toast_hovered_ = false; // pointer was over the toast last frame → pause its auto-dismiss
|
||||||
std::chrono::steady_clock::time_point last_drawn_{};
|
std::chrono::steady_clock::time_point last_drawn_{};
|
||||||
|
|||||||
@@ -869,7 +869,7 @@ void RenderSettingsPage(App* app) {
|
|||||||
if (skin.bundled) continue;
|
if (skin.bundled) continue;
|
||||||
bool is_selected = (skin.id == skinMgr.activeSkinId());
|
bool is_selected = (skin.id == skinMgr.activeSkinId());
|
||||||
if (!skin.valid) {
|
if (!skin.valid) {
|
||||||
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.3f, 0.3f, 1.0f));
|
ImGui::PushStyleColor(ImGuiCol_Text, material::ErrorVec4());
|
||||||
ImGui::BeginDisabled(true);
|
ImGui::BeginDisabled(true);
|
||||||
std::string lbl = skin.name + TR("grpa_invalid_suffix");
|
std::string lbl = skin.name + TR("grpa_invalid_suffix");
|
||||||
ImGui::Selectable(lbl.c_str(), false);
|
ImGui::Selectable(lbl.c_str(), false);
|
||||||
@@ -904,7 +904,7 @@ void RenderSettingsPage(App* app) {
|
|||||||
float cellGap = Layout::spacingLg();
|
float cellGap = Layout::spacingLg();
|
||||||
bool twoCol = contentW >= 460.0f * dp; // drop to a single column when too narrow (high font scale)
|
bool twoCol = contentW >= 460.0f * dp; // drop to a single column when too narrow (high font scale)
|
||||||
int cols = twoCol ? 2 : 1;
|
int cols = twoCol ? 2 : 1;
|
||||||
float colW = std::max(160.0f, twoCol ? (contentW - cellGap) * 0.5f : contentW);
|
float colW = std::max(160.0f * dp, twoCol ? (contentW - cellGap) * 0.5f : contentW);
|
||||||
float baseX = ImGui::GetCursorScreenPos().x;
|
float baseX = ImGui::GetCursorScreenPos().x;
|
||||||
|
|
||||||
const char* cellLabels[4] = { TR("theme"), TR("balance_layout"), TR("language"), TR("clock_format") };
|
const char* cellLabels[4] = { TR("theme"), TR("balance_layout"), TR("language"), TR("clock_format") };
|
||||||
@@ -1346,7 +1346,7 @@ void RenderSettingsPage(App* app) {
|
|||||||
ImGui::SetCursorScreenPos(ImVec2(cx, last + gp));
|
ImGui::SetCursorScreenPos(ImVec2(cx, last + gp));
|
||||||
ImGui::PushTextWrapPos((cx + cw) - ImGui::GetWindowPos().x); // wrap to the card content width
|
ImGui::PushTextWrapPos((cx + cw) - ImGui::GetWindowPos().x); // wrap to the card content width
|
||||||
if (app->daemonAutoShieldActive()) {
|
if (app->daemonAutoShieldActive()) {
|
||||||
ImGui::TextColored(ImVec4(0.40f, 0.78f, 0.40f, 1.0f), "%s", TR("autoshield_by_node"));
|
ImGui::TextColored(material::SuccessVec4(), "%s", TR("autoshield_by_node"));
|
||||||
if (!app->daemonAutoShieldAddress().empty())
|
if (!app->daemonAutoShieldAddress().empty())
|
||||||
ImGui::TextDisabled(" %s", app->daemonAutoShieldAddress().c_str());
|
ImGui::TextDisabled(" %s", app->daemonAutoShieldAddress().c_str());
|
||||||
} else {
|
} else {
|
||||||
@@ -1490,6 +1490,9 @@ void RenderSettingsPage(App* app) {
|
|||||||
last = cy + bh; cy += bh + bgp;
|
last = cy + bh; cy += bh + bgp;
|
||||||
if (cardBtn(cx, cy, cw, "##imp_vk", TR("settings_import_viewkey"), ICON_MD_VISIBILITY)) app->showImportViewingKeyDialog();
|
if (cardBtn(cx, cy, cw, "##imp_vk", TR("settings_import_viewkey"), ICON_MD_VISIBILITY)) app->showImportViewingKeyDialog();
|
||||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_import_viewkey"));
|
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_import_viewkey"));
|
||||||
|
last = cy + bh; cy += bh + bgp;
|
||||||
|
if (cardBtn(cx, cy, cw, "##imp_file", TR("settings_import_wallet_file"), ICON_MD_UPLOAD_FILE)) app->showImportWalletFileDialog();
|
||||||
|
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_import_wallet_file"));
|
||||||
last = cy + bh;
|
last = cy + bh;
|
||||||
if (fullNode) {
|
if (fullNode) {
|
||||||
cy += bh + bgp;
|
cy += bh + bgp;
|
||||||
@@ -1649,20 +1652,20 @@ void RenderSettingsPage(App* app) {
|
|||||||
ImGui::SameLine(0, Layout::spacingMd());
|
ImGui::SameLine(0, Layout::spacingMd());
|
||||||
if (isLocked) {
|
if (isLocked) {
|
||||||
ImGui::PushFont(Type().iconSmall());
|
ImGui::PushFont(Type().iconSmall());
|
||||||
ImGui::TextColored(ImVec4(1,0.7f,0.3f,1.0f), ICON_MD_LOCK);
|
ImGui::TextColored(material::WarningVec4(), ICON_MD_LOCK);
|
||||||
ImGui::PopFont();
|
ImGui::PopFont();
|
||||||
ImGui::SameLine(0, Layout::spacingXs());
|
ImGui::SameLine(0, Layout::spacingXs());
|
||||||
ImGui::TextColored(ImVec4(1,0.7f,0.3f,1.0f), "%s", TR("settings_locked"));
|
ImGui::TextColored(material::WarningVec4(), "%s", TR("settings_locked"));
|
||||||
} else {
|
} else {
|
||||||
if (TactileButton(TR("settings_lock_now"), ImVec2(secBtnW, 0), S.resolveFont("button")))
|
if (TactileButton(TR("settings_lock_now"), ImVec2(secBtnW, 0), S.resolveFont("button")))
|
||||||
app->lockWallet();
|
app->lockWallet();
|
||||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_lock"));
|
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_lock"));
|
||||||
ImGui::SameLine(0, Layout::spacingSm());
|
ImGui::SameLine(0, Layout::spacingSm());
|
||||||
ImGui::PushFont(Type().iconSmall());
|
ImGui::PushFont(Type().iconSmall());
|
||||||
ImGui::TextColored(ImVec4(0.3f,1.0f,0.5f,1.0f), ICON_MD_LOCK_OPEN);
|
ImGui::TextColored(material::SuccessVec4(), ICON_MD_LOCK_OPEN);
|
||||||
ImGui::PopFont();
|
ImGui::PopFont();
|
||||||
ImGui::SameLine(0, Layout::spacingXs());
|
ImGui::SameLine(0, Layout::spacingXs());
|
||||||
ImGui::TextColored(ImVec4(0.3f,1.0f,0.5f,1.0f), "%s", TR("settings_unlocked"));
|
ImGui::TextColored(material::SuccessVec4(), "%s", TR("settings_unlocked"));
|
||||||
}
|
}
|
||||||
// Remove Encryption button — trails the row, or wraps below
|
// Remove Encryption button — trails the row, or wraps below
|
||||||
// when the column is too narrow for three buttons + status.
|
// when the column is too narrow for three buttons + status.
|
||||||
@@ -1708,7 +1711,11 @@ void RenderSettingsPage(App* app) {
|
|||||||
|
|
||||||
// PIN unlock controls, trailing the auto-lock combo on the same row.
|
// PIN unlock controls, trailing the auto-lock combo on the same row.
|
||||||
bool isEncryptedPIN = app->state().isEncrypted();
|
bool isEncryptedPIN = app->state().isEncrypted();
|
||||||
if (isEncryptedPIN) {
|
// Lite has no PIN quick-unlock: its unlock paths (Settings, send-time prompt) take the passphrase
|
||||||
|
// directly and never consult a PIN vault, so these controls would be dead. Hide them.
|
||||||
|
if (app->isLiteBuild()) {
|
||||||
|
// nothing to show
|
||||||
|
} else if (isEncryptedPIN) {
|
||||||
bool hasPIN = app->hasPinVault();
|
bool hasPIN = app->hasPinVault();
|
||||||
float pinBtnW = std::min(rowBtnW({TR("settings_set_pin"), TR("settings_change_pin"), TR("settings_remove_pin")}), (secColW - Layout::spacingSm()) * 0.5f);
|
float pinBtnW = std::min(rowBtnW({TR("settings_set_pin"), TR("settings_change_pin"), TR("settings_remove_pin")}), (secColW - Layout::spacingSm()) * 0.5f);
|
||||||
|
|
||||||
@@ -1756,10 +1763,10 @@ void RenderSettingsPage(App* app) {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
ImGui::PushFont(Type().iconSmall());
|
ImGui::PushFont(Type().iconSmall());
|
||||||
ImGui::TextColored(ImVec4(0.3f,1.0f,0.5f,1.0f), ICON_MD_DIALPAD);
|
ImGui::TextColored(material::SuccessVec4(), ICON_MD_DIALPAD);
|
||||||
ImGui::PopFont();
|
ImGui::PopFont();
|
||||||
ImGui::SameLine(0, Layout::spacingXs());
|
ImGui::SameLine(0, Layout::spacingXs());
|
||||||
ImGui::TextColored(ImVec4(0.3f,1.0f,0.5f,1.0f), "%s", TR("settings_pin_active"));
|
ImGui::TextColored(material::SuccessVec4(), "%s", TR("settings_pin_active"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -2172,9 +2179,10 @@ void RenderSettingsPage(App* app) {
|
|||||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_lite_unlock_pass"));
|
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_lite_unlock_pass"));
|
||||||
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
|
ImGui::SetCursorScreenPos(ImVec2(leftX, ImGui::GetCursorScreenPos().y));
|
||||||
if (TactileButton(TrId("lite_unlock", "LiteUnlock").c_str(), ImVec2(0, 0), S.resolveFont("button"))) {
|
if (TactileButton(TrId("lite_unlock", "LiteUnlock").c_str(), ImVec2(0, 0), S.resolveFont("button"))) {
|
||||||
const bool ok = app->liteWallet()->unlockWallet(s_settingsState.lite_enc_pass);
|
std::string unlockErr;
|
||||||
|
const bool ok = app->unlockLiteWallet(std::string(s_settingsState.lite_enc_pass), &unlockErr);
|
||||||
sodium_memzero(s_settingsState.lite_enc_pass, sizeof(s_settingsState.lite_enc_pass));
|
sodium_memzero(s_settingsState.lite_enc_pass, sizeof(s_settingsState.lite_enc_pass));
|
||||||
s_settingsState.lite_encryption_status = ok ? TR("lite_wallet_unlocked") : TR("lite_unlock_failed");
|
s_settingsState.lite_encryption_status = ok ? std::string(TR("lite_wallet_unlocked")) : unlockErr;
|
||||||
}
|
}
|
||||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_lite_unlock"));
|
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_lite_unlock"));
|
||||||
} else {
|
} else {
|
||||||
@@ -2331,8 +2339,10 @@ void RenderSettingsPage(App* app) {
|
|||||||
cs.hoverBg = material::WithAlpha(OnSurface(), 30);
|
cs.hoverBg = material::WithAlpha(OnSurface(), 30);
|
||||||
cs.tooltip = TR("copy");
|
cs.tooltip = TR("copy");
|
||||||
const float ih = ImGui::GetFrameHeight();
|
const float ih = ImGui::GetFrameHeight();
|
||||||
if (material::IconButton("##copydir", ICON_MD_CONTENT_COPY, Type().iconSmall(), ImVec2(ih, ih), cs))
|
if (material::IconButton("##copydir", ICON_MD_CONTENT_COPY, Type().iconSmall(), ImVec2(ih, ih), cs)) {
|
||||||
ImGui::SetClipboardText(dirPath.c_str());
|
ImGui::SetClipboardText(dirPath.c_str());
|
||||||
|
Notifications::instance().info(TR("copied_to_clipboard"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Row 2: Wallet size — label left, value RIGHT-aligned.
|
// Row 2: Wallet size — label left, value RIGHT-aligned.
|
||||||
@@ -2595,6 +2605,21 @@ void RenderSettingsPage(App* app) {
|
|||||||
if (ui::DaemonUpdateDialog::consumeInstalled())
|
if (ui::DaemonUpdateDialog::consumeInstalled())
|
||||||
s_settingsState.daemon_info_loaded = false;
|
s_settingsState.daemon_info_loaded = false;
|
||||||
|
|
||||||
|
// Persistent reminder: a newer daemon binary is installed but the running node is still
|
||||||
|
// the old one until a restart (easy to miss on the updater's Done screen).
|
||||||
|
if (app->daemonRestartPending()) {
|
||||||
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||||
|
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + contentW);
|
||||||
|
Type().textColored(TypeStyle::Caption, material::Warning(), TR("daemon_restart_pending"));
|
||||||
|
ImGui::PopTextWrapPos();
|
||||||
|
if (app->isUsingEmbeddedDaemon()) {
|
||||||
|
if (material::ActionButton("##drestartpend", TR("daemon_update_restart_now"),
|
||||||
|
ICON_MD_RESTART_ALT, material::ActionTier::Primary))
|
||||||
|
app->restartDaemon();
|
||||||
|
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_daemon_restart_pending"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- UPDATES ---
|
// --- UPDATES ---
|
||||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||||
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("daemon_updates_label"));
|
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("daemon_updates_label"));
|
||||||
@@ -2972,7 +2997,7 @@ void RenderSettingsPage(App* app) {
|
|||||||
s_settingsState.stratum_allowip, sizeof(s_settingsState.stratum_allowip)))
|
s_settingsState.stratum_allowip, sizeof(s_settingsState.stratum_allowip)))
|
||||||
saveSettingsPageState(app->settings());
|
saveSettingsPageState(app->settings());
|
||||||
if (s_settingsState.stratum_allowip[0] != '\0')
|
if (s_settingsState.stratum_allowip[0] != '\0')
|
||||||
ImGui::TextColored(ImVec4(0.95f, 0.75f, 0.25f, 1.0f), " %s",
|
ImGui::TextColored(material::WarningVec4(), " %s",
|
||||||
TR("stratum_expose_warn"));
|
TR("stratum_expose_warn"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3001,14 +3026,23 @@ void RenderSettingsPage(App* app) {
|
|||||||
s_settingsState.debug_gate_verifying = false;
|
s_settingsState.debug_gate_verifying = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", s_settingsState.debug_expanded ? TR("tt_debug_collapse") : TR("tt_debug_expand"));
|
if (ImGui::IsItemHovered()) material::Tooltip("%s", s_settingsState.debug_expanded ? TR("tt_debug_collapse")
|
||||||
|
: (s_settingsState.debug_gate_passed ? TR("tt_debug_expand") : TR("tt_debug_expand_gated")));
|
||||||
ImGui::PopStyleColor(3);
|
ImGui::PopStyleColor(3);
|
||||||
|
|
||||||
// Draw overline label + arrow on top of the invisible button
|
// Draw overline label + arrow on top of the invisible button
|
||||||
{
|
{
|
||||||
ImFont* ovFont = Type().overline();
|
ImFont* ovFont = Type().overline();
|
||||||
float textY = headerPos.y + (ImGui::GetFrameHeight() - ovFont->LegacySize) * 0.5f;
|
float textY = headerPos.y + (ImGui::GetFrameHeight() - ovFont->LegacySize) * 0.5f;
|
||||||
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(headerPos.x, textY), OnSurfaceMedium(), TR("debug_logging"));
|
float labelX = headerPos.x;
|
||||||
|
// Show a lock beside the label while the session gate hasn't been passed, so it's clear the
|
||||||
|
// section is gated rather than just a bare collapsible header.
|
||||||
|
if (!s_settingsState.debug_gate_passed) {
|
||||||
|
ImFont* lockFont = Type().iconSmall(); if (!lockFont) lockFont = ovFont;
|
||||||
|
dl->AddText(lockFont, lockFont->LegacySize, ImVec2(labelX, textY), OnSurfaceMedium(), ICON_MD_LOCK);
|
||||||
|
labelX += lockFont->CalcTextSizeA(lockFont->LegacySize, FLT_MAX, 0, ICON_MD_LOCK).x + Layout::spacingXs();
|
||||||
|
}
|
||||||
|
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(labelX, textY), OnSurfaceMedium(), TR("debug_logging"));
|
||||||
// Use the icon font for the expand/collapse arrow
|
// Use the icon font for the expand/collapse arrow
|
||||||
ImFont* iconFont = Type().iconSmall();
|
ImFont* iconFont = Type().iconSmall();
|
||||||
if (!iconFont) iconFont = ovFont;
|
if (!iconFont) iconFont = ovFont;
|
||||||
@@ -3016,6 +3050,14 @@ void RenderSettingsPage(App* app) {
|
|||||||
dl->AddText(iconFont, iconFont->LegacySize, ImVec2(headerPos.x + availWidth - arrowW - pad, textY), OnSurfaceMedium(), arrow);
|
dl->AddText(iconFont, iconFont->LegacySize, ImVec2(headerPos.x + availWidth - arrowW - pad, textY), OnSurfaceMedium(), arrow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One-line explanation under the collapsed header (what it is + that it's gated).
|
||||||
|
if (!s_settingsState.debug_expanded) {
|
||||||
|
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(),
|
||||||
|
s_settingsState.debug_gate_passed ? TR("debug_options_caption_unlocked")
|
||||||
|
: TR("debug_options_caption_gated"));
|
||||||
|
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
||||||
|
}
|
||||||
|
|
||||||
if (s_settingsState.debug_expanded) {
|
if (s_settingsState.debug_expanded) {
|
||||||
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
||||||
|
|
||||||
@@ -3059,9 +3101,33 @@ void RenderSettingsPage(App* app) {
|
|||||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
ImGui::TextColored(ImVec4(1,1,1,0.5f), "%s", TR("settings_debug_select"));
|
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
|
||||||
ImGui::TextColored(ImVec4(1,1,1,0.35f), "%s", TR("settings_debug_restart_note"));
|
ImGui::TextUnformatted(TR("settings_debug_select"));
|
||||||
|
ImGui::TextWrapped("%s", TR("settings_debug_restart_note")); // was near-invisible 35% white
|
||||||
|
ImGui::PopStyleColor();
|
||||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||||
|
// Restart prompt moved directly under the note so the required action is prominent (appears the
|
||||||
|
// frame after a category toggles, which sets debug_cats_dirty while rendering the grid below).
|
||||||
|
if (s_settingsState.debug_cats_dirty && app->supportsFullNodeLifecycleActions()) {
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, material::Warning());
|
||||||
|
ImFont* iconFont = Type().iconSmall();
|
||||||
|
if (iconFont) {
|
||||||
|
ImGui::PushFont(iconFont);
|
||||||
|
ImGui::AlignTextToFramePadding();
|
||||||
|
ImGui::TextUnformatted(ICON_MD_INFO);
|
||||||
|
ImGui::PopFont();
|
||||||
|
ImGui::SameLine(0, Layout::spacingXs());
|
||||||
|
}
|
||||||
|
ImGui::AlignTextToFramePadding();
|
||||||
|
ImGui::TextUnformatted(TR("settings_debug_changed"));
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
ImGui::SameLine();
|
||||||
|
if (TactileButton(TR("settings_restart_daemon"), ImVec2(0, 0), S.resolveFont("button"))) {
|
||||||
|
s_settingsState.confirm_restart_daemon = true;
|
||||||
|
}
|
||||||
|
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_restart_daemon"));
|
||||||
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||||
|
}
|
||||||
|
|
||||||
// The 22 dragonxd debug categories
|
// The 22 dragonxd debug categories
|
||||||
static const char* debugCats[] = {
|
static const char* debugCats[] = {
|
||||||
@@ -3119,27 +3185,7 @@ void RenderSettingsPage(App* app) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||||
|
// (The "restart daemon" prompt was moved up to directly under the restart note above.)
|
||||||
// "Restart daemon" button — only active when categories changed
|
|
||||||
if (s_settingsState.debug_cats_dirty && app->supportsFullNodeLifecycleActions()) {
|
|
||||||
ImGui::PushStyleColor(ImGuiCol_Text, IM_COL32(255, 218, 0, 255));
|
|
||||||
ImFont* iconFont = Type().iconSmall();
|
|
||||||
if (iconFont) {
|
|
||||||
ImGui::PushFont(iconFont);
|
|
||||||
ImGui::AlignTextToFramePadding();
|
|
||||||
ImGui::TextUnformatted(ICON_MD_INFO);
|
|
||||||
ImGui::PopFont();
|
|
||||||
ImGui::SameLine(0, Layout::spacingXs());
|
|
||||||
}
|
|
||||||
ImGui::AlignTextToFramePadding();
|
|
||||||
ImGui::TextUnformatted(TR("settings_debug_changed"));
|
|
||||||
ImGui::PopStyleColor();
|
|
||||||
ImGui::SameLine();
|
|
||||||
if (TactileButton(TR("settings_restart_daemon"), ImVec2(0, 0), S.resolveFont("button"))) {
|
|
||||||
s_settingsState.confirm_restart_daemon = true;
|
|
||||||
}
|
|
||||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_restart_daemon"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Debug-options gate: confirmation + warning (+ re-auth if a PIN/passphrase is set) ----
|
// ---- Debug-options gate: confirmation + warning (+ re-auth if a PIN/passphrase is set) ----
|
||||||
@@ -3287,12 +3333,12 @@ void RenderSettingsPage(App* app) {
|
|||||||
// Confirmation dialog for deleting blockchain data
|
// Confirmation dialog for deleting blockchain data
|
||||||
if (s_settingsState.confirm_delete_blockchain) {
|
if (s_settingsState.confirm_delete_blockchain) {
|
||||||
if (BeginOverlayDialog(TR("confirm_delete_blockchain_title"), &s_settingsState.confirm_delete_blockchain, 500.0f, 0.94f)) {
|
if (BeginOverlayDialog(TR("confirm_delete_blockchain_title"), &s_settingsState.confirm_delete_blockchain, 500.0f, 0.94f)) {
|
||||||
material::DialogWarningHeader(TR("warning"), ImVec4(1.0f, 0.3f, 0.3f, 1.0f));
|
material::DialogWarningHeader(TR("warning"), material::ErrorVec4());
|
||||||
|
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::TextWrapped("%s", TR("confirm_delete_blockchain_msg"));
|
ImGui::TextWrapped("%s", TR("confirm_delete_blockchain_msg"));
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s", TR("confirm_delete_blockchain_safe"));
|
ImGui::TextColored(material::SuccessVec4(), "%s", TR("confirm_delete_blockchain_safe"));
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::Separator();
|
ImGui::Separator();
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
@@ -3334,7 +3380,7 @@ void RenderSettingsPage(App* app) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (BeginOverlayDialog(TR("confirm_rescan_title"), &s_settingsState.confirm_rescan, 500.0f, 0.94f)) {
|
if (BeginOverlayDialog(TR("confirm_rescan_title"), &s_settingsState.confirm_rescan, 500.0f, 0.94f)) {
|
||||||
material::DialogWarningHeader(TR("warning"), ImVec4(1.0f, 0.8f, 0.2f, 1.0f));
|
material::DialogWarningHeader(TR("warning"), material::WarningVec4());
|
||||||
|
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
|
|
||||||
@@ -3353,14 +3399,14 @@ void RenderSettingsPage(App* app) {
|
|||||||
} else {
|
} else {
|
||||||
if (s_settingsState.rescan_probe_failed) {
|
if (s_settingsState.rescan_probe_failed) {
|
||||||
// A long line: TextColored does not wrap, so colour a wrapped one.
|
// A long line: TextColored does not wrap, so colour a wrapped one.
|
||||||
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.8f, 0.2f, 1.0f));
|
ImGui::PushStyleColor(ImGuiCol_Text, material::WarningVec4());
|
||||||
ImGui::TextWrapped("%s", TR("rescan_probe_failed"));
|
ImGui::TextWrapped("%s", TR("rescan_probe_failed"));
|
||||||
ImGui::PopStyleColor();
|
ImGui::PopStyleColor();
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
}
|
}
|
||||||
ImGui::TextWrapped("%s", TR("confirm_rescan_msg"));
|
ImGui::TextWrapped("%s", TR("confirm_rescan_msg"));
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s", TR("confirm_rescan_safe"));
|
ImGui::TextColored(material::SuccessVec4(), "%s", TR("confirm_rescan_safe"));
|
||||||
}
|
}
|
||||||
|
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
@@ -3389,12 +3435,12 @@ void RenderSettingsPage(App* app) {
|
|||||||
// Confirm: repair wallet (-zapwallettxes=2 — wipe & rebuild wallet tx records, then rescan)
|
// Confirm: repair wallet (-zapwallettxes=2 — wipe & rebuild wallet tx records, then rescan)
|
||||||
if (s_settingsState.confirm_repair_wallet) {
|
if (s_settingsState.confirm_repair_wallet) {
|
||||||
if (BeginOverlayDialog(TR("confirm_repair_wallet_title"), &s_settingsState.confirm_repair_wallet, 500.0f, 0.94f)) {
|
if (BeginOverlayDialog(TR("confirm_repair_wallet_title"), &s_settingsState.confirm_repair_wallet, 500.0f, 0.94f)) {
|
||||||
material::DialogWarningHeader(TR("warning"), ImVec4(1.0f, 0.8f, 0.2f, 1.0f));
|
material::DialogWarningHeader(TR("warning"), material::WarningVec4());
|
||||||
|
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::TextWrapped("%s", TR("confirm_repair_wallet_msg"));
|
ImGui::TextWrapped("%s", TR("confirm_repair_wallet_msg"));
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s", TR("confirm_repair_wallet_safe"));
|
ImGui::TextColored(material::SuccessVec4(), "%s", TR("confirm_repair_wallet_safe"));
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::Separator();
|
ImGui::Separator();
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
@@ -3417,12 +3463,12 @@ void RenderSettingsPage(App* app) {
|
|||||||
// Confirm: reinstall the bundled daemon binary (stop → overwrite → restart)
|
// Confirm: reinstall the bundled daemon binary (stop → overwrite → restart)
|
||||||
if (s_settingsState.confirm_reinstall_daemon) {
|
if (s_settingsState.confirm_reinstall_daemon) {
|
||||||
if (BeginOverlayDialog(TR("confirm_reinstall_daemon_title"), &s_settingsState.confirm_reinstall_daemon, 500.0f, 0.94f)) {
|
if (BeginOverlayDialog(TR("confirm_reinstall_daemon_title"), &s_settingsState.confirm_reinstall_daemon, 500.0f, 0.94f)) {
|
||||||
material::DialogWarningHeader(TR("warning"), ImVec4(1.0f, 0.8f, 0.2f, 1.0f));
|
material::DialogWarningHeader(TR("warning"), material::WarningVec4());
|
||||||
|
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::TextWrapped("%s", TR("confirm_reinstall_daemon_msg"));
|
ImGui::TextWrapped("%s", TR("confirm_reinstall_daemon_msg"));
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s", TR("confirm_reinstall_daemon_safe"));
|
ImGui::TextColored(material::SuccessVec4(), "%s", TR("confirm_reinstall_daemon_safe"));
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::Separator();
|
ImGui::Separator();
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
@@ -3446,7 +3492,7 @@ void RenderSettingsPage(App* app) {
|
|||||||
// Confirm: restart daemon (briefly drops the connection to apply changed options)
|
// Confirm: restart daemon (briefly drops the connection to apply changed options)
|
||||||
if (s_settingsState.confirm_restart_daemon) {
|
if (s_settingsState.confirm_restart_daemon) {
|
||||||
if (BeginOverlayDialog(TR("confirm_restart_daemon_title"), &s_settingsState.confirm_restart_daemon, 500.0f, 0.94f)) {
|
if (BeginOverlayDialog(TR("confirm_restart_daemon_title"), &s_settingsState.confirm_restart_daemon, 500.0f, 0.94f)) {
|
||||||
material::DialogWarningHeader(TR("warning"), ImVec4(1.0f, 0.8f, 0.2f, 1.0f));
|
material::DialogWarningHeader(TR("warning"), material::WarningVec4());
|
||||||
|
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::TextWrapped("%s", TR("confirm_restart_daemon_msg"));
|
ImGui::TextWrapped("%s", TR("confirm_restart_daemon_msg"));
|
||||||
@@ -3473,12 +3519,12 @@ void RenderSettingsPage(App* app) {
|
|||||||
// Confirm: lite wallet re-download blocks (rescan from the lite server — long but safe)
|
// Confirm: lite wallet re-download blocks (rescan from the lite server — long but safe)
|
||||||
if (s_settingsState.confirm_lite_redownload) {
|
if (s_settingsState.confirm_lite_redownload) {
|
||||||
if (BeginOverlayDialog(TR("confirm_lite_redownload_title"), &s_settingsState.confirm_lite_redownload, 500.0f, 0.94f)) {
|
if (BeginOverlayDialog(TR("confirm_lite_redownload_title"), &s_settingsState.confirm_lite_redownload, 500.0f, 0.94f)) {
|
||||||
material::DialogWarningHeader(TR("warning"), ImVec4(1.0f, 0.8f, 0.2f, 1.0f));
|
material::DialogWarningHeader(TR("warning"), material::WarningVec4());
|
||||||
|
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::TextWrapped("%s", TR("confirm_lite_redownload_msg"));
|
ImGui::TextWrapped("%s", TR("confirm_lite_redownload_msg"));
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s", TR("confirm_lite_redownload_safe"));
|
ImGui::TextColored(material::SuccessVec4(), "%s", TR("confirm_lite_redownload_safe"));
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::Separator();
|
ImGui::Separator();
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ void RenderAboutDialog(App* app, bool* p_open)
|
|||||||
#ifdef DRAGONX_DEBUG
|
#ifdef DRAGONX_DEBUG
|
||||||
ImGui::Text("%s", TR("about_build_type"));
|
ImGui::Text("%s", TR("about_build_type"));
|
||||||
ImGui::SameLine(valX);
|
ImGui::SameLine(valX);
|
||||||
ImGui::TextColored(ImVec4(1.0f, 0.6f, 0.0f, 1.0f), "%s", TR("about_debug"));
|
ImGui::TextColored(material::WarningVec4(), "%s", TR("about_debug"));
|
||||||
#else
|
#else
|
||||||
ImGui::Text("%s", TR("about_build_type"));
|
ImGui::Text("%s", TR("about_build_type"));
|
||||||
ImGui::SameLine(valX);
|
ImGui::SameLine(valX);
|
||||||
@@ -92,7 +92,7 @@ void RenderAboutDialog(App* app, bool* p_open)
|
|||||||
|
|
||||||
ImGui::Text("%s", TR("about_daemon"));
|
ImGui::Text("%s", TR("about_daemon"));
|
||||||
ImGui::SameLine(valX);
|
ImGui::SameLine(valX);
|
||||||
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s", TR("connected"));
|
ImGui::TextColored(material::SuccessVec4(), "%s", TR("connected"));
|
||||||
|
|
||||||
const auto& state = app->getWalletState();
|
const auto& state = app->getWalletState();
|
||||||
ImGui::Text("%s", TR("about_chain"));
|
ImGui::Text("%s", TR("about_chain"));
|
||||||
|
|||||||
+447
-21
@@ -63,15 +63,18 @@ int s_msgsel_head = 0; // drag-current byte offset
|
|||||||
bool s_msgsel_dragging = false;
|
bool s_msgsel_dragging = false;
|
||||||
|
|
||||||
// Composer + new-conversation UI state.
|
// Composer + new-conversation UI state.
|
||||||
char s_compose[512] = "";
|
char s_compose[4100] = ""; // must be >= kChatBodyMaxBytes + 1 (static_assert'd below)
|
||||||
std::string s_compose_cid; // the conversation s_compose is a draft for; draft is wiped when it changes
|
std::string s_compose_cid; // the conversation s_compose is a draft for; draft is wiped when it changes
|
||||||
// Live byte offset of the composer's text caret, kept in sync by composeInputCallback while the composer
|
// Live byte offset of the composer's text caret, kept in sync by composeInputCallback while the composer
|
||||||
// is active (the callback only fires then). The emoji picker uses it to splice a glyph at the cursor
|
// is active (the callback only fires then). The emoji picker uses it to splice a glyph at the cursor
|
||||||
// instead of always appending. -1 = unknown/never-focused => append at the end.
|
// instead of always appending. -1 = unknown/never-focused => append at the end.
|
||||||
int s_composeCursor = -1;
|
int s_composeCursor = -1;
|
||||||
// On-chain chat body cap in bytes = (512 − len("utf8:"))/2 − secretstream ABYTES (see chat_outgoing.cpp).
|
// On-chain chat body cap in bytes. Larger-messages tier 2 splits the ciphertext across up to
|
||||||
// The composer hard-caps input to this; the emoji picker respects it too.
|
// kHushChatMaxMessageParts (16) memo outputs in one tx, so the body can be a few KB (protocol max ~4063:
|
||||||
constexpr int kChatBodyMaxBytes = (512 - 5) / 2 - 17; // = 236
|
// 16 × 255 cipher bytes − 17 secretstream tag). Kept a touch under that so the composer never produces a
|
||||||
|
// body that compose would reject as TooLong. The composer hard-caps input here; the emoji picker too.
|
||||||
|
constexpr int kChatBodyMaxBytes = 4000;
|
||||||
|
static_assert(sizeof(s_compose) >= kChatBodyMaxBytes + 1, "compose buffer too small for the body cap");
|
||||||
float s_composerAnimH = 0.0f; // animated composer-box height — grows with newlines, pushing the thread up
|
float s_composerAnimH = 0.0f; // animated composer-box height — grows with newlines, pushing the thread up
|
||||||
float s_composerTargetH = 0.0f; // target height measured in the composer block (wrapped content), consumed next frame
|
float s_composerTargetH = 0.0f; // target height measured in the composer block (wrapped content), consumed next frame
|
||||||
|
|
||||||
@@ -104,8 +107,19 @@ bool s_show_delete_confirm = false; // "Delete conversation?" confirm overlay (
|
|||||||
std::string s_delete_cid; // conversation targeted by the delete confirm
|
std::string s_delete_cid; // conversation targeted by the delete confirm
|
||||||
std::string s_delete_name; // its peer name (for the confirm copy / block-list label)
|
std::string s_delete_name; // its peer name (for the confirm copy / block-list label)
|
||||||
bool s_show_blocked = false; // blocked-conversations manager overlay (unblock)
|
bool s_show_blocked = false; // blocked-conversations manager overlay (unblock)
|
||||||
|
bool s_show_restore = false; // restore-conversations overlay (reverse an on-chain delete)
|
||||||
|
char s_restore_addr[128] = ""; // paste-a-chat-address buffer in the restore overlay
|
||||||
|
bool s_show_sensitive = false; // sensitive mark/unmark/setup/unlock overlay
|
||||||
|
std::string s_sensitive_cid; // target conversation (empty => "unlock the vault only", from the opener)
|
||||||
|
std::string s_sensitive_name; // peer display name (for the label)
|
||||||
|
std::string s_sensitive_peeraddr; // peer z-address (carried as the control label, for restore-by-address parity)
|
||||||
|
bool s_sensitive_unmark = false; // true => the dialog is UNmarking a currently-sensitive conversation
|
||||||
|
char s_sensitive_pass[128] = ""; // passphrase (setup / unlock)
|
||||||
|
char s_sensitive_pass2[128] = ""; // confirm passphrase (setup only)
|
||||||
bool s_show_export_confirm = false; // "export as plaintext" confirm overlay
|
bool s_show_export_confirm = false; // "export as plaintext" confirm overlay
|
||||||
std::string s_export_cid; // conversation targeted by the export confirm
|
std::string s_export_cid; // conversation targeted by the export confirm
|
||||||
|
bool s_show_fingerprint = false; // key-fingerprint verification dialog
|
||||||
|
std::string s_fingerprint_cid; // conversation targeted by the fingerprint dialog
|
||||||
|
|
||||||
// Export file path for a conversation (the same scheme the inline export used).
|
// Export file path for a conversation (the same scheme the inline export used).
|
||||||
static std::string chatExportPathFor(const std::string& peerName) {
|
static std::string chatExportPathFor(const std::string& peerName) {
|
||||||
@@ -589,6 +603,17 @@ void centeredHint(const char* text) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Centered empty state: big muted icon + title + optional wrapped hint (V4).
|
// Centered empty state: big muted icon + title + optional wrapped hint (V4).
|
||||||
|
// Copy the user's own chat address to the clipboard (shared by the sidebar button and the intro card).
|
||||||
|
void copyMyChatAddress(App* app) {
|
||||||
|
std::string myAddr = app->chatMyAddress();
|
||||||
|
if (myAddr.empty()) {
|
||||||
|
Notifications::instance().warning(TR("chat_no_my_address"));
|
||||||
|
} else {
|
||||||
|
ImGui::SetClipboardText(myAddr.c_str());
|
||||||
|
Notifications::instance().info(TR("address_copied"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void centeredEmptyState(const char* icon, const char* title, const char* hint) {
|
void centeredEmptyState(const char* icon, const char* title, const char* hint) {
|
||||||
const ImVec2 avail = ImGui::GetContentRegionAvail();
|
const ImVec2 avail = ImGui::GetContentRegionAvail();
|
||||||
const ImVec2 origin = ImGui::GetCursorPos();
|
const ImVec2 origin = ImGui::GetCursorPos();
|
||||||
@@ -948,21 +973,54 @@ void RenderChatTab(App* app)
|
|||||||
if (ImGui::SmallButton(bl.c_str())) s_show_blocked = true;
|
if (ImGui::SmallButton(bl.c_str())) s_show_blocked = true;
|
||||||
ImGui::PopStyleColor();
|
ImGui::PopStyleColor();
|
||||||
}
|
}
|
||||||
|
// Restore opener — reverse an on-chain "Delete everywhere". Shown when a delete is known from chain
|
||||||
|
// (works on a fresh seed import, which has no local state) or something is locally blocked; the
|
||||||
|
// dialog also offers paste-by-address + a quick scan for deletes made on another device.
|
||||||
|
const int onChainDelCount = (int)app->chatService().onChainDeletedConversations().size();
|
||||||
|
if (onChainDelCount > 0 || blockedCount > 0) {
|
||||||
|
if (blockedCount > 0) ImGui::SameLine();
|
||||||
|
const std::string rl = onChainDelCount > 0
|
||||||
|
? std::string(TR("chat_restore_manage")) + " (" + std::to_string(onChainDelCount) + ")"
|
||||||
|
: std::string(TR("chat_restore_manage"));
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
|
||||||
|
if (ImGui::SmallButton(rl.c_str())) s_show_restore = true;
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
}
|
||||||
|
// Sensitive-vault opener: lock / unlock the local key for sensitive conversations. Shown only once
|
||||||
|
// a vault exists (it's created on the first "Make sensitive"). While locked it shows the count of
|
||||||
|
// sensitive conversations currently withheld.
|
||||||
|
{
|
||||||
|
const auto vs = app->chatSensitiveVaultState();
|
||||||
|
if (vs != App::ChatVaultState::NotConfigured) {
|
||||||
|
if (blockedCount > 0 || onChainDelCount > 0) ImGui::SameLine();
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
|
||||||
|
if (vs == App::ChatVaultState::Unlocked) {
|
||||||
|
if (ImGui::SmallButton(TR("chat_sensitive_lock_btn"))) {
|
||||||
|
app->lockChatSensitiveVault();
|
||||||
|
s_convsKey = ~0ull; // sensitive threads vanish — rebuild the list
|
||||||
|
}
|
||||||
|
} else { // Locked
|
||||||
|
const int sn = app->settings() ? (int)app->settings()->sensitiveChatConversations().size() : 0;
|
||||||
|
const std::string ul = sn > 0
|
||||||
|
? std::string(TR("chat_sensitive_unlock_btn")) + " (" + std::to_string(sn) + ")"
|
||||||
|
: std::string(TR("chat_sensitive_unlock_btn"));
|
||||||
|
if (ImGui::SmallButton(ul.c_str())) {
|
||||||
|
s_sensitive_cid.clear(); s_sensitive_name.clear(); s_sensitive_peeraddr.clear();
|
||||||
|
s_sensitive_unmark = false;
|
||||||
|
s_sensitive_pass[0] = '\0'; s_sensitive_pass2[0] = '\0';
|
||||||
|
s_show_sensitive = true; // empty cid => unlock-only
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
}
|
||||||
|
}
|
||||||
// "Copy my chat address" — the address others use to reach this user. The conversation header
|
// "Copy my chat address" — the address others use to reach this user. The conversation header
|
||||||
// only copies a PEER's address, so without this there's no way to hand out your own. Copies on
|
// only copies a PEER's address, so without this there's no way to hand out your own. Copies on
|
||||||
// click only (chatMyAddress may persist a freshly chosen address).
|
// click only (chatMyAddress may persist a freshly chosen address).
|
||||||
{
|
{
|
||||||
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
|
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
|
||||||
// Text-only: SmallButton uses the body font, which has no Material icon glyph (would show "?").
|
// Text-only: SmallButton uses the body font, which has no Material icon glyph (would show "?").
|
||||||
if (ImGui::SmallButton(TR("chat_copy_my_address"))) {
|
if (ImGui::SmallButton(TR("chat_copy_my_address"))) copyMyChatAddress(app);
|
||||||
std::string myAddr = app->chatMyAddress();
|
|
||||||
if (myAddr.empty()) {
|
|
||||||
Notifications::instance().warning(TR("chat_no_my_address"));
|
|
||||||
} else {
|
|
||||||
ImGui::SetClipboardText(myAddr.c_str());
|
|
||||||
Notifications::instance().info(TR("address_copied"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ImGui::PopStyleColor();
|
ImGui::PopStyleColor();
|
||||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("chat_my_address_tip"));
|
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("chat_my_address_tip"));
|
||||||
}
|
}
|
||||||
@@ -1151,7 +1209,7 @@ void RenderChatTab(App* app)
|
|||||||
// The toolbar's left edge is known up front (from the button count). A rename (edit) icon is
|
// The toolbar's left edge is known up front (from the button count). A rename (edit) icon is
|
||||||
// shown whenever there's an address to save the contact under; the settings "notch" gear is
|
// shown whenever there's an address to save the contact under; the settings "notch" gear is
|
||||||
// always the rightmost icon.
|
// always the rightmost icon.
|
||||||
const int nBtns = 5 + (hasAddr ? 1 : 0); // export, mute, hide, delete, settings (+rename)
|
const int nBtns = 6 + (hasAddr ? 1 : 0); // export, mute, hide, sensitive, delete, settings (+rename)
|
||||||
const float toolbarLeft = rightX - (nBtns * ib + (nBtns - 1) * gap);
|
const float toolbarLeft = rightX - (nBtns * ib + (nBtns - 1) * gap);
|
||||||
|
|
||||||
// Compact address + lock (or waiting-chip) metrics, reserved to the right of the name.
|
// Compact address + lock (or waiting-chip) metrics, reserved to the right of the name.
|
||||||
@@ -1294,6 +1352,24 @@ void RenderChatTab(App* app)
|
|||||||
}
|
}
|
||||||
bx += ib + gap;
|
bx += ib + gap;
|
||||||
}
|
}
|
||||||
|
// Sensitive — per-conversation local-key encryption. Opens a dialog that (by vault state)
|
||||||
|
// sets up / unlocks the vault and marks or unmarks this conversation.
|
||||||
|
{
|
||||||
|
ImGui::SetCursorScreenPos(ImVec2(bx, by));
|
||||||
|
material::IconButtonStyle a = base;
|
||||||
|
const bool isSens = app->chatService().isConversationSensitive(sel->cid);
|
||||||
|
a.tooltip = isSens ? TR("chat_sensitive_unmark_tip") : TR("chat_sensitive_mark_tip");
|
||||||
|
if (material::IconButton("##hdr_sensitive",
|
||||||
|
isSens ? ICON_MD_LOCK : ICON_MD_ENHANCED_ENCRYPTION, ifont, ImVec2(ib, ib), a)) {
|
||||||
|
s_sensitive_cid = sel->cid;
|
||||||
|
s_sensitive_name = sel->peerName;
|
||||||
|
s_sensitive_peeraddr = sel->peerZaddr;
|
||||||
|
s_sensitive_unmark = isSens;
|
||||||
|
s_sensitive_pass[0] = '\0'; s_sensitive_pass2[0] = '\0';
|
||||||
|
s_show_sensitive = true;
|
||||||
|
}
|
||||||
|
bx += ib + gap;
|
||||||
|
}
|
||||||
// Delete — clears this conversation's LOCAL history. Destructive (and offers a
|
// Delete — clears this conversation's LOCAL history. Destructive (and offers a
|
||||||
// "delete & block" variant), so it opens a confirm dialog rather than acting inline.
|
// "delete & block" variant), so it opens a confirm dialog rather than acting inline.
|
||||||
{
|
{
|
||||||
@@ -1324,13 +1400,22 @@ void RenderChatTab(App* app)
|
|||||||
// same row. Hidden while renaming (the field takes the whole span).
|
// same row. Hidden while renaming (the field takes the whole span).
|
||||||
if (hasAddr && !renaming) {
|
if (hasAddr && !renaming) {
|
||||||
float ax = textX + std::min(nameW, nameAvail) + nameSpacing;
|
float ax = textX + std::min(nameW, nameAvail) + nameSpacing;
|
||||||
// Key-verify lock — we hold the peer's identity key; hover shows a comparable fingerprint.
|
// Key-verify lock — we hold the peer's identity key; now clickable to open a dialog with the
|
||||||
|
// fingerprint for out-of-band comparison (the old hover-only lock was unexplained).
|
||||||
if (showLock) {
|
if (showLock) {
|
||||||
const float ly = hp.y + (rowH - icoSz) * 0.5f;
|
const float ly = hp.y + (rowH - icoSz) * 0.5f;
|
||||||
|
ImGui::SetCursorScreenPos(ImVec2(ax, ly));
|
||||||
|
const bool lockClicked = ImGui::InvisibleButton("##hdr_keylock",
|
||||||
|
ImVec2(std::max(1.0f, lockW - 5.0f * hdpi), icoSz));
|
||||||
|
const bool lockHov = ImGui::IsItemHovered();
|
||||||
hdl->AddText(icoFont, icoSz, ImVec2(ax, ly),
|
hdl->AddText(icoFont, icoSz, ImVec2(ax, ly),
|
||||||
material::WithAlpha(material::Primary(), 220), ICON_MD_LOCK);
|
lockHov ? material::Primary() : material::WithAlpha(material::Primary(), 220),
|
||||||
if (ImGui::IsMouseHoveringRect(ImVec2(ax, ly), ImVec2(ax + lockW, ly + icoSz)))
|
ICON_MD_LOCK);
|
||||||
material::Tooltip("%s\n%s", TR("chat_verify_key"), keyFingerprint(sel->peerPubKey).c_str());
|
if (lockHov) {
|
||||||
|
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||||
|
material::Tooltip("%s", TR("chat_verify_tip"));
|
||||||
|
}
|
||||||
|
if (lockClicked) { s_fingerprint_cid = sel->cid; s_show_fingerprint = true; }
|
||||||
ax += lockW;
|
ax += lockW;
|
||||||
}
|
}
|
||||||
// Click-to-copy compact address.
|
// Click-to-copy compact address.
|
||||||
@@ -1724,7 +1809,10 @@ void RenderChatTab(App* app)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (ImGui::BeginPopup("##msgmenu")) {
|
if (ImGui::BeginPopup("##msgmenu")) {
|
||||||
if (ImGui::MenuItem(TR("copy"))) ImGui::SetClipboardText(m.body.c_str());
|
if (ImGui::MenuItem(TR("copy"))) {
|
||||||
|
ImGui::SetClipboardText(m.body.c_str());
|
||||||
|
Notifications::instance().success(TR("copied"));
|
||||||
|
}
|
||||||
ImGui::EndPopup();
|
ImGui::EndPopup();
|
||||||
} else if (s_msgmenu_index == static_cast<int>(mi)) {
|
} else if (s_msgmenu_index == static_cast<int>(mi)) {
|
||||||
s_msgmenu_index = -1; // popup closed
|
s_msgmenu_index = -1; // popup closed
|
||||||
@@ -1864,9 +1952,35 @@ void RenderChatTab(App* app)
|
|||||||
ImGui::EndChild();
|
ImGui::EndChild();
|
||||||
ImGui::PopStyleVar(2); // ##ChatMessages WindowPadding + zeroed vertical ItemSpacing
|
ImGui::PopStyleVar(2); // ##ChatMessages WindowPadding + zeroed vertical ItemSpacing
|
||||||
// Composer is rendered OUTSIDE this box, directly below it (see after EndChild).
|
// Composer is rendered OUTSIDE this box, directly below it (see after EndChild).
|
||||||
|
} else if (convs.empty() && app->settings() && !app->settings()->getChatIntroDismissed()) {
|
||||||
|
// First-run intro: explain how chat works + let the user copy their address, instead of a bare
|
||||||
|
// "no conversations yet" line. Dismissal is persisted.
|
||||||
|
const float idp = Layout::dpiScale();
|
||||||
|
const float availX = ImGui::GetContentRegionAvail().x;
|
||||||
|
const float cardW = std::min(availX - 40.0f * idp, 460.0f * idp);
|
||||||
|
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + std::max(0.0f, (availX - cardW) * 0.5f));
|
||||||
|
ImGui::BeginGroup();
|
||||||
|
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + cardW);
|
||||||
|
material::Type().textColored(material::TypeStyle::Subtitle1, material::OnSurface(), TR("chat_intro_title"));
|
||||||
|
ImGui::Spacing();
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
|
||||||
|
ImGui::TextWrapped("%s", TR("chat_intro_body"));
|
||||||
|
ImGui::Spacing();
|
||||||
|
ImGui::TextWrapped("%s", TR("chat_intro_fee"));
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
ImGui::PopTextWrapPos();
|
||||||
|
ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
|
||||||
|
if (material::TactileButton(TR("chat_copy_my_address"), ImVec2(cardW, 0))) copyMyChatAddress(app);
|
||||||
|
ImGui::Spacing();
|
||||||
|
if (material::TactileButton(TR("chat_intro_dismiss"), ImVec2(cardW, 0))) {
|
||||||
|
app->settings()->setChatIntroDismissed(true);
|
||||||
|
app->settings()->save();
|
||||||
|
}
|
||||||
|
ImGui::EndGroup();
|
||||||
|
} else if (convs.empty()) {
|
||||||
|
centeredEmptyState(ICON_MD_FORUM, TR("chat_empty_title"), TR("chat_empty_start"));
|
||||||
} else {
|
} else {
|
||||||
if (convs.empty()) centeredEmptyState(ICON_MD_FORUM, TR("chat_empty_title"), TR("chat_empty_start"));
|
centeredEmptyState(ICON_MD_CHAT_BUBBLE_OUTLINE, TR("chat_select_hint"), nullptr);
|
||||||
else centeredEmptyState(ICON_MD_CHAT_BUBBLE_OUTLINE, TR("chat_select_hint"), nullptr);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ImGui::EndChild();
|
ImGui::EndChild();
|
||||||
@@ -2207,6 +2321,53 @@ void RenderChatTab(App* app)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Key-fingerprint verification dialog ----
|
||||||
|
if (s_show_fingerprint) {
|
||||||
|
const ConvSummary* fp = nullptr;
|
||||||
|
for (const auto& c : convs) if (c.cid == s_fingerprint_cid) { fp = &c; break; }
|
||||||
|
if (!fp || fp->peerPubKey.empty()) {
|
||||||
|
s_show_fingerprint = false;
|
||||||
|
s_fingerprint_cid.clear();
|
||||||
|
} else {
|
||||||
|
material::OverlayDialogSpec ov;
|
||||||
|
ov.title = TR("chat_verify_title");
|
||||||
|
ov.p_open = &s_show_fingerprint;
|
||||||
|
ov.style = material::OverlayStyle::BlurFloat;
|
||||||
|
ov.cardWidth = 500.0f;
|
||||||
|
ov.idSuffix = "chatfp";
|
||||||
|
if (material::BeginOverlayDialog(ov)) {
|
||||||
|
const float fdp = Layout::dpiScale();
|
||||||
|
const std::string fpStr = keyFingerprint(fp->peerPubKey);
|
||||||
|
ImGui::PushTextWrapPos(0.0f);
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(material::OnSurfaceMedium()));
|
||||||
|
ImGui::TextWrapped("%s", TR("chat_verify_body"));
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
ImGui::Spacing();
|
||||||
|
ImGui::TextWrapped("%s", fp->peerName.c_str());
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(material::Primary()));
|
||||||
|
ImGui::TextWrapped("%s", fpStr.c_str());
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
ImGui::PopTextWrapPos();
|
||||||
|
ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
|
||||||
|
|
||||||
|
const float fgap = ImGui::GetStyle().ItemSpacing.x;
|
||||||
|
float wCopy = std::max(110.0f * fdp, ImGui::CalcTextSize(TR("chat_verify_copy")).x + 24.0f * fdp);
|
||||||
|
float wClose = std::max(90.0f * fdp, ImGui::CalcTextSize(TR("chat_cancel")).x + 24.0f * fdp);
|
||||||
|
material::BeginOverlayDialogFooter(wCopy + wClose + fgap, false);
|
||||||
|
if (material::TactileButton(TR("chat_verify_copy"), ImVec2(wCopy, 0))) {
|
||||||
|
ImGui::SetClipboardText(fp->peerPubKey.c_str());
|
||||||
|
Notifications::instance().success(TR("copied"));
|
||||||
|
}
|
||||||
|
ImGui::SameLine(0, fgap);
|
||||||
|
if (material::TactileButton(TR("chat_cancel"), ImVec2(wClose, 0))) {
|
||||||
|
s_show_fingerprint = false;
|
||||||
|
s_fingerprint_cid.clear();
|
||||||
|
}
|
||||||
|
material::EndOverlayDialog();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Delete-conversation confirm (revive-on-new-message vs delete & block) ----
|
// ---- Delete-conversation confirm (revive-on-new-message vs delete & block) ----
|
||||||
if (s_show_delete_confirm) {
|
if (s_show_delete_confirm) {
|
||||||
const float dp = Layout::dpiScale();
|
const float dp = Layout::dpiScale();
|
||||||
@@ -2252,6 +2413,57 @@ void RenderChatTab(App* app)
|
|||||||
s_delete_cid.clear(); s_delete_name.clear();
|
s_delete_cid.clear(); s_delete_name.clear();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// "Delete everywhere": block locally (instant, persistent — no sync flicker) AND broadcast a
|
||||||
|
// self-addressed Control(del) so other devices + a fresh seed import hide it too. Carries the
|
||||||
|
// peer z-address as the control label so Restore can match it by address.
|
||||||
|
auto doDeleteEverywhere = [&]() {
|
||||||
|
const std::string cid = s_delete_cid, name = s_delete_name;
|
||||||
|
std::string peerAddr;
|
||||||
|
for (const auto& m : app->chatService().store().conversation(cid)) {
|
||||||
|
if (!m.peer_zaddr.empty()) { peerAddr = m.peer_zaddr; break; }
|
||||||
|
}
|
||||||
|
if (!app->chatService().deleteConversation(cid, /*block=*/true)) {
|
||||||
|
Notifications::instance().error(TR("chat_delete_failed"));
|
||||||
|
s_show_delete_confirm = false; s_delete_cid.clear(); s_delete_name.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (app->settings()) {
|
||||||
|
app->settings()->setChatBlocked(cid, name, true);
|
||||||
|
app->settings()->setChatHidden(cid, false);
|
||||||
|
app->settings()->save();
|
||||||
|
}
|
||||||
|
app->sendChatControl(cid, "del", peerAddr.empty() ? name : peerAddr);
|
||||||
|
if (s_selected_cid == cid) s_selected_cid.clear();
|
||||||
|
Notifications::instance().info(TR("chat_delete_everywhere_toast"));
|
||||||
|
s_show_delete_confirm = false; s_delete_cid.clear(); s_delete_name.clear();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Full-width on-chain delete above the local footer, gated on a fully-synced wallet (so a
|
||||||
|
// conversation can't flicker in/out mid-sync while the delete signal is still catching up).
|
||||||
|
// Full node requires tip_known (isSynced); lite never sets it, so use its own "sync complete
|
||||||
|
// + at tip" signal there — otherwise the button would be permanently disabled in lite.
|
||||||
|
const auto& syncSt = app->state().sync;
|
||||||
|
const bool synced = app->isLiteBuild()
|
||||||
|
? (!syncSt.syncing && syncSt.blocks > 0 && syncSt.blocks >= syncSt.headers - 2)
|
||||||
|
: syncSt.isSynced();
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Error(), 205)));
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(material::Error()));
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Error(), 235)));
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(material::OnError()));
|
||||||
|
ImGui::BeginDisabled(!synced);
|
||||||
|
const bool doEvery = material::TactileButton(TR("chat_delete_everywhere"),
|
||||||
|
ImVec2(ImGui::GetContentRegionAvail().x, 0));
|
||||||
|
ImGui::EndDisabled();
|
||||||
|
ImGui::PopStyleColor(4);
|
||||||
|
if (doEvery) doDeleteEverywhere();
|
||||||
|
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
|
||||||
|
ImGui::PushTextWrapPos(0.0f);
|
||||||
|
ImGui::TextUnformatted(synced ? TR("chat_delete_everywhere_note") : TR("chat_delete_needs_sync"));
|
||||||
|
ImGui::PopTextWrapPos();
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
|
||||||
|
|
||||||
auto textW = [&](const char* t) {
|
auto textW = [&](const char* t) {
|
||||||
return ImGui::CalcTextSize(t).x + ImGui::GetStyle().FramePadding.x * 2.0f + 20.0f * dp;
|
return ImGui::CalcTextSize(t).x + ImGui::GetStyle().FramePadding.x * 2.0f + 20.0f * dp;
|
||||||
};
|
};
|
||||||
@@ -2323,6 +2535,215 @@ void RenderChatTab(App* app)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Restore conversations (reverse an on-chain "Delete everywhere") ----
|
||||||
|
if (s_show_restore) {
|
||||||
|
const float dp = Layout::dpiScale();
|
||||||
|
material::OverlayDialogSpec ov;
|
||||||
|
ov.title = TR("chat_restore_title");
|
||||||
|
ov.p_open = &s_show_restore;
|
||||||
|
ov.style = material::OverlayStyle::BlurFloat;
|
||||||
|
ov.cardWidth = 520.0f; ov.idSuffix = "chatrestore";
|
||||||
|
if (material::BeginOverlayDialog(ov)) {
|
||||||
|
ImGui::PushTextWrapPos(0.0f);
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
|
||||||
|
ImGui::TextUnformatted(TR("chat_restore_desc"));
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
ImGui::PopTextWrapPos();
|
||||||
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||||
|
|
||||||
|
// Restore: clear any local block/hide here AND broadcast Control("res") so every device un-hides.
|
||||||
|
// The messages re-import from the chain on the next scan (the reducer no longer hides the cid).
|
||||||
|
auto doRestore = [&](const std::string& cid, const std::string& label) {
|
||||||
|
if (cid.empty()) return;
|
||||||
|
if (app->settings()) {
|
||||||
|
app->settings()->setChatBlocked(cid, "", false);
|
||||||
|
app->settings()->setChatHidden(cid, false);
|
||||||
|
app->settings()->save();
|
||||||
|
}
|
||||||
|
app->forgetChatConversationSeen(cid);
|
||||||
|
app->sendChatControl(cid, "res", label.empty() ? cid : label);
|
||||||
|
Notifications::instance().info(TR("chat_restore_toast"));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Chain-derived deleted conversations — present even on a wallet freshly restored from seed.
|
||||||
|
const auto deleted = app->chatService().onChainDeletedConversations();
|
||||||
|
if (deleted.empty()) {
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
|
||||||
|
ImGui::PushTextWrapPos(0.0f);
|
||||||
|
ImGui::TextUnformatted(TR("chat_restore_none"));
|
||||||
|
ImGui::PopTextWrapPos();
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
} else {
|
||||||
|
std::string restoreCid, restoreLabel;
|
||||||
|
for (const auto& d : deleted) {
|
||||||
|
ImGui::PushID(d.first.c_str());
|
||||||
|
const std::string label = d.second.empty() ? shorten(d.first, 10, 6) : shorten(d.second, 12, 6);
|
||||||
|
const float bw = ImGui::CalcTextSize(TR("chat_restore_btn")).x +
|
||||||
|
ImGui::GetStyle().FramePadding.x * 2.0f + 16.0f * dp;
|
||||||
|
ImGui::AlignTextToFramePadding();
|
||||||
|
ImGui::TextUnformatted(label.c_str());
|
||||||
|
ImGui::SameLine();
|
||||||
|
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + ImGui::GetContentRegionAvail().x - bw);
|
||||||
|
if (material::TactileButton(TR("chat_restore_btn"), ImVec2(bw, 0))) {
|
||||||
|
restoreCid = d.first; restoreLabel = d.second;
|
||||||
|
}
|
||||||
|
ImGui::PopID();
|
||||||
|
}
|
||||||
|
if (!restoreCid.empty()) doRestore(restoreCid, restoreLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||||
|
ImGui::Separator();
|
||||||
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||||
|
|
||||||
|
// Paste the chat (z) address to restore a conversation not in the list (matched against the
|
||||||
|
// address each delete signal carries; otherwise the input is treated as a raw conversation id).
|
||||||
|
ImGui::TextUnformatted(TR("chat_restore_paste_label"));
|
||||||
|
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
||||||
|
ImGui::SetNextItemWidth(-FLT_MIN);
|
||||||
|
ImGui::InputTextWithHint("##chatrestoreaddr", TR("chat_restore_paste_hint"),
|
||||||
|
s_restore_addr, sizeof(s_restore_addr));
|
||||||
|
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
||||||
|
const float gap = Layout::spacingSm();
|
||||||
|
const float wPaste = std::max(120.0f * dp, ImGui::CalcTextSize(TR("chat_restore_btn")).x + 44.0f * dp);
|
||||||
|
const float wScan = std::max(120.0f * dp, ImGui::CalcTextSize(TR("chat_restore_scan")).x + 44.0f * dp);
|
||||||
|
if (material::TactileButton(TR("chat_restore_btn"), ImVec2(wPaste, 0))) {
|
||||||
|
std::string input(s_restore_addr);
|
||||||
|
while (!input.empty() && (input.front() == ' ' || input.front() == '\n' || input.front() == '\t')) input.erase(input.begin());
|
||||||
|
while (!input.empty() && (input.back() == ' ' || input.back() == '\n' || input.back() == '\t')) input.pop_back();
|
||||||
|
if (!input.empty()) {
|
||||||
|
std::string cid, label;
|
||||||
|
for (const auto& d : deleted) { if (d.second == input) { cid = d.first; label = d.second; break; } }
|
||||||
|
if (cid.empty()) { cid = input; label = input; } // not in the list — treat as a raw cid
|
||||||
|
doRestore(cid, label);
|
||||||
|
s_restore_addr[0] = '\0';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ImGui::SameLine(0, gap);
|
||||||
|
if (material::TactileButton(TR("chat_restore_scan"), ImVec2(wScan, 0))) {
|
||||||
|
app->noteWalletMutation(); // re-run the change-gated scans → re-harvest control memos
|
||||||
|
Notifications::instance().info(TR("chat_restore_scanning"));
|
||||||
|
}
|
||||||
|
material::EndOverlayDialog();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Sensitive: setup / unlock / mark / unmark (per-conversation local-key encryption) ----
|
||||||
|
if (s_show_sensitive) {
|
||||||
|
const float dp = Layout::dpiScale();
|
||||||
|
const auto vs = app->chatSensitiveVaultState();
|
||||||
|
const bool unlockOnly = s_sensitive_cid.empty();
|
||||||
|
const bool needSetup = (vs == App::ChatVaultState::NotConfigured);
|
||||||
|
const bool needUnlock = (vs == App::ChatVaultState::Locked);
|
||||||
|
material::OverlayDialogSpec ov;
|
||||||
|
ov.title = unlockOnly ? TR("chat_sensitive_unlock_title")
|
||||||
|
: s_sensitive_unmark ? TR("chat_sensitive_unmark_title")
|
||||||
|
: TR("chat_sensitive_mark_title");
|
||||||
|
ov.p_open = &s_show_sensitive;
|
||||||
|
ov.style = material::OverlayStyle::BlurFloat;
|
||||||
|
ov.cardWidth = 520.0f; ov.idSuffix = "chatsensitive";
|
||||||
|
if (material::BeginOverlayDialog(ov)) {
|
||||||
|
ImGui::PushTextWrapPos(0.0f);
|
||||||
|
if (unlockOnly) {
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
|
||||||
|
ImGui::TextUnformatted(TR("chat_sensitive_unlock_body"));
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
} else if (s_sensitive_unmark) {
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
|
||||||
|
ImGui::TextUnformatted(TR("chat_sensitive_unmark_body"));
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
} else {
|
||||||
|
ImGui::TextUnformatted(TR("chat_sensitive_warn_1"));
|
||||||
|
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, material::Error());
|
||||||
|
ImGui::TextUnformatted(TR("chat_sensitive_warn_lose"));
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
|
||||||
|
ImGui::TextUnformatted(TR("chat_sensitive_warn_dir"));
|
||||||
|
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
||||||
|
ImGui::TextUnformatted(TR("chat_sensitive_warn_hist"));
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
}
|
||||||
|
ImGui::PopTextWrapPos();
|
||||||
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||||
|
|
||||||
|
if (needSetup) {
|
||||||
|
ImGui::TextUnformatted(TR("chat_sensitive_setup_label"));
|
||||||
|
ImGui::SetNextItemWidth(-FLT_MIN);
|
||||||
|
ImGui::InputTextWithHint("##senspass", TR("chat_sensitive_pass_hint"), s_sensitive_pass,
|
||||||
|
sizeof(s_sensitive_pass), ImGuiInputTextFlags_Password);
|
||||||
|
ImGui::SetNextItemWidth(-FLT_MIN);
|
||||||
|
ImGui::InputTextWithHint("##senspass2", TR("chat_sensitive_pass2_hint"), s_sensitive_pass2,
|
||||||
|
sizeof(s_sensitive_pass2), ImGuiInputTextFlags_Password);
|
||||||
|
ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
|
||||||
|
} else if (needUnlock) {
|
||||||
|
ImGui::TextUnformatted(TR("chat_sensitive_unlock_label"));
|
||||||
|
ImGui::SetNextItemWidth(-FLT_MIN);
|
||||||
|
ImGui::InputTextWithHint("##senspass", TR("chat_sensitive_pass_hint"), s_sensitive_pass,
|
||||||
|
sizeof(s_sensitive_pass), ImGuiInputTextFlags_Password);
|
||||||
|
ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* actLabel =
|
||||||
|
needSetup ? TR("chat_sensitive_btn_setup")
|
||||||
|
: needUnlock ? (unlockOnly ? TR("chat_sensitive_btn_unlock")
|
||||||
|
: s_sensitive_unmark ? TR("chat_sensitive_btn_unlock_unmark")
|
||||||
|
: TR("chat_sensitive_btn_unlock_mark"))
|
||||||
|
: (unlockOnly ? TR("chat_sensitive_btn_unlock")
|
||||||
|
: s_sensitive_unmark ? TR("chat_sensitive_btn_unmark") : TR("chat_sensitive_btn_mark"));
|
||||||
|
|
||||||
|
auto closeWipe = [&]() {
|
||||||
|
std::memset(s_sensitive_pass, 0, sizeof(s_sensitive_pass));
|
||||||
|
std::memset(s_sensitive_pass2, 0, sizeof(s_sensitive_pass2));
|
||||||
|
s_show_sensitive = false;
|
||||||
|
s_sensitive_cid.clear(); s_sensitive_name.clear(); s_sensitive_peeraddr.clear();
|
||||||
|
};
|
||||||
|
|
||||||
|
const float gap2 = Layout::spacingSm();
|
||||||
|
const float wAct = std::max(160.0f * dp, ImGui::CalcTextSize(actLabel).x + 40.0f * dp);
|
||||||
|
const float wCan = std::max(90.0f * dp, ImGui::CalcTextSize(TR("chat_cancel")).x + 32.0f * dp);
|
||||||
|
material::BeginOverlayDialogFooter(wAct + wCan + gap2, /*drawSeparator=*/false);
|
||||||
|
const bool doAct = material::TactileButton(actLabel, ImVec2(wAct, 0));
|
||||||
|
ImGui::SameLine(0, gap2);
|
||||||
|
const bool doCancel = material::TactileButton(TR("chat_cancel"), ImVec2(wCan, 0));
|
||||||
|
|
||||||
|
if (doAct) {
|
||||||
|
bool ok = true;
|
||||||
|
if (needSetup) {
|
||||||
|
const std::string p1(s_sensitive_pass), p2(s_sensitive_pass2);
|
||||||
|
if (p1.empty() || p1 != p2) {
|
||||||
|
Notifications::instance().error(TR("chat_sensitive_err_pass")); ok = false;
|
||||||
|
} else if (!app->createChatSensitiveVault(p1)) {
|
||||||
|
Notifications::instance().error(TR("chat_sensitive_err_create")); ok = false;
|
||||||
|
}
|
||||||
|
} else if (needUnlock) {
|
||||||
|
const std::string p1(s_sensitive_pass);
|
||||||
|
if (p1.empty() || !app->unlockChatSensitiveVault(p1)) {
|
||||||
|
Notifications::instance().error(TR("chat_sensitive_err_unlock")); ok = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ok) {
|
||||||
|
if (!unlockOnly) {
|
||||||
|
if (s_sensitive_unmark) {
|
||||||
|
app->unmarkChatConversationSensitive(s_sensitive_cid);
|
||||||
|
Notifications::instance().info(TR("chat_sensitive_toast_unmarked"));
|
||||||
|
} else {
|
||||||
|
app->markChatConversationSensitive(s_sensitive_cid, s_sensitive_peeraddr);
|
||||||
|
Notifications::instance().info(TR("chat_sensitive_toast_marked"));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Notifications::instance().info(TR("chat_sensitive_toast_unlocked"));
|
||||||
|
}
|
||||||
|
s_convsKey = ~0ull; // sensitive threads appear/disappear — rebuild the list
|
||||||
|
closeWipe();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (doCancel) closeWipe();
|
||||||
|
material::EndOverlayDialog();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Chat customization modal (opened by the header settings "notch") — house BlurFloat overlay ----
|
// ---- Chat customization modal (opened by the header settings "notch") — house BlurFloat overlay ----
|
||||||
if (s_show_chat_settings) {
|
if (s_show_chat_settings) {
|
||||||
material::OverlayDialogSpec ov;
|
material::OverlayDialogSpec ov;
|
||||||
@@ -2394,11 +2815,16 @@ void ResetChatTab()
|
|||||||
s_show_blocked = false;
|
s_show_blocked = false;
|
||||||
// Drop the per-frame memoization caches so the next wallet doesn't briefly render the previous one's
|
// Drop the per-frame memoization caches so the next wallet doesn't briefly render the previous one's
|
||||||
// conversations/thread (store.revision() is monotonic and would rebuild anyway, but be explicit).
|
// conversations/thread (store.revision() is monotonic and would rebuild anyway, but be explicit).
|
||||||
|
// Zero the decrypted plaintext the memo caches hold (conversation previews + full thread bodies)
|
||||||
|
// before dropping them, so the previous wallet's messages don't linger in freed heap.
|
||||||
|
auto wipeStr2 = [](std::string& s) { if (!s.empty()) sodium_memzero(&s[0], s.size()); s.clear(); };
|
||||||
|
for (auto& c : s_convs) wipeStr2(c.lastBody);
|
||||||
s_convs.clear();
|
s_convs.clear();
|
||||||
s_convsHidden = 0;
|
s_convsHidden = 0;
|
||||||
s_convsKey = ~0ull;
|
s_convsKey = ~0ull;
|
||||||
s_threadCid.clear();
|
s_threadCid.clear();
|
||||||
s_threadRev = ~0ull;
|
s_threadRev = ~0ull;
|
||||||
|
for (auto& m : s_threadMsgs) { wipeStr2(m.body); wipeStr2(m.fail_reason); wipeStr2(m.peer_zaddr); }
|
||||||
s_threadMsgs.clear();
|
s_threadMsgs.clear();
|
||||||
s_threadFlags.clear(); s_threadRows.clear(); s_threadHeights.clear();
|
s_threadFlags.clear(); s_threadRows.clear(); s_threadHeights.clear();
|
||||||
s_threadPrefix.assign(1, 0.0f); s_threadFailed.clear(); s_threadBodySizes.clear();
|
s_threadPrefix.assign(1, 0.0f); s_threadFailed.clear(); s_threadBodySizes.clear();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
// tab completion, daemon log display, and color-coded output.
|
// tab completion, daemon log display, and color-coded output.
|
||||||
|
|
||||||
#include "console_tab.h"
|
#include "console_tab.h"
|
||||||
|
#include <sodium.h> // sodium_memzero — scrub masked secret-result plaintext
|
||||||
#include "console_command_executor.h"
|
#include "console_command_executor.h"
|
||||||
#include "console_command_reference.h"
|
#include "console_command_reference.h"
|
||||||
#include "console_input_model.h"
|
#include "console_input_model.h"
|
||||||
@@ -298,7 +299,26 @@ void ConsoleTab::render(ConsoleCommandExecutor& exec)
|
|||||||
{
|
{
|
||||||
std::string result;
|
std::string result;
|
||||||
bool isError = false;
|
bool isError = false;
|
||||||
while (exec.pollResult(result, isError)) addFormattedResult(result, isError);
|
while (exec.pollResult(result, isError)) {
|
||||||
|
const bool secret = secret_result_armed_ && !isError;
|
||||||
|
secret_result_armed_ = false; // the first (non-error) result consumes the arming
|
||||||
|
if (secret) {
|
||||||
|
// Hold the plaintext ONLY in secret_result_ and show a masked placeholder — it never
|
||||||
|
// enters the scrollback model, so Copy All / Ctrl+C / the filter can't leak it.
|
||||||
|
if (!secret_result_.empty()) sodium_memzero(&secret_result_[0], secret_result_.size());
|
||||||
|
secret_result_ = result;
|
||||||
|
secret_result_deadline_ = std::chrono::steady_clock::now() + std::chrono::seconds(60);
|
||||||
|
addLine(TR("console_secret_result_hidden"), ConsoleChannel::Warning);
|
||||||
|
} else {
|
||||||
|
addFormattedResult(result, isError);
|
||||||
|
}
|
||||||
|
if (!result.empty()) sodium_memzero(&result[0], result.size());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Auto-wipe a revealed secret result after its deadline so it doesn't linger for the session.
|
||||||
|
if (!secret_result_.empty() && std::chrono::steady_clock::now() >= secret_result_deadline_) {
|
||||||
|
wipeSecretResult();
|
||||||
|
addLine(TR("console_secret_result_expired"), ConsoleChannel::Info);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drain the ingest queue into the visible model once per frame, after this frame's own
|
// Drain the ingest queue into the visible model once per frame, after this frame's own
|
||||||
@@ -547,7 +567,21 @@ void ConsoleTab::renderToolbar(ConsoleCommandExecutor& exec)
|
|||||||
if (ImGui::IsItemHovered()) {
|
if (ImGui::IsItemHovered()) {
|
||||||
material::Tooltip("%s", selection_.active() ? TR("console_copy_selected") : TR("console_copy_all"));
|
material::Tooltip("%s", selection_.active() ? TR("console_copy_selected") : TR("console_copy_all"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When a key-revealing result is masked, offer an explicit Copy-secret (through the auto-clearing
|
||||||
|
// clipboard) and a Hide action. The secret is never in the model, so the normal Copy above is safe.
|
||||||
|
if (hasSecretResult()) {
|
||||||
|
ImGui::SameLine();
|
||||||
|
if (TactileButton(TR("console_copy_secret"), ImVec2(0, 0), schema::UI().resolveFont("button"))) {
|
||||||
|
if (copy_secret_cb_) copy_secret_cb_(secret_result_);
|
||||||
|
else ImGui::SetClipboardText(secret_result_.c_str());
|
||||||
|
}
|
||||||
|
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("console_copy_secret_tip"));
|
||||||
|
ImGui::SameLine();
|
||||||
|
if (TactileButton(TR("console_hide_secret"), ImVec2(0, 0), schema::UI().resolveFont("button")))
|
||||||
|
wipeSecretResult();
|
||||||
|
}
|
||||||
|
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
|
|
||||||
// Commands reference button — shown whenever the backend offers a reference table (full-node
|
// Commands reference button — shown whenever the backend offers a reference table (full-node
|
||||||
@@ -1552,6 +1586,12 @@ bool IsDestructiveConsoleCommand(const std::string& firstLower, bool fullNode)
|
|||||||
if (n == firstLower) return cat.commands[i].destructive;
|
if (n == firstLower) return cat.commands[i].destructive;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Secret-revealing full-node commands that aren't in the reference catalog still deserve the gate
|
||||||
|
// (e.g. z_exportmnemonic dumps the seed phrase; the lite "seed" equivalent is already flagged).
|
||||||
|
if (fullNode) {
|
||||||
|
static const char* kSecretReveal[] = { "z_exportmnemonic", "z_exportviewingkey" };
|
||||||
|
for (const char* s : kSecretReveal) if (firstLower == s) return true;
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} // namespace
|
} // namespace
|
||||||
@@ -1559,6 +1599,7 @@ bool IsDestructiveConsoleCommand(const std::string& firstLower, bool fullNode)
|
|||||||
bool ConsoleTab::submitConsoleCommand(ConsoleCommandExecutor& exec, const std::string& cmd)
|
bool ConsoleTab::submitConsoleCommand(ConsoleCommandExecutor& exec, const std::string& cmd)
|
||||||
{
|
{
|
||||||
if (cmd.empty()) return false;
|
if (cmd.empty()) return false;
|
||||||
|
secret_result_armed_ = false; // cleared each submit so a no-result command can't mask the next one
|
||||||
|
|
||||||
// Redact secret-bearing commands (walletpassphrase, z_importkey, …) before they reach the visible
|
// Redact secret-bearing commands (walletpassphrase, z_importkey, …) before they reach the visible
|
||||||
// log and the recall history. The real `cmd` below is still executed unredacted.
|
// log and the recall history. The real `cmd` below is still executed unredacted.
|
||||||
@@ -1616,6 +1657,8 @@ bool ConsoleTab::submitConsoleCommand(ConsoleCommandExecutor& exec, const std::s
|
|||||||
addLine(TR("console_destructive_confirm"), ConsoleChannel::Warning);
|
addLine(TR("console_destructive_confirm"), ConsoleChannel::Warning);
|
||||||
} else {
|
} else {
|
||||||
destructive_confirm_cmd_.clear();
|
destructive_confirm_cmd_.clear();
|
||||||
|
// Arm result-masking for key-revealing commands so their OUTPUT is hidden in the scrollback.
|
||||||
|
secret_result_armed_ = ConsoleCommandRevealsSecret(cmd, exec.hasRpcReference());
|
||||||
exec.submit(cmd);
|
exec.submit(cmd);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -1882,6 +1925,9 @@ void ConsoleTab::renderCommandDetail(const ConsoleCommandEntry& cmd, const char*
|
|||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
if (material::TactileButton(TR("console_ref_run"), ImVec2(cbw, 0))) {
|
if (material::TactileButton(TR("console_ref_run"), ImVec2(cbw, 0))) {
|
||||||
pending_submit_ = built;
|
pending_submit_ = built;
|
||||||
|
// The reference dialog already confirmed this run, so pre-arm the destructive gate to an exact
|
||||||
|
// match — otherwise the replayed submit re-arms it and silently does nothing until retyped.
|
||||||
|
destructive_confirm_cmd_ = built;
|
||||||
command_search_[0] = '\0';
|
command_search_[0] = '\0';
|
||||||
run_confirm_cmd_ = nullptr;
|
run_confirm_cmd_ = nullptr;
|
||||||
show_commands_popup_ = false;
|
show_commands_popup_ = false;
|
||||||
@@ -2193,8 +2239,17 @@ void ConsoleTab::addRpcTraceLine(const std::string& source, const std::string& m
|
|||||||
addLine("[" + rpcTraceTimestamp() + "] [" + source + "] " + method, ConsoleChannel::Rpc);
|
addLine("[" + rpcTraceTimestamp() + "] [" + source + "] " + method, ConsoleChannel::Rpc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ConsoleTab::wipeSecretResult()
|
||||||
|
{
|
||||||
|
if (!secret_result_.empty()) sodium_memzero(&secret_result_[0], secret_result_.size());
|
||||||
|
secret_result_.clear();
|
||||||
|
secret_result_armed_ = false;
|
||||||
|
secret_result_deadline_ = {};
|
||||||
|
}
|
||||||
|
|
||||||
void ConsoleTab::clear()
|
void ConsoleTab::clear()
|
||||||
{
|
{
|
||||||
|
wipeSecretResult(); // the clear command / toolbar button also drops any held secret result
|
||||||
// View-only clear (main thread). The executor keeps its own log cursors, so new output
|
// 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.
|
// still appends. The "cleared" line is ingested and appears on the next frame's drain.
|
||||||
model_.clear();
|
model_.clear();
|
||||||
|
|||||||
@@ -20,6 +20,8 @@
|
|||||||
#include <vector>
|
#include <vector>
|
||||||
#include <deque>
|
#include <deque>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
|
#include <functional>
|
||||||
|
#include <chrono>
|
||||||
|
|
||||||
namespace dragonx {
|
namespace dragonx {
|
||||||
namespace ui {
|
namespace ui {
|
||||||
@@ -66,6 +68,13 @@ public:
|
|||||||
*/
|
*/
|
||||||
void clear();
|
void clear();
|
||||||
|
|
||||||
|
// Masked secret-result support: the plaintext of a key-revealing command's output is held ONLY here
|
||||||
|
// (never in the scrollback model), so Copy All / Ctrl+C / the filter can't leak it. An explicit
|
||||||
|
// "Copy secret" action routes through this handler (the app wires it to the auto-clearing clipboard).
|
||||||
|
void setCopySecretHandler(std::function<void(const std::string&)> f) { copy_secret_cb_ = std::move(f); }
|
||||||
|
bool hasSecretResult() const { return !secret_result_.empty(); }
|
||||||
|
void wipeSecretResult(); // zero + drop the buffer, disarm, reset the deadline (call on lock/clear)
|
||||||
|
|
||||||
// Ask the console to place the keyboard focus in the command input on the next render (consumed once).
|
// Ask the console to place the keyboard focus in the command input on the next render (consumed once).
|
||||||
// Called when the user switches to the Console tab, gated by the console_auto_focus setting.
|
// Called when the user switches to the Console tab, gated by the console_auto_focus setting.
|
||||||
void requestInputFocus() { focus_input_pending_ = true; }
|
void requestInputFocus() { focus_input_pending_ = true; }
|
||||||
@@ -162,6 +171,10 @@ private:
|
|||||||
char input_buffer_[4096] = {0};
|
char input_buffer_[4096] = {0};
|
||||||
bool stop_confirm_pending_ = false; // 'stop' typed once, awaiting a confirming second 'stop'
|
bool stop_confirm_pending_ = false; // 'stop' typed once, awaiting a confirming second 'stop'
|
||||||
std::string destructive_confirm_cmd_; // a destructive command typed once, awaiting an identical re-entry
|
std::string destructive_confirm_cmd_; // a destructive command typed once, awaiting an identical re-entry
|
||||||
|
bool secret_result_armed_ = false; // the just-submitted command reveals key material in its result
|
||||||
|
std::string secret_result_; // plaintext of the masked result (member-only; never in model_)
|
||||||
|
std::chrono::steady_clock::time_point secret_result_deadline_{}; // auto-wipe the buffer at this time
|
||||||
|
std::function<void(const std::string&)> copy_secret_cb_; // routes "Copy secret" to the app
|
||||||
bool focus_input_pending_ = false; // one-shot: focus the command input next render (tab-open auto-focus)
|
bool focus_input_pending_ = false; // one-shot: focus the command input next render (tab-open auto-focus)
|
||||||
// (log-ingestion cursors + result queue moved to the ConsoleCommandExecutor)
|
// (log-ingestion cursors + result queue moved to the ConsoleCommandExecutor)
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ const char* const kSecretConsoleCommands[] = {
|
|||||||
"importprivkey", "importwallet", "importmulti",
|
"importprivkey", "importwallet", "importmulti",
|
||||||
"z_importkey", "z_importviewingkey", "z_importwallet",
|
"z_importkey", "z_importviewingkey", "z_importwallet",
|
||||||
"signrawtransaction", "magicrecoverkey", "sethdseed", "importmnemonic",
|
"signrawtransaction", "magicrecoverkey", "sethdseed", "importmnemonic",
|
||||||
|
// Lite-wallet verbs (console_command_reference kLiteKeyCommands): these take a spending/viewing key
|
||||||
|
// or a password as their argument. Full-node has no bare import/encrypt/decrypt/unlock verb, so a
|
||||||
|
// flat match is safe.
|
||||||
|
"import", "timport", "encrypt", "decrypt", "unlock",
|
||||||
};
|
};
|
||||||
|
|
||||||
std::string firstConsoleTokenLower(const std::string& cmd, size_t& tokenEnd) {
|
std::string firstConsoleTokenLower(const std::string& cmd, size_t& tokenEnd) {
|
||||||
@@ -67,6 +71,25 @@ bool ConsoleCommandCarriesSecret(const std::string& cmd)
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool ConsoleCommandRevealsSecret(const std::string& cmd, bool fullNode)
|
||||||
|
{
|
||||||
|
size_t end = 0;
|
||||||
|
const std::string name = firstConsoleTokenLower(cmd, end);
|
||||||
|
if (name.empty()) return false;
|
||||||
|
if (fullNode) {
|
||||||
|
static const char* const kFull[] = {
|
||||||
|
"dumpprivkey", "z_exportkey", "z_exportviewingkey",
|
||||||
|
"z_exportmnemonic", "dumpwallet", "z_exportwallet",
|
||||||
|
};
|
||||||
|
for (const char* s : kFull) if (name == s) return true;
|
||||||
|
} else {
|
||||||
|
// Lite backend verbs whose output is the seed phrase / private keys.
|
||||||
|
static const char* const kLite[] = { "seed", "export" };
|
||||||
|
for (const char* s : kLite) if (name == s) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
std::string RedactConsoleCommand(const std::string& cmd)
|
std::string RedactConsoleCommand(const std::string& cmd)
|
||||||
{
|
{
|
||||||
size_t end = 0;
|
size_t end = 0;
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ bool ShouldPrintDaemonErrorLine(bool wasError, bool isError,
|
|||||||
// z_exportkey, z_exportmnemonic) are NOT covered — their secret is in the result, a separate concern.
|
// z_exportkey, z_exportmnemonic) are NOT covered — their secret is in the result, a separate concern.
|
||||||
bool ConsoleCommandCarriesSecret(const std::string& cmd);
|
bool ConsoleCommandCarriesSecret(const std::string& cmd);
|
||||||
|
|
||||||
|
// True when the command's RESULT (not its args) reveals key material — dumpprivkey / z_exportkey /
|
||||||
|
// z_exportviewingkey / z_exportmnemonic / dumpwallet / z_exportwallet on the full node, or the lite
|
||||||
|
// `seed` / `export` verbs. Used to mask the output in the console scrollback. `fullNode` selects the
|
||||||
|
// command set (the bare lite verbs `seed`/`export` must not match full-node commands).
|
||||||
|
bool ConsoleCommandRevealsSecret(const std::string& cmd, bool fullNode);
|
||||||
|
|
||||||
// A display/history-safe copy of `cmd`: the command name with its arguments replaced by "****" when
|
// A display/history-safe copy of `cmd`: the command name with its arguments replaced by "****" when
|
||||||
// it carries a secret, else `cmd` unchanged. The real command is still executed unredacted.
|
// it carries a secret, else `cmd` unchanged. The real command is still executed unredacted.
|
||||||
std::string RedactConsoleCommand(const std::string& cmd);
|
std::string RedactConsoleCommand(const std::string& cmd);
|
||||||
|
|||||||
@@ -445,11 +445,20 @@ private:
|
|||||||
|
|
||||||
static void renderDone(float, const Progress& p) {
|
static void renderDone(float, const Progress& p) {
|
||||||
using namespace material;
|
using namespace material;
|
||||||
if (!s_notified) { s_installed_flag = true; s_notified = true; }
|
if (!s_notified) {
|
||||||
|
s_installed_flag = true;
|
||||||
|
s_notified = true;
|
||||||
|
// Record a persistent "restart pending" reminder so it isn't lost if the user clicks Later.
|
||||||
|
if (s_app) s_app->markDaemonRestartPending();
|
||||||
|
}
|
||||||
Type().textColored(TypeStyle::Subtitle2, Success(), TR("daemon_update_installed_ok"));
|
Type().textColored(TypeStyle::Subtitle2, Success(), TR("daemon_update_installed_ok"));
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::Text("%s %s", TR("daemon_update_version"), p.latest_tag.c_str());
|
ImGui::Text("%s %s", TR("daemon_update_version"), p.latest_tag.c_str());
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
|
// Lead with a warning-level headline that a restart is required, then the detail note.
|
||||||
|
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + ImGui::GetContentRegionAvail().x);
|
||||||
|
Type().textColored(TypeStyle::Subtitle2, Warning(), TR("daemon_update_restart_needed"));
|
||||||
|
ImGui::PopTextWrapPos();
|
||||||
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("daemon_update_restart_note"));
|
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("daemon_update_restart_note"));
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
if (s_app->isUsingEmbeddedDaemon()) {
|
if (s_app->isUsingEmbeddedDaemon()) {
|
||||||
@@ -459,6 +468,7 @@ private:
|
|||||||
}
|
}
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
if (TactileButton(TR("daemon_update_later"), ImVec2(fullW(), 0))) s_open = false;
|
if (TactileButton(TR("daemon_update_later"), ImVec2(fullW(), 0))) s_open = false;
|
||||||
|
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("daemon_update_later_hint"));
|
||||||
} else {
|
} else {
|
||||||
if (TactileButton(TR("close"), ImVec2(fullW(), 0))) s_open = false;
|
if (TactileButton(TR("close"), ImVec2(fullW(), 0))) s_open = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -269,7 +269,7 @@ static void fetchBlockDetail(App* app, int height) {
|
|||||||
auto hashResult = rpc->call("getblockhash", {height});
|
auto hashResult = rpc->call("getblockhash", {height});
|
||||||
std::string hash = hashResult.get<std::string>();
|
std::string hash = hashResult.get<std::string>();
|
||||||
result = rpc->call("getblock", {hash});
|
result = rpc->call("getblock", {hash});
|
||||||
} catch (const std::exception& e) { error = e.what(); }
|
} catch (const std::exception& e) { error = util::friendlyRpcError(e.what()); }
|
||||||
return [result, error]() {
|
return [result, error]() {
|
||||||
applyBlockDetailResult(result, error);
|
applyBlockDetailResult(result, error);
|
||||||
};
|
};
|
||||||
@@ -383,19 +383,27 @@ static void navigateToHash(App* app, const std::string& hash) {
|
|||||||
worker->post([rpc, hash]() -> rpc::RPCWorker::MainCb {
|
worker->post([rpc, hash]() -> rpc::RPCWorker::MainCb {
|
||||||
json blockResult, txResult;
|
json blockResult, txResult;
|
||||||
bool gotBlock = false, gotTx = false;
|
bool gotBlock = false, gotTx = false;
|
||||||
|
bool rpcFailed = false; std::string rpcErr;
|
||||||
|
// Distinguish a genuine "not found" (RPC -5/-8) from a transport/timeout failure, so a flaky
|
||||||
|
// connection isn't reported as "no block or transaction found".
|
||||||
|
auto noteErr = [&](const std::exception& e) {
|
||||||
|
const auto* re = dynamic_cast<const rpc::RpcError*>(&e);
|
||||||
|
if (re && (re->code == -5 || re->code == -8)) return; // not-found: a real negative answer
|
||||||
|
if (!rpcFailed) { rpcFailed = true; rpcErr = e.what(); }
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
rpc::RPCClient::TraceScope trace("Explorer tab / Search");
|
rpc::RPCClient::TraceScope trace("Explorer tab / Search");
|
||||||
blockResult = rpc->call("getblock", {hash});
|
blockResult = rpc->call("getblock", {hash});
|
||||||
gotBlock = true;
|
gotBlock = true;
|
||||||
} catch (...) {}
|
} catch (const std::exception& e) { noteErr(e); }
|
||||||
if (!gotBlock) {
|
if (!gotBlock) {
|
||||||
try {
|
try {
|
||||||
rpc::RPCClient::TraceScope trace("Explorer tab / Search");
|
rpc::RPCClient::TraceScope trace("Explorer tab / Search");
|
||||||
txResult = rpc->call("getrawtransaction", {hash, 1});
|
txResult = rpc->call("getrawtransaction", {hash, 1});
|
||||||
gotTx = true;
|
gotTx = true;
|
||||||
} catch (...) {}
|
} catch (const std::exception& e) { noteErr(e); }
|
||||||
}
|
}
|
||||||
return [blockResult, txResult, gotBlock, gotTx]() {
|
return [blockResult, txResult, gotBlock, gotTx, rpcFailed, rpcErr]() {
|
||||||
s_search_loading = false;
|
s_search_loading = false;
|
||||||
if (gotBlock) {
|
if (gotBlock) {
|
||||||
int h = blockResult.value("height", -1);
|
int h = blockResult.value("height", -1);
|
||||||
@@ -407,6 +415,8 @@ static void navigateToHash(App* app, const std::string& hash) {
|
|||||||
s_tx_loading = false;
|
s_tx_loading = false;
|
||||||
s_expanded_tx_idx = 0;
|
s_expanded_tx_idx = 0;
|
||||||
s_show_detail_modal = false;
|
s_show_detail_modal = false;
|
||||||
|
} else if (rpcFailed) {
|
||||||
|
s_search_error = std::string(TR("explorer_lookup_failed")) + ": " + util::friendlyRpcError(rpcErr);
|
||||||
} else {
|
} else {
|
||||||
s_search_error = TR("explorer_hash_not_found");
|
s_search_error = TR("explorer_hash_not_found");
|
||||||
}
|
}
|
||||||
@@ -836,7 +846,8 @@ static void renderRecentBlocks(App* app, float availWidth) {
|
|||||||
|
|
||||||
std::map<int, BlockSummary> cachedBlocks;
|
std::map<int, BlockSummary> cachedBlocks;
|
||||||
if (!pageHeights.empty()) {
|
if (!pageHeights.empty()) {
|
||||||
cachedBlocks = s_recent_block_cache.loadRange(pageHeights.back(), pageHeights.front());
|
// Memoized on the cache revision: SQLite is only hit after the cache changed, not every frame.
|
||||||
|
cachedBlocks = s_recent_block_cache.loadRangeCached(pageHeights.back(), pageHeights.front());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fuzzy mode: a non-numeric, non-full-hash query filters the list to cached blocks whose hash
|
// Fuzzy mode: a non-numeric, non-full-hash query filters the list to cached blocks whose hash
|
||||||
@@ -880,7 +891,7 @@ static void renderRecentBlocks(App* app, float availWidth) {
|
|||||||
}
|
}
|
||||||
std::map<int, BlockSummary> nextCachedBlocks;
|
std::map<int, BlockSummary> nextCachedBlocks;
|
||||||
if (!nextPageHeights.empty()) {
|
if (!nextPageHeights.empty()) {
|
||||||
nextCachedBlocks = s_recent_block_cache.loadRange(nextPageHeights.back(), nextPageHeights.front());
|
nextCachedBlocks = s_recent_block_cache.loadRangeCached(nextPageHeights.back(), nextPageHeights.front());
|
||||||
}
|
}
|
||||||
for (int height : nextPageHeights) {
|
for (int height : nextPageHeights) {
|
||||||
if (nextCachedBlocks.find(height) == nextCachedBlocks.end()) {
|
if (nextCachedBlocks.find(height) == nextCachedBlocks.end()) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
#include "../../rpc/rpc_worker.h"
|
#include "../../rpc/rpc_worker.h"
|
||||||
#include "../../util/i18n.h"
|
#include "../../util/i18n.h"
|
||||||
#include "../../util/platform.h"
|
#include "../../util/platform.h"
|
||||||
|
#include "../../util/export_filename.h"
|
||||||
#include "../notifications.h"
|
#include "../notifications.h"
|
||||||
#include "../schema/ui_schema.h"
|
#include "../schema/ui_schema.h"
|
||||||
#include "../material/draw_helpers.h"
|
#include "../material/draw_helpers.h"
|
||||||
@@ -117,7 +118,7 @@ void ExportAllKeysDialog::render(App* app)
|
|||||||
} else if (!s_status.empty()) {
|
} else if (!s_status.empty()) {
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::PushTextWrapPos(0.0f);
|
ImGui::PushTextWrapPos(0.0f);
|
||||||
ImGui::TextColored(ImVec4(0.8f, 0.3f, 0.3f, 1.0f), "%s", s_status.c_str());
|
ImGui::TextColored(material::ErrorVec4(), "%s", s_status.c_str());
|
||||||
ImGui::PopTextWrapPos();
|
ImGui::PopTextWrapPos();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,8 +161,11 @@ void ExportAllKeysDialog::render(App* app)
|
|||||||
if (s_include_t) {
|
if (s_include_t) {
|
||||||
for (const auto& a : state.t_addresses) t_addrs.push_back(a.address);
|
for (const auto& a : state.t_addresses) t_addrs.push_back(a.address);
|
||||||
}
|
}
|
||||||
std::string filename(s_filename);
|
// Sanitize the typed filename (basename only, no traversal) and reflect it back in the
|
||||||
|
// buffer so the user sees exactly what gets written. Mirrors the CSV export dialog.
|
||||||
|
std::string filename = util::sanitizeExportFilename(s_filename, ".txt", "dragonx_keys.txt");
|
||||||
|
snprintf(s_filename, sizeof(s_filename), "%s", filename.c_str());
|
||||||
|
|
||||||
// Run all key exports on worker thread
|
// Run all key exports on worker thread
|
||||||
if (app->worker()) {
|
if (app->worker()) {
|
||||||
app->worker()->post([rpc = app->rpc(), z_addrs, t_addrs, filename]() -> rpc::RPCWorker::MainCb {
|
app->worker()->post([rpc = app->rpc(), z_addrs, t_addrs, filename]() -> rpc::RPCWorker::MainCb {
|
||||||
|
|||||||
@@ -6,7 +6,9 @@
|
|||||||
#include "../../app.h"
|
#include "../../app.h"
|
||||||
#include "../../util/i18n.h"
|
#include "../../util/i18n.h"
|
||||||
#include "../../util/platform.h"
|
#include "../../util/platform.h"
|
||||||
|
#include "../../util/export_filename.h"
|
||||||
#include "../../data/transaction_csv.h"
|
#include "../../data/transaction_csv.h"
|
||||||
|
#include "transactions_tab.h"
|
||||||
#include "../notifications.h"
|
#include "../notifications.h"
|
||||||
#include "../schema/ui_schema.h"
|
#include "../schema/ui_schema.h"
|
||||||
#include "../material/draw_helpers.h"
|
#include "../material/draw_helpers.h"
|
||||||
@@ -18,6 +20,8 @@
|
|||||||
|
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <ctime>
|
#include <ctime>
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cctype>
|
||||||
|
|
||||||
namespace dragonx {
|
namespace dragonx {
|
||||||
namespace ui {
|
namespace ui {
|
||||||
@@ -27,12 +31,34 @@ static bool s_open = false;
|
|||||||
static char s_filename[256] = "";
|
static char s_filename[256] = "";
|
||||||
static std::string s_saved_path; // full path on success (shown as a copy field); empty otherwise
|
static std::string s_saved_path; // full path on success (shown as a copy field); empty otherwise
|
||||||
static std::string s_error; // error message on failure; empty otherwise
|
static std::string s_error; // error message on failure; empty otherwise
|
||||||
|
static int s_scope = 0; // 0 = all, 1 = current History filter, 2 = date range
|
||||||
|
static char s_from[16] = ""; // YYYY-MM-DD (date-range scope)
|
||||||
|
static char s_to[16] = "";
|
||||||
|
|
||||||
|
// Parse a YYYY-MM-DD string to the local-time start-of-day unix seconds. Returns false on empty/invalid.
|
||||||
|
static bool parseYmd(const char* s, std::int64_t& outStartOfDay) {
|
||||||
|
if (!s || !*s) return false;
|
||||||
|
int y = 0, mo = 0, d = 0;
|
||||||
|
if (std::sscanf(s, "%d-%d-%d", &y, &mo, &d) != 3) return false;
|
||||||
|
if (y < 1970 || y > 9999 || mo < 1 || mo > 12 || d < 1 || d > 31) return false;
|
||||||
|
std::tm tm{}; tm.tm_year = y - 1900; tm.tm_mon = mo - 1; tm.tm_mday = d; tm.tm_isdst = -1;
|
||||||
|
std::time_t t = std::mktime(&tm);
|
||||||
|
if (t == (std::time_t)-1) return false;
|
||||||
|
// Reject rollover (e.g. Feb 31 → Mar) by requiring the normalized fields to round-trip.
|
||||||
|
if (tm.tm_year != y - 1900 || tm.tm_mon != mo - 1 || tm.tm_mday != d) return false;
|
||||||
|
outStartOfDay = (std::int64_t)t;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
void ExportTransactionsDialog::show()
|
void ExportTransactionsDialog::show()
|
||||||
{
|
{
|
||||||
s_open = true;
|
s_open = true;
|
||||||
s_saved_path.clear();
|
s_saved_path.clear();
|
||||||
s_error.clear();
|
s_error.clear();
|
||||||
|
// Pre-select the current History filter only when one is actually applied.
|
||||||
|
s_scope = GetTransactionsFilterSnapshot().active ? 1 : 0;
|
||||||
|
s_from[0] = '\0';
|
||||||
|
s_to[0] = '\0';
|
||||||
|
|
||||||
// Generate default filename with timestamp
|
// Generate default filename with timestamp
|
||||||
std::time_t now = std::time(nullptr);
|
std::time_t now = std::time(nullptr);
|
||||||
@@ -74,8 +100,64 @@ void ExportTransactionsDialog::render(App* app)
|
|||||||
if (!m::BeginOverlayDialog(ov)) return;
|
if (!m::BeginOverlayDialog(ov)) return;
|
||||||
|
|
||||||
const auto& state = app->getWalletState();
|
const auto& state = app->getWalletState();
|
||||||
|
const TxFilterSnapshot& snap = GetTransactionsFilterSnapshot();
|
||||||
|
const bool filterUsable = snap.valid && snap.active && snap.txRevision == state.tx_revision;
|
||||||
|
|
||||||
ImGui::Text(TR("export_tx_count"), state.transactions.size());
|
// ---- Export scope ----
|
||||||
|
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceMedium()), "%s", TR("export_tx_scope"));
|
||||||
|
ImGui::RadioButton(TR("export_tx_scope_all"), &s_scope, 0);
|
||||||
|
if (!filterUsable) ImGui::BeginDisabled();
|
||||||
|
ImGui::RadioButton(TR("export_tx_scope_filter"), &s_scope, 1);
|
||||||
|
if (!filterUsable) ImGui::EndDisabled();
|
||||||
|
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled) && !filterUsable)
|
||||||
|
m::Tooltip("%s", TR("export_tx_scope_filter_none"));
|
||||||
|
if (s_scope == 1 && filterUsable && !snap.description.empty()) {
|
||||||
|
ImGui::SameLine();
|
||||||
|
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceDisabled()), "(%s)", snap.description.c_str());
|
||||||
|
}
|
||||||
|
ImGui::RadioButton(TR("export_tx_scope_range"), &s_scope, 2);
|
||||||
|
|
||||||
|
std::int64_t fromT = 0, toT = 0; bool fromOk = false, toOk = false, rangeBad = false;
|
||||||
|
if (s_scope == 2) {
|
||||||
|
ImGui::Indent();
|
||||||
|
const float dw = 130.0f * Layout::dpiScale();
|
||||||
|
ImGui::SetNextItemWidth(dw);
|
||||||
|
ImGui::InputTextWithHint("##exfrom", TR("export_tx_range_from"), s_from, sizeof(s_from));
|
||||||
|
ImGui::SameLine();
|
||||||
|
ImGui::SetNextItemWidth(dw);
|
||||||
|
ImGui::InputTextWithHint("##exto", TR("export_tx_range_to"), s_to, sizeof(s_to));
|
||||||
|
fromOk = parseYmd(s_from, fromT);
|
||||||
|
toOk = parseYmd(s_to, toT);
|
||||||
|
if (toOk) toT += 24 * 3600 - 1; // inclusive end-of-day
|
||||||
|
if ((s_from[0] && !fromOk) || (s_to[0] && !toOk)) rangeBad = true;
|
||||||
|
if (fromOk && toOk && fromT > toT) rangeBad = true;
|
||||||
|
if (rangeBad)
|
||||||
|
ImGui::TextColored(m::ErrorVec4(), "%s", TR("export_tx_range_invalid"));
|
||||||
|
else
|
||||||
|
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(m::OnSurfaceDisabled()), "%s", TR("export_tx_range_pending_note"));
|
||||||
|
ImGui::Unindent();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the subset for the chosen scope (scopeOk=false ⇒ can't export: stale snapshot / bad range).
|
||||||
|
std::vector<size_t> subsetIdx; bool scopeOk = true;
|
||||||
|
if (s_scope == 1) {
|
||||||
|
if (!filterUsable) scopeOk = false;
|
||||||
|
else for (size_t i : snap.indices) if (i < state.transactions.size()) subsetIdx.push_back(i);
|
||||||
|
} else if (s_scope == 2) {
|
||||||
|
if (rangeBad) scopeOk = false;
|
||||||
|
else for (size_t i = 0; i < state.transactions.size(); ++i) {
|
||||||
|
const auto& tx = state.transactions[i];
|
||||||
|
if (tx.timestamp <= 0) continue; // pending rows have no date
|
||||||
|
if (fromOk && tx.timestamp < fromT) continue;
|
||||||
|
if (toOk && tx.timestamp > toT) continue;
|
||||||
|
subsetIdx.push_back(i);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (size_t i = 0; i < state.transactions.size(); ++i) subsetIdx.push_back(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
ImGui::Spacing();
|
||||||
|
ImGui::Text(TR("export_tx_count"), scopeOk ? subsetIdx.size() : (size_t)0);
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
|
|
||||||
// Filename + save-location hint.
|
// Filename + save-location hint.
|
||||||
@@ -94,6 +176,8 @@ void ExportTransactionsDialog::render(App* app)
|
|||||||
ImGui::SameLine(0, 4.0f * Layout::dpiScale());
|
ImGui::SameLine(0, 4.0f * Layout::dpiScale());
|
||||||
ImGui::TextColored(m::SuccessVec4(), "%s", TR("export_tx_success"));
|
ImGui::TextColored(m::SuccessVec4(), "%s", TR("export_tx_success"));
|
||||||
widgets::AddressCopyField("##exportedpath", s_saved_path);
|
widgets::AddressCopyField("##exportedpath", s_saved_path);
|
||||||
|
if (m::TactileButton(TR("export_tx_open_folder"), ImVec2(0, 0)))
|
||||||
|
util::Platform::openFolder(util::Platform::getConfigDir());
|
||||||
} else if (!s_error.empty()) {
|
} else if (!s_error.empty()) {
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::PushTextWrapPos(0.0f);
|
ImGui::PushTextWrapPos(0.0f);
|
||||||
@@ -109,14 +193,29 @@ void ExportTransactionsDialog::render(App* app)
|
|||||||
if (doExport) {
|
if (doExport) {
|
||||||
s_saved_path.clear();
|
s_saved_path.clear();
|
||||||
s_error.clear();
|
s_error.clear();
|
||||||
if (state.transactions.empty()) {
|
if (!scopeOk) {
|
||||||
|
// Stale filter snapshot (tx list changed) or an invalid date range — never silently export all.
|
||||||
|
Notifications::instance().warning(s_scope == 1 ? TR("export_tx_scope_stale") : TR("export_tx_range_invalid"));
|
||||||
|
} else if (subsetIdx.empty()) {
|
||||||
Notifications::instance().warning(TR("export_tx_none"));
|
Notifications::instance().warning(TR("export_tx_none"));
|
||||||
} else {
|
} else {
|
||||||
std::string configDir = util::Platform::getConfigDir();
|
std::string configDir = util::Platform::getConfigDir();
|
||||||
std::string filepath = configDir + "/" + s_filename;
|
// Sanitize in-buffer so the user sees exactly what was written (and we can't be pointed outside
|
||||||
// Build in memory, then write atomically (temp + fsync + rename): a disk-full /
|
// the config dir by a pasted path).
|
||||||
// removable-media failure is reported instead of leaving a truncated CSV behind a success toast.
|
std::string safeName = util::sanitizeExportFilename(s_filename, ".csv", "dragonx_transactions.csv");
|
||||||
const std::string csv = data::formatTransactionsCsv(state.transactions);
|
snprintf(s_filename, sizeof(s_filename), "%s", safeName.c_str());
|
||||||
|
std::string filepath = configDir + "/" + safeName;
|
||||||
|
// Build the chosen subset (scope 0 exports the whole list with no copy), then write atomically
|
||||||
|
// (temp + fsync + rename) so a disk-full failure is reported instead of a truncated CSV.
|
||||||
|
std::string csv;
|
||||||
|
if (s_scope == 0) {
|
||||||
|
csv = data::formatTransactionsCsv(state.transactions);
|
||||||
|
} else {
|
||||||
|
std::vector<TransactionInfo> subset;
|
||||||
|
subset.reserve(subsetIdx.size());
|
||||||
|
for (size_t i : subsetIdx) subset.push_back(state.transactions[i]);
|
||||||
|
csv = data::formatTransactionsCsv(subset);
|
||||||
|
}
|
||||||
if (!util::Platform::writeFileAtomically(filepath, csv)) {
|
if (!util::Platform::writeFileAtomically(filepath, csv)) {
|
||||||
s_error = TR("export_tx_file_fail");
|
s_error = TR("export_tx_file_fail");
|
||||||
Notifications::instance().error(TR("export_tx_file_fail"));
|
Notifications::instance().error(TR("export_tx_file_fail"));
|
||||||
|
|||||||
@@ -121,9 +121,9 @@ void KeyExportDialog::render(App* app)
|
|||||||
ImGui::Text("%s", key_label);
|
ImGui::Text("%s", key_label);
|
||||||
|
|
||||||
if (s_fetching) {
|
if (s_fetching) {
|
||||||
ImGui::TextColored(ImVec4(1.0f, 1.0f, 0.0f, 1.0f), "%s", TR("key_export_fetching"));
|
ImGui::TextColored(material::WarningVec4(), "%s%s", TR("key_export_fetching_base"), material::LoadingDots());
|
||||||
} else if (!s_error.empty()) {
|
} else if (!s_error.empty()) {
|
||||||
ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), TR("error_format"), s_error.c_str());
|
ImGui::TextColored(material::ErrorVec4(), TR("error_format"), s_error.c_str());
|
||||||
// Not a dead-end: clearing the error falls back through to the Reveal button next frame.
|
// Not a dead-end: clearing the error falls back through to the Reveal button next frame.
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
if (material::TactileButton(TR("retry"), ImVec2(revealBtn.width, 0), S.resolveFont(revealBtn.font))) {
|
if (material::TactileButton(TR("retry"), ImVec2(revealBtn.width, 0), S.resolveFont(revealBtn.font))) {
|
||||||
@@ -132,7 +132,17 @@ void KeyExportDialog::render(App* app)
|
|||||||
} else if (s_key.empty() && s_address.length() < 26) {
|
} else if (s_key.empty() && s_address.length() < 26) {
|
||||||
// Guard against an empty/blatantly-malformed address before dispatching a doomed reveal.
|
// Guard against an empty/blatantly-malformed address before dispatching a doomed reveal.
|
||||||
// (Also avoids reading s_address[0] on an empty string in the Reveal branch below.)
|
// (Also avoids reading s_address[0] on an empty string in the Reveal branch below.)
|
||||||
ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "%s", TR("invalid_address"));
|
ImGui::TextColored(material::ErrorVec4(), "%s", TR("invalid_address"));
|
||||||
|
} else if (s_key.empty() && app->getWalletState().isLocked()) {
|
||||||
|
// Reveal would just fail with a locked-wallet RPC error — say so and offer to unlock.
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, material::Warning());
|
||||||
|
ImGui::TextWrapped("%s", TR("key_export_unlock_first"));
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
ImGui::Spacing();
|
||||||
|
if (material::TactileButton(TR("lite_unlock"), ImVec2(revealBtn.width, 0), S.resolveFont(revealBtn.font))) {
|
||||||
|
if (app->liteWallet()) app->requestLiteUnlock();
|
||||||
|
else s_open = false; // full node: closing reveals the lock screen already shown while locked
|
||||||
|
}
|
||||||
} else if (s_key.empty()) {
|
} else if (s_key.empty()) {
|
||||||
// Show button to fetch key
|
// Show button to fetch key
|
||||||
if (material::TactileButton(TR("key_export_reveal"), ImVec2(revealBtn.width, 0), S.resolveFont(revealBtn.font))) {
|
if (material::TactileButton(TR("key_export_reveal"), ImVec2(revealBtn.width, 0), S.resolveFont(revealBtn.font))) {
|
||||||
@@ -173,7 +183,7 @@ void KeyExportDialog::render(App* app)
|
|||||||
s_key = found;
|
s_key = found;
|
||||||
s_show_key = wantViewing; // viewing keys are less sensitive
|
s_show_key = wantViewing; // viewing keys are less sensitive
|
||||||
} else {
|
} else {
|
||||||
s_error = r.ok ? std::string(TR("grpc_key_not_available")) : r.error;
|
s_error = r.ok ? std::string(TR("grpc_key_not_available")) : util::friendlyRpcError(r.error);
|
||||||
}
|
}
|
||||||
wallet::secureWipeLiteSecret(found);
|
wallet::secureWipeLiteSecret(found);
|
||||||
s_fetching = false;
|
s_fetching = false;
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ using namespace material;
|
|||||||
// Track selected peer for ban action
|
// Track selected peer for ban action
|
||||||
static int s_selected_peer_idx = -1;
|
static int s_selected_peer_idx = -1;
|
||||||
static int s_selected_banned_idx = -1;
|
static int s_selected_banned_idx = -1;
|
||||||
|
static bool s_confirm_clear_bans = false; // inline confirm for the bulk Clear-All-Bans action
|
||||||
|
|
||||||
// Helper: Extract IP without port
|
// Helper: Extract IP without port
|
||||||
static std::string ExtractIP(const std::string& addr)
|
static std::string ExtractIP(const std::string& addr)
|
||||||
@@ -775,9 +776,11 @@ void RenderPeersTab(App* app)
|
|||||||
ImGui::Separator();
|
ImGui::Separator();
|
||||||
if (ImGui::MenuItem(TR("copy_address"))) {
|
if (ImGui::MenuItem(TR("copy_address"))) {
|
||||||
ImGui::SetClipboardText(peer.addr.c_str());
|
ImGui::SetClipboardText(peer.addr.c_str());
|
||||||
|
ui::Notifications::instance().info(TR("copied_to_clipboard"));
|
||||||
}
|
}
|
||||||
if (ImGui::MenuItem(TR("peers_copy_ip"))) {
|
if (ImGui::MenuItem(TR("peers_copy_ip"))) {
|
||||||
ImGui::SetClipboardText(ExtractIP(peer.addr).c_str());
|
ImGui::SetClipboardText(ExtractIP(peer.addr).c_str());
|
||||||
|
ui::Notifications::instance().info(TR("copied_to_clipboard"));
|
||||||
}
|
}
|
||||||
ImGui::Separator();
|
ImGui::Separator();
|
||||||
if (ImGui::MenuItem(TR("peers_ban_24h"))) {
|
if (ImGui::MenuItem(TR("peers_ban_24h"))) {
|
||||||
@@ -891,6 +894,7 @@ void RenderPeersTab(App* app)
|
|||||||
if (effects::ImGuiAcrylic::BeginAcrylicContextItem(nullptr, 0, acrylicTheme2.menu)) {
|
if (effects::ImGuiAcrylic::BeginAcrylicContextItem(nullptr, 0, acrylicTheme2.menu)) {
|
||||||
if (ImGui::MenuItem(TR("copy_address"))) {
|
if (ImGui::MenuItem(TR("copy_address"))) {
|
||||||
ImGui::SetClipboardText(banned.address.c_str());
|
ImGui::SetClipboardText(banned.address.c_str());
|
||||||
|
ui::Notifications::instance().info(TR("copied_to_clipboard"));
|
||||||
}
|
}
|
||||||
if (ImGui::MenuItem(TR("peers_unban"))) {
|
if (ImGui::MenuItem(TR("peers_unban"))) {
|
||||||
app->unbanPeer(banned.address);
|
app->unbanPeer(banned.address);
|
||||||
@@ -931,10 +935,25 @@ void RenderPeersTab(App* app)
|
|||||||
// ================================================================
|
// ================================================================
|
||||||
if (s_show_banned && !state.bannedPeers.empty()) {
|
if (s_show_banned && !state.bannedPeers.empty()) {
|
||||||
ImGui::BeginDisabled(!app->isConnected());
|
ImGui::BeginDisabled(!app->isConnected());
|
||||||
if (TactileSmallButton(TR("peers_clear_all_bans"), S.resolveFont("button"))) {
|
if (!s_confirm_clear_bans) {
|
||||||
app->clearBans();
|
if (TactileSmallButton(TR("peers_clear_all_bans"), S.resolveFont("button")))
|
||||||
|
s_confirm_clear_bans = true; // bulk action takes a confirming second click
|
||||||
|
} else {
|
||||||
|
char cb[96];
|
||||||
|
snprintf(cb, sizeof(cb), TR("peers_clear_bans_confirm"), (int)state.bannedPeers.size());
|
||||||
|
ImGui::TextColored(material::WarningVec4(), "%s", cb);
|
||||||
|
ImGui::SameLine();
|
||||||
|
if (TactileSmallButton(TR("peers_clear_bans_confirm_yes"), S.resolveFont("button"))) {
|
||||||
|
app->clearBans();
|
||||||
|
s_confirm_clear_bans = false;
|
||||||
|
}
|
||||||
|
ImGui::SameLine();
|
||||||
|
if (TactileSmallButton(TR("cancel"), S.resolveFont("button")))
|
||||||
|
s_confirm_clear_bans = false;
|
||||||
}
|
}
|
||||||
ImGui::EndDisabled();
|
ImGui::EndDisabled();
|
||||||
|
} else {
|
||||||
|
s_confirm_clear_bans = false; // no stale confirm across tab toggles / after bans clear
|
||||||
}
|
}
|
||||||
|
|
||||||
ImGui::EndChild(); // ##PeersScroll
|
ImGui::EndChild(); // ##PeersScroll
|
||||||
|
|||||||
@@ -333,10 +333,11 @@ static void RenderAddressDropdown(App* app, float width) {
|
|||||||
} else {
|
} else {
|
||||||
s_generating_address = true;
|
s_generating_address = true;
|
||||||
if (s_addr_type_filter != 2) {
|
if (s_addr_type_filter != 2) {
|
||||||
app->createNewZAddress([](const std::string& addr) {
|
app->createNewZAddress([app](const std::string& addr) {
|
||||||
s_generating_address = false;
|
s_generating_address = false;
|
||||||
if (addr.empty())
|
if (addr.empty())
|
||||||
Notifications::instance().error(TR("failed_create_shielded"));
|
Notifications::instance().error(app->lastAddressError().empty()
|
||||||
|
? std::string(TR("failed_create_shielded")) : app->lastAddressError());
|
||||||
else {
|
else {
|
||||||
s_pending_select_address = addr;
|
s_pending_select_address = addr;
|
||||||
Notifications::instance().success(TR("new_shielded_created"));
|
Notifications::instance().success(TR("new_shielded_created"));
|
||||||
@@ -400,10 +401,11 @@ static void RenderMiningAddressNotice(App* app, const WalletState& state, float
|
|||||||
s_cached_qr_data.clear();
|
s_cached_qr_data.clear();
|
||||||
} else {
|
} else {
|
||||||
s_generating_address = true; // no shielded address yet: make one, as "New" does
|
s_generating_address = true; // no shielded address yet: make one, as "New" does
|
||||||
app->createNewZAddress([](const std::string& addr) {
|
app->createNewZAddress([app](const std::string& addr) {
|
||||||
s_generating_address = false;
|
s_generating_address = false;
|
||||||
if (addr.empty())
|
if (addr.empty())
|
||||||
Notifications::instance().error(TR("failed_create_shielded"));
|
Notifications::instance().error(app->lastAddressError().empty()
|
||||||
|
? std::string(TR("failed_create_shielded")) : app->lastAddressError());
|
||||||
else {
|
else {
|
||||||
s_pending_select_address = addr;
|
s_pending_select_address = addr;
|
||||||
Notifications::instance().success(TR("new_shielded_created"));
|
Notifications::instance().success(TR("new_shielded_created"));
|
||||||
|
|||||||
@@ -119,10 +119,11 @@ void RequestPaymentDialog::render(App* app)
|
|||||||
if (material::TactileButton(TR("request_create_shielded"), ImVec2(0, 0), S.resolveFont(actionBtn.font))) {
|
if (material::TactileButton(TR("request_create_shielded"), ImVec2(0, 0), S.resolveFont(actionBtn.font))) {
|
||||||
if (app->isConnected()) {
|
if (app->isConnected()) {
|
||||||
s_creating_address = true;
|
s_creating_address = true;
|
||||||
app->createNewZAddress([](const std::string& addr) {
|
app->createNewZAddress([app](const std::string& addr) {
|
||||||
s_creating_address = false;
|
s_creating_address = false;
|
||||||
if (addr.empty()) {
|
if (addr.empty()) {
|
||||||
Notifications::instance().error(TR("failed_create_shielded"));
|
Notifications::instance().error(app->lastAddressError().empty()
|
||||||
|
? std::string(TR("failed_create_shielded")) : app->lastAddressError());
|
||||||
} else {
|
} else {
|
||||||
Notifications::instance().success(TR("new_shielded_created"));
|
Notifications::instance().success(TR("new_shielded_created"));
|
||||||
// Pre-select the new address if the dialog is still open and none chosen.
|
// Pre-select the new address if the dialog is still open and none chosen.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
#include <sodium.h>
|
||||||
|
|
||||||
#include "imgui.h"
|
#include "imgui.h"
|
||||||
#include "../layout.h" // Layout::dpiScale()
|
#include "../layout.h" // Layout::dpiScale()
|
||||||
@@ -15,19 +16,31 @@
|
|||||||
namespace dragonx {
|
namespace dragonx {
|
||||||
namespace ui {
|
namespace ui {
|
||||||
|
|
||||||
|
// Best-effort zero of a seed-word vector (each word holds part of the phrase). Call after rendering.
|
||||||
|
inline void WipeSeedWords(std::vector<std::string>& words)
|
||||||
|
{
|
||||||
|
for (auto& w : words) { if (!w.empty()) sodium_memzero(&w[0], w.size()); w.clear(); }
|
||||||
|
words.clear();
|
||||||
|
}
|
||||||
|
|
||||||
// Split a whitespace-separated seed phrase into its individual words.
|
// Split a whitespace-separated seed phrase into its individual words.
|
||||||
inline std::vector<std::string> SplitSeedWords(const std::string& phrase)
|
inline std::vector<std::string> SplitSeedWords(const std::string& phrase)
|
||||||
{
|
{
|
||||||
std::vector<std::string> words;
|
std::vector<std::string> words;
|
||||||
|
words.reserve(32); // avoid reallocation stranding un-wiped moved-from copies in freed heap
|
||||||
std::string cur;
|
std::string cur;
|
||||||
for (char c : phrase) {
|
for (char c : phrase) {
|
||||||
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') {
|
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') {
|
||||||
if (!cur.empty()) { words.push_back(cur); cur.clear(); }
|
if (!cur.empty()) {
|
||||||
|
words.push_back(cur);
|
||||||
|
sodium_memzero(&cur[0], cur.size()); // zero the scratch copy before reuse
|
||||||
|
cur.clear();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
cur.push_back(c);
|
cur.push_back(c);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!cur.empty()) words.push_back(cur);
|
if (!cur.empty()) { words.push_back(cur); sodium_memzero(&cur[0], cur.size()); cur.clear(); }
|
||||||
return words;
|
return words;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +58,7 @@ inline void RenderSeedWordGrid(const std::vector<std::string>& words, float colS
|
|||||||
std::snprintf(cell, sizeof(cell), "%2zu. %s", i + 1, words[i].c_str());
|
std::snprintf(cell, sizeof(cell), "%2zu. %s", i + 1, words[i].c_str());
|
||||||
const float need = ImGui::CalcTextSize(cell).x + 20.0f * dp; // cell width + inter-column gap
|
const float need = ImGui::CalcTextSize(cell).x + 20.0f * dp; // cell width + inter-column gap
|
||||||
if (need > colStride) colStride = need;
|
if (need > colStride) colStride = need;
|
||||||
|
sodium_memzero(cell, sizeof(cell)); // the cell held a seed word
|
||||||
}
|
}
|
||||||
for (size_t i = 0; i < words.size(); ++i) {
|
for (size_t i = 0; i < words.size(); ++i) {
|
||||||
char cell[96];
|
char cell[96];
|
||||||
@@ -52,6 +66,7 @@ inline void RenderSeedWordGrid(const std::vector<std::string>& words, float colS
|
|||||||
ImGui::TextUnformatted(cell);
|
ImGui::TextUnformatted(cell);
|
||||||
if ((i % 4) != 3 && i + 1 < words.size())
|
if ((i % 4) != 3 && i + 1 < words.size())
|
||||||
ImGui::SameLine(((i % 4) + 1) * colStride);
|
ImGui::SameLine(((i % 4) + 1) * colStride);
|
||||||
|
sodium_memzero(cell, sizeof(cell));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,8 @@
|
|||||||
#include "../../util/address_validation.h"
|
#include "../../util/address_validation.h"
|
||||||
#include "../../util/i18n.h"
|
#include "../../util/i18n.h"
|
||||||
#include "../../util/text_format.h"
|
#include "../../util/text_format.h"
|
||||||
|
#include "../../util/amount_format.h" // toSats / canAfford — integer-satoshi affordability check
|
||||||
|
#include "../../rpc/rpc_failure.h" // isSendMaybeInFlight — lost-reply (outcome unknown) send tag
|
||||||
#include "../notifications.h"
|
#include "../notifications.h"
|
||||||
#include "../layout.h"
|
#include "../layout.h"
|
||||||
#include "contact_picker.h"
|
#include "contact_picker.h"
|
||||||
@@ -915,8 +917,9 @@ void RenderSendConfirmPopup(App* app) {
|
|||||||
// since Review. Don't broadcast a now-invalid transaction.
|
// since Review. Don't broadcast a now-invalid transaction.
|
||||||
const auto& liveState = app->getWalletState();
|
const auto& liveState = app->getWalletState();
|
||||||
double liveAvail = GetAvailableBalance(app);
|
double liveAvail = GetAvailableBalance(app);
|
||||||
if (!app->isConnected() || liveState.sync.syncing || s_amount <= 0 ||
|
if (!app->isConnected() || liveState.sync.syncing ||
|
||||||
(s_amount + s_fee) > liveAvail) {
|
!liveState.sync.sendTipReady(app->isLiteBuild()) || s_amount <= 0 ||
|
||||||
|
!util::canAfford(s_amount, s_fee, liveAvail)) {
|
||||||
s_tx_status = TR("send_cannot_send_now");
|
s_tx_status = TR("send_cannot_send_now");
|
||||||
s_status_success = false;
|
s_status_success = false;
|
||||||
s_status_timestamp = ImGui::GetTime();
|
s_status_timestamp = ImGui::GetTime();
|
||||||
@@ -954,6 +957,18 @@ void RenderSendConfirmPopup(App* app) {
|
|||||||
s_amount = 0.0;
|
s_amount = 0.0;
|
||||||
s_memo[0] = '\0';
|
s_memo[0] = '\0';
|
||||||
s_send_max = false;
|
s_send_max = false;
|
||||||
|
} else if (rpc::isSendMaybeInFlight(result)) {
|
||||||
|
// Outcome unknown: the reply was lost after the request left. Do NOT present a
|
||||||
|
// plain failure (it invites a retry/double-send). Warn, point at History, and
|
||||||
|
// clear the form so a resend has to be retyped deliberately.
|
||||||
|
s_tx_status = std::string(TR("send_maybe_sent")) + "\n\n" + rpc::stripSendMaybeInFlight(result);
|
||||||
|
s_result_txid.clear();
|
||||||
|
s_status_success = false;
|
||||||
|
Notifications::instance().warning(TR("send_maybe_sent"), 15.0f);
|
||||||
|
s_to_address[0] = '\0';
|
||||||
|
s_amount = 0.0;
|
||||||
|
s_memo[0] = '\0';
|
||||||
|
s_send_max = false;
|
||||||
} else {
|
} else {
|
||||||
// Show a friendly cause in the toast; keep the raw backend text below it in
|
// Show a friendly cause in the toast; keep the raw backend text below it in
|
||||||
// the overlay (copyable) so the detail isn't lost for diagnostics.
|
// the overlay (copyable) so the detail isn't lost for diagnostics.
|
||||||
@@ -1034,10 +1049,11 @@ static void RenderActionButtons(App* app, float width, float vScale,
|
|||||||
}
|
}
|
||||||
bool can_send = app->isConnected() &&
|
bool can_send = app->isConnected() &&
|
||||||
!state.sync.syncing &&
|
!state.sync.syncing &&
|
||||||
|
state.sync.sendTipReady(app->isLiteBuild()) &&
|
||||||
is_valid_address &&
|
is_valid_address &&
|
||||||
s_amount > 0 &&
|
s_amount > 0 &&
|
||||||
s_from_address[0] != '\0' &&
|
s_from_address[0] != '\0' &&
|
||||||
total <= available &&
|
util::canAfford(s_amount, s_fee, available) &&
|
||||||
sourceSpendable &&
|
sourceSpendable &&
|
||||||
!s_sending;
|
!s_sending;
|
||||||
|
|
||||||
@@ -1058,7 +1074,7 @@ static void RenderActionButtons(App* app, float width, float vScale,
|
|||||||
float btnH = std::max(schema::UI().drawElement("tabs.send", "action-btn-min-height").size, schema::UI().drawElement("tabs.send", "action-btn-height").size * vScale);
|
float btnH = std::max(schema::UI().drawElement("tabs.send", "action-btn-min-height").size, schema::UI().drawElement("tabs.send", "action-btn-height").size * vScale);
|
||||||
|
|
||||||
// Explain why Review/Send is greyed while syncing (the fields above are usable now).
|
// Explain why Review/Send is greyed while syncing (the fields above are usable now).
|
||||||
if (state.sync.syncing) {
|
if (state.sync.syncing || (app->isConnected() && !state.sync.sendTipReady(app->isLiteBuild()))) {
|
||||||
Type().textColored(TypeStyle::Caption, Warning(), TR("send_available_when_synced"));
|
Type().textColored(TypeStyle::Caption, Warning(), TR("send_available_when_synced"));
|
||||||
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
||||||
}
|
}
|
||||||
@@ -1087,6 +1103,8 @@ static void RenderActionButtons(App* app, float width, float vScale,
|
|||||||
material::Tooltip("%s", TR(app->isLiteBuild() ? "lite_no_wallet_short" : "send_tooltip_not_connected"));
|
material::Tooltip("%s", TR(app->isLiteBuild() ? "lite_no_wallet_short" : "send_tooltip_not_connected"));
|
||||||
else if (state.sync.syncing)
|
else if (state.sync.syncing)
|
||||||
material::Tooltip("%s", TR("send_tooltip_syncing"));
|
material::Tooltip("%s", TR("send_tooltip_syncing"));
|
||||||
|
else if (!state.sync.sendTipReady(app->isLiteBuild()))
|
||||||
|
material::Tooltip("%s", TR("send_tooltip_no_tip"));
|
||||||
else if (s_from_address[0] == '\0')
|
else if (s_from_address[0] == '\0')
|
||||||
material::Tooltip("%s", TR("send_tooltip_select_source"));
|
material::Tooltip("%s", TR("send_tooltip_select_source"));
|
||||||
else if (is_mining_addr)
|
else if (is_mining_addr)
|
||||||
@@ -1095,7 +1113,7 @@ static void RenderActionButtons(App* app, float width, float vScale,
|
|||||||
material::Tooltip("%s", TR("send_tooltip_invalid_address"));
|
material::Tooltip("%s", TR("send_tooltip_invalid_address"));
|
||||||
else if (s_amount <= 0)
|
else if (s_amount <= 0)
|
||||||
material::Tooltip("%s", TR("send_tooltip_enter_amount"));
|
material::Tooltip("%s", TR("send_tooltip_enter_amount"));
|
||||||
else if (total > available)
|
else if (!util::canAfford(s_amount, s_fee, available))
|
||||||
material::Tooltip("%s", TR("send_tooltip_exceeds_balance"));
|
material::Tooltip("%s", TR("send_tooltip_exceeds_balance"));
|
||||||
else if (!sourceSpendable)
|
else if (!sourceSpendable)
|
||||||
material::Tooltip("%s", TR("send_tooltip_view_only"));
|
material::Tooltip("%s", TR("send_tooltip_view_only"));
|
||||||
@@ -1530,6 +1548,7 @@ void RenderSendTab(App* app)
|
|||||||
if (ImGui::InputDouble("##AmountUSD", &s_usd_amount, 0, 0, "$%.2f")) {
|
if (ImGui::InputDouble("##AmountUSD", &s_usd_amount, 0, 0, "$%.2f")) {
|
||||||
// Normalize to 8dp (satoshi precision) so the reviewed/sent DRGX matches the preview.
|
// Normalize to 8dp (satoshi precision) so the reviewed/sent DRGX matches the preview.
|
||||||
s_amount = std::round((s_usd_amount / market.price_usd) * 1e8) / 1e8;
|
s_amount = std::round((s_usd_amount / market.price_usd) * 1e8) / 1e8;
|
||||||
|
s_send_max = false; // a manual edit is no longer "max" — don't let a later fee bump overwrite it
|
||||||
}
|
}
|
||||||
// Draw DRGX equivalent inside the input field (right-aligned overlay)
|
// Draw DRGX equivalent inside the input field (right-aligned overlay)
|
||||||
{
|
{
|
||||||
@@ -1555,6 +1574,7 @@ void RenderSendTab(App* app)
|
|||||||
if (s_amount < 0) s_amount = 0;
|
if (s_amount < 0) s_amount = 0;
|
||||||
if (market.price_usd > 0)
|
if (market.price_usd > 0)
|
||||||
s_usd_amount = s_amount * market.price_usd;
|
s_usd_amount = s_amount * market.price_usd;
|
||||||
|
s_send_max = false; // a manual edit is no longer "max" — don't let a later fee bump overwrite it
|
||||||
}
|
}
|
||||||
// Draw USD equivalent inside the input field (right-aligned overlay)
|
// Draw USD equivalent inside the input field (right-aligned overlay)
|
||||||
if (market.price_usd > 0 && s_amount > 0) {
|
if (market.price_usd > 0 && s_amount > 0) {
|
||||||
@@ -1619,7 +1639,7 @@ void RenderSendTab(App* app)
|
|||||||
RenderAmountBar(dl, available, colW, capFont, market.price_usd, "##f");
|
RenderAmountBar(dl, available, colW, capFont, market.price_usd, "##f");
|
||||||
|
|
||||||
// Amount error
|
// Amount error
|
||||||
if (s_amount > 0 && s_amount + s_fee > available && available > 0) {
|
if (s_amount > 0 && !util::canAfford(s_amount, s_fee, available) && available > 0) {
|
||||||
snprintf(buf, sizeof(buf), TR("send_exceeds_available"), available - s_fee);
|
snprintf(buf, sizeof(buf), TR("send_exceeds_available"), available - s_fee);
|
||||||
Type().textColored(TypeStyle::Caption, Error(), buf);
|
Type().textColored(TypeStyle::Caption, Error(), buf);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,608 +0,0 @@
|
|||||||
// DragonX Wallet - ImGui Edition
|
|
||||||
// Copyright 2024-2026 The Hush Developers
|
|
||||||
// Released under the GPLv3
|
|
||||||
|
|
||||||
#include "settings_window.h"
|
|
||||||
#include "../../app.h"
|
|
||||||
#include "../../config/version.h"
|
|
||||||
#include "../../config/settings.h"
|
|
||||||
#include "../../util/i18n.h"
|
|
||||||
#include "../../util/platform.h"
|
|
||||||
#include "../../rpc/rpc_client.h"
|
|
||||||
#include "../theme.h"
|
|
||||||
#include "../schema/ui_schema.h"
|
|
||||||
#include "../schema/skin_manager.h"
|
|
||||||
#include "../notifications.h"
|
|
||||||
#include "../effects/imgui_acrylic.h"
|
|
||||||
#include "../material/draw_helpers.h"
|
|
||||||
#include "../../embedded/IconsMaterialDesign.h"
|
|
||||||
#include "imgui.h"
|
|
||||||
#include <vector>
|
|
||||||
#include <filesystem>
|
|
||||||
|
|
||||||
// Icon text for settings UI
|
|
||||||
#define ICON_CUSTOM_THEME ICON_MD_TUNE
|
|
||||||
#define ICON_REFRESH_THEMES ICON_MD_REFRESH
|
|
||||||
|
|
||||||
namespace dragonx {
|
|
||||||
namespace ui {
|
|
||||||
|
|
||||||
// Helper: build "TranslatedLabel##id" for ImGui widgets that use label as ID
|
|
||||||
static std::string TrId(const char* tr_key, const char* id) {
|
|
||||||
std::string s = TR(tr_key);
|
|
||||||
s += "##";
|
|
||||||
s += id;
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Settings state - these get loaded from Settings on window open
|
|
||||||
static bool s_initialized = false;
|
|
||||||
static int s_language_index = 0;
|
|
||||||
static bool s_save_ztxs = true;
|
|
||||||
static bool s_allow_custom_fees = false;
|
|
||||||
static bool s_auto_shield = false;
|
|
||||||
static bool s_fetch_prices = true;
|
|
||||||
static bool s_use_tor = false;
|
|
||||||
static char s_rpc_host[128] = DRAGONX_DEFAULT_RPC_HOST;
|
|
||||||
static char s_rpc_port[16] = DRAGONX_DEFAULT_RPC_PORT;
|
|
||||||
static char s_rpc_user[64] = "";
|
|
||||||
static char s_rpc_password[64] = "";
|
|
||||||
static char s_tx_explorer[256] = "https://explorer.dragonx.is/tx/";
|
|
||||||
static char s_addr_explorer[256] = "https://explorer.dragonx.is/address/";
|
|
||||||
|
|
||||||
// Acrylic settings
|
|
||||||
static bool s_acrylic_enabled = true;
|
|
||||||
static float s_blur_amount = 1.5f; // 0.0=Off, continuous blur multiplier
|
|
||||||
static float s_noise_opacity = 0.5f;
|
|
||||||
static bool s_reduced_transparency = false; // Accessibility option
|
|
||||||
static bool s_gradient_background = false; // Gradient background mode
|
|
||||||
|
|
||||||
// Saved skin ID for cancel/revert
|
|
||||||
static std::string s_saved_skin_id;
|
|
||||||
|
|
||||||
// Load current settings into UI state
|
|
||||||
static void loadSettingsToUI(config::Settings* settings) {
|
|
||||||
if (!settings) return;
|
|
||||||
|
|
||||||
s_saved_skin_id = settings->getSkinId();
|
|
||||||
s_save_ztxs = settings->getSaveZtxs();
|
|
||||||
s_allow_custom_fees = settings->getAllowCustomFees();
|
|
||||||
s_auto_shield = settings->getAutoShield();
|
|
||||||
s_fetch_prices = settings->getFetchPrices();
|
|
||||||
s_use_tor = settings->getUseTor();
|
|
||||||
|
|
||||||
strncpy(s_tx_explorer, settings->getTxExplorerUrl().c_str(), sizeof(s_tx_explorer) - 1);
|
|
||||||
strncpy(s_addr_explorer, settings->getAddressExplorerUrl().c_str(), sizeof(s_addr_explorer) - 1);
|
|
||||||
|
|
||||||
// Set language index
|
|
||||||
auto& i18n = util::I18n::instance();
|
|
||||||
const auto& languages = i18n.getAvailableLanguages();
|
|
||||||
std::string current_lang = settings->getLanguage();
|
|
||||||
if (current_lang.empty()) current_lang = "en";
|
|
||||||
|
|
||||||
s_language_index = 0;
|
|
||||||
int idx = 0;
|
|
||||||
for (const auto& lang : languages) {
|
|
||||||
if (lang.first == current_lang) {
|
|
||||||
s_language_index = idx;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
idx++;
|
|
||||||
}
|
|
||||||
|
|
||||||
s_initialized = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save UI state to settings
|
|
||||||
static void saveSettingsFromUI(config::Settings* settings) {
|
|
||||||
if (!settings) return;
|
|
||||||
|
|
||||||
settings->setTheme(settings->getSkinId()); // Theme now synced with skin
|
|
||||||
settings->setSaveZtxs(s_save_ztxs);
|
|
||||||
settings->setAllowCustomFees(s_allow_custom_fees);
|
|
||||||
settings->setAutoShield(s_auto_shield);
|
|
||||||
settings->setFetchPrices(s_fetch_prices);
|
|
||||||
settings->setUseTor(s_use_tor);
|
|
||||||
settings->setTxExplorerUrl(s_tx_explorer);
|
|
||||||
settings->setAddressExplorerUrl(s_addr_explorer);
|
|
||||||
|
|
||||||
// Save language
|
|
||||||
auto& i18n = util::I18n::instance();
|
|
||||||
const auto& languages = i18n.getAvailableLanguages();
|
|
||||||
auto it = languages.begin();
|
|
||||||
std::advance(it, s_language_index);
|
|
||||||
if (it != languages.end()) {
|
|
||||||
settings->setLanguage(it->first);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save acrylic / visual effects settings
|
|
||||||
settings->setAcrylicEnabled(s_acrylic_enabled);
|
|
||||||
settings->setAcrylicQuality(s_blur_amount > 0.001f ? static_cast<int>(effects::AcrylicQuality::Low) : static_cast<int>(effects::AcrylicQuality::Off));
|
|
||||||
settings->setBlurMultiplier(s_blur_amount);
|
|
||||||
settings->setReducedTransparency(s_reduced_transparency);
|
|
||||||
settings->setNoiseOpacity(s_noise_opacity);
|
|
||||||
settings->setGradientBackground(s_gradient_background);
|
|
||||||
|
|
||||||
settings->save();
|
|
||||||
}
|
|
||||||
|
|
||||||
void RenderSettingsWindow(App* app, bool* p_open)
|
|
||||||
{
|
|
||||||
// Load settings on first open
|
|
||||||
if (!s_initialized && app->settings()) {
|
|
||||||
loadSettingsToUI(app->settings());
|
|
||||||
// Initialize acrylic settings from current state
|
|
||||||
s_acrylic_enabled = effects::ImGuiAcrylic::IsEnabled();
|
|
||||||
s_blur_amount = effects::ImGuiAcrylic::GetBlurMultiplier();
|
|
||||||
s_noise_opacity = effects::ImGuiAcrylic::GetNoiseOpacity();
|
|
||||||
s_reduced_transparency = effects::ImGuiAcrylic::GetReducedTransparency();
|
|
||||||
s_gradient_background = schema::SkinManager::instance().isGradientMode();
|
|
||||||
}
|
|
||||||
|
|
||||||
auto& S = schema::UI();
|
|
||||||
auto win = S.window("dialogs.settings");
|
|
||||||
auto lbl = S.label("dialogs.settings", "label");
|
|
||||||
auto cmb = S.combo("dialogs.settings", "combo");
|
|
||||||
auto connLbl = S.label("dialogs.settings", "connection-label");
|
|
||||||
auto portInput = S.input("dialogs.settings", "port-input");
|
|
||||||
auto walletBtn = S.button("dialogs.settings", "wallet-button");
|
|
||||||
auto saveBtn = S.button("dialogs.settings", "save-button");
|
|
||||||
auto cancelBtn = S.button("dialogs.settings", "cancel-button");
|
|
||||||
|
|
||||||
if (!material::BeginOverlayDialog(TR("settings"), p_open, win.width, 0.94f)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ImGui::BeginTabBar("SettingsTabs")) {
|
|
||||||
// General settings tab
|
|
||||||
if (ImGui::BeginTabItem(TR("general"))) {
|
|
||||||
ImGui::Spacing();
|
|
||||||
|
|
||||||
// Skin/theme selection
|
|
||||||
ImGui::Text("%s", TR("theme"));
|
|
||||||
ImGui::SameLine(lbl.position);
|
|
||||||
|
|
||||||
// Active skin combo (populated from SkinManager)
|
|
||||||
auto& skinMgr = schema::SkinManager::instance();
|
|
||||||
const auto& skins = skinMgr.available();
|
|
||||||
|
|
||||||
// Find active skin for preview text
|
|
||||||
std::string active_preview = "DragonX";
|
|
||||||
bool active_is_custom = false;
|
|
||||||
for (const auto& skin : skins) {
|
|
||||||
if (skin.id == skinMgr.activeSkinId()) {
|
|
||||||
active_preview = skin.name;
|
|
||||||
active_is_custom = !skin.bundled;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGui::SetNextItemWidth(cmb.width);
|
|
||||||
if (ImGui::BeginCombo("##Theme", active_preview.c_str())) {
|
|
||||||
// Bundled themes header
|
|
||||||
ImGui::TextDisabled("%s", TR("settings_builtin"));
|
|
||||||
ImGui::Separator();
|
|
||||||
for (size_t i = 0; i < skins.size(); i++) {
|
|
||||||
const auto& skin = skins[i];
|
|
||||||
if (!skin.bundled) continue;
|
|
||||||
bool is_selected = (skin.id == skinMgr.activeSkinId());
|
|
||||||
|
|
||||||
if (ImGui::Selectable(skin.name.c_str(), is_selected)) {
|
|
||||||
skinMgr.setActiveSkin(skin.id);
|
|
||||||
if (app->settings()) {
|
|
||||||
app->settings()->setSkinId(skin.id);
|
|
||||||
app->settings()->save();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (is_selected) {
|
|
||||||
ImGui::SetItemDefaultFocus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Custom themes (if any)
|
|
||||||
bool has_custom = false;
|
|
||||||
for (const auto& skin : skins) {
|
|
||||||
if (!skin.bundled) { has_custom = true; break; }
|
|
||||||
}
|
|
||||||
if (has_custom) {
|
|
||||||
ImGui::Spacing();
|
|
||||||
ImGui::TextDisabled("%s", TR("settings_custom"));
|
|
||||||
ImGui::Separator();
|
|
||||||
for (size_t i = 0; i < skins.size(); i++) {
|
|
||||||
const auto& skin = skins[i];
|
|
||||||
if (skin.bundled) continue;
|
|
||||||
bool is_selected = (skin.id == skinMgr.activeSkinId());
|
|
||||||
|
|
||||||
if (!skin.valid) {
|
|
||||||
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.3f, 0.3f, 1.0f));
|
|
||||||
ImGui::BeginDisabled(true);
|
|
||||||
std::string label = skin.name + TR("swin_invalid_suffix");
|
|
||||||
ImGui::Selectable(label.c_str(), false);
|
|
||||||
ImGui::EndDisabled();
|
|
||||||
ImGui::PopStyleColor();
|
|
||||||
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
|
|
||||||
material::Tooltip("%s", skin.validationError.c_str());
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
std::string label = skin.name;
|
|
||||||
if (!skin.author.empty()) {
|
|
||||||
label += " (" + skin.author + ")";
|
|
||||||
}
|
|
||||||
if (ImGui::Selectable(label.c_str(), is_selected)) {
|
|
||||||
skinMgr.setActiveSkin(skin.id);
|
|
||||||
if (app->settings()) {
|
|
||||||
app->settings()->setSkinId(skin.id);
|
|
||||||
app->settings()->save();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (is_selected) {
|
|
||||||
ImGui::SetItemDefaultFocus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ImGui::EndCombo();
|
|
||||||
}
|
|
||||||
if (ImGui::IsItemHovered())
|
|
||||||
material::Tooltip("%s", TR("tt_theme_hotkey"));
|
|
||||||
|
|
||||||
// Show indicator if custom theme is active
|
|
||||||
if (active_is_custom) {
|
|
||||||
ImGui::SameLine();
|
|
||||||
ImGui::PushFont(material::Type().iconSmall());
|
|
||||||
ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, 1.0f), ICON_CUSTOM_THEME);
|
|
||||||
ImGui::PopFont();
|
|
||||||
if (ImGui::IsItemHovered()) {
|
|
||||||
material::Tooltip("%s", TR("tt_custom_theme"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGui::SameLine();
|
|
||||||
ImGui::PushFont(material::Type().iconSmall());
|
|
||||||
if (material::StyledButton(ICON_REFRESH_THEMES, ImVec2(0, 0))) {
|
|
||||||
skinMgr.refresh();
|
|
||||||
Notifications::instance().info(TR("swin_theme_list_refreshed"));
|
|
||||||
}
|
|
||||||
ImGui::PopFont();
|
|
||||||
if (ImGui::IsItemHovered()) {
|
|
||||||
material::Tooltip(TR("tt_scan_themes"),
|
|
||||||
schema::SkinManager::getUserSkinsDirectory().c_str());
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGui::Spacing();
|
|
||||||
|
|
||||||
// Language selection
|
|
||||||
ImGui::Text("%s", TR("language"));
|
|
||||||
ImGui::SameLine(lbl.position);
|
|
||||||
auto& i18n = util::I18n::instance();
|
|
||||||
const auto& languages = i18n.getAvailableLanguages();
|
|
||||||
|
|
||||||
// Build language display names array
|
|
||||||
std::vector<const char*> lang_names;
|
|
||||||
lang_names.reserve(languages.size());
|
|
||||||
for (const auto& lang : languages) {
|
|
||||||
lang_names.push_back(lang.second.c_str()); // Display name
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGui::SetNextItemWidth(cmb.width);
|
|
||||||
if (ImGui::Combo("##Language", &s_language_index, lang_names.data(), static_cast<int>(lang_names.size()))) {
|
|
||||||
// Get locale code from index
|
|
||||||
auto it = languages.begin();
|
|
||||||
std::advance(it, s_language_index);
|
|
||||||
i18n.loadLanguage(it->first);
|
|
||||||
}
|
|
||||||
ImGui::TextDisabled(" %s", TR("settings_language_note"));
|
|
||||||
|
|
||||||
ImGui::Spacing();
|
|
||||||
ImGui::Separator();
|
|
||||||
ImGui::Spacing();
|
|
||||||
|
|
||||||
// Acrylic Effects settings
|
|
||||||
ImGui::Text("%s", TR("settings_visual_effects"));
|
|
||||||
ImGui::Spacing();
|
|
||||||
|
|
||||||
ImGui::Text("%s", TR("settings_acrylic_level"));
|
|
||||||
ImGui::SameLine(lbl.position);
|
|
||||||
ImGui::SetNextItemWidth(cmb.width);
|
|
||||||
{
|
|
||||||
char blur_fmt[16];
|
|
||||||
if (s_blur_amount < 0.01f)
|
|
||||||
snprintf(blur_fmt, sizeof(blur_fmt), "Off");
|
|
||||||
else
|
|
||||||
snprintf(blur_fmt, sizeof(blur_fmt), "%.0f%%%%", s_blur_amount * 25.0f);
|
|
||||||
if (ImGui::SliderFloat("##AcrylicBlur", &s_blur_amount, 0.0f, 4.0f, blur_fmt,
|
|
||||||
ImGuiSliderFlags_AlwaysClamp)) {
|
|
||||||
if (s_blur_amount > 0.0f && s_blur_amount < 0.15f) s_blur_amount = 0.0f;
|
|
||||||
s_acrylic_enabled = (s_blur_amount > 0.001f);
|
|
||||||
effects::ImGuiAcrylic::ApplyBlurAmount(s_blur_amount);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ImGui::TextDisabled(" %s", TR("tt_blur"));
|
|
||||||
|
|
||||||
ImGui::Spacing();
|
|
||||||
ImGui::Text("%s", TR("settings_noise_opacity"));
|
|
||||||
ImGui::SameLine(lbl.position);
|
|
||||||
ImGui::SetNextItemWidth(cmb.width);
|
|
||||||
{
|
|
||||||
char noise_fmt[16];
|
|
||||||
if (s_noise_opacity < 0.01f)
|
|
||||||
snprintf(noise_fmt, sizeof(noise_fmt), "Off");
|
|
||||||
else
|
|
||||||
snprintf(noise_fmt, sizeof(noise_fmt), "%.0f%%%%", s_noise_opacity * 100.0f);
|
|
||||||
if (ImGui::SliderFloat("##NoiseOpacity", &s_noise_opacity, 0.0f, 1.0f, noise_fmt,
|
|
||||||
ImGuiSliderFlags_AlwaysClamp)) {
|
|
||||||
effects::ImGuiAcrylic::SetNoiseOpacity(s_noise_opacity);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ImGui::TextDisabled(" %s", TR("tt_noise"));
|
|
||||||
|
|
||||||
ImGui::Spacing();
|
|
||||||
|
|
||||||
// Accessibility: Reduced transparency
|
|
||||||
if (ImGui::Checkbox(TrId("settings_reduce_transparency", "reduce_trans").c_str(), &s_reduced_transparency)) {
|
|
||||||
effects::ImGuiAcrylic::SetReducedTransparency(s_reduced_transparency);
|
|
||||||
}
|
|
||||||
ImGui::TextDisabled(" %s", TR("settings_solid_colors_desc"));
|
|
||||||
|
|
||||||
ImGui::Spacing();
|
|
||||||
|
|
||||||
if (ImGui::Checkbox(TrId("simple_background", "simple_bg").c_str(), &s_gradient_background)) {
|
|
||||||
schema::SkinManager::instance().setGradientMode(s_gradient_background);
|
|
||||||
}
|
|
||||||
ImGui::TextDisabled(" %s", TR("settings_gradient_desc"));
|
|
||||||
|
|
||||||
ImGui::Spacing();
|
|
||||||
ImGui::Separator();
|
|
||||||
ImGui::Spacing();
|
|
||||||
|
|
||||||
// Privacy settings
|
|
||||||
ImGui::Text("%s", TR("settings_privacy"));
|
|
||||||
ImGui::Spacing();
|
|
||||||
|
|
||||||
ImGui::Checkbox(TrId("settings_save_shielded_local", "save_ztx_w").c_str(), &s_save_ztxs);
|
|
||||||
ImGui::TextDisabled(" %s", TR("settings_save_shielded_desc"));
|
|
||||||
|
|
||||||
ImGui::Spacing();
|
|
||||||
|
|
||||||
ImGui::Checkbox(TrId("settings_auto_shield_funds", "auto_shld_w").c_str(), &s_auto_shield);
|
|
||||||
ImGui::TextDisabled(" %s", TR("settings_auto_shield_desc"));
|
|
||||||
|
|
||||||
ImGui::Spacing();
|
|
||||||
|
|
||||||
{
|
|
||||||
// Not implemented: disabled and never shown as "on", even for a stored use_tor=true.
|
|
||||||
bool torOff = false;
|
|
||||||
ImGui::BeginDisabled();
|
|
||||||
ImGui::Checkbox(TrId("settings_use_tor_network", "tor_w").c_str(), &torOff);
|
|
||||||
ImGui::EndDisabled();
|
|
||||||
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
|
|
||||||
const float wrapW = S.drawElement("components.settings-page", "tor-tooltip-wrap-width")
|
|
||||||
.sizeOr(420.0f) * Layout::dpiScale();
|
|
||||||
material::BeginTooltip();
|
|
||||||
ImGui::PushTextWrapPos(wrapW);
|
|
||||||
ImGui::TextUnformatted(TR(app && app->isLiteBuild() ? "tt_tor_unavailable_lite"
|
|
||||||
: "tt_tor_unavailable"));
|
|
||||||
ImGui::PopTextWrapPos();
|
|
||||||
material::EndTooltip();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGui::Spacing();
|
|
||||||
ImGui::Separator();
|
|
||||||
ImGui::Spacing();
|
|
||||||
|
|
||||||
// Other settings
|
|
||||||
ImGui::Text("%s", TR("settings_other"));
|
|
||||||
ImGui::Spacing();
|
|
||||||
|
|
||||||
ImGui::Checkbox(TrId("custom_fees", "fees_w").c_str(), &s_allow_custom_fees);
|
|
||||||
ImGui::Checkbox(TrId("fetch_prices", "prices_w").c_str(), &s_fetch_prices);
|
|
||||||
|
|
||||||
ImGui::EndTabItem();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connection settings tab
|
|
||||||
if (ImGui::BeginTabItem(TR("settings_connection"))) {
|
|
||||||
ImGui::Spacing();
|
|
||||||
|
|
||||||
ImGui::Text("%s", TR("settings_rpc_connection"));
|
|
||||||
ImGui::TextDisabled("%s", TR("settings_configure_rpc"));
|
|
||||||
ImGui::Spacing();
|
|
||||||
ImGui::Separator();
|
|
||||||
ImGui::Spacing();
|
|
||||||
|
|
||||||
material::LabeledInputRow(TR("rpc_host"), connLbl.position, "##RPCHost",
|
|
||||||
s_rpc_host, sizeof(s_rpc_host), cmb.width);
|
|
||||||
|
|
||||||
material::LabeledInputRow(TR("rpc_port"), connLbl.position, "##RPCPort",
|
|
||||||
s_rpc_port, sizeof(s_rpc_port), portInput.width);
|
|
||||||
|
|
||||||
ImGui::Spacing();
|
|
||||||
|
|
||||||
material::LabeledInputRow(TR("rpc_user"), connLbl.position, "##RPCUser",
|
|
||||||
s_rpc_user, sizeof(s_rpc_user), cmb.width);
|
|
||||||
|
|
||||||
material::LabeledInputRow(TR("rpc_pass"), connLbl.position, "##RPCPassword",
|
|
||||||
s_rpc_password, sizeof(s_rpc_password), cmb.width, nullptr,
|
|
||||||
ImGuiInputTextFlags_Password);
|
|
||||||
|
|
||||||
ImGui::Spacing();
|
|
||||||
ImGui::Separator();
|
|
||||||
ImGui::Spacing();
|
|
||||||
|
|
||||||
ImGui::TextDisabled("%s", TR("settings_rpc_note"));
|
|
||||||
|
|
||||||
ImGui::Spacing();
|
|
||||||
|
|
||||||
if (material::StyledButton(TR("test_connection"), ImVec2(0,0), S.resolveFont("button"))) {
|
|
||||||
if (app->rpc()) {
|
|
||||||
rpc::RPCClient::TraceScope trace("Settings / Test connection");
|
|
||||||
app->rpc()->getInfo([](const nlohmann::json& result, const std::string& error) {
|
|
||||||
if (error.empty()) {
|
|
||||||
std::string version = result.value("version", "unknown");
|
|
||||||
std::string msg = std::string(TR("swin_connection_successful")) + version;
|
|
||||||
Notifications::instance().success(msg);
|
|
||||||
} else {
|
|
||||||
Notifications::instance().error(std::string(TR("swin_connection_failed")) + error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
Notifications::instance().error(TR("swin_rpc_client_not_initialized"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGui::EndTabItem();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wallet tab
|
|
||||||
if (ImGui::BeginTabItem(TR("wallet"))) {
|
|
||||||
ImGui::Spacing();
|
|
||||||
|
|
||||||
ImGui::Text("%s", TR("settings_wallet_maintenance"));
|
|
||||||
ImGui::Spacing();
|
|
||||||
ImGui::Separator();
|
|
||||||
ImGui::Spacing();
|
|
||||||
|
|
||||||
if (material::StyledButton(TR("rescan"), ImVec2(walletBtn.width, 0), S.resolveFont(walletBtn.font))) {
|
|
||||||
if (app->rpc()) {
|
|
||||||
// Start rescan from block 0
|
|
||||||
app->rpc()->rescanBlockchain(0, [](const nlohmann::json& result, const std::string& error) {
|
|
||||||
if (error.empty()) {
|
|
||||||
int start = result.value("start_height", 0);
|
|
||||||
int end = result.value("stop_height", 0);
|
|
||||||
std::string msg = std::string(TR("swin_rescan_started_from_block")) + std::to_string(start) +
|
|
||||||
TR("swin_rescan_to") + std::to_string(end);
|
|
||||||
Notifications::instance().success(msg);
|
|
||||||
} else {
|
|
||||||
Notifications::instance().error(std::string(TR("swin_rescan_failed")) + error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
Notifications::instance().error(TR("swin_rpc_client_not_initialized"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ImGui::TextDisabled(" %s", TR("settings_rescan_desc"));
|
|
||||||
|
|
||||||
ImGui::Spacing();
|
|
||||||
|
|
||||||
static bool s_confirm_clear_ztx = false;
|
|
||||||
if (material::StyledButton(TR("settings_clear_ztx_long"), ImVec2(walletBtn.width, 0), S.resolveFont(walletBtn.font))) {
|
|
||||||
s_confirm_clear_ztx = true;
|
|
||||||
}
|
|
||||||
ImGui::TextDisabled(" %s", TR("settings_clear_ztx_desc"));
|
|
||||||
|
|
||||||
// Confirmation dialog
|
|
||||||
if (s_confirm_clear_ztx) {
|
|
||||||
// Distinct idSuffix: this confirm renders nested inside (and the same frame as) the parent
|
|
||||||
// settings dialog, so it must not share the default ##OverlayDialogContent key — otherwise it
|
|
||||||
// inherits the parent's OverlayCardState (incl. the sticky overflow flag) and collides on the
|
|
||||||
// child window id.
|
|
||||||
if (material::BeginOverlayDialog(TR("confirm_clear_ztx_title"), &s_confirm_clear_ztx, 480.0f,
|
|
||||||
0.94f, 0.85f, "settings_clearztx")) {
|
|
||||||
material::DialogWarningHeader(TR("warning"), ImVec4(1.0f, 0.6f, 0.0f, 1.0f));
|
|
||||||
|
|
||||||
ImGui::Spacing();
|
|
||||||
ImGui::TextWrapped("%s", TR("confirm_clear_ztx_warning1"));
|
|
||||||
ImGui::Spacing();
|
|
||||||
ImGui::TextWrapped("%s", TR("confirm_clear_ztx_warning2"));
|
|
||||||
ImGui::Spacing();
|
|
||||||
ImGui::Separator();
|
|
||||||
ImGui::Spacing();
|
|
||||||
|
|
||||||
bool doCancel = false, doConfirm = false;
|
|
||||||
material::DialogConfirmFooter(TR("cancel"), TrId("clear_anyway", "clear_ztx_w").c_str(),
|
|
||||||
true, doCancel, doConfirm);
|
|
||||||
if (doCancel) {
|
|
||||||
s_confirm_clear_ztx = false;
|
|
||||||
}
|
|
||||||
if (doConfirm) {
|
|
||||||
std::string ztx_file = util::Platform::getDragonXDataDir() + "ztx_history.json";
|
|
||||||
if (util::Platform::deleteFile(ztx_file)) {
|
|
||||||
Notifications::instance().success(TR("swin_ztx_history_cleared"));
|
|
||||||
} else {
|
|
||||||
Notifications::instance().info(TR("swin_no_history_file_found"));
|
|
||||||
}
|
|
||||||
s_confirm_clear_ztx = false;
|
|
||||||
}
|
|
||||||
material::EndOverlayDialog();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGui::Spacing();
|
|
||||||
ImGui::Separator();
|
|
||||||
ImGui::Spacing();
|
|
||||||
|
|
||||||
ImGui::Text("%s", TR("settings_wallet_info"));
|
|
||||||
ImGui::Spacing();
|
|
||||||
|
|
||||||
// Get actual wallet size
|
|
||||||
std::string wallet_path = util::Platform::getDragonXDataDir() + "wallet.dat";
|
|
||||||
uint64_t wallet_size = util::Platform::getFileSize(wallet_path);
|
|
||||||
if (wallet_size > 0) {
|
|
||||||
std::string size_str = util::Platform::formatFileSize(wallet_size);
|
|
||||||
ImGui::Text(TR("settings_wallet_file_size"), size_str.c_str());
|
|
||||||
} else {
|
|
||||||
ImGui::TextDisabled("%s", TR("settings_wallet_not_found"));
|
|
||||||
}
|
|
||||||
ImGui::Text(TR("settings_wallet_location"), wallet_path.c_str());
|
|
||||||
|
|
||||||
ImGui::EndTabItem();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Explorer tab
|
|
||||||
if (ImGui::BeginTabItem(TR("explorer"))) {
|
|
||||||
ImGui::Spacing();
|
|
||||||
|
|
||||||
ImGui::Text("%s", TR("settings_block_explorer_urls"));
|
|
||||||
ImGui::TextDisabled("%s", TR("settings_configure_explorer"));
|
|
||||||
ImGui::Spacing();
|
|
||||||
ImGui::Separator();
|
|
||||||
ImGui::Spacing();
|
|
||||||
|
|
||||||
ImGui::Text("%s", TR("transaction_url"));
|
|
||||||
ImGui::SetNextItemWidth(-1);
|
|
||||||
ImGui::InputText("##TxExplorer", s_tx_explorer, sizeof(s_tx_explorer));
|
|
||||||
|
|
||||||
ImGui::Text("%s", TR("address_url"));
|
|
||||||
ImGui::SetNextItemWidth(-1);
|
|
||||||
ImGui::InputText("##AddrExplorer", s_addr_explorer, sizeof(s_addr_explorer));
|
|
||||||
|
|
||||||
ImGui::Spacing();
|
|
||||||
ImGui::TextDisabled("%s", TR("settings_explorer_hint"));
|
|
||||||
|
|
||||||
ImGui::EndTabItem();
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGui::EndTabBar();
|
|
||||||
}
|
|
||||||
|
|
||||||
ImGui::Spacing();
|
|
||||||
ImGui::Separator();
|
|
||||||
ImGui::Spacing();
|
|
||||||
|
|
||||||
// Save/Cancel buttons
|
|
||||||
if (material::StyledButton(TR("save"), ImVec2(saveBtn.width, 0), S.resolveFont(saveBtn.font))) {
|
|
||||||
saveSettingsFromUI(app->settings());
|
|
||||||
Notifications::instance().success(TR("swin_settings_saved"));
|
|
||||||
*p_open = false;
|
|
||||||
}
|
|
||||||
ImGui::SameLine();
|
|
||||||
if (material::StyledButton(TR("cancel"), ImVec2(cancelBtn.width, 0), S.resolveFont(cancelBtn.font))) {
|
|
||||||
// Reload settings to revert changes
|
|
||||||
loadSettingsToUI(app->settings());
|
|
||||||
// Revert skin to what was active when settings opened
|
|
||||||
if (!s_saved_skin_id.empty()) {
|
|
||||||
schema::SkinManager::instance().setActiveSkin(s_saved_skin_id);
|
|
||||||
if (app->settings()) {
|
|
||||||
app->settings()->setSkinId(s_saved_skin_id);
|
|
||||||
app->settings()->save();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*p_open = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
material::EndOverlayDialog();
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace ui
|
|
||||||
} // namespace dragonx
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
// DragonX Wallet - ImGui Edition
|
|
||||||
// Copyright 2024-2026 The Hush Developers
|
|
||||||
// Released under the GPLv3
|
|
||||||
|
|
||||||
#pragma once
|
|
||||||
|
|
||||||
namespace dragonx {
|
|
||||||
class App;
|
|
||||||
|
|
||||||
namespace ui {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @brief Render the Settings window
|
|
||||||
* Modal dialog for application settings
|
|
||||||
*/
|
|
||||||
void RenderSettingsWindow(App* app, bool* p_open);
|
|
||||||
|
|
||||||
} // namespace ui
|
|
||||||
} // namespace dragonx
|
|
||||||
@@ -58,16 +58,16 @@ void TransactionDetailsDialog::render(App* app)
|
|||||||
ImVec4 type_color;
|
ImVec4 type_color;
|
||||||
std::string type_display;
|
std::string type_display;
|
||||||
if (tx.type == "receive") {
|
if (tx.type == "receive") {
|
||||||
type_color = ImVec4(0.3f, 0.8f, 0.3f, 1.0f);
|
type_color = material::SuccessVec4();
|
||||||
type_display = TR("tx_received");
|
type_display = TR("tx_received");
|
||||||
} else if (tx.type == "send") {
|
} else if (tx.type == "send") {
|
||||||
type_color = ImVec4(0.8f, 0.3f, 0.3f, 1.0f);
|
type_color = material::ErrorVec4();
|
||||||
type_display = TR("tx_sent");
|
type_display = TR("tx_sent");
|
||||||
} else if (tx.type == "generate" || tx.type == "mined") {
|
} else if (tx.type == "generate" || tx.type == "mined") {
|
||||||
type_color = ImVec4(0.3f, 0.6f, 0.9f, 1.0f);
|
type_color = ImVec4(0.3f, 0.6f, 0.9f, 1.0f);
|
||||||
type_display = TR("tx_mined");
|
type_display = TR("tx_mined");
|
||||||
} else if (tx.type == "immature") {
|
} else if (tx.type == "immature") {
|
||||||
type_color = ImVec4(0.8f, 0.8f, 0.3f, 1.0f);
|
type_color = material::WarningVec4();
|
||||||
type_display = TR("tx_immature");
|
type_display = TR("tx_immature");
|
||||||
} else {
|
} else {
|
||||||
type_color = ImVec4(0.7f, 0.7f, 0.7f, 1.0f);
|
type_color = ImVec4(0.7f, 0.7f, 0.7f, 1.0f);
|
||||||
@@ -79,11 +79,11 @@ void TransactionDetailsDialog::render(App* app)
|
|||||||
|
|
||||||
// Confirmations badge
|
// Confirmations badge
|
||||||
if (tx.confirmations == 0) {
|
if (tx.confirmations == 0) {
|
||||||
ImGui::TextColored(ImVec4(0.8f, 0.6f, 0.0f, 1.0f), "%s", TR("pending"));
|
ImGui::TextColored(material::WarningVec4(), "%s", TR("pending"));
|
||||||
} else if (tx.confirmations < 10) {
|
} else if (tx.confirmations < 10) {
|
||||||
ImGui::TextColored(ImVec4(0.8f, 0.8f, 0.3f, 1.0f), TR("tx_confirmations"), tx.confirmations);
|
ImGui::TextColored(material::WarningVec4(), TR("tx_confirmations"), tx.confirmations);
|
||||||
} else {
|
} else {
|
||||||
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), TR("tx_confirmations"), tx.confirmations);
|
ImGui::TextColored(material::SuccessVec4(), TR("tx_confirmations"), tx.confirmations);
|
||||||
}
|
}
|
||||||
|
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
@@ -94,9 +94,9 @@ void TransactionDetailsDialog::render(App* app)
|
|||||||
ImGui::Text("%s", TR("amount_label"));
|
ImGui::Text("%s", TR("amount_label"));
|
||||||
ImGui::SameLine(lbl.position);
|
ImGui::SameLine(lbl.position);
|
||||||
if (tx.amount >= 0) {
|
if (tx.amount >= 0) {
|
||||||
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "+%.8f DRGX", tx.amount);
|
ImGui::TextColored(material::SuccessVec4(), "+%.8f DRGX", tx.amount);
|
||||||
} else {
|
} else {
|
||||||
ImGui::TextColored(ImVec4(0.8f, 0.3f, 0.3f, 1.0f), "%.8f DRGX", tx.amount);
|
ImGui::TextColored(material::ErrorVec4(), "%.8f DRGX", tx.amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
// USD equivalent if price available
|
// USD equivalent if price available
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
// Released under the GPLv3
|
// Released under the GPLv3
|
||||||
|
|
||||||
#include "transactions_tab.h"
|
#include "transactions_tab.h"
|
||||||
|
#include "../notifications.h"
|
||||||
#include "transaction_details_dialog.h"
|
#include "transaction_details_dialog.h"
|
||||||
#include "export_transactions_dialog.h"
|
#include "export_transactions_dialog.h"
|
||||||
#include "transactions_tab_helpers.h"
|
#include "transactions_tab_helpers.h"
|
||||||
@@ -36,6 +37,11 @@ namespace ui {
|
|||||||
|
|
||||||
using namespace material;
|
using namespace material;
|
||||||
|
|
||||||
|
// Latest History-tab filter snapshot, published from the filtered-list rebuild below. Used by the
|
||||||
|
// transactions CSV export to offer "export only the current filter".
|
||||||
|
static TxFilterSnapshot s_filter_snapshot;
|
||||||
|
const TxFilterSnapshot& GetTransactionsFilterSnapshot() { return s_filter_snapshot; }
|
||||||
|
|
||||||
static std::string TrId(const char* key, const char* id) {
|
static std::string TrId(const char* key, const char* id) {
|
||||||
return std::string(TR(key)) + "##" + id;
|
return std::string(TR(key)) + "##" + id;
|
||||||
}
|
}
|
||||||
@@ -213,10 +219,11 @@ void RenderTransactionsTab(App* app)
|
|||||||
// txid grouping shared by the summary pass and the display pass. Indices only, rebuilt once per tx_revision
|
// txid grouping shared by the summary pass and the display pass. Indices only, rebuilt once per tx_revision
|
||||||
// (a sort-combo change re-sorts without regrouping).
|
// (a sort-combo change re-sorts without regrouping).
|
||||||
static history::TxidGroups s_groups;
|
static history::TxidGroups s_groups;
|
||||||
static std::uint64_t s_groups_key = 0; // 0 never matches: tx_revision starts at 1 and only grows
|
static std::uint64_t s_groups_key = 0; // 0 never matches: tx_structure_revision starts at 1 and only grows
|
||||||
if (s_groups_key != contentKey) {
|
const std::uint64_t structKey = state.tx_structure_revision; // ignores pure confirmation-count bumps
|
||||||
|
if (s_groups_key != structKey) {
|
||||||
s_groups = history::groupByTxid(state.transactions);
|
s_groups = history::groupByTxid(state.transactions);
|
||||||
s_groups_key = contentKey;
|
s_groups_key = structKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Summary-card totals: Received / Sent / Mined (counts + amounts). These are three static
|
// Summary-card totals: Received / Sent / Mined (counts + amounts). These are three static
|
||||||
@@ -409,13 +416,14 @@ void RenderTransactionsTab(App* app)
|
|||||||
// case while the user reads/scrolls) the cache is reused. The result is already sorted newest-first by
|
// case while the user reads/scrolls) the cache is reused. The result is already sorted newest-first by
|
||||||
// the refresh service, but we re-sort here to apply the "pending first" ordering.
|
// the refresh service, but we re-sort here to apply the "pending first" ordering.
|
||||||
static std::vector<DisplayTx> s_display_cache;
|
static std::vector<DisplayTx> s_display_cache;
|
||||||
static std::uint64_t s_display_cache_key = 0; // tx_revision the cache was built from
|
static std::uint64_t s_display_cache_key = 0; // tx_structure_revision the cache was built from
|
||||||
|
static std::uint64_t s_display_conf_rev = 0; // tx_revision the cached confirmations were last synced to
|
||||||
static int s_display_cache_sort = 0; // sort mode the cache was built with
|
static int s_display_cache_sort = 0; // sort mode the cache was built with
|
||||||
static bool s_display_cache_valid = false;
|
static bool s_display_cache_valid = false;
|
||||||
static std::uint64_t s_display_gen = 0; // bumped on every rebuild; keys the filtered-index cache below
|
static std::uint64_t s_display_gen = 0; // bumped on every rebuild; keys the filtered-index cache below
|
||||||
|
|
||||||
// Two separate fields, not a mixed hash: small revision/sort values would collide under xor-mixing.
|
// Two separate fields, not a mixed hash: small revision/sort values would collide under xor-mixing.
|
||||||
if (!s_display_cache_valid || contentKey != s_display_cache_key || s_sort_mode != s_display_cache_sort) {
|
if (!s_display_cache_valid || structKey != s_display_cache_key || s_sort_mode != s_display_cache_sort) {
|
||||||
++s_display_gen;
|
++s_display_gen;
|
||||||
s_display_cache.clear();
|
s_display_cache.clear();
|
||||||
std::vector<DisplayTx>& display_txns = s_display_cache;
|
std::vector<DisplayTx>& display_txns = s_display_cache;
|
||||||
@@ -518,9 +526,27 @@ void RenderTransactionsTab(App* app)
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
s_display_cache_key = contentKey;
|
s_display_cache_key = structKey;
|
||||||
|
s_display_conf_rev = contentKey; // freshly built from the current list
|
||||||
s_display_cache_sort = s_sort_mode;
|
s_display_cache_sort = s_sort_mode;
|
||||||
s_display_cache_valid = true;
|
s_display_cache_valid = true;
|
||||||
|
} else if (s_display_conf_rev != contentKey) {
|
||||||
|
// Confirmation-count-only delta (setTransactionsIfChanged kept tx_structure_revision): the merged rows, their
|
||||||
|
// order and the indices into state.transactions are unchanged, so refresh just the count (same rule as the
|
||||||
|
// build above: a shield shows the larger of its two legs). Sort order cannot change: mode 0 only reads
|
||||||
|
// conf==0, which the layout check holds constant, and the other modes ignore confirmations.
|
||||||
|
for (DisplayTx& dtx : s_display_cache) {
|
||||||
|
if (dtx.is_shield) {
|
||||||
|
dtx.confirmations = std::max(state.transactions[dtx.send_idx].confirmations,
|
||||||
|
state.transactions[dtx.recv_idx].confirmations);
|
||||||
|
} else {
|
||||||
|
dtx.confirmations = state.transactions[dtx.orig_idx].confirmations;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The filtered-index cache keys on s_display_gen (unchanged, correct: filters ignore confirmations) and
|
||||||
|
// the snapshot's indices remain valid; only re-stamp it so 'export current filter' stays usable.
|
||||||
|
if (s_filter_snapshot.valid) s_filter_snapshot.txRevision = contentKey;
|
||||||
|
s_display_conf_rev = contentKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The merged + sorted list (rebuilt above only on change). Pagination below runs per-frame (it only
|
// The merged + sorted list (rebuilt above only on change). Pagination below runs per-frame (it only
|
||||||
@@ -565,6 +591,35 @@ void RenderTransactionsTab(App* app)
|
|||||||
s_filtered_search = search_str;
|
s_filtered_search = search_str;
|
||||||
s_filtered_chat_rev = chatRev;
|
s_filtered_chat_rev = chatRev;
|
||||||
s_filtered_valid = true;
|
s_filtered_valid = true;
|
||||||
|
|
||||||
|
// Publish a snapshot of the visible set for "export current filter". A merged autoshield row
|
||||||
|
// contributes BOTH original legs, so the CSV stays a per-leg record like an unfiltered export.
|
||||||
|
s_filter_snapshot.indices.clear();
|
||||||
|
s_filter_snapshot.indices.reserve(s_filtered_cache.size() * 2);
|
||||||
|
for (size_t fi : s_filtered_cache) {
|
||||||
|
const auto& dtx = display_txns[fi];
|
||||||
|
if (dtx.is_shield) {
|
||||||
|
if (dtx.send_idx >= 0) s_filter_snapshot.indices.push_back(static_cast<size_t>(dtx.send_idx));
|
||||||
|
if (dtx.recv_idx >= 0) s_filter_snapshot.indices.push_back(static_cast<size_t>(dtx.recv_idx));
|
||||||
|
} else if (dtx.orig_idx >= 0) {
|
||||||
|
s_filter_snapshot.indices.push_back(static_cast<size_t>(dtx.orig_idx));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::sort(s_filter_snapshot.indices.begin(), s_filter_snapshot.indices.end());
|
||||||
|
s_filter_snapshot.active = (type_filter != 0 || !search_str.empty());
|
||||||
|
s_filter_snapshot.txRevision = contentKey;
|
||||||
|
s_filter_snapshot.valid = true;
|
||||||
|
{
|
||||||
|
const char* typeNames[] = { "", TR("sent_filter"), TR("received_filter"),
|
||||||
|
TR("mined_filter"), TR("chat_filter") };
|
||||||
|
std::string desc = (type_filter >= 1 && type_filter <= 4) ? typeNames[type_filter] : "";
|
||||||
|
if (!search_str.empty()) {
|
||||||
|
if (!desc.empty()) desc += ", ";
|
||||||
|
desc += "\"" + search_str + "\"";
|
||||||
|
}
|
||||||
|
if (desc.empty()) desc = TR("all_filter");
|
||||||
|
s_filter_snapshot.description = desc;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const std::vector<size_t>& filtered_indices = s_filtered_cache;
|
const std::vector<size_t>& filtered_indices = s_filtered_cache;
|
||||||
filtered_count = static_cast<int>(filtered_indices.size());
|
filtered_count = static_cast<int>(filtered_indices.size());
|
||||||
@@ -800,7 +855,12 @@ void RenderTransactionsTab(App* app)
|
|||||||
{
|
{
|
||||||
const char* statusStr;
|
const char* statusStr;
|
||||||
ImU32 statusCol;
|
ImU32 statusCol;
|
||||||
if (tx.confirmations == 0) {
|
const int64_t nowSec = static_cast<int64_t>(std::time(nullptr));
|
||||||
|
if (tx.confirmations < 0) { // gettransaction < 0: conflicted / double-spent / reorged out
|
||||||
|
statusStr = TR("tx_dropped"); statusCol = Error();
|
||||||
|
} else if (tx.confirmations == 0 && tx.timestamp > 0 && nowSec - tx.timestamp > 3600) {
|
||||||
|
statusStr = TR("tx_stale_pending"); statusCol = Warning();
|
||||||
|
} else if (tx.confirmations == 0) {
|
||||||
statusStr = TR("pending"); statusCol = Warning();
|
statusStr = TR("pending"); statusCol = Warning();
|
||||||
} else if (tx.confirmations < 10) {
|
} else if (tx.confirmations < 10) {
|
||||||
snprintf(buf, sizeof(buf), TR("conf_count"), tx.confirmations);
|
snprintf(buf, sizeof(buf), TR("conf_count"), tx.confirmations);
|
||||||
@@ -871,9 +931,11 @@ void RenderTransactionsTab(App* app)
|
|||||||
if (effects::ImGuiAcrylic::BeginAcrylicContextItem("TxContext", 0, acrylicTheme.menu)) {
|
if (effects::ImGuiAcrylic::BeginAcrylicContextItem("TxContext", 0, acrylicTheme.menu)) {
|
||||||
if (ImGui::MenuItem(TR("copy_address")) && !tx.address.empty()) {
|
if (ImGui::MenuItem(TR("copy_address")) && !tx.address.empty()) {
|
||||||
ImGui::SetClipboardText(tx.address.c_str());
|
ImGui::SetClipboardText(tx.address.c_str());
|
||||||
|
Notifications::instance().info(TR("copied_to_clipboard"));
|
||||||
}
|
}
|
||||||
if (ImGui::MenuItem(TR("copy_txid"))) {
|
if (ImGui::MenuItem(TR("copy_txid"))) {
|
||||||
ImGui::SetClipboardText(tx.txid.c_str());
|
ImGui::SetClipboardText(tx.txid.c_str());
|
||||||
|
Notifications::instance().info(TR("copied_to_clipboard"));
|
||||||
}
|
}
|
||||||
ImGui::Separator();
|
ImGui::Separator();
|
||||||
if (ImGui::MenuItem(TR("view_on_explorer"))) {
|
if (ImGui::MenuItem(TR("view_on_explorer"))) {
|
||||||
@@ -916,7 +978,10 @@ void RenderTransactionsTab(App* app)
|
|||||||
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("transaction_id"));
|
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("transaction_id"));
|
||||||
ImGui::TextWrapped("%s", tx.txid.c_str());
|
ImGui::TextWrapped("%s", tx.txid.c_str());
|
||||||
if (ImGui::IsItemHovered()) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
if (ImGui::IsItemHovered()) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||||
if (ImGui::IsItemClicked()) ImGui::SetClipboardText(tx.txid.c_str());
|
if (ImGui::IsItemClicked()) {
|
||||||
|
ImGui::SetClipboardText(tx.txid.c_str());
|
||||||
|
Notifications::instance().info(TR("copied_to_clipboard"));
|
||||||
|
}
|
||||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||||
|
|
||||||
// Memo
|
// Memo
|
||||||
@@ -938,10 +1003,12 @@ void RenderTransactionsTab(App* app)
|
|||||||
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, schema::UI().drawElement("tabs.transactions", "detail-btn-rounding").size);
|
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, schema::UI().drawElement("tabs.transactions", "detail-btn-rounding").size);
|
||||||
if (TactileSmallButton(TrId("copy_txid", "detail").c_str(), S.resolveFont("button"))) {
|
if (TactileSmallButton(TrId("copy_txid", "detail").c_str(), S.resolveFont("button"))) {
|
||||||
ImGui::SetClipboardText(tx.txid.c_str());
|
ImGui::SetClipboardText(tx.txid.c_str());
|
||||||
|
Notifications::instance().info(TR("copied_to_clipboard"));
|
||||||
}
|
}
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
if (!tx.address.empty() && TactileSmallButton(TrId("copy_address", "detail").c_str(), S.resolveFont("button"))) {
|
if (!tx.address.empty() && TactileSmallButton(TrId("copy_address", "detail").c_str(), S.resolveFont("button"))) {
|
||||||
ImGui::SetClipboardText(tx.address.c_str());
|
ImGui::SetClipboardText(tx.address.c_str());
|
||||||
|
Notifications::instance().info(TR("copied_to_clipboard"));
|
||||||
}
|
}
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
if (TactileSmallButton(TrId("explorer", "detail").c_str(), S.resolveFont("button"))) {
|
if (TactileSmallButton(TrId("explorer", "detail").c_str(), S.resolveFont("button"))) {
|
||||||
|
|||||||
@@ -5,12 +5,34 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstddef>
|
||||||
|
|
||||||
namespace dragonx {
|
namespace dragonx {
|
||||||
class App;
|
class App;
|
||||||
|
|
||||||
namespace ui {
|
namespace ui {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Snapshot of what the History tab currently shows, for "export current filter".
|
||||||
|
*
|
||||||
|
* `indices` are positions into WalletState::transactions of every leg currently visible (a merged
|
||||||
|
* autoshield row contributes both its send and receive legs), sorted ascending. Published only when the
|
||||||
|
* filtered list is (re)built, so it reflects the last state the History tab rendered. `txRevision` lets a
|
||||||
|
* consumer reject a snapshot that predates the current tx list.
|
||||||
|
*/
|
||||||
|
struct TxFilterSnapshot {
|
||||||
|
bool valid = false;
|
||||||
|
bool active = false; // a type filter and/or search text is applied
|
||||||
|
std::uint64_t txRevision = 0;
|
||||||
|
std::string description; // human label of the active filter
|
||||||
|
std::vector<std::size_t> indices;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The latest History-tab filter snapshot (see TxFilterSnapshot).
|
||||||
|
const TxFilterSnapshot& GetTransactionsFilterSnapshot();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Render the Transactions tab
|
* @brief Render the Transactions tab
|
||||||
* Shows transaction history
|
* Shows transaction history
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ void ValidateAddressDialog::render(App* app)
|
|||||||
|
|
||||||
if (s_validating) {
|
if (s_validating) {
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
ImGui::TextColored(ImVec4(1.0f, 1.0f, 0.0f, 1.0f), "%s", TR("validating"));
|
ImGui::TextColored(material::WarningVec4(), "%s", TR("validating"));
|
||||||
}
|
}
|
||||||
|
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
@@ -194,15 +194,15 @@ void ValidateAddressDialog::render(App* app)
|
|||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
|
|
||||||
if (!s_error_message.empty()) {
|
if (!s_error_message.empty()) {
|
||||||
ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), TR("error_format"), s_error_message.c_str());
|
ImGui::TextColored(material::ErrorVec4(), TR("error_format"), s_error_message.c_str());
|
||||||
} else {
|
} else {
|
||||||
// Valid/Invalid indicator
|
// Valid/Invalid indicator
|
||||||
ImGui::Text("%s", TR("validate_status"));
|
ImGui::Text("%s", TR("validate_status"));
|
||||||
ImGui::SameLine(lbl.position);
|
ImGui::SameLine(lbl.position);
|
||||||
if (s_is_valid) {
|
if (s_is_valid) {
|
||||||
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s", TR("validate_valid"));
|
ImGui::TextColored(material::SuccessVec4(), "%s", TR("validate_valid"));
|
||||||
} else {
|
} else {
|
||||||
ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "%s", TR("validate_invalid"));
|
ImGui::TextColored(material::ErrorVec4(), "%s", TR("validate_invalid"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (s_is_valid) {
|
if (s_is_valid) {
|
||||||
@@ -219,7 +219,7 @@ void ValidateAddressDialog::render(App* app)
|
|||||||
ImGui::TextDisabled("%s", TR(app->isLiteBuild() ? "validate_ownership_unknown_lite"
|
ImGui::TextDisabled("%s", TR(app->isLiteBuild() ? "validate_ownership_unknown_lite"
|
||||||
: "validate_ownership_unknown_offline"));
|
: "validate_ownership_unknown_offline"));
|
||||||
} else if (s_is_mine) {
|
} else if (s_is_mine) {
|
||||||
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s", TR("validate_is_mine"));
|
ImGui::TextColored(material::SuccessVec4(), "%s", TR("validate_is_mine"));
|
||||||
} else {
|
} else {
|
||||||
ImGui::TextDisabled("%s", TR("validate_not_mine"));
|
ImGui::TextDisabled("%s", TR("validate_not_mine"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,32 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
namespace dragonx {
|
namespace dragonx {
|
||||||
namespace util {
|
namespace util {
|
||||||
|
|
||||||
std::string formatAmountFixed(double amount, int decimals = 8);
|
std::string formatAmountFixed(double amount, int decimals = 8);
|
||||||
|
|
||||||
|
// DRGX amounts are 8-decimal. Converting to int64 satoshis and comparing there avoids the IEEE-double
|
||||||
|
// rounding that could block a valid exact-max send (amount+fee computed in doubles landed 1 ulp over).
|
||||||
|
inline std::int64_t toSats(double coins) { return static_cast<std::int64_t>(std::llround(coins * 1e8)); }
|
||||||
|
|
||||||
|
// True when amount + fee fits within available, compared in integer satoshis (not doubles).
|
||||||
|
inline bool canAfford(double amount, double fee, double available) {
|
||||||
|
return toSats(amount) + toSats(fee) <= toSats(available);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert a coin amount to zatoshis for a send, rejecting non-finite / non-positive / rounds-to-zero /
|
||||||
|
// over-supply values. Returns false (and leaves out untouched) on any of those.
|
||||||
|
inline bool coinsToZatoshis(double coins, std::uint64_t& out) {
|
||||||
|
if (!std::isfinite(coins) || coins <= 0.0) return false;
|
||||||
|
const std::int64_t z = toSats(coins);
|
||||||
|
if (z <= 0 || z > static_cast<std::int64_t>(21000000) * 100000000LL) return false;
|
||||||
|
out = static_cast<std::uint64_t>(z);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace util
|
} // namespace util
|
||||||
} // namespace dragonx
|
} // namespace dragonx
|
||||||
|
|||||||
+22
-45
@@ -1125,56 +1125,33 @@ bool Bootstrap::verifyChecksums(const std::string& zipPath, const std::string& b
|
|||||||
std::string expectedSha = parseChecksumFile(sha256Content, 64);
|
std::string expectedSha = parseChecksumFile(sha256Content, 64);
|
||||||
const bool haveSHA256 = !expectedSha.empty();
|
const bool haveSHA256 = !expectedSha.empty();
|
||||||
|
|
||||||
std::string expectedMd5;
|
// SHA-256 is now required: the collision-prone MD5 fallback was removed. (A same-origin checksum
|
||||||
bool haveMD5 = false;
|
// only protects against transport corruption, not a malicious server — true authenticity needs a
|
||||||
if (!haveSHA256) { // MD5 only as a fallback when no SHA-256 is published
|
// detached ed25519 signature like the daemon/xmrig updaters; that is a separate release-coordinated
|
||||||
std::string md5Content = httpGetString(baseUrl + "/" + kZipName + ".md5", "[bootstrap]", opt);
|
// change, since it requires publishing per-archive .sig files and pinning a key.)
|
||||||
if (cancel_requested_) return false;
|
if (!haveSHA256) {
|
||||||
expectedMd5 = parseChecksumFile(md5Content, 32);
|
DEBUG_LOGF("[Bootstrap] No SHA-256 published - refusing to install an unverified archive\n");
|
||||||
haveMD5 = !expectedMd5.empty();
|
setProgress(State::Failed, "Verification failed: no SHA-256 checksum was published for the bootstrap. "
|
||||||
}
|
|
||||||
|
|
||||||
if (!haveSHA256 && !haveMD5) {
|
|
||||||
DEBUG_LOGF("[Bootstrap] No checksum published - refusing to install an unverified archive\n");
|
|
||||||
setProgress(State::Failed, "Verification failed: no checksum was published for the bootstrap. "
|
|
||||||
"Refusing to install an unverified archive.");
|
"Refusing to install an unverified archive.");
|
||||||
return false; // fail CLOSED; archive is KEPT
|
return false; // fail CLOSED; archive is KEPT
|
||||||
}
|
}
|
||||||
|
|
||||||
if (haveSHA256) {
|
setProgress(State::Verifying, "Verifying SHA-256...", 0, 1);
|
||||||
setProgress(State::Verifying, "Verifying SHA-256...", 0, 1);
|
std::string actual = computeSHA256(zipPath, 0.0f, 100.0f);
|
||||||
std::string actual = computeSHA256(zipPath, 0.0f, 100.0f);
|
if (cancel_requested_) return false;
|
||||||
if (cancel_requested_) return false;
|
if (actual.empty()) { setProgress(State::Failed, "SHA-256 verification error"); return false; }
|
||||||
if (actual.empty()) { setProgress(State::Failed, "SHA-256 verification error"); return false; }
|
if (expectedSha != actual) {
|
||||||
if (expectedSha != actual) {
|
last_verify_mismatch_ = true;
|
||||||
last_verify_mismatch_ = true;
|
DEBUG_LOGF("[Bootstrap] SHA-256 MISMATCH!\n expected: %s\n actual: %s\n",
|
||||||
DEBUG_LOGF("[Bootstrap] SHA-256 MISMATCH!\n expected: %s\n actual: %s\n",
|
expectedSha.c_str(), actual.c_str());
|
||||||
expectedSha.c_str(), actual.c_str());
|
setProgress(State::Failed,
|
||||||
setProgress(State::Failed,
|
"SHA-256 mismatch \xe2\x80\x94 the server's checksum file may be out of date.\n"
|
||||||
"SHA-256 mismatch \xe2\x80\x94 the server's checksum file may be out of date.\n"
|
"Expected: " + expectedSha.substr(0, 16) + "...\n"
|
||||||
"Expected: " + expectedSha.substr(0, 16) + "...\n"
|
"Got: " + actual.substr(0, 16) + "...\n"
|
||||||
"Got: " + actual.substr(0, 16) + "...\n"
|
"Try again or use a fresh download.");
|
||||||
"Try again or use a fresh download.");
|
return false;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
DEBUG_LOGF("[Bootstrap] SHA-256 verified: %s\n", actual.c_str());
|
|
||||||
} else { // haveMD5
|
|
||||||
setProgress(State::Verifying, "Verifying MD5...", 0, 1);
|
|
||||||
std::string actual = computeMD5(zipPath, 0.0f, 100.0f);
|
|
||||||
if (cancel_requested_) return false;
|
|
||||||
if (actual.empty()) { setProgress(State::Failed, "MD5 verification error"); return false; }
|
|
||||||
if (expectedMd5 != actual) {
|
|
||||||
last_verify_mismatch_ = true;
|
|
||||||
DEBUG_LOGF("[Bootstrap] MD5 MISMATCH!\n expected: %s\n actual: %s\n",
|
|
||||||
expectedMd5.c_str(), actual.c_str());
|
|
||||||
setProgress(State::Failed,
|
|
||||||
"MD5 mismatch \xe2\x80\x94 the server's checksum file may be out of date.\n"
|
|
||||||
"Expected: " + expectedMd5 + "\nGot: " + actual + "\n"
|
|
||||||
"Try again or use a fresh download.");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
DEBUG_LOGF("[Bootstrap] MD5 verified: %s\n", actual.c_str());
|
|
||||||
}
|
}
|
||||||
|
DEBUG_LOGF("[Bootstrap] SHA-256 verified: %s\n", actual.c_str());
|
||||||
|
|
||||||
setProgress(State::Verifying, "Checksums verified \xe2\x9c\x93", 100.0, 100.0);
|
setProgress(State::Verifying, "Checksums verified \xe2\x9c\x93", 100.0, 100.0);
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// DragonX Wallet - ImGui Edition
|
||||||
|
// Copyright 2024-2026 The Hush Developers
|
||||||
|
// Released under the GPLv3
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cctype>
|
||||||
|
|
||||||
|
namespace dragonx {
|
||||||
|
namespace util {
|
||||||
|
|
||||||
|
// Make a user-typed filename safe to write into a trusted config/export directory: basename only
|
||||||
|
// (no path traversal), illegal / control chars -> '_', no leading dots or trailing dots-or-spaces,
|
||||||
|
// Windows reserved device stems prefixed with '_', length-capped, and always ending in requiredExt
|
||||||
|
// (e.g. ".csv"). Returns `fallback` if nothing usable remains. Header-only so every export dialog
|
||||||
|
// shares one implementation.
|
||||||
|
inline std::string sanitizeExportFilename(std::string name, const char* requiredExt, const char* fallback) {
|
||||||
|
const std::size_t slash = name.find_last_of("/\\");
|
||||||
|
if (slash != std::string::npos) name = name.substr(slash + 1);
|
||||||
|
auto notSpace = [](unsigned char c) { return !std::isspace(c); };
|
||||||
|
name.erase(name.begin(), std::find_if(name.begin(), name.end(), notSpace));
|
||||||
|
name.erase(std::find_if(name.rbegin(), name.rend(), notSpace).base(), name.end());
|
||||||
|
for (char& c : name) {
|
||||||
|
const unsigned char u = static_cast<unsigned char>(c);
|
||||||
|
if (u < 0x20 || c == '<' || c == '>' || c == ':' || c == '"' || c == '|' ||
|
||||||
|
c == '?' || c == '*' || c == '/' || c == '\\')
|
||||||
|
c = '_';
|
||||||
|
}
|
||||||
|
const std::size_t firstGood = name.find_first_not_of('.');
|
||||||
|
if (firstGood == std::string::npos) name.clear(); else name.erase(0, firstGood);
|
||||||
|
while (!name.empty() && (name.back() == '.' || name.back() == ' ')) name.pop_back();
|
||||||
|
if (name.size() > 120) name.resize(120);
|
||||||
|
// Windows reserved device names (case-insensitive, checked on the stem before the first dot).
|
||||||
|
{
|
||||||
|
std::string stem = name;
|
||||||
|
const std::size_t dot = stem.find('.');
|
||||||
|
if (dot != std::string::npos) stem = stem.substr(0, dot);
|
||||||
|
std::transform(stem.begin(), stem.end(), stem.begin(),
|
||||||
|
[](unsigned char c) { return static_cast<char>(std::toupper(c)); });
|
||||||
|
static const char* const kReserved[] = {
|
||||||
|
"CON", "PRN", "AUX", "NUL",
|
||||||
|
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
|
||||||
|
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
|
||||||
|
};
|
||||||
|
for (const char* r : kReserved) if (stem == r) { name = "_" + name; break; }
|
||||||
|
}
|
||||||
|
std::string lower = name;
|
||||||
|
std::transform(lower.begin(), lower.end(), lower.begin(),
|
||||||
|
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||||||
|
std::string lext = requiredExt ? requiredExt : "";
|
||||||
|
std::transform(lext.begin(), lext.end(), lext.begin(),
|
||||||
|
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||||||
|
if (!lext.empty() &&
|
||||||
|
(lower.size() < lext.size() || lower.compare(lower.size() - lext.size(), lext.size(), lext) != 0))
|
||||||
|
name += requiredExt;
|
||||||
|
if (name.empty() || (requiredExt && name == requiredExt)) name = fallback ? fallback : "export";
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace util
|
||||||
|
} // namespace dragonx
|
||||||
@@ -60,6 +60,20 @@ int cancelXferCb(void* clientp, curl_off_t, curl_off_t, curl_off_t, curl_off_t)
|
|||||||
return static_cast<const std::atomic<bool>*>(clientp)->load(std::memory_order_relaxed) ? 1 : 0;
|
return static_cast<const std::atomic<bool>*>(clientp)->load(std::memory_order_relaxed) ? 1 : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Restrict the transfer AND any redirect to HTTPS so a redirect can't silently downgrade to http://,
|
||||||
|
// file://, ftp:// or an unexpected scheme. Every caller of these shared helpers (daemon/xmrig updaters,
|
||||||
|
// pool stats) uses https URLs. (Verify-peer/host stay on at the call sites.)
|
||||||
|
void restrictToHttps(CURL* curl)
|
||||||
|
{
|
||||||
|
#if LIBCURL_VERSION_NUM >= 0x075500 // 7.85.0: the string-form protocol setopts
|
||||||
|
curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, "https");
|
||||||
|
curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS_STR, "https");
|
||||||
|
#else
|
||||||
|
curl_easy_setopt(curl, CURLOPT_PROTOCOLS, CURLPROTO_HTTPS);
|
||||||
|
curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS, CURLPROTO_HTTPS);
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
HttpGetResult httpGet(const std::string& url, const char* logTag, const HttpGetOptions& opt)
|
HttpGetResult httpGet(const std::string& url, const char* logTag, const HttpGetOptions& opt)
|
||||||
@@ -78,6 +92,7 @@ HttpGetResult httpGet(const std::string& url, const char* logTag, const HttpGetO
|
|||||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &sink);
|
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &sink);
|
||||||
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
||||||
curl_easy_setopt(curl, CURLOPT_MAXREDIRS, opt.maxRedirects);
|
curl_easy_setopt(curl, CURLOPT_MAXREDIRS, opt.maxRedirects);
|
||||||
|
restrictToHttps(curl);
|
||||||
curl_easy_setopt(curl, CURLOPT_USERAGENT, "ObsidianDragon/1.0");
|
curl_easy_setopt(curl, CURLOPT_USERAGENT, "ObsidianDragon/1.0");
|
||||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT, opt.timeoutSec);
|
curl_easy_setopt(curl, CURLOPT_TIMEOUT, opt.timeoutSec);
|
||||||
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, opt.connectTimeoutSec);
|
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, opt.connectTimeoutSec);
|
||||||
@@ -131,6 +146,8 @@ bool httpDownloadToFile(const std::string& url, const std::string& destPath,
|
|||||||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writeFileCb);
|
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writeFileCb);
|
||||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, fp);
|
curl_easy_setopt(curl, CURLOPT_WRITEDATA, fp);
|
||||||
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
||||||
|
curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 5L); // bound redirects (this path set none before)
|
||||||
|
restrictToHttps(curl);
|
||||||
curl_easy_setopt(curl, CURLOPT_USERAGENT, "ObsidianDragon/1.0");
|
curl_easy_setopt(curl, CURLOPT_USERAGENT, "ObsidianDragon/1.0");
|
||||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 0L);
|
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 0L);
|
||||||
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 30L);
|
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 30L);
|
||||||
|
|||||||
@@ -114,6 +114,11 @@ void I18n::overlayTranslations(const nlohmann::json& translations)
|
|||||||
|
|
||||||
bool I18n::loadLanguage(const std::string& locale)
|
bool I18n::loadLanguage(const std::string& locale)
|
||||||
{
|
{
|
||||||
|
// Exclusive for the whole load: strings_ is cleared/rebuilt below, and translateCopy() readers on
|
||||||
|
// other threads (daemon monitor) must never observe it mid-rewrite. loadBuiltinEnglish() and
|
||||||
|
// overlayTranslations() are called only from here and do not lock (no recursion).
|
||||||
|
std::unique_lock<std::shared_mutex> lock(strings_mutex_);
|
||||||
|
|
||||||
// Try to load from file: CWD-relative first, then exe-relative
|
// Try to load from file: CWD-relative first, then exe-relative
|
||||||
std::string rel_path = "res/lang/" + locale + ".json";
|
std::string rel_path = "res/lang/" + locale + ".json";
|
||||||
const std::string exe_dir = Platform::getExecutableDirectory();
|
const std::string exe_dir = Platform::getExecutableDirectory();
|
||||||
@@ -260,6 +265,7 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["chat_toast_lite_busy"] = "A send is already in progress, or no wallet is open.";
|
strings_["chat_toast_lite_busy"] = "A send is already in progress, or no wallet is open.";
|
||||||
strings_["chat_toast_waiting_reply"] = "Waiting for the contact to reply before you can message them.";
|
strings_["chat_toast_waiting_reply"] = "Waiting for the contact to reply before you can message them.";
|
||||||
strings_["chat_toast_compose_failed"] = "Could not compose the message (too long?).";
|
strings_["chat_toast_compose_failed"] = "Could not compose the message (too long?).";
|
||||||
|
strings_["chat_toast_too_long"] = "Message too long — shorten it and send again.";
|
||||||
strings_["chat_toast_request_compose_failed"] = "Could not compose the contact request (invalid address / text?).";
|
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_request_queued"] = "Contact request queued.";
|
||||||
strings_["chat_toast_need_funds"] = "Need a small shielded balance to send chat (to cover the fee).";
|
strings_["chat_toast_need_funds"] = "Need a small shielded balance to send chat (to cover the fee).";
|
||||||
@@ -268,6 +274,10 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["chat_retry"] = "Retry";
|
strings_["chat_retry"] = "Retry";
|
||||||
strings_["chat_jump_latest"] = "Latest";
|
strings_["chat_jump_latest"] = "Latest";
|
||||||
strings_["chat_empty_title"] = "No conversations yet";
|
strings_["chat_empty_title"] = "No conversations yet";
|
||||||
|
strings_["chat_intro_title"] = "How chat works";
|
||||||
|
strings_["chat_intro_body"] = "Messages are end-to-end encrypted and sent as shielded transactions, so only you and your contact can read them. Your chat identity is tied to your wallet seed, so restoring your seed restores your chat address.";
|
||||||
|
strings_["chat_intro_fee"] = "Each message costs a small network fee. Share your chat address to let people start a conversation with you.";
|
||||||
|
strings_["chat_intro_dismiss"] = "Got it";
|
||||||
strings_["chat_empty_start"] = "Start one with \"New conversation\".";
|
strings_["chat_empty_start"] = "Start one with \"New conversation\".";
|
||||||
strings_["chat_copy_my_address"] = "Copy my chat address";
|
strings_["chat_copy_my_address"] = "Copy my chat address";
|
||||||
strings_["chat_my_address_tip"] = "Share this address so others can message you";
|
strings_["chat_my_address_tip"] = "Share this address so others can message you";
|
||||||
@@ -303,11 +313,54 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["chat_deleted_toast"] = "Conversation deleted";
|
strings_["chat_deleted_toast"] = "Conversation deleted";
|
||||||
strings_["chat_delete_failed"] = "Couldn't delete the conversation — nothing was changed.";
|
strings_["chat_delete_failed"] = "Couldn't delete the conversation — nothing was changed.";
|
||||||
strings_["chat_blocked_toast"] = "Conversation deleted & blocked";
|
strings_["chat_blocked_toast"] = "Conversation deleted & blocked";
|
||||||
|
strings_["chat_delete_everywhere"] = "Delete everywhere";
|
||||||
|
strings_["chat_delete_everywhere_note"] = "Also tells your other devices - and a future restore from your seed phrase - to hide this conversation. It sends a tiny on-chain signal (costs the usual chat fee). You can bring it back later from Restore.";
|
||||||
|
strings_["chat_delete_everywhere_toast"] = "Deleting everywhere - signal sent";
|
||||||
|
strings_["chat_delete_needs_sync"] = "Wait until the wallet is fully synced to delete everywhere.";
|
||||||
strings_["chat_blocked_manage"] = "Blocked";
|
strings_["chat_blocked_manage"] = "Blocked";
|
||||||
strings_["chat_blocked_title"] = "Blocked conversations";
|
strings_["chat_blocked_title"] = "Blocked conversations";
|
||||||
strings_["chat_blocked_desc"] = "Blocked conversations are removed and their messages are dropped — old and new — until you unblock. Unblocking re-imports the conversation from the chain.";
|
strings_["chat_blocked_desc"] = "Blocked conversations are removed and their messages are dropped — old and new — until you unblock. Unblocking re-imports the conversation from the chain.";
|
||||||
strings_["chat_unblock"] = "Unblock";
|
strings_["chat_unblock"] = "Unblock";
|
||||||
strings_["chat_unblocked_toast"] = "Conversation unblocked";
|
strings_["chat_unblocked_toast"] = "Conversation unblocked";
|
||||||
|
strings_["chat_restore_manage"] = "Restore";
|
||||||
|
strings_["chat_restore_title"] = "Restore conversations";
|
||||||
|
strings_["chat_restore_desc"] = "Conversations you deleted everywhere can be brought back here - on this device or after importing your seed phrase. Restoring re-imports the messages from the chain and tells your other devices to show it again.";
|
||||||
|
strings_["chat_restore_none"] = "No deleted conversations found yet. If you deleted one on another device, try Quick scan or paste its chat address below.";
|
||||||
|
strings_["chat_restore_btn"] = "Restore";
|
||||||
|
strings_["chat_restore_scan"] = "Quick scan";
|
||||||
|
strings_["chat_restore_scanning"] = "Scanning the chain for deleted conversations...";
|
||||||
|
strings_["chat_restore_toast"] = "Restoring conversation - it reappears after the next scan";
|
||||||
|
strings_["chat_restore_paste_label"] = "Restore by chat address";
|
||||||
|
strings_["chat_restore_paste_hint"] = "Paste the conversation's chat (z) address";
|
||||||
|
strings_["chat_sensitive_mark_tip"] = "Make sensitive";
|
||||||
|
strings_["chat_sensitive_unmark_tip"] = "Sensitive - tap to change";
|
||||||
|
strings_["chat_sensitive_mark_title"] = "Make this conversation sensitive?";
|
||||||
|
strings_["chat_sensitive_unmark_title"] = "Make this conversation normal?";
|
||||||
|
strings_["chat_sensitive_unlock_title"] = "Unlock sensitive conversations";
|
||||||
|
strings_["chat_sensitive_warn_1"] = "Its messages will be encrypted on this device under a separate passphrase - not your seed phrase. Anyone who later restores your wallet from your seed alone won't see this conversation.";
|
||||||
|
strings_["chat_sensitive_warn_lose"] = "If you lose this passphrase, these messages are gone forever - they can't be recovered from your seed.";
|
||||||
|
strings_["chat_sensitive_warn_dir"] = "Your sent messages here are protected completely. Received messages are still on the blockchain and could, in theory, be recovered by someone who has your seed and other software - DragonX itself will never show them without your passphrase.";
|
||||||
|
strings_["chat_sensitive_warn_hist"] = "Messages already in this conversation stay recoverable from the chain; this fully protects messages from here on.";
|
||||||
|
strings_["chat_sensitive_unmark_body"] = "This conversation will no longer be encrypted under your sensitive-chat passphrase. New messages will be stored like any other conversation.";
|
||||||
|
strings_["chat_sensitive_unlock_body"] = "Enter your sensitive-chat passphrase to show your sensitive conversations again.";
|
||||||
|
strings_["chat_sensitive_setup_label"] = "Create a passphrase for sensitive conversations";
|
||||||
|
strings_["chat_sensitive_unlock_label"] = "Enter your sensitive-chat passphrase";
|
||||||
|
strings_["chat_sensitive_pass_hint"] = "Passphrase";
|
||||||
|
strings_["chat_sensitive_pass2_hint"] = "Confirm passphrase";
|
||||||
|
strings_["chat_sensitive_btn_setup"] = "Set up & make sensitive";
|
||||||
|
strings_["chat_sensitive_btn_mark"] = "Make sensitive";
|
||||||
|
strings_["chat_sensitive_btn_unmark"] = "Make normal";
|
||||||
|
strings_["chat_sensitive_btn_unlock"] = "Unlock";
|
||||||
|
strings_["chat_sensitive_btn_unlock_mark"] = "Unlock & make sensitive";
|
||||||
|
strings_["chat_sensitive_btn_unlock_unmark"] = "Unlock & make normal";
|
||||||
|
strings_["chat_sensitive_lock_btn"] = "Lock sensitive";
|
||||||
|
strings_["chat_sensitive_unlock_btn"] = "Unlock sensitive";
|
||||||
|
strings_["chat_sensitive_toast_marked"] = "Conversation is now sensitive";
|
||||||
|
strings_["chat_sensitive_toast_unmarked"] = "Conversation is no longer sensitive";
|
||||||
|
strings_["chat_sensitive_toast_unlocked"] = "Sensitive conversations unlocked";
|
||||||
|
strings_["chat_sensitive_err_pass"] = "Passphrases don't match, or are empty.";
|
||||||
|
strings_["chat_sensitive_err_unlock"] = "Wrong passphrase.";
|
||||||
|
strings_["chat_sensitive_err_create"] = "Couldn't set up the sensitive-chat vault.";
|
||||||
strings_["chat_pick_contact"] = "Choose from contacts\xE2\x80\xA6";
|
strings_["chat_pick_contact"] = "Choose from contacts\xE2\x80\xA6";
|
||||||
strings_["chat_no_z_contacts"] = "No shielded-address contacts yet";
|
strings_["chat_no_z_contacts"] = "No shielded-address contacts yet";
|
||||||
strings_["chat_contact_needs_z"] = "Needs a shielded (z) address to chat";
|
strings_["chat_contact_needs_z"] = "Needs a shielded (z) address to chat";
|
||||||
@@ -315,6 +368,10 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["contacts_message_needs_z"] = "Chat needs a shielded (z) address — this contact only has a transparent one";
|
strings_["contacts_message_needs_z"] = "Chat needs a shielded (z) address — this contact only has a transparent one";
|
||||||
strings_["chat_copy_address_tip"] = "Click to copy address";
|
strings_["chat_copy_address_tip"] = "Click to copy address";
|
||||||
strings_["chat_verify_key"] = "Identity key \xE2\x80\x94 compare to verify";
|
strings_["chat_verify_key"] = "Identity key \xE2\x80\x94 compare to verify";
|
||||||
|
strings_["chat_verify_tip"] = "End-to-end encrypted to this contact's identity key. Click to view the key fingerprint and compare it with your contact over a separate channel (call or in person) to be sure no one is impersonating them.";
|
||||||
|
strings_["chat_verify_title"] = "Verify contact key";
|
||||||
|
strings_["chat_verify_body"] = "Messages in this chat are encrypted to the identity key below, which was learned from this contact's first message. Ask your contact to read you their key fingerprint over a separate channel (a call or in person). If it matches exactly, you are talking to the right person; if not, do not trust this conversation.";
|
||||||
|
strings_["chat_verify_copy"] = "Copy key";
|
||||||
strings_["chat_awaiting_key"] = "Waiting for reply";
|
strings_["chat_awaiting_key"] = "Waiting for reply";
|
||||||
strings_["chat_rename"] = "Rename contact";
|
strings_["chat_rename"] = "Rename contact";
|
||||||
strings_["chat_rename_hint"] = "Contact name";
|
strings_["chat_rename_hint"] = "Contact name";
|
||||||
@@ -474,6 +531,23 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["mig_receive_addr"] = "New wallet receive address:";
|
strings_["mig_receive_addr"] = "New wallet receive address:";
|
||||||
strings_["mig_copy_seed"] = "Copy seed";
|
strings_["mig_copy_seed"] = "Copy seed";
|
||||||
strings_["mig_backed_up"] = "I've written down my seed phrase";
|
strings_["mig_backed_up"] = "I've written down my seed phrase";
|
||||||
|
strings_["err_not_connected_daemon"] = "Not connected to the daemon.";
|
||||||
|
strings_["sweep_err_need_spending_key"] = "Enter a spending key (a viewing key can't move funds).";
|
||||||
|
strings_["sweep_status_importing"] = "Importing key & rescanning…";
|
||||||
|
strings_["sweep_err_unknown"] = "Sweep failed (unknown error)";
|
||||||
|
strings_["sweep_err_nofunds"] = "This key holds no funds to sweep.";
|
||||||
|
strings_["sweep_err_unconfirmed"] = "This key's funds are still unconfirmed — try again shortly.";
|
||||||
|
strings_["sweep_err_already"] = "This key is already in your wallet — use Send to move its funds.";
|
||||||
|
strings_["sweep_err_dust"] = "This key's balance is too small to cover the network fee.";
|
||||||
|
strings_["sweep_status_sweeping"] = "Sweeping funds to your address…";
|
||||||
|
strings_["migrate_status_checking_sweep"] = "Checking on the previous sweep…";
|
||||||
|
strings_["migrate_err_no_dest"] = "No destination address for the sweep.";
|
||||||
|
strings_["migrate_status_building"] = "Building the sweep transaction…";
|
||||||
|
strings_["migrate_status_waiting_accept"] = "Waiting for the sweep transaction to be accepted…";
|
||||||
|
strings_["migrate_status_restarting"] = "Restarting with your new wallet — this rescan can take a while…";
|
||||||
|
strings_["rescan_status_from_block"] = "Rescanning from block %d...";
|
||||||
|
strings_["mig_understood"] = "I understand that once I continue, my funds will be moved into this new wallet and can only be recovered with this seed phrase";
|
||||||
|
strings_["mig_ack_required"] = "Confirm both statements above to continue";
|
||||||
strings_["mig_step1_done"] = "Step 1 of 2 complete — your funds have NOT moved yet. Sweeping them into this new wallet is the next step.";
|
strings_["mig_step1_done"] = "Step 1 of 2 complete — your funds have NOT moved yet. Sweeping them into this new wallet is the next step.";
|
||||||
strings_["mig_continue_sweep"] = "Continue to sweep";
|
strings_["mig_continue_sweep"] = "Continue to sweep";
|
||||||
strings_["mig_discard"] = "Discard migration";
|
strings_["mig_discard"] = "Discard migration";
|
||||||
@@ -482,6 +556,8 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["mig_to"] = "To: ";
|
strings_["mig_to"] = "To: ";
|
||||||
strings_["mig_unlock_first"] = "Unlock your wallet first to spend from it.";
|
strings_["mig_unlock_first"] = "Unlock your wallet first to spend from it.";
|
||||||
strings_["mig_sweep_all"] = "Sweep all funds";
|
strings_["mig_sweep_all"] = "Sweep all funds";
|
||||||
|
strings_["mig_unlock_prompt"] = "Your wallet is locked. Enter your passphrase to unlock it, then sweep.";
|
||||||
|
strings_["mig_unlock_btn"] = "Unlock";
|
||||||
strings_["mig_later"] = "Later";
|
strings_["mig_later"] = "Later";
|
||||||
strings_["mig_sweeping"] = "Sweeping your funds into the new wallet…";
|
strings_["mig_sweeping"] = "Sweeping your funds into the new wallet…";
|
||||||
strings_["mig_confirming"] = "Waiting for the sweep to confirm on-chain before switching wallets — this guarantees your funds have actually moved.";
|
strings_["mig_confirming"] = "Waiting for the sweep to confirm on-chain before switching wallets — this guarantees your funds have actually moved.";
|
||||||
@@ -491,7 +567,13 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["mig_adopt"] = "Make this my wallet";
|
strings_["mig_adopt"] = "Make this my wallet";
|
||||||
strings_["mig_remainder"] = "Some funds are still in your old wallet (too many inputs for one transaction) — sweep the remainder before switching.";
|
strings_["mig_remainder"] = "Some funds are still in your old wallet (too many inputs for one transaction) — sweep the remainder before switching.";
|
||||||
strings_["mig_sweep_remaining"] = "Sweep remaining";
|
strings_["mig_sweep_remaining"] = "Sweep remaining";
|
||||||
|
strings_["mig_discard_confirm"] = "Discard this migration? The new seed wallet created on this device will be deleted. Make sure you have not sent anything to it. Your current wallet is not changed.";
|
||||||
|
strings_["mig_discard_yes"] = "Yes, discard";
|
||||||
|
strings_["mig_sweep_confirm"] = "Send %.8f DRGX from your current wallet to the new seed wallet in a single transaction? This moves all of your funds.";
|
||||||
|
strings_["mig_sweep_confirm_yes"] = "Yes, sweep funds";
|
||||||
strings_["mig_waiting_mine"] = "Waiting for the sweep transaction to be mined…";
|
strings_["mig_waiting_mine"] = "Waiting for the sweep transaction to be mined…";
|
||||||
|
strings_["mig_waiting_depth"] = "Sweep mined. Waiting for %d confirmations before switching wallets, so a short chain reorganisation cannot undo it…";
|
||||||
|
strings_["mig_adopt_reverify_failed"] = "Could not confirm it is safe to switch wallets yet (the node may still be syncing, the sweep is not deep enough, or funds remain). Nothing was changed - wait and try again.";
|
||||||
strings_["mig_adopting"] = "Installing your new wallet and rescanning…";
|
strings_["mig_adopting"] = "Installing your new wallet and rescanning…";
|
||||||
strings_["mig_done"] = "Migration complete. Your wallet is now backed by your seed phrase.";
|
strings_["mig_done"] = "Migration complete. Your wallet is now backed by your seed phrase.";
|
||||||
strings_["mig_done_detail"] = "The daemon is rescanning to show your funds — this can take a few minutes. Your previous wallet was saved as a .bak in the data folder.";
|
strings_["mig_done_detail"] = "The daemon is rescanning to show your funds — this can take a few minutes. Your previous wallet was saved as a .bak in the data folder.";
|
||||||
@@ -592,6 +674,15 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["settings_merge_to_address"] = "Merge to Address…";
|
strings_["settings_merge_to_address"] = "Merge to Address…";
|
||||||
strings_["settings_clear_ztx"] = "Clear Z-Tx History";
|
strings_["settings_clear_ztx"] = "Clear Z-Tx History";
|
||||||
strings_["settings_import_key"] = "Import Private Key…";
|
strings_["settings_import_key"] = "Import Private Key…";
|
||||||
|
strings_["settings_import_wallet_file"] = "Import From Backup File…";
|
||||||
|
strings_["import_wallet_file_title"] = "Import keys from a backup file";
|
||||||
|
strings_["import_wallet_file_note"] = "Imports every private key from an old wallet's export file (its z_exportwallet / dumpwallet output, or SilentDragon's \"Export all private keys\") and rescans once — the fastest way to recover a legacy wallet.dat this client can't open.";
|
||||||
|
strings_["import_wallet_file_path_label"] = "Path to the key dump file:";
|
||||||
|
strings_["import_wallet_file_import_btn"] = "Import and rescan";
|
||||||
|
strings_["import_wallet_file_importing"] = "Importing keys and rescanning — this can take a while";
|
||||||
|
strings_["import_wallet_file_ok"] = "Keys imported. Once the rescan finishes, shield any mined (coinbase) transparent funds from the Shield screen to make them spendable.";
|
||||||
|
strings_["import_wallet_file_need_path"] = "Enter the path to the key dump file.";
|
||||||
|
strings_["import_wallet_file_not_found"] = "Couldn't open that file — check the path is correct and the file is readable.";
|
||||||
strings_["settings_import_viewkey"] = "Import Viewing Key…";
|
strings_["settings_import_viewkey"] = "Import Viewing Key…";
|
||||||
strings_["settings_export_key"] = "Export Key…";
|
strings_["settings_export_key"] = "Export Key…";
|
||||||
strings_["settings_export_all"] = "Export All…";
|
strings_["settings_export_all"] = "Export All…";
|
||||||
@@ -767,6 +858,8 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["wiz_pass_spaces"] = "Passphrase has leading/trailing spaces \xe2\x80\x94 remove them";
|
strings_["wiz_pass_spaces"] = "Passphrase has leading/trailing spaces \xe2\x80\x94 remove them";
|
||||||
strings_["wiz_encrypt_continue"] = "Encrypt & Continue";
|
strings_["wiz_encrypt_continue"] = "Encrypt & Continue";
|
||||||
strings_["wiz_encrypt_bg"] = "Encryption will complete in the background";
|
strings_["wiz_encrypt_bg"] = "Encryption will complete in the background";
|
||||||
|
strings_["wiz_handoff_starting"] = "Setting up your wallet. The node is starting and will keep syncing in the background. You can start exploring now, but balances may take a while to appear.";
|
||||||
|
strings_["wiz_handoff_encrypting"] = "Setting up your wallet. The node is starting, and encryption will finish in the background once it connects. Balances may take a while to appear.";
|
||||||
strings_["wiz_skip_confirm"] = "Continue WITHOUT encryption? Keys will be stored unencrypted \xe2\x80\x94 click Skip again to confirm.";
|
strings_["wiz_skip_confirm"] = "Continue WITHOUT encryption? Keys will be stored unencrypted \xe2\x80\x94 click Skip again to confirm.";
|
||||||
strings_["wiz_pin_optional"] = "Quick-Unlock PIN (optional)";
|
strings_["wiz_pin_optional"] = "Quick-Unlock PIN (optional)";
|
||||||
strings_["wiz_pin_label"] = "PIN (4-8 digits):";
|
strings_["wiz_pin_label"] = "PIN (4-8 digits):";
|
||||||
@@ -1052,10 +1145,14 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["tt_reset_settings"] = "Revert this page to the last saved settings (undo unsaved changes)";
|
strings_["tt_reset_settings"] = "Revert this page to the last saved settings (undo unsaved changes)";
|
||||||
strings_["tt_debug_collapse"] = "Collapse debug logging options";
|
strings_["tt_debug_collapse"] = "Collapse debug logging options";
|
||||||
strings_["tt_debug_expand"] = "Expand debug logging options";
|
strings_["tt_debug_expand"] = "Expand debug logging options";
|
||||||
|
strings_["debug_options_caption_gated"] = "Diagnostics and node tuning for troubleshooting. Locked: opening requires confirmation (and your PIN or passphrase if set).";
|
||||||
|
strings_["debug_options_caption_unlocked"] = "Diagnostics and node tuning for troubleshooting. Unlocked for this session.";
|
||||||
|
strings_["tt_debug_expand_gated"] = "Locked: confirm (and enter your PIN or passphrase if set) to show debug options";
|
||||||
strings_["tt_restart_daemon"] = "Restart the daemon to apply debug logging changes";
|
strings_["tt_restart_daemon"] = "Restart the daemon to apply debug logging changes";
|
||||||
|
|
||||||
// Settings: additional tooltips (keys/data row)
|
// Settings: additional tooltips (keys/data row)
|
||||||
strings_["tt_import_key"] = "Import a private key (zkey or tkey) into this wallet";
|
strings_["tt_import_key"] = "Import a private key (zkey or tkey) into this wallet";
|
||||||
|
strings_["tt_import_wallet_file"] = "Import all keys from an old wallet's exported dump file (one rescan)";
|
||||||
strings_["tt_import_viewkey"] = "Import a shielded viewing key to watch an address (read-only)";
|
strings_["tt_import_viewkey"] = "Import a shielded viewing key to watch an address (read-only)";
|
||||||
strings_["tt_export_key"] = "Export the private key for the selected address";
|
strings_["tt_export_key"] = "Export the private key for the selected address";
|
||||||
strings_["tt_export_all"] = "Export all private keys to a file";
|
strings_["tt_export_all"] = "Export all private keys to a file";
|
||||||
@@ -1318,6 +1415,7 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["lite_unlock_btn"] = "Unlock";
|
strings_["lite_unlock_btn"] = "Unlock";
|
||||||
strings_["lite_unlock_ok"] = "Wallet unlocked — you can send now";
|
strings_["lite_unlock_ok"] = "Wallet unlocked — you can send now";
|
||||||
strings_["lite_unlock_failed"] = "Unlock failed — wrong passphrase?";
|
strings_["lite_unlock_failed"] = "Unlock failed — wrong passphrase?";
|
||||||
|
strings_["lite_encrypt_failed_unencrypted"] = "Wallet encryption failed — your wallet may still be UNENCRYPTED. Open Settings > Security and set the passphrase again.";
|
||||||
|
|
||||||
// Send Tab
|
// Send Tab
|
||||||
strings_["pay_from"] = "Pay From";
|
strings_["pay_from"] = "Pay From";
|
||||||
@@ -1469,6 +1567,8 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["confirmations"] = "Confirmations";
|
strings_["confirmations"] = "Confirmations";
|
||||||
strings_["confirmed"] = "Confirmed";
|
strings_["confirmed"] = "Confirmed";
|
||||||
strings_["pending"] = "Pending";
|
strings_["pending"] = "Pending";
|
||||||
|
strings_["tx_dropped"] = "Dropped";
|
||||||
|
strings_["tx_stale_pending"] = "Expired?";
|
||||||
strings_["baltab_pending_chip"] = "Pending +%.4f";
|
strings_["baltab_pending_chip"] = "Pending +%.4f";
|
||||||
strings_["baltab_pending_tip"] = "Awaiting confirmations — not spendable yet; it moves to your available balance once confirmed.";
|
strings_["baltab_pending_tip"] = "Awaiting confirmations — not spendable yet; it moves to your available balance once confirmed.";
|
||||||
strings_["sent"] = "sent";
|
strings_["sent"] = "sent";
|
||||||
@@ -1561,6 +1661,7 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["sb_mining_hs"] = "%.1f H/s";
|
strings_["sb_mining_hs"] = "%.1f H/s";
|
||||||
strings_["sb_syncing_eta"] = "Syncing %.1f%% (%d left, %.0f blk/s, ~%s)";
|
strings_["sb_syncing_eta"] = "Syncing %.1f%% (%d left, %.0f blk/s, ~%s)";
|
||||||
strings_["sb_syncing_basic"] = "Syncing %.1f%% (%d left)";
|
strings_["sb_syncing_basic"] = "Syncing %.1f%% (%d left)";
|
||||||
|
strings_["sb_lite_sync_error"] = "Sync error: %.80s";
|
||||||
strings_["sb_rescanning_pct"] = "Rescanning %.0f%%";
|
strings_["sb_rescanning_pct"] = "Rescanning %.0f%%";
|
||||||
strings_["sb_rescanning"] = "Rescanning";
|
strings_["sb_rescanning"] = "Rescanning";
|
||||||
strings_["sb_building_witnesses_pct"] = "Rebuilding witnesses %.0f%%";
|
strings_["sb_building_witnesses_pct"] = "Rebuilding witnesses %.0f%%";
|
||||||
@@ -1594,6 +1695,9 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["node_banner_open_config"] = "Open config folder";
|
strings_["node_banner_open_config"] = "Open config folder";
|
||||||
strings_["node_banner_auth_title"] = "Wallet can't sign in to the node";
|
strings_["node_banner_auth_title"] = "Wallet can't sign in to the node";
|
||||||
strings_["node_banner_auth_detail"] = "The node rejected the wallet's saved login. Restart the node, or open the config folder to fix the login in DRAGONX.conf.";
|
strings_["node_banner_auth_detail"] = "The node rejected the wallet's saved login. Restart the node, or open the config folder to fix the login in DRAGONX.conf.";
|
||||||
|
strings_["node_banner_legacy_title"] = "Your transparent funds can't be moved yet";
|
||||||
|
strings_["node_banner_legacy_detail"] = "This wallet has no usable seed, so it can't create a shielded address to move its transparent funds. Recover them into a fresh seed-backed wallet.";
|
||||||
|
strings_["node_banner_recover"] = "Recover funds";
|
||||||
// Refresh-staleness badge (W6-2) on the Total Balance card.
|
// Refresh-staleness badge (W6-2) on the Total Balance card.
|
||||||
strings_["data_stale_prefix"] = "Updated";
|
strings_["data_stale_prefix"] = "Updated";
|
||||||
strings_["data_stale_tooltip"] =
|
strings_["data_stale_tooltip"] =
|
||||||
@@ -1985,6 +2089,9 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["toast_stop_mining_failed"] = "Couldn't stop mining: ";
|
strings_["toast_stop_mining_failed"] = "Couldn't stop mining: ";
|
||||||
strings_["toast_pool_start_failed"] = "Couldn't start the pool miner: ";
|
strings_["toast_pool_start_failed"] = "Couldn't start the pool miner: ";
|
||||||
strings_["toast_ban_failed"] = "Couldn't ban the peer: ";
|
strings_["toast_ban_failed"] = "Couldn't ban the peer: ";
|
||||||
|
strings_["toast_peer_banned"] = "Peer banned.";
|
||||||
|
strings_["toast_peer_unbanned"] = "Peer unbanned.";
|
||||||
|
strings_["toast_bans_cleared"] = "All bans cleared.";
|
||||||
strings_["toast_unban_failed"] = "Couldn't unban the peer: ";
|
strings_["toast_unban_failed"] = "Couldn't unban the peer: ";
|
||||||
strings_["toast_clear_bans_failed"] = "Couldn't clear bans: ";
|
strings_["toast_clear_bans_failed"] = "Couldn't clear bans: ";
|
||||||
strings_["toast_key_export_failed"] = "Key export failed: ";
|
strings_["toast_key_export_failed"] = "Key export failed: ";
|
||||||
@@ -1994,6 +2101,11 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["console_clear"] = "Clear";
|
strings_["console_clear"] = "Clear";
|
||||||
strings_["console_clear_console"] = "Clear Console";
|
strings_["console_clear_console"] = "Clear Console";
|
||||||
strings_["console_cleared"] = "Console cleared";
|
strings_["console_cleared"] = "Console cleared";
|
||||||
|
strings_["console_secret_result_hidden"] = "[result hidden — contains private key material. Use \"Copy secret\" to copy it, or it clears automatically.]";
|
||||||
|
strings_["console_secret_result_expired"] = "[hidden secret result cleared]";
|
||||||
|
strings_["console_copy_secret"] = "Copy secret";
|
||||||
|
strings_["console_copy_secret_tip"] = "Copy the hidden key/seed result to the clipboard (auto-clears after ~45s).";
|
||||||
|
strings_["console_hide_secret"] = "Hide";
|
||||||
strings_["console_click_commands"] = "Click commands above to insert them";
|
strings_["console_click_commands"] = "Click commands above to insert them";
|
||||||
strings_["console_click_insert"] = "Click to insert";
|
strings_["console_click_insert"] = "Click to insert";
|
||||||
strings_["console_click_insert_params"] = "Click to insert with parameters";
|
strings_["console_click_insert_params"] = "Click to insert with parameters";
|
||||||
@@ -2108,6 +2220,17 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["export_tx_file_fail"] = "Failed to create CSV file";
|
strings_["export_tx_file_fail"] = "Failed to create CSV file";
|
||||||
strings_["export_tx_none"] = "No transactions to export";
|
strings_["export_tx_none"] = "No transactions to export";
|
||||||
strings_["export_tx_success"] = "Transactions exported successfully";
|
strings_["export_tx_success"] = "Transactions exported successfully";
|
||||||
|
strings_["export_tx_open_folder"] = "Open folder";
|
||||||
|
strings_["export_tx_scope"] = "Export scope";
|
||||||
|
strings_["export_tx_scope_all"] = "All transactions";
|
||||||
|
strings_["export_tx_scope_filter"] = "Current History filter";
|
||||||
|
strings_["export_tx_scope_range"] = "Date range";
|
||||||
|
strings_["export_tx_scope_filter_none"] = "No filter is active on the History tab";
|
||||||
|
strings_["export_tx_scope_stale"] = "The History filter changed while loading. Close and reopen this dialog.";
|
||||||
|
strings_["export_tx_range_from"] = "From (YYYY-MM-DD)";
|
||||||
|
strings_["export_tx_range_to"] = "To (YYYY-MM-DD)";
|
||||||
|
strings_["export_tx_range_invalid"] = "Enter valid dates as YYYY-MM-DD, with From on or before To";
|
||||||
|
strings_["export_tx_range_pending_note"] = "Pending transactions have no date and are not included in a date range.";
|
||||||
strings_["export_tx_title"] = "Export Transactions to CSV";
|
strings_["export_tx_title"] = "Export Transactions to CSV";
|
||||||
|
|
||||||
// --- Import Key Dialog ---
|
// --- Import Key Dialog ---
|
||||||
@@ -2159,6 +2282,8 @@ void I18n::loadBuiltinEnglish()
|
|||||||
|
|
||||||
// --- Key Export Dialog ---
|
// --- Key Export Dialog ---
|
||||||
strings_["key_export_fetching"] = "Fetching key from wallet...";
|
strings_["key_export_fetching"] = "Fetching key from wallet...";
|
||||||
|
strings_["key_export_fetching_base"] = "Fetching key from wallet";
|
||||||
|
strings_["key_export_unlock_first"] = "Unlock your wallet first, then reveal the key.";
|
||||||
strings_["key_export_failed"] = "Couldn't export the key \xE2\x80\x94 unlock the wallet (if encrypted) and try again.";
|
strings_["key_export_failed"] = "Couldn't export the key \xE2\x80\x94 unlock the wallet (if encrypted) and try again.";
|
||||||
strings_["key_export_private_key"] = "Private Key:";
|
strings_["key_export_private_key"] = "Private Key:";
|
||||||
strings_["key_export_private_warning"] = "Keep this key SECRET! Anyone with this key can spend your funds. Never share it online or with untrusted parties.";
|
strings_["key_export_private_warning"] = "Keep this key SECRET! Anyone with this key can spend your funds. Never share it online or with untrusted parties.";
|
||||||
@@ -2476,6 +2601,10 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["daemon_update_installed_ok"] = "Node installed";
|
strings_["daemon_update_installed_ok"] = "Node installed";
|
||||||
strings_["daemon_update_restart_note"] = "Restart the daemon to start running the new version.";
|
strings_["daemon_update_restart_note"] = "Restart the daemon to start running the new version.";
|
||||||
strings_["daemon_update_restart_now"] = "Restart daemon now";
|
strings_["daemon_update_restart_now"] = "Restart daemon now";
|
||||||
|
strings_["daemon_update_restart_needed"] = "Restart required to use the new version";
|
||||||
|
strings_["daemon_update_later_hint"] = "A reminder stays in Settings until you restart the daemon.";
|
||||||
|
strings_["daemon_restart_pending"] = "A new daemon version is installed but not running yet. Restart the daemon to use it.";
|
||||||
|
strings_["tt_daemon_restart_pending"] = "Restarts the embedded daemon so the newly installed version takes effect.";
|
||||||
strings_["daemon_update_later"] = "Later";
|
strings_["daemon_update_later"] = "Later";
|
||||||
strings_["daemon_update_failed"] = "Update failed";
|
strings_["daemon_update_failed"] = "Update failed";
|
||||||
strings_["daemon_update_unknown_error"] = "Unknown error.";
|
strings_["daemon_update_unknown_error"] = "Unknown error.";
|
||||||
@@ -2532,6 +2661,8 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["peers_blocks"] = "Blocks";
|
strings_["peers_blocks"] = "Blocks";
|
||||||
strings_["peers_blocks_left"] = "%d blocks left";
|
strings_["peers_blocks_left"] = "%d blocks left";
|
||||||
strings_["peers_clear_all_bans"] = "Clear All Bans";
|
strings_["peers_clear_all_bans"] = "Clear All Bans";
|
||||||
|
strings_["peers_clear_bans_confirm"] = "Clear all %d bans?";
|
||||||
|
strings_["peers_clear_bans_confirm_yes"] = "Clear all";
|
||||||
strings_["peers_click_copy"] = "Click to copy";
|
strings_["peers_click_copy"] = "Click to copy";
|
||||||
strings_["peers_connected"] = "Connected";
|
strings_["peers_connected"] = "Connected";
|
||||||
strings_["peers_connected_count"] = "Connected: %d";
|
strings_["peers_connected_count"] = "Connected: %d";
|
||||||
@@ -2633,10 +2764,12 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["send_tooltip_not_connected"] = "Not connected to daemon";
|
strings_["send_tooltip_not_connected"] = "Not connected to daemon";
|
||||||
strings_["send_tooltip_select_source"] = "Select a source address first";
|
strings_["send_tooltip_select_source"] = "Select a source address first";
|
||||||
strings_["send_tooltip_syncing"] = "Wait for blockchain to sync";
|
strings_["send_tooltip_syncing"] = "Wait for blockchain to sync";
|
||||||
|
strings_["send_tooltip_no_tip"] = "Waiting for peers to report the network height. Sending is disabled until the wallet knows it is caught up.";
|
||||||
strings_["send_available_when_synced"] = "You can fill in the details now. Sending is available once the wallet is fully synced.";
|
strings_["send_available_when_synced"] = "You can fill in the details now. Sending is available once the wallet is fully synced.";
|
||||||
strings_["send_tooltip_view_only"] = "View-only address — no spending key, cannot send";
|
strings_["send_tooltip_view_only"] = "View-only address — no spending key, cannot send";
|
||||||
strings_["send_total"] = "Total";
|
strings_["send_total"] = "Total";
|
||||||
strings_["send_tx_failed"] = "Transaction failed";
|
strings_["send_tx_failed"] = "Transaction failed";
|
||||||
|
strings_["send_maybe_sent"] = "This transaction may already have been broadcast (the reply was lost). Do NOT send again yet - check History first.";
|
||||||
strings_["send_tx_sent"] = "Transaction sent!";
|
strings_["send_tx_sent"] = "Transaction sent!";
|
||||||
strings_["send_cannot_send_now"] = "Cannot send now — check connection, sync, and available balance.";
|
strings_["send_cannot_send_now"] = "Cannot send now — check connection, sync, and available balance.";
|
||||||
strings_["send_tx_success"] = "Transaction sent successfully!";
|
strings_["send_tx_success"] = "Transaction sent successfully!";
|
||||||
@@ -2822,6 +2955,7 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["explorer_invalid_query"] = "Enter a block height or 64-character hash";
|
strings_["explorer_invalid_query"] = "Enter a block height or 64-character hash";
|
||||||
strings_["explorer_invalid_response"] = "Invalid response from daemon";
|
strings_["explorer_invalid_response"] = "Invalid response from daemon";
|
||||||
strings_["explorer_hash_not_found"] = "No block or transaction found for this hash";
|
strings_["explorer_hash_not_found"] = "No block or transaction found for this hash";
|
||||||
|
strings_["explorer_lookup_failed"] = "Lookup failed";
|
||||||
strings_["explorer_not_connected"] = "Not connected to daemon — cannot look up a block or transaction hash";
|
strings_["explorer_not_connected"] = "Not connected to daemon — cannot look up a block or transaction hash";
|
||||||
strings_["explorer_no_results"] = "No matching cached blocks";
|
strings_["explorer_no_results"] = "No matching cached blocks";
|
||||||
strings_["explorer_beyond_tip"] = "Block %d is beyond the chain tip (%d)";
|
strings_["explorer_beyond_tip"] = "Block %d is beyond the chain tip (%d)";
|
||||||
@@ -3049,6 +3183,15 @@ const char* I18n::translate(const char* key) const
|
|||||||
return key;
|
return key;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::string I18n::translateCopy(const char* key) const
|
||||||
|
{
|
||||||
|
if (!key) return std::string();
|
||||||
|
std::shared_lock<std::shared_mutex> lock(strings_mutex_);
|
||||||
|
auto it = strings_.find(key);
|
||||||
|
if (it != strings_.end()) return it->second;
|
||||||
|
return std::string(key);
|
||||||
|
}
|
||||||
|
|
||||||
void I18n::registerLanguage(const std::string& code, const std::string& name)
|
void I18n::registerLanguage(const std::string& code, const std::string& name)
|
||||||
{
|
{
|
||||||
// Check if already registered
|
// Check if already registered
|
||||||
|
|||||||
+22
-1
@@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <mutex>
|
||||||
|
#include <shared_mutex>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
@@ -38,7 +40,16 @@ public:
|
|||||||
* @return Translated string, or key if not found
|
* @return Translated string, or key if not found
|
||||||
*/
|
*/
|
||||||
const char* translate(const char* key) const;
|
const char* translate(const char* key) const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Thread-safe translate: returns a COPY under a read lock.
|
||||||
|
*
|
||||||
|
* translate() hands back a pointer into the string table, which loadLanguage() rewrites on the
|
||||||
|
* main thread. Any caller off the main thread (e.g. the EmbeddedDaemon monitor thread) MUST use
|
||||||
|
* this instead; main-thread UI code keeps using translate()/TR().
|
||||||
|
*/
|
||||||
|
std::string translateCopy(const char* key) const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Get current locale
|
* @brief Get current locale
|
||||||
*/
|
*/
|
||||||
@@ -66,6 +77,9 @@ private:
|
|||||||
void overlayTranslations(const nlohmann::json& translations);
|
void overlayTranslations(const nlohmann::json& translations);
|
||||||
|
|
||||||
std::string current_locale_ = "en";
|
std::string current_locale_ = "en";
|
||||||
|
// Guards strings_/current_locale_ against loadLanguage() (main thread, writer) racing
|
||||||
|
// translateCopy() (off-thread readers). The lock-free translate() is main-thread only.
|
||||||
|
mutable std::shared_mutex strings_mutex_;
|
||||||
std::unordered_map<std::string, std::string> strings_;
|
std::unordered_map<std::string, std::string> strings_;
|
||||||
std::vector<std::pair<std::string, std::string>> available_languages_;
|
std::vector<std::pair<std::string, std::string>> available_languages_;
|
||||||
};
|
};
|
||||||
@@ -79,6 +93,13 @@ inline const char* tr(const char* key) {
|
|||||||
return I18n::instance().translate(key);
|
return I18n::instance().translate(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Thread-safe translation returning an owned copy. Use from any non-main thread.
|
||||||
|
*/
|
||||||
|
inline std::string trCopy(const char* key) {
|
||||||
|
return I18n::instance().translateCopy(key);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Map a raw daemon / JSON-RPC error string to a short, friendly, TRANSLATED explanation.
|
* @brief Map a raw daemon / JSON-RPC error string to a short, friendly, TRANSLATED explanation.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -10,6 +10,29 @@
|
|||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cctype>
|
#include <cctype>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// Strictly parse a decimal DRGX amount from an (attacker-influenceable) payment URI / QR: digits with at
|
||||||
|
// most one '.', at most 8 fractional digits, in [0, 21000000]. Rejects empty, NaN/inf, signs, whitespace
|
||||||
|
// and trailing garbage that std::stod would otherwise accept — so a crafted URI can't inject a bogus
|
||||||
|
// amount into the Send form.
|
||||||
|
bool parseStrictAmount(const std::string& s, double& out) {
|
||||||
|
if (s.empty() || s.size() > 32) return false;
|
||||||
|
int dots = 0, digits = 0, frac = 0; bool afterDot = false;
|
||||||
|
for (char c : s) {
|
||||||
|
if (c == '.') { if (++dots > 1) return false; afterDot = true; }
|
||||||
|
else if (c >= '0' && c <= '9') { ++digits; if (afterDot && ++frac > 8) return false; }
|
||||||
|
else return false;
|
||||||
|
}
|
||||||
|
if (digits == 0) return false;
|
||||||
|
double v = 0.0;
|
||||||
|
try { v = std::stod(s); } catch (...) { return false; }
|
||||||
|
if (!std::isfinite(v) || v < 0.0 || v > 21000000.0) return false;
|
||||||
|
out = v;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
namespace dragonx {
|
namespace dragonx {
|
||||||
namespace util {
|
namespace util {
|
||||||
@@ -141,11 +164,13 @@ PaymentURI parsePaymentURI(const std::string& uri)
|
|||||||
std::transform(key.begin(), key.end(), key.begin(), ::tolower);
|
std::transform(key.begin(), key.end(), key.begin(), ::tolower);
|
||||||
|
|
||||||
if (key == "amount" || key == "amt") {
|
if (key == "amount" || key == "amt") {
|
||||||
try {
|
double a = 0.0;
|
||||||
result.amount = std::stod(value);
|
if (!parseStrictAmount(value, a)) {
|
||||||
} catch (...) {
|
result.error = "Invalid amount";
|
||||||
// Invalid amount, keep as 0
|
result.valid = false;
|
||||||
|
return result; // reject the whole URI rather than silently treating it as 0
|
||||||
}
|
}
|
||||||
|
result.amount = a;
|
||||||
} else if (key == "label") {
|
} else if (key == "label") {
|
||||||
result.label = value;
|
result.label = value;
|
||||||
} else if (key == "memo") {
|
} else if (key == "memo") {
|
||||||
@@ -174,7 +199,7 @@ PaymentURI parsePaymentURI(const std::string& uri)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate amount if present
|
// Validate amount if present
|
||||||
if (result.amount < 0) {
|
if (!std::isfinite(result.amount) || result.amount < 0 || result.amount > 21000000.0) {
|
||||||
result.error = "Invalid negative amount";
|
result.error = "Invalid negative amount";
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-3
@@ -25,6 +25,7 @@
|
|||||||
#include <shlobj.h>
|
#include <shlobj.h>
|
||||||
#include <psapi.h>
|
#include <psapi.h>
|
||||||
#include <tlhelp32.h>
|
#include <tlhelp32.h>
|
||||||
|
#include <aclapi.h> // SetEntriesInAcl / EXPLICIT_ACCESS — owner-only DACL for restricted writes
|
||||||
#else
|
#else
|
||||||
#include <unistd.h>
|
#include <unistd.h>
|
||||||
#include <fcntl.h>
|
#include <fcntl.h>
|
||||||
@@ -361,15 +362,54 @@ bool Platform::writeFileAtomically(const std::string& path,
|
|||||||
const std::string tmp = path + ".tmp";
|
const std::string tmp = path + ".tmp";
|
||||||
|
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
|
// When restrictPermissions, build a PROTECTED owner-only DACL so the secret file (exported keys,
|
||||||
|
// seed backup, vault, chat export) is readable only by the current user — not whatever the parent
|
||||||
|
// dir's inherited ACL grants. Best-effort: on any failure fall back to default ACLs (the per-user
|
||||||
|
// profile dir still protects it). The ACL is set on the temp file; MoveFileEx is a same-volume
|
||||||
|
// rename, so the explicit (non-inherited) DACL carries to the final path.
|
||||||
|
PACL dacl = nullptr;
|
||||||
|
PTOKEN_USER tokenUser = nullptr;
|
||||||
|
SECURITY_DESCRIPTOR sd;
|
||||||
|
SECURITY_ATTRIBUTES saOwner{};
|
||||||
|
SECURITY_ATTRIBUTES* psa = nullptr;
|
||||||
|
if (restrictPermissions) {
|
||||||
|
HANDLE tok = nullptr;
|
||||||
|
if (OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &tok)) {
|
||||||
|
DWORD need = 0;
|
||||||
|
GetTokenInformation(tok, TokenUser, nullptr, 0, &need);
|
||||||
|
if (need > 0) {
|
||||||
|
tokenUser = static_cast<PTOKEN_USER>(LocalAlloc(LPTR, need));
|
||||||
|
if (tokenUser && GetTokenInformation(tok, TokenUser, tokenUser, need, &need)) {
|
||||||
|
EXPLICIT_ACCESSA ea{};
|
||||||
|
ea.grfAccessPermissions = GENERIC_ALL;
|
||||||
|
ea.grfAccessMode = SET_ACCESS;
|
||||||
|
ea.grfInheritance = NO_INHERITANCE;
|
||||||
|
ea.Trustee.TrusteeForm = TRUSTEE_IS_SID;
|
||||||
|
ea.Trustee.TrusteeType = TRUSTEE_IS_USER;
|
||||||
|
ea.Trustee.ptstrName = static_cast<LPSTR>(tokenUser->User.Sid);
|
||||||
|
if (SetEntriesInAclA(1, &ea, nullptr, &dacl) == ERROR_SUCCESS &&
|
||||||
|
InitializeSecurityDescriptor(&sd, SECURITY_DESCRIPTOR_REVISION) &&
|
||||||
|
SetSecurityDescriptorControl(&sd, SE_DACL_PROTECTED, SE_DACL_PROTECTED) &&
|
||||||
|
SetSecurityDescriptorDacl(&sd, TRUE, dacl, FALSE)) {
|
||||||
|
saOwner.nLength = sizeof(saOwner);
|
||||||
|
saOwner.lpSecurityDescriptor = &sd;
|
||||||
|
saOwner.bInheritHandle = FALSE;
|
||||||
|
psa = &saOwner;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CloseHandle(tok);
|
||||||
|
}
|
||||||
|
}
|
||||||
{
|
{
|
||||||
// One handle for write + flush. CreateFileA with no SECURITY_ATTRIBUTES is NOT inheritable: the old
|
// One handle for write + flush. CreateFileA with no SECURITY_ATTRIBUTES is NOT inheritable: the old
|
||||||
// std::ofstream (CRT) handle was, so a child spawned with bInheritHandles=TRUE at that moment (xmrig,
|
// std::ofstream (CRT) handle was, so a child spawned with bInheritHandles=TRUE at that moment (xmrig,
|
||||||
// runHiddenCapture) kept "<path>.tmp" open without FILE_SHARE_DELETE and every later MoveFileEx
|
// runHiddenCapture) kept "<path>.tmp" open without FILE_SHARE_DELETE and every later MoveFileEx
|
||||||
// failed while it ran. Also no second share-mode-0 open, which silently skipped the flush whenever
|
// failed while it ran. Also no second share-mode-0 open, which silently skipped the flush whenever
|
||||||
// AV / the indexer had the new file open.
|
// AV / the indexer had the new file open.
|
||||||
HANDLE h = CreateFileA(tmp.c_str(), GENERIC_WRITE, 0, nullptr,
|
HANDLE h = CreateFileA(tmp.c_str(), GENERIC_WRITE, 0, psa,
|
||||||
CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
|
CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||||
if (h == INVALID_HANDLE_VALUE) return false;
|
if (h == INVALID_HANDLE_VALUE) { if (dacl) LocalFree(dacl); if (tokenUser) LocalFree(tokenUser); return false; }
|
||||||
bool ok = true;
|
bool ok = true;
|
||||||
size_t written = 0;
|
size_t written = 0;
|
||||||
while (written < content.size()) {
|
while (written < content.size()) {
|
||||||
@@ -382,9 +422,12 @@ bool Platform::writeFileAtomically(const std::string& path,
|
|||||||
// Flush the temp file's contents to stable storage before the rename.
|
// Flush the temp file's contents to stable storage before the rename.
|
||||||
if (ok && !FlushFileBuffers(h)) ok = false;
|
if (ok && !FlushFileBuffers(h)) ok = false;
|
||||||
CloseHandle(h);
|
CloseHandle(h);
|
||||||
|
// The DACL is baked into the temp file's security descriptor at CreateFileA time, so release the
|
||||||
|
// builders now; every path below (including the early returns) is past this point.
|
||||||
|
if (dacl) LocalFree(dacl);
|
||||||
|
if (tokenUser) LocalFree(tokenUser);
|
||||||
if (!ok) { DeleteFileA(tmp.c_str()); return false; }
|
if (!ok) { DeleteFileA(tmp.c_str()); return false; }
|
||||||
}
|
}
|
||||||
(void)restrictPermissions; // owner-only ACLs rely on the per-user profile dir on Windows
|
|
||||||
// MoveFileEx with REPLACE_EXISTING is atomic on NTFS; WRITE_THROUGH makes it durable. AV / the search
|
// MoveFileEx with REPLACE_EXISTING is atomic on NTFS; WRITE_THROUGH makes it durable. AV / the search
|
||||||
// indexer can hold the destination briefly (ACCESS_DENIED / SHARING_VIOLATION / LOCK_VIOLATION): retry
|
// indexer can hold the destination briefly (ACCESS_DENIED / SHARING_VIOLATION / LOCK_VIOLATION): retry
|
||||||
// a few times (20+40+80+160 ms worst case, only on a write that used to just fail) instead of dropping it.
|
// a few times (20+40+80+160 ms worst case, only on a write that used to just fail) instead of dropping it.
|
||||||
|
|||||||
@@ -48,7 +48,11 @@ void SecureVault::setWalletScope(const std::string& walletFile) {
|
|||||||
// any other wallet gets a per-wallet tag. A readable sanitized prefix PLUS an 8-hex FNV-1a of the
|
// 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
|
// 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").
|
// sanitized forms collide (e.g. "wallet foo.dat" vs "wallet_foo.dat").
|
||||||
if (walletFile.empty() || walletFile == "wallet.dat") { scope_.clear(); return; }
|
if (walletFile.empty() || walletFile == "wallet.dat") {
|
||||||
|
std::lock_guard<std::mutex> lk(scopeMutex_);
|
||||||
|
scope_.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
std::string s;
|
std::string s;
|
||||||
s.reserve(walletFile.size());
|
s.reserve(walletFile.size());
|
||||||
for (unsigned char c : walletFile)
|
for (unsigned char c : walletFile)
|
||||||
@@ -58,11 +62,18 @@ void SecureVault::setWalletScope(const std::string& walletFile) {
|
|||||||
char suffix[9];
|
char suffix[9];
|
||||||
std::snprintf(suffix, sizeof(suffix), "%08x", static_cast<unsigned>(h & 0xffffffffu));
|
std::snprintf(suffix, sizeof(suffix), "%08x", static_cast<unsigned>(h & 0xffffffffu));
|
||||||
if (s.size() > 48) s.resize(48);
|
if (s.size() > 48) s.resize(48);
|
||||||
scope_ = s + "-" + suffix;
|
std::string newScope = s + "-" + suffix;
|
||||||
|
std::lock_guard<std::mutex> lk(scopeMutex_);
|
||||||
|
scope_ = std::move(newScope);
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string SecureVault::getVaultPath() const {
|
std::string SecureVault::getVaultPath() const {
|
||||||
const std::string name = scope_.empty() ? std::string("vault.dat") : ("vault-" + scope_ + ".dat");
|
std::string scope;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lk(scopeMutex_);
|
||||||
|
scope = scope_;
|
||||||
|
}
|
||||||
|
const std::string name = scope.empty() ? std::string("vault.dat") : ("vault-" + scope + ".dat");
|
||||||
return (fs::path(Platform::getConfigDir()) / name).string();
|
return (fs::path(Platform::getConfigDir()) / name).string();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
#include <mutex>
|
||||||
|
|
||||||
namespace dragonx {
|
namespace dragonx {
|
||||||
namespace util {
|
namespace util {
|
||||||
@@ -101,6 +102,9 @@ private:
|
|||||||
const uint8_t* salt, size_t saltLen,
|
const uint8_t* salt, size_t saltLen,
|
||||||
uint8_t* key, size_t keyLen) const;
|
uint8_t* key, size_t keyLen) const;
|
||||||
|
|
||||||
|
// Guards scope_: setWalletScope() runs on the main thread (wallet switch) while retrieve()/hasVault()/
|
||||||
|
// changePin() run on RPC worker threads and read it via getVaultPath().
|
||||||
|
mutable std::mutex scopeMutex_;
|
||||||
std::string scope_; // "" = default/legacy wallet (vault.dat); else a sanitized wallet-file tag
|
std::string scope_; // "" = default/legacy wallet (vault.dat); else a sanitized wallet-file tag
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -94,11 +94,11 @@ LiteClientBridge LiteClientBridge::linkedSdxl()
|
|||||||
LiteClientBridge::LiteClientBridge(LiteClientBridge&& other) noexcept
|
LiteClientBridge::LiteClientBridge(LiteClientBridge&& other) noexcept
|
||||||
: api_(other.api_),
|
: api_(other.api_),
|
||||||
unavailableReason_(std::move(other.unavailableReason_)),
|
unavailableReason_(std::move(other.unavailableReason_)),
|
||||||
shutdownCalled_(other.shutdownCalled_),
|
shutdownCalled_(other.shutdownCalled_.load(std::memory_order_acquire)),
|
||||||
mutationEpoch_(other.mutationEpoch_.load(std::memory_order_acquire))
|
mutationEpoch_(other.mutationEpoch_.load(std::memory_order_acquire))
|
||||||
{
|
{
|
||||||
other.api_ = {};
|
other.api_ = {};
|
||||||
other.shutdownCalled_ = true;
|
other.shutdownCalled_.store(true, std::memory_order_release);
|
||||||
}
|
}
|
||||||
|
|
||||||
LiteClientBridge& LiteClientBridge::operator=(LiteClientBridge&& other) noexcept
|
LiteClientBridge& LiteClientBridge::operator=(LiteClientBridge&& other) noexcept
|
||||||
@@ -107,11 +107,11 @@ LiteClientBridge& LiteClientBridge::operator=(LiteClientBridge&& other) noexcept
|
|||||||
shutdown();
|
shutdown();
|
||||||
api_ = other.api_;
|
api_ = other.api_;
|
||||||
unavailableReason_ = std::move(other.unavailableReason_);
|
unavailableReason_ = std::move(other.unavailableReason_);
|
||||||
shutdownCalled_ = other.shutdownCalled_;
|
shutdownCalled_.store(other.shutdownCalled_.load(std::memory_order_acquire), std::memory_order_release);
|
||||||
mutationEpoch_.store(other.mutationEpoch_.load(std::memory_order_acquire),
|
mutationEpoch_.store(other.mutationEpoch_.load(std::memory_order_acquire),
|
||||||
std::memory_order_release);
|
std::memory_order_release);
|
||||||
other.api_ = {};
|
other.api_ = {};
|
||||||
other.shutdownCalled_ = true;
|
other.shutdownCalled_.store(true, std::memory_order_release);
|
||||||
return *this;
|
return *this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,6 +140,7 @@ bool LiteClientBridge::checkServerOnline(const std::string& server) const
|
|||||||
LiteBridgeStringResult LiteClientBridge::initializeNew(bool dangerous, const std::string& server)
|
LiteBridgeStringResult LiteClientBridge::initializeNew(bool dangerous, const std::string& server)
|
||||||
{
|
{
|
||||||
if (!available()) return unavailableResult();
|
if (!available()) return unavailableResult();
|
||||||
|
if (isShutdown()) return shutDownResult();
|
||||||
return takeOwnedString(api_.initializeNew(dangerous, server.c_str()));
|
return takeOwnedString(api_.initializeNew(dangerous, server.c_str()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,6 +152,7 @@ LiteBridgeStringResult LiteClientBridge::initializeNewFromPhrase(bool dangerous,
|
|||||||
bool overwrite)
|
bool overwrite)
|
||||||
{
|
{
|
||||||
if (!available()) return unavailableResult();
|
if (!available()) return unavailableResult();
|
||||||
|
if (isShutdown()) return shutDownResult();
|
||||||
return takeOwnedString(api_.initializeNewFromPhrase(
|
return takeOwnedString(api_.initializeNewFromPhrase(
|
||||||
dangerous, server.c_str(), seed.c_str(), birthday, account, overwrite));
|
dangerous, server.c_str(), seed.c_str(), birthday, account, overwrite));
|
||||||
}
|
}
|
||||||
@@ -158,6 +160,7 @@ LiteBridgeStringResult LiteClientBridge::initializeNewFromPhrase(bool dangerous,
|
|||||||
LiteBridgeStringResult LiteClientBridge::initializeExisting(bool dangerous, const std::string& server)
|
LiteBridgeStringResult LiteClientBridge::initializeExisting(bool dangerous, const std::string& server)
|
||||||
{
|
{
|
||||||
if (!available()) return unavailableResult();
|
if (!available()) return unavailableResult();
|
||||||
|
if (isShutdown()) return shutDownResult();
|
||||||
return takeOwnedString(api_.initializeExisting(dangerous, server.c_str()));
|
return takeOwnedString(api_.initializeExisting(dangerous, server.c_str()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,6 +183,9 @@ LiteBridgeStringResult LiteClientBridge::execute(const std::string& command, con
|
|||||||
{
|
{
|
||||||
if (!available()) return unavailableResult();
|
if (!available()) return unavailableResult();
|
||||||
if (command.empty()) return {false, {}, "lite command is empty"};
|
if (command.empty()) return {false, {}, "lite command is empty"};
|
||||||
|
// A retired controller's bridge is shut down; litelib_execute would run on the NEW global client
|
||||||
|
// (a stale send/save/sync/console command). Refuse it here, once, for every detached thread.
|
||||||
|
if (isShutdown()) return shutDownResult();
|
||||||
auto result = takeOwnedString(api_.execute(command.c_str(), args.c_str()));
|
auto result = takeOwnedString(api_.execute(command.c_str(), args.c_str()));
|
||||||
if (!commandIsReadOnly(command)) // bump AFTER the command returns, even on failure (be conservative)
|
if (!commandIsReadOnly(command)) // bump AFTER the command returns, even on failure (be conservative)
|
||||||
mutationEpoch_.fetch_add(1, std::memory_order_acq_rel);
|
mutationEpoch_.fetch_add(1, std::memory_order_acq_rel);
|
||||||
@@ -188,11 +194,15 @@ LiteBridgeStringResult LiteClientBridge::execute(const std::string& command, con
|
|||||||
|
|
||||||
void LiteClientBridge::shutdown()
|
void LiteClientBridge::shutdown()
|
||||||
{
|
{
|
||||||
if (shutdownCalled_) return;
|
if (shutdownCalled_.exchange(true, std::memory_order_acq_rel)) return; // exactly one caller shuts the client down
|
||||||
shutdownCalled_ = true;
|
|
||||||
if (available()) api_.shutdown();
|
if (available()) api_.shutdown();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LiteBridgeStringResult LiteClientBridge::shutDownResult()
|
||||||
|
{
|
||||||
|
return {false, {}, "lite client was shut down"};
|
||||||
|
}
|
||||||
|
|
||||||
LiteBridgeStringResult LiteClientBridge::unavailableResult() const
|
LiteBridgeStringResult LiteClientBridge::unavailableResult() const
|
||||||
{
|
{
|
||||||
return {false, {}, unavailableReason_.empty() ? "lite backend is unavailable" : unavailableReason_};
|
return {false, {}, unavailableReason_.empty() ? "lite backend is unavailable" : unavailableReason_};
|
||||||
|
|||||||
@@ -70,6 +70,10 @@ public:
|
|||||||
LiteBridgeStringResult initializeExisting(bool dangerous, const std::string& server);
|
LiteBridgeStringResult initializeExisting(bool dangerous, const std::string& server);
|
||||||
LiteBridgeStringResult execute(const std::string& command, const std::string& args);
|
LiteBridgeStringResult execute(const std::string& command, const std::string& args);
|
||||||
void shutdown();
|
void shutdown();
|
||||||
|
// True once shutdown() ran (retire() / destruction / move-from). After this every backend call
|
||||||
|
// (execute / initialize*) is refused: litelib_* acts on the process-global client, which may by
|
||||||
|
// then belong to a NEW controller.
|
||||||
|
bool isShutdown() const { return shutdownCalled_.load(std::memory_order_acquire); }
|
||||||
|
|
||||||
// Monotonic counter bumped AFTER every execute() of a command that could change wallet-visible
|
// Monotonic counter bumped AFTER every execute() of a command that could change wallet-visible
|
||||||
// data (anything not on the read-only allow-list). The refresh worker samples this to detect
|
// data (anything not on the read-only allow-list). The refresh worker samples this to detect
|
||||||
@@ -82,12 +86,13 @@ private:
|
|||||||
LiteClientBridge(LiteClientBridgeApi api, std::string unavailableReason);
|
LiteClientBridge(LiteClientBridgeApi api, std::string unavailableReason);
|
||||||
|
|
||||||
LiteBridgeStringResult unavailableResult() const;
|
LiteBridgeStringResult unavailableResult() const;
|
||||||
|
static LiteBridgeStringResult shutDownResult();
|
||||||
LiteBridgeStringResult takeOwnedString(char* rawValue) const;
|
LiteBridgeStringResult takeOwnedString(char* rawValue) const;
|
||||||
static bool commandIsReadOnly(const std::string& command);
|
static bool commandIsReadOnly(const std::string& command);
|
||||||
|
|
||||||
LiteClientBridgeApi api_;
|
LiteClientBridgeApi api_;
|
||||||
std::string unavailableReason_;
|
std::string unavailableReason_;
|
||||||
bool shutdownCalled_ = false;
|
std::atomic<bool> shutdownCalled_{false}; // read by detached threads in execute() while retire() sets it
|
||||||
std::atomic<std::uint64_t> mutationEpoch_{0};
|
std::atomic<std::uint64_t> mutationEpoch_{0};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ LiteBridgeOwnedString::LiteBridgeOwnedString(LiteBridgeOwnedString&& other) noex
|
|||||||
LiteBridgeOwnedString& LiteBridgeOwnedString::operator=(LiteBridgeOwnedString&& other) noexcept
|
LiteBridgeOwnedString& LiteBridgeOwnedString::operator=(LiteBridgeOwnedString&& other) noexcept
|
||||||
{
|
{
|
||||||
if (this == &other) return *this;
|
if (this == &other) return *this;
|
||||||
copyValueBeforeFree();
|
// Free only — do NOT copy the raw value here: nothing reads copiedValue_ after this, and the copy
|
||||||
|
// (std::string assignment) can throw bad_alloc, which from this noexcept path would call std::terminate.
|
||||||
releaseRawValue();
|
releaseRawValue();
|
||||||
wipeString(copiedValue_);
|
wipeString(copiedValue_);
|
||||||
moveFrom(std::move(other));
|
moveFrom(std::move(other));
|
||||||
@@ -49,7 +50,8 @@ LiteBridgeOwnedString& LiteBridgeOwnedString::operator=(LiteBridgeOwnedString&&
|
|||||||
|
|
||||||
LiteBridgeOwnedString::~LiteBridgeOwnedString()
|
LiteBridgeOwnedString::~LiteBridgeOwnedString()
|
||||||
{
|
{
|
||||||
copyValueBeforeFree();
|
// Free only (see operator= note): the copy is unnecessary when the object is dying and must never
|
||||||
|
// throw out of a destructor. The copy happens solely in intoResult(), the one path that reads it.
|
||||||
releaseRawValue();
|
releaseRawValue();
|
||||||
wipeString(copiedValue_);
|
wipeString(copiedValue_);
|
||||||
}
|
}
|
||||||
@@ -66,13 +68,16 @@ LiteBridgeStringResult LiteBridgeOwnedString::intoResult()
|
|||||||
return {false, {}, "lite bridge freeString function is unavailable"};
|
return {false, {}, "lite bridge freeString function is unavailable"};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// copyValueBeforeFree() can throw bad_alloc; if it does, rawValue_ is still set and the destructor
|
||||||
|
// (now free-only) releases it — no leak, no re-copy, no terminate.
|
||||||
copyValueBeforeFree();
|
copyValueBeforeFree();
|
||||||
releaseRawValue();
|
releaseRawValue();
|
||||||
|
|
||||||
const std::string resultValue = copiedValue_;
|
// Move the copy into the result instead of making a second const copy that would linger unwiped.
|
||||||
|
std::string resultValue = std::move(copiedValue_);
|
||||||
wipeString(copiedValue_);
|
wipeString(copiedValue_);
|
||||||
if (looksLikeError(resultValue)) return {false, {}, resultValue};
|
if (looksLikeError(resultValue)) return {false, {}, resultValue};
|
||||||
return {true, resultValue, {}};
|
return {true, std::move(resultValue), {}};
|
||||||
}
|
}
|
||||||
|
|
||||||
void LiteBridgeOwnedString::copyValueBeforeFree()
|
void LiteBridgeOwnedString::copyValueBeforeFree()
|
||||||
|
|||||||
@@ -79,6 +79,9 @@ bool requireArray(const json& value, const std::string& path, Result& result)
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 21,000,000 DRGX in zatoshi: no single backend-reported amount can legitimately exceed the supply.
|
||||||
|
constexpr std::int64_t kMaxMoneyZat = static_cast<std::int64_t>(21000000) * 100000000LL;
|
||||||
|
|
||||||
bool jsonToUnsigned(const json& value, std::uint64_t& output)
|
bool jsonToUnsigned(const json& value, std::uint64_t& output)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
@@ -95,6 +98,9 @@ bool jsonToUnsigned(const json& value, std::uint64_t& output)
|
|||||||
if (value.is_string()) {
|
if (value.is_string()) {
|
||||||
const auto text = value.get<std::string>();
|
const auto text = value.get<std::string>();
|
||||||
if (text.empty()) return false;
|
if (text.empty()) return false;
|
||||||
|
// stoull follows strtoull: it accepts a leading '-' (wrapping to a huge value), '+' and
|
||||||
|
// whitespace. Require a leading digit so a hostile/buggy backend can't forge a balance.
|
||||||
|
if (!std::isdigit(static_cast<unsigned char>(text[0]))) return false;
|
||||||
std::size_t parsedSize = 0;
|
std::size_t parsedSize = 0;
|
||||||
const auto parsed = std::stoull(text, &parsedSize, 10);
|
const auto parsed = std::stoull(text, &parsedSize, 10);
|
||||||
if (parsedSize != text.size()) return false;
|
if (parsedSize != text.size()) return false;
|
||||||
@@ -125,6 +131,8 @@ bool jsonToSigned(const json& value, std::int64_t& output)
|
|||||||
if (value.is_string()) {
|
if (value.is_string()) {
|
||||||
const auto text = value.get<std::string>();
|
const auto text = value.get<std::string>();
|
||||||
if (text.empty()) return false;
|
if (text.empty()) return false;
|
||||||
|
// Reject leading whitespace / '+' that strtoll would otherwise accept (a '-' is valid here).
|
||||||
|
if (text[0] != '-' && !std::isdigit(static_cast<unsigned char>(text[0]))) return false;
|
||||||
std::size_t parsedSize = 0;
|
std::size_t parsedSize = 0;
|
||||||
const auto parsed = std::stoll(text, &parsedSize, 10);
|
const auto parsed = std::stoll(text, &parsedSize, 10);
|
||||||
if (parsedSize != text.size()) return false;
|
if (parsedSize != text.size()) return false;
|
||||||
@@ -277,9 +285,20 @@ bool parseSpendableOutput(const json& value,
|
|||||||
if (!readRequiredStringField(value, "created_in_txid", output.createdInTxid, result)) return false;
|
if (!readRequiredStringField(value, "created_in_txid", output.createdInTxid, result)) return false;
|
||||||
if (!readOptionalSignedField(value, "created_in_block", output.createdInBlock, result)) return false;
|
if (!readOptionalSignedField(value, "created_in_block", output.createdInBlock, result)) return false;
|
||||||
if (!readRequiredUnsignedField(value, "value", output.value, result)) return false;
|
if (!readRequiredUnsignedField(value, "value", output.value, result)) return false;
|
||||||
|
if (output.value > static_cast<std::uint64_t>(kMaxMoneyZat)) {
|
||||||
|
return fail(result, LiteResultParserError::InvalidFieldValue, path + ".value", "note value exceeds the maximum supply");
|
||||||
|
}
|
||||||
if (!readOptionalBoolField(value, "spent", output.spent, result)) return false;
|
if (!readOptionalBoolField(value, "spent", output.spent, result)) return false;
|
||||||
if (!readOptionalBoolField(value, "unconfirmed_spent", output.unconfirmedSpent, result)) return false;
|
if (!readOptionalBoolField(value, "unconfirmed_spent", output.unconfirmedSpent, result)) return false;
|
||||||
output.spendable = !output.pending && !output.spent && !output.unconfirmedSpent;
|
output.spendable = !output.pending && !output.spent && !output.unconfirmedSpent;
|
||||||
|
// The backend's per-note 'spendable' (we hold the spending key AND the note is at/below the anchor
|
||||||
|
// height) is authoritative when present (Sapling notes only; transparent utxos carry no such field,
|
||||||
|
// so they keep the confirmed/unspent rule above). It can only narrow, never widen, spendability.
|
||||||
|
bool backendSpendable = true;
|
||||||
|
if (!readOptionalBoolField(value, "spendable", backendSpendable, result)) return false;
|
||||||
|
if (value.contains("spendable") && value.at("spendable").is_boolean()) {
|
||||||
|
output.spendable = output.spendable && backendSpendable;
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,15 +356,37 @@ bool parseTransactionRecord(const json& value,
|
|||||||
for (std::size_t index = 0; index < metadata.size(); ++index) {
|
for (std::size_t index = 0; index < metadata.size(); ++index) {
|
||||||
LiteTransactionOutput parsedOutput;
|
LiteTransactionOutput parsedOutput;
|
||||||
if (!parseTransactionOutput(metadata[index], path + ".outgoing_metadata[" + std::to_string(index) + "]", parsedOutput, result)) return false;
|
if (!parseTransactionOutput(metadata[index], path + ".outgoing_metadata[" + std::to_string(index) + "]", parsedOutput, result)) return false;
|
||||||
|
// Reject negative values and guard the running sum: signed overflow is UB, and a hostile
|
||||||
|
// backend could otherwise overflow the total into a bogus (even negative) amount.
|
||||||
|
const std::string outputPath = path + ".outgoing_metadata[" + std::to_string(index) + "]";
|
||||||
|
if (parsedOutput.value < 0 || parsedOutput.value > kMaxMoneyZat) {
|
||||||
|
return fail(result, LiteResultParserError::InvalidFieldValue, outputPath + ".value", "outgoing output value is negative or exceeds the maximum supply");
|
||||||
|
}
|
||||||
|
if (output.amount > kMaxMoneyZat - parsedOutput.value) {
|
||||||
|
return fail(result, LiteResultParserError::InvalidFieldValue, path + ".outgoing_metadata", "outgoing output total exceeds the maximum supply");
|
||||||
|
}
|
||||||
output.amount += parsedOutput.value;
|
output.amount += parsedOutput.value;
|
||||||
output.outgoingMetadata.push_back(std::move(parsedOutput));
|
output.outgoingMetadata.push_back(std::move(parsedOutput));
|
||||||
}
|
}
|
||||||
|
// A send this wallet did not author (seed restore / other device) has empty outgoing_metadata,
|
||||||
|
// so the sum is 0. Fall back to the record's net 'amount' magnitude (includes the fee) so Sent
|
||||||
|
// totals aren't understated. Display-only; tolerant of a missing/odd field.
|
||||||
|
if (output.amount == 0 && value.contains("amount") && !value.at("amount").is_null()) {
|
||||||
|
std::int64_t net = 0;
|
||||||
|
if (jsonToSigned(value.at("amount"), net) && net != std::numeric_limits<std::int64_t>::min()
|
||||||
|
&& (net < 0 ? -net : net) <= kMaxMoneyZat) {
|
||||||
|
output.amount = net < 0 ? -net : net;
|
||||||
|
}
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
output.direction = LiteTransactionDirection::Receive;
|
output.direction = LiteTransactionDirection::Receive;
|
||||||
if (!readRequiredStringField(value, "address", output.address, result)) return false;
|
if (!readRequiredStringField(value, "address", output.address, result)) return false;
|
||||||
if (!readRequiredSignedField(value, "amount", output.amount, result)) return false;
|
if (!readRequiredSignedField(value, "amount", output.amount, result)) return false;
|
||||||
|
if (output.amount < 0 || output.amount > kMaxMoneyZat) {
|
||||||
|
return fail(result, LiteResultParserError::InvalidFieldValue, path + ".amount", "received amount is negative or exceeds the maximum supply");
|
||||||
|
}
|
||||||
if (!readOptionalStringField(value, "memo", output.memo, result)) return false;
|
if (!readOptionalStringField(value, "memo", output.memo, result)) return false;
|
||||||
if (!readOptionalSignedField(value, "position", output.position, result)) return false;
|
if (!readOptionalSignedField(value, "position", output.position, result)) return false;
|
||||||
return true;
|
return true;
|
||||||
@@ -561,11 +602,20 @@ LiteSyncStatusParseResult parseLiteSyncStatusResponse(const json& value)
|
|||||||
|
|
||||||
// The backend reports "syncing" as a STRING ("true"/"false"). synced_blocks/total_blocks
|
// The backend reports "syncing" as a STRING ("true"/"false"). synced_blocks/total_blocks
|
||||||
// are present only while actively syncing; an idle wallet returns just {"syncing":"false"}.
|
// are present only while actively syncing; an idle wallet returns just {"syncing":"false"}.
|
||||||
std::string syncing = "false";
|
std::optional<std::string> syncingField;
|
||||||
if (!readOptionalStringField(value, "syncing", syncing, result)) return result;
|
if (!readOptionalStringField(value, "syncing", syncingField, result)) return result;
|
||||||
|
const std::string syncing = syncingField.value_or(std::string());
|
||||||
const bool isSyncing = (syncing == "true" || syncing == "1");
|
const bool isSyncing = (syncing == "true" || syncing == "1");
|
||||||
|
const bool isIdle = (syncing == "false" || syncing == "0");
|
||||||
|
|
||||||
if (isSyncing) {
|
if (!isSyncing && !isIdle) {
|
||||||
|
// Missing/odd "syncing": we do not KNOW the wallet is caught up, so never default to synced.
|
||||||
|
result.syncStatus.syncedBlocks = 0;
|
||||||
|
result.syncStatus.totalBlocks = 0;
|
||||||
|
result.syncStatus.complete = false;
|
||||||
|
result.syncStatus.indeterminate = true;
|
||||||
|
result.syncStatus.progress = 0.0;
|
||||||
|
} else if (isSyncing) {
|
||||||
if (!readRequiredUnsignedField(value, "synced_blocks", result.syncStatus.syncedBlocks, result)) return result;
|
if (!readRequiredUnsignedField(value, "synced_blocks", result.syncStatus.syncedBlocks, result)) return result;
|
||||||
if (!readRequiredUnsignedField(value, "total_blocks", result.syncStatus.totalBlocks, result)) return result;
|
if (!readRequiredUnsignedField(value, "total_blocks", result.syncStatus.totalBlocks, result)) return result;
|
||||||
result.syncStatus.complete = false; // actively syncing
|
result.syncStatus.complete = false; // actively syncing
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ struct LiteSyncStatusResponse {
|
|||||||
std::uint64_t totalBlocks = 0;
|
std::uint64_t totalBlocks = 0;
|
||||||
double progress = 0.0;
|
double progress = 0.0;
|
||||||
bool complete = false;
|
bool complete = false;
|
||||||
|
bool indeterminate = false; // "syncing" missing/unrecognised: state unknown, NOT assumed synced
|
||||||
};
|
};
|
||||||
|
|
||||||
struct LiteInfoParseResult : LiteParseResultBase { LiteInfoResponse info; };
|
struct LiteInfoParseResult : LiteParseResultBase { LiteInfoResponse info; };
|
||||||
|
|||||||
@@ -110,6 +110,21 @@ bool persistAfterBroadcast(LiteClientBridge& bridge)
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// True when the wallet is encrypted AND currently unlocked. The backend's `save` re-locks such a wallet (do_save),
|
||||||
|
// so a background save must not run under a user who just unlocked. Fail-open: any error reads as "not unlocked" and
|
||||||
|
// the save proceeds as it always did.
|
||||||
|
bool liteWalletEncryptedAndUnlocked(LiteClientBridge& bridge)
|
||||||
|
{
|
||||||
|
const auto r = bridge.execute("encryptionstatus", "");
|
||||||
|
if (!r.ok) return false;
|
||||||
|
try {
|
||||||
|
const auto j = nlohmann::json::parse(r.value);
|
||||||
|
return j.is_object() && !j.contains("error") && j.value("encrypted", false) && !j.value("locked", false);
|
||||||
|
} catch (...) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The detached sync/rescan thread. `firstCommand` is "sync" or "rescan"; every retry is "sync" (a failed rescan has
|
// The detached sync/rescan thread. `firstCommand` is "sync" or "rescan"; every retry is "sync" (a failed rescan has
|
||||||
// already cleared the wallet, so re-running it would throw away the partial progress). Partial progress is saved after
|
// already cleared the wallet, so re-running it would throw away the partial progress). Partial progress is saved after
|
||||||
// every attempt. Captures shared state only.
|
// every attempt. Captures shared state only.
|
||||||
@@ -129,7 +144,12 @@ void runLiteScanThread(const std::shared_ptr<LiteClientBridge>& bridge,
|
|||||||
op.error = call.error.empty() ? "sync failed" : call.error;
|
op.error = call.error.empty() ? "sync failed" : call.error;
|
||||||
}
|
}
|
||||||
// The backend does NOT auto-save after a sync; persist what was scanned even when the attempt failed.
|
// The backend does NOT auto-save after a sync; persist what was scanned even when the attempt failed.
|
||||||
if (!bridge->execute("save", "").ok) // W5-2
|
// Not while the user has the wallet unlocked: the backend save re-locks an encrypted+unlocked wallet, which
|
||||||
|
// would yank the unlock out from under a spend/unlock in progress. The progress is saved by the next scan
|
||||||
|
// once locked (and by the shutdown save); a crash in between only costs a re-scan from the checkpoint.
|
||||||
|
if (liteWalletEncryptedAndUnlocked(*bridge))
|
||||||
|
liteLog("sync save deferred: wallet is unlocked (a save would re-lock it)");
|
||||||
|
else if (!bridge->execute("save", "").ok) // W5-2
|
||||||
liteLog("save failed after sync — the next launch will re-scan from the checkpoint");
|
liteLog("save failed after sync — the next launch will re-scan from the checkpoint");
|
||||||
if (op.ok) {
|
if (op.ok) {
|
||||||
scan->setError(std::string());
|
scan->setError(std::string());
|
||||||
@@ -358,13 +378,38 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model,
|
|||||||
state.privateBalance = static_cast<double>(model.balance.shieldedZatoshis) / kZatoshisPerCoin;
|
state.privateBalance = static_cast<double>(model.balance.shieldedZatoshis) / kZatoshisPerCoin;
|
||||||
state.transparentBalance = static_cast<double>(model.balance.transparentZatoshis) / kZatoshisPerCoin;
|
state.transparentBalance = static_cast<double>(model.balance.transparentZatoshis) / kZatoshisPerCoin;
|
||||||
state.totalBalance = static_cast<double>(model.balance.totalZatoshis) / kZatoshisPerCoin;
|
state.totalBalance = static_cast<double>(model.balance.totalZatoshis) / kZatoshisPerCoin;
|
||||||
state.unconfirmedBalance = static_cast<double>(model.balance.unconfirmedZatoshis) / kZatoshisPerCoin;
|
// Full node defines unconfirmedBalance as total - spendable (the pending portion; balance_tab's "pending"
|
||||||
// Lite already tracks confirmed/unconfirmed itself and its per-address balances are confirmed
|
// chip and the fast-poll hint read it that way). Lite: the not-yet-spendable shielded part (unverified /
|
||||||
// (spendable outputs only). Mirror the aggregate spendable fields so full-node-shaped spend
|
// unconfirmed notes) is that portion; the backend's own `unconfirmed` is mempool-incoming. Take the larger so
|
||||||
// validation isn't zeroed on lite (the actual lite spend gate is per-address, set below).
|
// neither is hidden and a note counted in both is not double-reported. Display-only; never feeds a spend gate.
|
||||||
state.spendablePrivateBalance = state.privateBalance;
|
{
|
||||||
state.spendableTransparentBalance = state.transparentBalance;
|
const std::uint64_t notYetSpendable = model.balance.shieldedZatoshis -
|
||||||
state.spendableTotalBalance = state.totalBalance;
|
std::min(model.balance.spendableShieldedZatoshis, model.balance.shieldedZatoshis);
|
||||||
|
state.unconfirmedBalance = static_cast<double>(
|
||||||
|
std::max(model.balance.unconfirmedZatoshis, notYetSpendable)) / kZatoshisPerCoin;
|
||||||
|
}
|
||||||
|
// Use the backend's SPENDABLE (verified + confirmed) shielded balance for the spend gate, not the
|
||||||
|
// gross shielded total which includes unverified/unconfirmed notes you can't yet spend. The
|
||||||
|
// spendable_zbalance field is required by the parser, so it's always present when hasBalance is
|
||||||
|
// true; clamp to the shielded total defensively. Transparent is already confirmed on lite.
|
||||||
|
const double spendableShielded = static_cast<double>(
|
||||||
|
std::min(model.balance.spendableShieldedZatoshis, model.balance.shieldedZatoshis)) / kZatoshisPerCoin;
|
||||||
|
state.spendablePrivateBalance = spendableShielded;
|
||||||
|
// Backend tbalance sums every utxo with spent==None (lightwallet.rs get_utxos), so a t-UTXO already spent by
|
||||||
|
// an unconfirmed tx of ours is still in transparentBalance. Subtract those from the SPENDABLE figure only
|
||||||
|
// (clamped; gross transparentBalance display unchanged). Needs this cycle's utxo list: if the notes call
|
||||||
|
// failed, nothing is subtracted (same as before).
|
||||||
|
std::uint64_t unconfirmedSpentTransparent = 0;
|
||||||
|
if (model.hasSpendableOutputs) {
|
||||||
|
for (const auto& output : model.spendableOutputs) {
|
||||||
|
if (output.kind == LiteSpendableOutputKind::Utxo && output.unconfirmedSpent && !output.spent)
|
||||||
|
unconfirmedSpentTransparent += output.valueZatoshis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const std::uint64_t spendableTransparentZat = model.balance.transparentZatoshis -
|
||||||
|
std::min(unconfirmedSpentTransparent, model.balance.transparentZatoshis);
|
||||||
|
state.spendableTransparentBalance = static_cast<double>(spendableTransparentZat) / kZatoshisPerCoin;
|
||||||
|
state.spendableTotalBalance = spendableShielded + state.spendableTransparentBalance;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (model.hasAddresses) {
|
if (model.hasAddresses) {
|
||||||
@@ -373,10 +418,12 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model,
|
|||||||
// unconfirmed-spent, or still pending (notes/utxos from an unconfirmed received tx) so
|
// unconfirmed-spent, or still pending (notes/utxos from an unconfirmed received tx) so
|
||||||
// the per-address figures match the confirmed balance the wallet treats as available.
|
// the per-address figures match the confirmed balance the wallet treats as available.
|
||||||
std::unordered_map<std::string, std::uint64_t> perAddressZatoshis;
|
std::unordered_map<std::string, std::uint64_t> perAddressZatoshis;
|
||||||
|
std::unordered_map<std::string, std::uint64_t> perAddressSpendableZatoshis; // backend 'spendable' only
|
||||||
if (model.hasSpendableOutputs) {
|
if (model.hasSpendableOutputs) {
|
||||||
for (const auto& output : model.spendableOutputs) {
|
for (const auto& output : model.spendableOutputs) {
|
||||||
if (output.spent || output.unconfirmedSpent || output.pending) continue;
|
if (output.spent || output.unconfirmedSpent || output.pending) continue;
|
||||||
perAddressZatoshis[output.address] += output.valueZatoshis;
|
perAddressZatoshis[output.address] += output.valueZatoshis;
|
||||||
|
if (output.spendable) perAddressSpendableZatoshis[output.address] += output.valueZatoshis;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,8 +432,14 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model,
|
|||||||
// of zeroing every address — a zeroed breakdown next to a correct nonzero total looks
|
// of zeroing every address — a zeroed breakdown next to a correct nonzero total looks
|
||||||
// like fund loss and breaks "send from this address".
|
// like fund loss and breaks "send from this address".
|
||||||
std::unordered_map<std::string, double> priorBalances;
|
std::unordered_map<std::string, double> priorBalances;
|
||||||
|
std::unordered_map<std::string, double> priorSpendable;
|
||||||
|
std::unordered_map<std::string, bool> priorHasKey;
|
||||||
if (!model.hasSpendableOutputs) {
|
if (!model.hasSpendableOutputs) {
|
||||||
for (const auto& a : state.addresses) priorBalances[a.address] = a.balance;
|
for (const auto& a : state.addresses) {
|
||||||
|
priorBalances[a.address] = a.balance;
|
||||||
|
priorSpendable[a.address] = a.spendableBalance;
|
||||||
|
priorHasKey[a.address] = a.has_spending_key;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
state.addresses.clear();
|
state.addresses.clear();
|
||||||
@@ -404,9 +457,22 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model,
|
|||||||
} else {
|
} else {
|
||||||
info.balance = 0.0; // notes succeeded and address has no spendable outputs
|
info.balance = 0.0; // notes succeeded and address has no spendable outputs
|
||||||
}
|
}
|
||||||
info.spendableBalance = info.balance; // lite per-address balance is already confirmed/spendable
|
// balance stays the gross confirmed-unspent sum; spendableBalance counts only outputs the backend
|
||||||
|
// marks spendable (spending key + anchor depth). Notes failed this cycle -> keep last-known.
|
||||||
|
if (model.hasSpendableOutputs) {
|
||||||
|
const auto sit = perAddressSpendableZatoshis.find(addr.address);
|
||||||
|
info.spendableBalance = sit != perAddressSpendableZatoshis.end()
|
||||||
|
? static_cast<double>(sit->second) / kZatoshisPerCoin : 0.0;
|
||||||
|
} else {
|
||||||
|
const auto psit = priorSpendable.find(addr.address);
|
||||||
|
info.spendableBalance = psit != priorSpendable.end() ? psit->second : 0.0;
|
||||||
|
}
|
||||||
info.type = (addr.kind == LiteWalletAppAddressKind::Shielded) ? "shielded" : "transparent";
|
info.type = (addr.kind == LiteWalletAppAddressKind::Shielded) ? "shielded" : "transparent";
|
||||||
info.has_spending_key = addr.spendabilityKnown ? addr.spendable : true;
|
info.has_spending_key = addr.spendabilityKnown ? addr.spendable : true;
|
||||||
|
if (!addr.spendabilityKnown && !model.hasSpendableOutputs) {
|
||||||
|
const auto pkit = priorHasKey.find(addr.address); // keep last-known view-only verdict
|
||||||
|
if (pkit != priorHasKey.end()) info.has_spending_key = pkit->second;
|
||||||
|
}
|
||||||
if (addr.kind == LiteWalletAppAddressKind::Shielded) {
|
if (addr.kind == LiteWalletAppAddressKind::Shielded) {
|
||||||
state.z_addresses.push_back(info);
|
state.z_addresses.push_back(info);
|
||||||
} else {
|
} else {
|
||||||
@@ -432,7 +498,9 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model,
|
|||||||
} else {
|
} else {
|
||||||
tx.type = record.signedAmountZatoshis < 0 ? "send" : "receive";
|
tx.type = record.signedAmountZatoshis < 0 ? "send" : "receive";
|
||||||
}
|
}
|
||||||
tx.amount = static_cast<double>(record.amountZatoshis) / kZatoshisPerCoin;
|
// Signed like the full node: a send is a negative debit, a receive positive. Consumers
|
||||||
|
// (History/Details/CSV) pick sign + colour from this; the summary cards use std::abs.
|
||||||
|
tx.amount = static_cast<double>(record.signedAmountZatoshis) / kZatoshisPerCoin;
|
||||||
tx.timestamp = record.timestamp;
|
tx.timestamp = record.timestamp;
|
||||||
tx.address = record.address;
|
tx.address = record.address;
|
||||||
tx.memo = record.memo;
|
tx.memo = record.memo;
|
||||||
@@ -912,9 +980,11 @@ void LiteWalletController::applyPendingLifecycleSecurity()
|
|||||||
if (op == PendingSecurityOp::Encrypt) {
|
if (op == PendingSecurityOp::Encrypt) {
|
||||||
// encryptWallet() takes its own copy and wipes it; the backend encrypts + locks + saves.
|
// encryptWallet() takes its own copy and wipes it; the backend encrypts + locks + saves.
|
||||||
const auto enc = encryptWallet(pendingLifecyclePassphrase_);
|
const auto enc = encryptWallet(pendingLifecyclePassphrase_);
|
||||||
if (!enc.ok)
|
if (!enc.ok) {
|
||||||
|
lifecycleEncryptFailed_ = true; // surfaced to the user by the App (takeEncryptionFailureNotice)
|
||||||
liteLog("wallet created/restored but ENCRYPTION FAILED — wallet is NOT encrypted: " +
|
liteLog("wallet created/restored but ENCRYPTION FAILED — wallet is NOT encrypted: " +
|
||||||
enc.error);
|
enc.error);
|
||||||
|
}
|
||||||
} else if (op == PendingSecurityOp::Unlock) {
|
} else if (op == PendingSecurityOp::Unlock) {
|
||||||
if (!unlockWallet(pendingLifecyclePassphrase_))
|
if (!unlockWallet(pendingLifecyclePassphrase_))
|
||||||
liteLog("wallet opened but unlock failed (wrong passphrase?)");
|
liteLog("wallet opened but unlock failed (wrong passphrase?)");
|
||||||
@@ -1044,7 +1114,12 @@ std::optional<LiteWalletAppRefreshModel> LiteWalletController::refreshModelImpl(
|
|||||||
// returns just {"syncing":"false"}, so the mapped walletHeight is 0 and the status bar showed
|
// returns just {"syncing":"false"}, so the mapped walletHeight is 0 and the status bar showed
|
||||||
// "blocks: 0" when fully synced. Query the wallet's last-scanned height (a fast local read) and
|
// "blocks: 0" when fully synced. Query the wallet's last-scanned height (a fast local read) and
|
||||||
// surface it as the synced height/tip so the block count is correct at rest.
|
// surface it as the synced height/tip so the block count is correct at rest.
|
||||||
if (bridge_ && (!honorStop || running_.load())) {
|
// Do not force "synced" from the height read while syncstatus says the backend is still scanning (work remaining)
|
||||||
|
// or could not be interpreted: the height only proves the last scanned block, not that we reached the tip.
|
||||||
|
const bool backendBusy = syncResult.ok && !syncResult.syncStatus.complete &&
|
||||||
|
(syncResult.syncStatus.indeterminate ||
|
||||||
|
syncResult.syncStatus.syncedBlocks < syncResult.syncStatus.totalBlocks);
|
||||||
|
if (bridge_ && !backendBusy && (!honorStop || running_.load())) {
|
||||||
const auto h = bridge_->execute("height", "");
|
const auto h = bridge_->execute("height", "");
|
||||||
if (h.ok) {
|
if (h.ok) {
|
||||||
const auto parsed = parseLiteHeightResponse(h.value);
|
const auto parsed = parseLiteHeightResponse(h.value);
|
||||||
@@ -1068,7 +1143,14 @@ std::optional<LiteWalletAppRefreshModel> LiteWalletController::refreshModelImpl(
|
|||||||
model.sync.progress = std::min(model.sync.progress, 0.999);
|
model.sync.progress = std::min(model.sync.progress, 0.999);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A server-bound read failed this cycle (server lost after catch-up): not synced, and say why. Without this the
|
||||||
|
// height override above kept reporting Synced because only a failed *scan* (scan_->failed) demoted it.
|
||||||
|
if (refreshResult.syncReadFailed && model.hasSyncStatus) {
|
||||||
|
model.sync.complete = false;
|
||||||
|
model.sync.progress = std::min(model.sync.progress, 0.999);
|
||||||
|
}
|
||||||
model.syncError = scan_->errorCopy();
|
model.syncError = scan_->errorCopy();
|
||||||
|
if (model.syncError.empty() && refreshResult.syncReadFailed) model.syncError = "server unreachable";
|
||||||
return model;
|
return model;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1235,6 +1317,14 @@ LiteImportResult LiteWalletController::importKey(std::string spendingOrViewingKe
|
|||||||
return importKeyCore(*bridge_, spendingOrViewingKey); // wipes the key
|
return importKeyCore(*bridge_, spendingOrViewingKey); // wipes the key
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recursively zero every string node of a parsed secret JSON tree (seed / private keys) so the parse
|
||||||
|
// copies don't linger in freed heap. Best-effort (nlohmann's own lexer scratch isn't reachable).
|
||||||
|
static void wipeJsonStrings(nlohmann::json& j)
|
||||||
|
{
|
||||||
|
if (j.is_string()) { secureWipeLiteSecret(j.get_ref<nlohmann::json::string_t&>()); }
|
||||||
|
else if (j.is_object() || j.is_array()) { for (auto& el : j) wipeJsonStrings(el); }
|
||||||
|
}
|
||||||
|
|
||||||
LiteExportResult LiteWalletController::exportPrivateKeys(const std::string& optionalAddress)
|
LiteExportResult LiteWalletController::exportPrivateKeys(const std::string& optionalAddress)
|
||||||
{
|
{
|
||||||
LiteExportResult out;
|
LiteExportResult out;
|
||||||
@@ -1243,13 +1333,18 @@ LiteExportResult LiteWalletController::exportPrivateKeys(const std::string& opti
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
// Empty address -> export keys for all addresses; otherwise just the given address.
|
// Empty address -> export keys for all addresses; otherwise just the given address.
|
||||||
const auto result = bridge_->execute("export", optionalAddress);
|
auto result = bridge_->execute("export", optionalAddress); // non-const: result.value is a secret
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
out.error = result.error.empty() ? "export failed" : result.error;
|
out.error = result.error.empty() ? "export failed" : result.error;
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
nlohmann::json j;
|
||||||
|
// result.value + the parse tree hold the private keys; wipe them on every return path. out already
|
||||||
|
// has its own copy (out.privateKeysJson) which the caller wipes after use.
|
||||||
|
struct Scrub { std::string& v; nlohmann::json& tree; ~Scrub() { secureWipeLiteSecret(v); wipeJsonStrings(tree); } }
|
||||||
|
scrub{result.value, j};
|
||||||
try {
|
try {
|
||||||
const auto j = nlohmann::json::parse(result.value);
|
j = nlohmann::json::parse(result.value);
|
||||||
if (j.is_object() && j.contains("error")) {
|
if (j.is_object() && j.contains("error")) {
|
||||||
out.error = extractJsonError(j);
|
out.error = extractJsonError(j);
|
||||||
return out;
|
return out;
|
||||||
@@ -1270,13 +1365,18 @@ LiteSeedResult LiteWalletController::exportSeed()
|
|||||||
out.error = "no wallet is open";
|
out.error = "no wallet is open";
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
const auto result = bridge_->execute("seed", "");
|
auto result = bridge_->execute("seed", ""); // non-const: result.value holds the seed
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
out.error = result.error.empty() ? "seed export failed" : result.error;
|
out.error = result.error.empty() ? "seed export failed" : result.error;
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
nlohmann::json j;
|
||||||
|
// result.value + the parse tree contain the seed phrase; wipe them on every return path (out.seedPhrase
|
||||||
|
// is extracted separately and returned to the caller).
|
||||||
|
struct Scrub { std::string& v; nlohmann::json& tree; ~Scrub() { secureWipeLiteSecret(v); wipeJsonStrings(tree); } }
|
||||||
|
scrub{result.value, j};
|
||||||
try {
|
try {
|
||||||
const auto j = nlohmann::json::parse(result.value);
|
j = nlohmann::json::parse(result.value);
|
||||||
if (j.is_object()) {
|
if (j.is_object()) {
|
||||||
if (j.contains("error")) {
|
if (j.contains("error")) {
|
||||||
out.error = extractJsonError(j);
|
out.error = extractJsonError(j);
|
||||||
@@ -1541,6 +1641,13 @@ bool LiteWalletController::unlockWallet(std::string passphrase)
|
|||||||
return ok;
|
return ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool LiteWalletController::takeEncryptionFailureNotice()
|
||||||
|
{
|
||||||
|
const bool failed = lifecycleEncryptFailed_;
|
||||||
|
lifecycleEncryptFailed_ = false;
|
||||||
|
return failed;
|
||||||
|
}
|
||||||
|
|
||||||
bool LiteWalletController::lockWallet()
|
bool LiteWalletController::lockWallet()
|
||||||
{
|
{
|
||||||
if (!walletOpen_.load() || !bridge_) return false;
|
if (!walletOpen_.load() || !bridge_) return false;
|
||||||
@@ -1589,6 +1696,7 @@ void LiteWalletController::workerLoop()
|
|||||||
{
|
{
|
||||||
int failStreak = 0; // consecutive cycles whose syncing reads hit a backend error
|
int failStreak = 0; // consecutive cycles whose syncing reads hit a backend error
|
||||||
RefreshGate gate; // worker-local; haveFull=false => the FIRST cycle is always a full refresh
|
RefreshGate gate; // worker-local; haveFull=false => the FIRST cycle is always a full refresh
|
||||||
|
std::optional<LiteWalletAppRefreshModel> lastModel; // last published full model (to demote on a failed probe)
|
||||||
while (running_.load()) {
|
while (running_.load()) {
|
||||||
if (walletOpen_.load()) {
|
if (walletOpen_.load()) {
|
||||||
const GateDecision decision = evaluateRefreshGate(gate, failStreak);
|
const GateDecision decision = evaluateRefreshGate(gate, failStreak);
|
||||||
@@ -1600,6 +1708,19 @@ void LiteWalletController::workerLoop()
|
|||||||
// run a second do_sync this cycle; treat it as a failed cycle so the existing back-off
|
// run a second do_sync this cycle; treat it as a failed cycle so the existing back-off
|
||||||
// applies and we don't double the dead-server stall. Next cycle takes the full path.
|
// applies and we don't double the dead-server stall. Next cycle takes the full path.
|
||||||
failStreak += 1;
|
failStreak += 1;
|
||||||
|
// The probe path never re-derived sync state, so the last 'Synced' model stayed on screen while the
|
||||||
|
// server was gone. Re-publish it demoted, with the reason.
|
||||||
|
if (lastModel && running_.load()) {
|
||||||
|
auto demoted = *lastModel;
|
||||||
|
if (demoted.hasSyncStatus) {
|
||||||
|
demoted.sync.complete = false;
|
||||||
|
demoted.sync.progress = std::min(demoted.sync.progress, 0.999);
|
||||||
|
}
|
||||||
|
if (demoted.syncError.empty()) demoted.syncError = "server unreachable";
|
||||||
|
lastModel = demoted;
|
||||||
|
std::lock_guard<std::mutex> lock(modelMutex_);
|
||||||
|
pendingModel_ = std::move(demoted);
|
||||||
|
}
|
||||||
} else { // DoFull
|
} else { // DoFull
|
||||||
// Sample the change baselines BEFORE the data commands (race rule).
|
// Sample the change baselines BEFORE the data commands (race rule).
|
||||||
const std::uint64_t epoch0 = bridge_ ? bridge_->mutationEpoch() : 0;
|
const std::uint64_t epoch0 = bridge_ ? bridge_->mutationEpoch() : 0;
|
||||||
@@ -1617,6 +1738,7 @@ void LiteWalletController::workerLoop()
|
|||||||
gate.encrypted = m.encrypted;
|
gate.encrypted = m.encrypted;
|
||||||
gate.locked = m.locked;
|
gate.locked = m.locked;
|
||||||
gate.fullAt = std::chrono::steady_clock::now();
|
gate.fullAt = std::chrono::steady_clock::now();
|
||||||
|
lastModel = m;
|
||||||
std::lock_guard<std::mutex> lock(modelMutex_);
|
std::lock_guard<std::mutex> lock(modelMutex_);
|
||||||
pendingModel_ = std::move(model);
|
pendingModel_ = std::move(model);
|
||||||
} else {
|
} else {
|
||||||
@@ -1696,7 +1818,10 @@ LiteWalletLifecycleResult LiteWalletController::createWallet(LiteWalletCreateReq
|
|||||||
// copy and wipes it.
|
// copy and wipes it.
|
||||||
if (walletOpen_.load() && !request.passphrase.empty()) {
|
if (walletOpen_.load() && !request.passphrase.empty()) {
|
||||||
const auto enc = encryptWallet(request.passphrase);
|
const auto enc = encryptWallet(request.passphrase);
|
||||||
if (!enc.ok) liteLog("wallet created but encryption failed: " + enc.error);
|
if (!enc.ok) {
|
||||||
|
lifecycleEncryptFailed_ = true; // the wallet is open but NOT encrypted; App surfaces this
|
||||||
|
liteLog("wallet created but encryption failed: " + enc.error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
secureWipeLiteSecret(request.passphrase);
|
secureWipeLiteSecret(request.passphrase);
|
||||||
return result;
|
return result;
|
||||||
@@ -1726,7 +1851,10 @@ LiteWalletLifecycleResult LiteWalletController::restoreWallet(LiteWalletRestoreR
|
|||||||
// If the user supplied a passphrase, encrypt the restored wallet with it now that it's open (W5-3).
|
// If the user supplied a passphrase, encrypt the restored wallet with it now that it's open (W5-3).
|
||||||
if (walletOpen_.load() && !request.passphrase.empty()) {
|
if (walletOpen_.load() && !request.passphrase.empty()) {
|
||||||
const auto enc = encryptWallet(request.passphrase);
|
const auto enc = encryptWallet(request.passphrase);
|
||||||
if (!enc.ok) liteLog("wallet restored but encryption failed: " + enc.error);
|
if (!enc.ok) {
|
||||||
|
lifecycleEncryptFailed_ = true; // the wallet is open but NOT encrypted; App surfaces this
|
||||||
|
liteLog("wallet restored but encryption failed: " + enc.error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
secureWipeLiteSecret(request.seedPhrase);
|
secureWipeLiteSecret(request.seedPhrase);
|
||||||
secureWipeLiteSecret(request.passphrase);
|
secureWipeLiteSecret(request.passphrase);
|
||||||
|
|||||||
@@ -363,6 +363,9 @@ public:
|
|||||||
bool lockWallet();
|
bool lockWallet();
|
||||||
LiteEncryptionResult decryptWallet(std::string passphrase);
|
LiteEncryptionResult decryptWallet(std::string passphrase);
|
||||||
LiteEncryptionStatus encryptionStatus();
|
LiteEncryptionStatus encryptionStatus();
|
||||||
|
// True once (then cleared) if the passphrase encryption of a just-created/restored wallet FAILED, leaving it
|
||||||
|
// unencrypted. Main thread; the App turns it into a visible error instead of a log line.
|
||||||
|
bool takeEncryptionFailureNotice();
|
||||||
|
|
||||||
// Poll sync status + fetch balance/addresses/transactions, and apply the result into the
|
// Poll sync status + fetch balance/addresses/transactions, and apply the result into the
|
||||||
// app's WalletState. Returns true if state was updated. Safe no-op when no wallet is open.
|
// app's WalletState. Returns true if state was updated. Safe no-op when no wallet is open.
|
||||||
@@ -488,6 +491,7 @@ private:
|
|||||||
enum class PendingSecurityOp { None, Encrypt, Unlock };
|
enum class PendingSecurityOp { None, Encrypt, Unlock };
|
||||||
std::string pendingLifecyclePassphrase_; // main-thread only; wiped after use
|
std::string pendingLifecyclePassphrase_; // main-thread only; wiped after use
|
||||||
PendingSecurityOp pendingLifecycleSecurityOp_ = PendingSecurityOp::None;
|
PendingSecurityOp pendingLifecycleSecurityOp_ = PendingSecurityOp::None;
|
||||||
|
bool lifecycleEncryptFailed_ = false; // main-thread only; see takeEncryptionFailureNotice()
|
||||||
void stashPendingLifecycleSecurity(const std::string& passphrase, PendingSecurityOp op);
|
void stashPendingLifecycleSecurity(const std::string& passphrase, PendingSecurityOp op);
|
||||||
void applyPendingLifecycleSecurity(); // encrypt (create/restore) or unlock (open), then wipe
|
void applyPendingLifecycleSecurity(); // encrypt (create/restore) or unlock (open), then wipe
|
||||||
void clearPendingLifecycleSecurity(); // wipe + reset (rejected request / failed open)
|
void clearPendingLifecycleSecurity(); // wipe + reset (rejected request / failed open)
|
||||||
|
|||||||
@@ -190,6 +190,32 @@ LiteWalletStateMapResult mapLiteWalletRefreshBundle(const LiteWalletRefreshBundl
|
|||||||
result.model.sync.complete = bundle.syncStatus.complete;
|
result.model.sync.complete = bundle.syncStatus.complete;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-address spending-key evidence from the backend's per-note 'spendable' flag. Only POSITIVE evidence
|
||||||
|
// sets spendabilityKnown: a spendable note proves a key; a confirmed, long-mature (well past any anchor
|
||||||
|
// offset) unspent note that is still not spendable can only mean a view-only address. Fresh/immature
|
||||||
|
// notes, no notes, or no chain height stay unknown (the controller then keeps its spendable default).
|
||||||
|
if (result.model.hasAddresses && result.model.hasSpendableOutputs) {
|
||||||
|
constexpr std::int64_t kMatureDepth = 100;
|
||||||
|
const std::int64_t tip = result.model.hasSyncStatus
|
||||||
|
? static_cast<std::int64_t>(result.model.sync.chainHeight) : 0;
|
||||||
|
for (auto& addr : result.model.addresses) {
|
||||||
|
if (addr.kind != LiteWalletAppAddressKind::Shielded) continue;
|
||||||
|
bool anySpendable = false;
|
||||||
|
bool matureNonSpendable = false;
|
||||||
|
for (const auto& out : result.model.spendableOutputs) {
|
||||||
|
if (out.address != addr.address || out.kind != LiteSpendableOutputKind::UnspentNote) continue;
|
||||||
|
if (out.spent || out.unconfirmedSpent || out.pending) continue;
|
||||||
|
if (out.spendable) { anySpendable = true; break; }
|
||||||
|
if (tip > 0 && out.createdInBlock && *out.createdInBlock > 0 &&
|
||||||
|
tip - *out.createdInBlock >= kMatureDepth) {
|
||||||
|
matureNonSpendable = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (anySpendable) { addr.spendabilityKnown = true; addr.spendable = true; }
|
||||||
|
else if (matureNonSpendable) { addr.spendabilityKnown = true; addr.spendable = false; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!modelHasAnyMappedField(result.model)) {
|
if (!modelHasAnyMappedField(result.model)) {
|
||||||
addIssue(result, LiteWalletStateMapIssue::EmptyBundle, "lite refresh bundle has no mappable fields");
|
addIssue(result, LiteWalletStateMapIssue::EmptyBundle, "lite refresh bundle has no mappable fields");
|
||||||
result.error = result.issues.back().message;
|
result.error = result.issues.back().message;
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ inline std::atomic<bool> g_liteFakeBlockBalance{false}; // "balance" blocks (a
|
|||||||
inline std::atomic<bool> g_liteFakeBalanceError{false}; // "balance" returns the backend's plain-text sync error
|
inline std::atomic<bool> g_liteFakeBalanceError{false}; // "balance" returns the backend's plain-text sync error
|
||||||
inline std::atomic<long> g_liteFakeBalanceEntered{0}; // calls that reached "balance"
|
inline std::atomic<long> g_liteFakeBalanceEntered{0}; // calls that reached "balance"
|
||||||
inline std::atomic<bool> g_liteFakeBlockEncrypt{false}; // "encrypt" blocks (before changing state)
|
inline std::atomic<bool> g_liteFakeBlockEncrypt{false}; // "encrypt" blocks (before changing state)
|
||||||
|
inline std::atomic<bool> g_liteFakeEncryptFails{false}; // "encrypt" returns an error and changes no state
|
||||||
inline std::atomic<bool> g_liteFakeBlockImport{false}; // "import"/"timport" block
|
inline std::atomic<bool> g_liteFakeBlockImport{false}; // "import"/"timport" block
|
||||||
inline std::atomic<bool> g_liteFakeImportFails{false}; // "import"/"timport" reject the key (real do_import_key shape)
|
inline std::atomic<bool> g_liteFakeImportFails{false}; // "import"/"timport" reject the key (real do_import_key shape)
|
||||||
inline std::atomic<bool> g_liteFakeImportEncrypted{false}; // "import"/"timport" refuse: the wallet is encrypted
|
inline std::atomic<bool> g_liteFakeImportEncrypted{false}; // "import"/"timport" refuse: the wallet is encrypted
|
||||||
@@ -261,6 +262,8 @@ inline char* liteFakeExecute(const char* command, const char* args)
|
|||||||
// unlock/lock toggle locked; decrypt clears it; encryptionstatus reports {encrypted,locked}.
|
// unlock/lock toggle locked; decrypt clears it; encryptionstatus reports {encrypted,locked}.
|
||||||
if (std::strcmp(c, "encrypt") == 0) {
|
if (std::strcmp(c, "encrypt") == 0) {
|
||||||
liteFakeWaitWhile(g_liteFakeBlockEncrypt);
|
liteFakeWaitWhile(g_liteFakeBlockEncrypt);
|
||||||
|
if (g_liteFakeEncryptFails.load())
|
||||||
|
return liteFakeDup("{\"result\":\"error\",\"error\":\"encrypt failed\"}");
|
||||||
g_liteFakeEncrypted = true; g_liteFakeLocked = true;
|
g_liteFakeEncrypted = true; g_liteFakeLocked = true;
|
||||||
return liteFakeDup("{\"result\":\"success\"}");
|
return liteFakeDup("{\"result\":\"success\"}");
|
||||||
}
|
}
|
||||||
|
|||||||
+971
-14
File diff suppressed because it is too large
Load Diff
+47
-6
@@ -2023,9 +2023,19 @@ pub fn scan_full_mempool_tx(&self, tx: &Transaction, height: i32, _datetime: u64
|
|||||||
}
|
}
|
||||||
|
|
||||||
// unconfirmed_spent is left as it is: a pending send can still be mined on the
|
// unconfirmed_spent is left as it is: a pending send can still be mined on the
|
||||||
// new branch. Only a block that spends the note (scan_block) or a reload clears
|
// new branch. Only a block that spends the note (scan_block), the send expiring
|
||||||
// it, the same as for a send that expires unmined.
|
// unmined (cleanup_mempool) or a reload clears it.
|
||||||
})
|
});
|
||||||
|
|
||||||
|
// Same for transparent utxos: one whose spender was reorged out is unspent again
|
||||||
|
// (else it stays spent=Some(removed txid) and drops out of the balance until a rescan).
|
||||||
|
wtx.utxos.iter_mut()
|
||||||
|
.for_each(|utxo| {
|
||||||
|
if utxo.spent.is_some() && txids_to_remove.contains(&utxo.spent.unwrap()) {
|
||||||
|
utxo.spent = None;
|
||||||
|
}
|
||||||
|
// unconfirmed_spent is left as it is, for the same reason as on the notes.
|
||||||
|
});
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3007,16 +3017,47 @@ pub fn scan_full_mempool_tx(&self, tx: &Transaction, height: i32, _datetime: u64
|
|||||||
// 2. The Tx has been added to the wallet via a mined block
|
// 2. The Tx has been added to the wallet via a mined block
|
||||||
pub fn cleanup_mempool(&self) {
|
pub fn cleanup_mempool(&self) {
|
||||||
const DEFAULT_TX_EXPIRY_DELTA: i32 = 20;
|
const DEFAULT_TX_EXPIRY_DELTA: i32 = 20;
|
||||||
|
// A send is built with Builder::new(tip) (wtx.block = tip), so its expiry_height is tip + 20 and it can
|
||||||
|
// still be mined through block tip+20 (consensus rejects it only once the height exceeds the expiry). It
|
||||||
|
// is provably dead once the chain reaches tip+21, so the pending lock on its inputs must be held until
|
||||||
|
// then — one block longer than the old drop point (tip+20), which would release the notes while the tx
|
||||||
|
// could still be mined. The point is that the lock IS released now; previously unconfirmed_spent never was.
|
||||||
|
const MEMPOOL_KEEP: i32 = DEFAULT_TX_EXPIRY_DELTA + 1;
|
||||||
|
|
||||||
let current_height = self.blocks.read().unwrap().last().map(|b| b.height).unwrap_or(0);
|
let current_height = self.blocks.read().unwrap().last().map(|b| b.height).unwrap_or(0);
|
||||||
|
|
||||||
|
let mut expired: HashSet<TxId> = HashSet::new();
|
||||||
{
|
{
|
||||||
// Remove all expired Txns
|
// Remove all expired Txns, remembering which ones so their inputs can be released below.
|
||||||
self.mempool_txs.write().unwrap().retain( | _, wtx| {
|
self.mempool_txs.write().unwrap().retain( | txid, wtx| {
|
||||||
current_height < (wtx.block + DEFAULT_TX_EXPIRY_DELTA)
|
let keep = current_height < (wtx.block + MEMPOOL_KEEP);
|
||||||
|
if !keep {
|
||||||
|
expired.insert(txid.clone());
|
||||||
|
}
|
||||||
|
keep
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !expired.is_empty() {
|
||||||
|
// An expired send can never be mined: release the notes/utxos it locked (unconfirmed_spent). The
|
||||||
|
// mempool_txs lock above is released first, so mempool_txs and txs are never held together.
|
||||||
|
let mut txs = self.txs.write().unwrap();
|
||||||
|
// A txid already in txs was mined; scan_block/scan_full_tx settled its inputs, so skip it.
|
||||||
|
expired.retain(|txid| !txs.contains_key(txid));
|
||||||
|
for wtx in txs.values_mut() {
|
||||||
|
for nd in wtx.notes.iter_mut() {
|
||||||
|
if nd.unconfirmed_spent.map_or(false, |t| expired.contains(&t)) {
|
||||||
|
nd.unconfirmed_spent = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for utxo in wtx.utxos.iter_mut() {
|
||||||
|
if utxo.unconfirmed_spent.map_or(false, |t| expired.contains(&t)) {
|
||||||
|
utxo.unconfirmed_spent = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
// Remove all txns where the txid is added to the wallet directly
|
// Remove all txns where the txid is added to the wallet directly
|
||||||
self.mempool_txs.write().unwrap().retain ( |txid, _| {
|
self.mempool_txs.write().unwrap().retain ( |txid, _| {
|
||||||
|
|||||||
-1
@@ -94,7 +94,6 @@ pub struct SaplingNoteData {
|
|||||||
pub unconfirmed_spent: Option<TxId>, // If this note was spent in a send, but has not yet been confirmed.
|
pub unconfirmed_spent: Option<TxId>, // If this note was spent in a send, but has not yet been confirmed.
|
||||||
pub memo: Option<Memo>,
|
pub memo: Option<Memo>,
|
||||||
pub is_change: bool,
|
pub is_change: bool,
|
||||||
// TODO: We need to remove the unconfirmed_spent (i.e., set it to None) if the Tx has expired
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+22
-1
@@ -987,7 +987,28 @@ impl Sim {
|
|||||||
let ids: HashSet<TxId> = txs.iter().map(|g| g.txid()).collect();
|
let ids: HashSet<TxId> = txs.iter().map(|g| g.txid()).collect();
|
||||||
// cleanup_mempool also drops a send that `txs` holds (the mempool monitor may have recorded it).
|
// cleanup_mempool also drops a send that `txs` holds (the mempool monitor may have recorded it).
|
||||||
let rel = &self.rel;
|
let rel = &self.rel;
|
||||||
self.mempool.retain(|t, b| !ids.contains(t) && !rel.contains_key(t) && h < *b + TX_EXPIRY_DELTA);
|
// Mirror cleanup_mempool: it keeps a send for MEMPOOL_KEEP (= TX_EXPIRY_DELTA + 1, i.e. one block past the
|
||||||
|
// tx's expiry height, when it is provably dead) past the sending tip, and for a send dropped purely by
|
||||||
|
// expiry (never mined, i.e. not in `ids`/`rel`/the wallet's `txs`) it now RELEASES the pending lock on the
|
||||||
|
// inputs. So clear `pending` for those, exactly as the wallet clears `unconfirmed_spent`. The real tx
|
||||||
|
// expiry stays TX_EXPIRY_DELTA (used at verify_tx below).
|
||||||
|
const MEMPOOL_KEEP: i32 = TX_EXPIRY_DELTA + 1;
|
||||||
|
let mut expired: HashSet<TxId> = HashSet::new();
|
||||||
|
self.mempool.retain(|t, b| {
|
||||||
|
let mined = ids.contains(t) || rel.contains_key(t);
|
||||||
|
let keep = !mined && h < *b + MEMPOOL_KEEP;
|
||||||
|
if !keep && !mined {
|
||||||
|
expired.insert(*t);
|
||||||
|
}
|
||||||
|
keep
|
||||||
|
});
|
||||||
|
if !expired.is_empty() {
|
||||||
|
for n in self.notes.iter_mut() {
|
||||||
|
if n.pending.map_or(false, |p| expired.contains(&p)) {
|
||||||
|
n.pending = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let rel_txs: Vec<GenTx> = txs.iter().filter(|g| self.rel.get(&g.txid()).map_or(false, |r| r.0 == h))
|
let rel_txs: Vec<GenTx> = txs.iter().filter(|g| self.rel.get(&g.txid()).map_or(false, |r| r.0 == h))
|
||||||
.cloned().collect();
|
.cloned().collect();
|
||||||
|
|||||||
Reference in New Issue
Block a user