Compare commits
85
Commits
a792a1ec0c
...
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 | ||
|
|
3f1838cfdd | ||
|
|
dcb8e6eb25 | ||
|
|
3f127f9b62 | ||
|
|
f2d9590058 | ||
|
|
ee6e99965c | ||
|
|
b3e09a3314 | ||
|
|
2fdf33f99b | ||
|
|
092a802959 | ||
|
|
e9eeaf00c3 | ||
|
|
572b98cbcf | ||
|
|
bcfc324426 | ||
|
|
7cea9b7339 | ||
|
|
f3f0b32a48 | ||
|
|
8fe20f6385 | ||
|
|
15c1181aef | ||
|
|
0101cc707c | ||
|
|
adf61e43d6 | ||
|
|
fb281714f9 | ||
|
|
74c497922e | ||
|
|
ae16c06d24 | ||
|
|
408393a547 | ||
|
|
a80be443d1 |
+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": "聊天自定义",
|
||||||
|
|||||||
@@ -1225,6 +1225,8 @@ pill-margin-y = { size = 3.0 }
|
|||||||
pill-rounding = { size = 12.0 }
|
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)
|
||||||
|
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
|
||||||
|
|||||||
+332
-105
@@ -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;
|
||||||
@@ -1233,14 +1262,18 @@ void App::update()
|
|||||||
if (xmrig_manager_ && state_.pool_mining.xmrig_running
|
if (xmrig_manager_ && state_.pool_mining.xmrig_running
|
||||||
&& xmrig_manager_->getState() == daemon::XmrigManager::State::Error) {
|
&& xmrig_manager_->getState() == daemon::XmrigManager::State::Error) {
|
||||||
state_.pool_mining.xmrig_running = false;
|
state_.pool_mining.xmrig_running = false;
|
||||||
|
state_.pool_mining.running_since = 0;
|
||||||
|
state_.pool_mining.last_error.clear();
|
||||||
state_.pool_mining.hashrate_10s = 0.0;
|
state_.pool_mining.hashrate_10s = 0.0;
|
||||||
state_.pool_mining.hashrate_60s = 0.0;
|
state_.pool_mining.hashrate_60s = 0.0;
|
||||||
state_.pool_mining.hashrate_15m = 0.0;
|
state_.pool_mining.hashrate_15m = 0.0;
|
||||||
pool_starting_.store(false, std::memory_order_relaxed);
|
pool_starting_.store(false, std::memory_order_relaxed);
|
||||||
pool_threads_target_.store(0, std::memory_order_relaxed); // that miner is gone
|
pool_threads_target_.store(0, std::memory_order_relaxed); // that miner is gone
|
||||||
const std::string err = xmrig_manager_->getLastError();
|
const std::string err = xmrig_manager_->getLastError();
|
||||||
ui::Notifications::instance().error(err.empty() ? TR("appx_miner_stopped_unexpectedly")
|
const std::string mmsg = err.empty() ? std::string(TR("appx_miner_stopped_unexpectedly"))
|
||||||
: (std::string(TR("appx_miner_stopped_prefix")) + err));
|
: (std::string(TR("appx_miner_stopped_prefix")) + err);
|
||||||
|
setMiningError(mmsg); // persist: a miner that crashed/was killed otherwise just "did nothing"
|
||||||
|
ui::Notifications::instance().error(mmsg);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Poll xmrig stats every ~2 seconds (use a simple toggle)
|
// Poll xmrig stats every ~2 seconds (use a simple toggle)
|
||||||
@@ -1273,6 +1306,19 @@ void App::update()
|
|||||||
ps.threads_active = xs.threads_active;
|
ps.threads_active = xs.threads_active;
|
||||||
ps.pool_hashrate = xs.pool_hashrate;
|
ps.pool_hashrate = xs.pool_hashrate;
|
||||||
ps.log_lines = xmrig_manager_->getRecentLines(30);
|
ps.log_lines = xmrig_manager_->getRecentLines(30);
|
||||||
|
if (ps.running_since == 0) ps.running_since = std::time(nullptr);
|
||||||
|
// While not yet connected, surface the newest error-looking log line so "Connecting" can flip
|
||||||
|
// to a real "can't reach pool" after a timeout instead of sitting on red forever.
|
||||||
|
ps.last_error.clear();
|
||||||
|
if (!ps.connected) {
|
||||||
|
for (auto it = ps.log_lines.rbegin(); it != ps.log_lines.rend(); ++it) {
|
||||||
|
std::string l = *it;
|
||||||
|
std::transform(l.begin(), l.end(), l.begin(), [](unsigned char c){ return (char)std::tolower(c); });
|
||||||
|
if (l.find("error") != std::string::npos || l.find("refused") != std::string::npos ||
|
||||||
|
l.find("timed out") != std::string::npos || l.find("unreachable") != std::string::npos ||
|
||||||
|
l.find("dns") != std::string::npos) { ps.last_error = *it; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Record hashrate sample for the chart
|
// Record hashrate sample for the chart
|
||||||
ps.hashrate_history.push_back(ps.hashrate_10s);
|
ps.hashrate_history.push_back(ps.hashrate_10s);
|
||||||
@@ -1283,6 +1329,8 @@ void App::update()
|
|||||||
}
|
}
|
||||||
} else if (xmrig_manager_ && !xmrig_manager_->isRunning()) {
|
} else if (xmrig_manager_ && !xmrig_manager_->isRunning()) {
|
||||||
state_.pool_mining.xmrig_running = false;
|
state_.pool_mining.xmrig_running = false;
|
||||||
|
state_.pool_mining.running_since = 0;
|
||||||
|
state_.pool_mining.last_error.clear();
|
||||||
pool_starting_.store(false, std::memory_order_relaxed); // stopped / never came up
|
pool_starting_.store(false, std::memory_order_relaxed); // stopped / never came up
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1944,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)
|
||||||
@@ -2368,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
|
||||||
@@ -2509,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_);
|
||||||
@@ -2529,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();
|
||||||
}
|
}
|
||||||
@@ -2662,6 +2719,11 @@ void App::renderNodeStatusBanner()
|
|||||||
const NodeBusyText busy = nodeBusyText();
|
const NodeBusyText busy = nodeBusyText();
|
||||||
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_;
|
||||||
|
// 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;
|
||||||
@@ -2698,6 +2760,10 @@ void App::renderNodeStatusBanner()
|
|||||||
title = TR("node_banner_no_peers_title"); icon = ICON_MD_CLOUD_OFF; break;
|
title = TR("node_banner_no_peers_title"); icon = ICON_MD_CLOUD_OFF; break;
|
||||||
case ui::NodeBannerReason::NodeBusy:
|
case ui::NodeBannerReason::NodeBusy:
|
||||||
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:
|
||||||
|
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;
|
||||||
@@ -2706,13 +2772,17 @@ void App::renderNodeStatusBanner()
|
|||||||
const char* actionLabel = nullptr;
|
const char* actionLabel = nullptr;
|
||||||
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::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
|
||||||
// translated, actionable hint instead; every other reason keeps its live status/error text.
|
// translated, actionable hint instead; every other reason keeps its live status/error text.
|
||||||
const std::string detailText = (banner.reason == ui::NodeBannerReason::NoPeers)
|
std::string detailText;
|
||||||
? std::string(TR("node_banner_no_peers_detail"))
|
if (banner.reason == ui::NodeBannerReason::NoPeers) detailText = TR("node_banner_no_peers_detail");
|
||||||
: banner.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;
|
||||||
|
|
||||||
const float padX = ui::Layout::spacingLg();
|
const float padX = ui::Layout::spacingLg();
|
||||||
|
|
||||||
@@ -2801,6 +2871,12 @@ 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) {
|
||||||
|
const std::string conf = rpc::Connection::getDefaultConfPath();
|
||||||
|
util::Platform::openFolder(std::filesystem::path(conf).parent_path().string(),
|
||||||
|
/*createIfMissing=*/false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3050,50 +3126,28 @@ 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;
|
||||||
if (blocksLeft < 0) blocksLeft = 0;
|
if (blocksLeft < 0) blocksLeft = 0;
|
||||||
|
|
||||||
// Calculate sync speed (blocks/sec) using a smoothed rolling average
|
// App-owned smoothed sync-rate estimator: self-resets after a rescan/restart height drop (and on
|
||||||
static int s_prev_blocks = 0;
|
// connect/disconnect via the pending flag), so the ETA never goes stale/absurd ("412h"); days-aware.
|
||||||
static double s_prev_time = 0.0;
|
if (sync_eta_reset_pending_.exchange(false)) sync_eta_.reset();
|
||||||
static double s_blocks_per_sec = 0.0;
|
sync_eta_.update(state_.sync.blocks, ImGui::GetTime());
|
||||||
double now = ImGui::GetTime();
|
const ImVec4 syncCol = ImGui::ColorConvertU32ToFloat4(ui::material::Warning());
|
||||||
if (s_prev_time == 0.0) {
|
const long long etaSec = sync_eta_.etaSeconds(blocksLeft);
|
||||||
// First sample — seed baseline, don't compute rate yet
|
if (etaSec >= 0) {
|
||||||
s_prev_blocks = state_.sync.blocks;
|
|
||||||
s_prev_time = now;
|
|
||||||
} else if (state_.sync.blocks > s_prev_blocks) {
|
|
||||||
double dt = now - s_prev_time;
|
|
||||||
if (dt > 0.5) {
|
|
||||||
double raw = (state_.sync.blocks - s_prev_blocks) / dt;
|
|
||||||
// Exponential smoothing (alpha ~0.3 per update)
|
|
||||||
s_blocks_per_sec = s_blocks_per_sec > 0.0
|
|
||||||
? s_blocks_per_sec * 0.7 + raw * 0.3
|
|
||||||
: raw;
|
|
||||||
s_prev_blocks = state_.sync.blocks;
|
|
||||||
s_prev_time = now;
|
|
||||||
}
|
|
||||||
} else if (now - s_prev_time > 10.0) {
|
|
||||||
// No new blocks for 10s — decay the displayed rate
|
|
||||||
s_blocks_per_sec *= 0.5;
|
|
||||||
s_prev_time = now;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (s_blocks_per_sec > 0.1) {
|
|
||||||
int eta_sec = (int)(blocksLeft / s_blocks_per_sec);
|
|
||||||
char eta[32];
|
char eta[32];
|
||||||
if (eta_sec >= 3600)
|
util::formatEta(etaSec, eta, sizeof(eta));
|
||||||
snprintf(eta, sizeof(eta), "%dh %dm", eta_sec / 3600, (eta_sec % 3600) / 60);
|
ImGui::TextColored(syncCol, TR("sb_syncing_eta"),
|
||||||
else if (eta_sec >= 60)
|
state_.sync.verification_progress * 100.0, blocksLeft, sync_eta_.rate(), eta);
|
||||||
snprintf(eta, sizeof(eta), "%dm %ds", eta_sec / 60, eta_sec % 60);
|
|
||||||
else
|
|
||||||
snprintf(eta, sizeof(eta), "%ds", eta_sec);
|
|
||||||
ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.0f, 1.0f), TR("sb_syncing_eta"),
|
|
||||||
state_.sync.verification_progress * 100.0, blocksLeft, s_blocks_per_sec, eta);
|
|
||||||
} else {
|
} else {
|
||||||
ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.0f, 1.0f), TR("sb_syncing_basic"),
|
ImGui::TextColored(syncCol, TR("sb_syncing_basic"),
|
||||||
state_.sync.verification_progress * 100.0, blocksLeft);
|
state_.sync.verification_progress * 100.0, blocksLeft);
|
||||||
}
|
}
|
||||||
} else if (state_.connected && !isLiteBuild()) {
|
} else if (state_.connected && !isLiteBuild()) {
|
||||||
@@ -3312,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");
|
||||||
}
|
}
|
||||||
@@ -3459,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;
|
||||||
@@ -3574,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);
|
||||||
@@ -3585,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 {
|
||||||
@@ -3648,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 {
|
||||||
@@ -3747,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;
|
||||||
@@ -3785,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();
|
||||||
@@ -3892,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;
|
||||||
@@ -4319,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);
|
||||||
@@ -4704,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);
|
||||||
@@ -4759,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; };
|
||||||
|
|
||||||
@@ -4871,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"));
|
||||||
@@ -4892,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;
|
||||||
@@ -4904,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;
|
||||||
}
|
}
|
||||||
@@ -4972,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)))
|
||||||
@@ -4996,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5018,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"));
|
||||||
@@ -5038,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5973,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);
|
||||||
@@ -6308,6 +6532,7 @@ void App::rescanBlockchain()
|
|||||||
// actually observe the restarted daemon rescanning — so the first poll (which may still reach the
|
// actually observe the restarted daemon rescanning — so the first poll (which may still reach the
|
||||||
// pre-restart daemon and see rescanning=false) can't be misread as instant completion.
|
// pre-restart daemon and see rescanning=false) can't be misread as instant completion.
|
||||||
state_.sync.rescanning = true;
|
state_.sync.rescanning = true;
|
||||||
|
sync_eta_reset_pending_.store(true, std::memory_order_relaxed); // rescan drops height → reset ETA
|
||||||
rescan_confirmed_active_ = false;
|
rescan_confirmed_active_ = false;
|
||||||
user_initiated_rescan_ = true; // user-triggered rescan → its completion should toast
|
user_initiated_rescan_ = true; // user-triggered rescan → its completion should toast
|
||||||
state_.sync.rescan_progress = 0.0f;
|
state_.sync.rescan_progress = 0.0f;
|
||||||
@@ -8107,6 +8332,7 @@ std::string App::buildDiagnosticsReport()
|
|||||||
|
|
||||||
void App::restartDaemon()
|
void App::restartDaemon()
|
||||||
{
|
{
|
||||||
|
sync_eta_reset_pending_.store(true, std::memory_order_relaxed);
|
||||||
if (!supportsFullNodeLifecycleActions()) {
|
if (!supportsFullNodeLifecycleActions()) {
|
||||||
ui::Notifications::instance().warning(TR("appx_fullnode_lifecycle_unavailable_lite"));
|
ui::Notifications::instance().warning(TR("appx_fullnode_lifecycle_unavailable_lite"));
|
||||||
return;
|
return;
|
||||||
@@ -8117,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());
|
||||||
|
|||||||
@@ -42,12 +42,14 @@
|
|||||||
#include "resources/provision_state.h"
|
#include "resources/provision_state.h"
|
||||||
#include "util/background_sampler.h"
|
#include "util/background_sampler.h"
|
||||||
#include "util/coalescing_writer.h"
|
#include "util/coalescing_writer.h"
|
||||||
|
#include "util/sync_eta.h"
|
||||||
#include "util/fetch_backoff.h"
|
#include "util/fetch_backoff.h"
|
||||||
#include "util/idle_tracker.h"
|
#include "util/idle_tracker.h"
|
||||||
#include "util/pool_stats_service.h"
|
#include "util/pool_stats_service.h"
|
||||||
#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"
|
||||||
@@ -113,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
|
||||||
@@ -302,6 +305,9 @@ public:
|
|||||||
wallet::LiteWalletController* liteWallet() { return lite_wallet_.get(); }
|
wallet::LiteWalletController* liteWallet() { return lite_wallet_.get(); }
|
||||||
// HushChat service (identity + in-memory message store); the Chat tab reads its store.
|
// HushChat service (identity + in-memory message store); the Chat tab reads its store.
|
||||||
chat::ChatService& chatService() { return chat_service_; }
|
chat::ChatService& chatService() { return chat_service_; }
|
||||||
|
// Public forwarder for the UI: the wallet z-address others use to reach this user in chat. Call on
|
||||||
|
// click (not per-frame) — the underlying chatReplyZaddr() may persist a freshly chosen address.
|
||||||
|
std::string chatMyAddress() { return chatReplyZaddr(); }
|
||||||
// Why the Chat tab has no identity yet, so it can show the right prompt instead of always
|
// Why the Chat tab has no identity yet, so it can show the right prompt instead of always
|
||||||
// "unlock your wallet". Locked = encrypted & locked (seed unreadable); Preparing = unlocked and
|
// "unlock your wallet". Locked = encrypted & locked (seed unreadable); Preparing = unlocked and
|
||||||
// deriving the identity (fetch in flight, or waiting on the node / address list); Unavailable =
|
// deriving the identity (fetch in flight, or waiting on the node / address list); Unavailable =
|
||||||
@@ -316,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
|
||||||
@@ -328,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);
|
||||||
@@ -386,6 +415,11 @@ public:
|
|||||||
void startMining(int threads);
|
void startMining(int threads);
|
||||||
void stopMining();
|
void stopMining();
|
||||||
bool isMiningToggleInProgress() const { return mining_toggle_in_progress_.load(std::memory_order_relaxed); }
|
bool isMiningToggleInProgress() const { return mining_toggle_in_progress_.load(std::memory_order_relaxed); }
|
||||||
|
// Persistent mining error (last start/stop/pool failure) so a failed miner isn't just a fading toast.
|
||||||
|
const std::string& miningLastError() const { return mining_last_error_; }
|
||||||
|
bool miningErrorIsAntivirus() const { return mining_error_av_; }
|
||||||
|
void clearMiningError() { mining_last_error_.clear(); mining_error_av_ = false; }
|
||||||
|
void showAntivirusHelp() { pending_antivirus_dialog_ = true; } // Windows-only dialog; inert elsewhere
|
||||||
|
|
||||||
// Pool mining (xmrig)
|
// Pool mining (xmrig)
|
||||||
void startPoolMining(int threads);
|
void startPoolMining(int threads);
|
||||||
@@ -439,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);
|
||||||
@@ -548,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.
|
||||||
@@ -632,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; }
|
||||||
@@ -760,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.
|
||||||
@@ -918,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
|
||||||
@@ -994,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
|
||||||
@@ -1173,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
|
||||||
@@ -1300,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
|
||||||
@@ -1328,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
|
||||||
@@ -1390,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;
|
||||||
@@ -1444,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
|
||||||
@@ -1556,6 +1615,7 @@ private:
|
|||||||
|
|
||||||
// Connection
|
// Connection
|
||||||
std::string connection_status_ = "Disconnected";
|
std::string connection_status_ = "Disconnected";
|
||||||
|
bool rpc_auth_failed_ = false; // last connect hit an auth failure (bad rpcuser/rpcpassword); drives the banner
|
||||||
bool connection_in_progress_ = false;
|
bool connection_in_progress_ = false;
|
||||||
bool remote_rpc_plaintext_warning_shown_ = false;
|
bool remote_rpc_plaintext_warning_shown_ = false;
|
||||||
// Startup daemon-launch diagnostics: bound the "RPC port busy, no config" wait before warning,
|
// Startup daemon-launch diagnostics: bound the "RPC port busy, no config" wait before warning,
|
||||||
@@ -1741,6 +1801,14 @@ private:
|
|||||||
|
|
||||||
// Mining toggle guard (prevents concurrent setgenerate calls)
|
// Mining toggle guard (prevents concurrent setgenerate calls)
|
||||||
std::atomic<bool> mining_toggle_in_progress_{false};
|
std::atomic<bool> mining_toggle_in_progress_{false};
|
||||||
|
// Mining error state (main-thread only — every writer is in a MainCb or render).
|
||||||
|
std::string mining_last_error_;
|
||||||
|
// Sync-ETA estimator (UI thread only). onConnected/onDisconnected may run off-thread, so they set
|
||||||
|
// the atomic flag and the status bar consumes it before update().
|
||||||
|
util::SyncRateEstimator sync_eta_;
|
||||||
|
std::atomic<bool> sync_eta_reset_pending_{false};
|
||||||
|
bool mining_error_av_ = false;
|
||||||
|
void setMiningError(std::string msg, bool av = false) { mining_last_error_ = std::move(msg); mining_error_av_ = av; }
|
||||||
|
|
||||||
// True from a successful startPoolMining() until the miner is confirmed connected/hashing in the
|
// True from a successful startPoolMining() until the miner is confirmed connected/hashing in the
|
||||||
// poll — drives the "connecting…" → "connected" feedback for pool mining (which has a connect delay).
|
// poll — drives the "connecting…" → "connected" feedback for pool mining (which has a connect delay).
|
||||||
@@ -1909,6 +1977,7 @@ private:
|
|||||||
// First-run wizard state
|
// First-run wizard state
|
||||||
WizardPhase wizard_phase_ = WizardPhase::None;
|
WizardPhase wizard_phase_ = WizardPhase::None;
|
||||||
std::unique_ptr<util::Bootstrap> bootstrap_;
|
std::unique_ptr<util::Bootstrap> bootstrap_;
|
||||||
|
std::string wizard_bootstrap_url_; // source the wizard last started ("" = default); Retry keeps it
|
||||||
bool bootstrap_downloading_ = false; // true while settings bootstrap dialog is active
|
bool bootstrap_downloading_ = false; // true while settings bootstrap dialog is active
|
||||||
std::string wizard_pending_passphrase_; // held until daemon connects
|
std::string wizard_pending_passphrase_; // held until daemon connects
|
||||||
std::string wizard_saved_passphrase_; // held until PinSetup completes/skipped
|
std::string wizard_saved_passphrase_; // held until PinSetup completes/skipped
|
||||||
@@ -1954,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
|
||||||
@@ -2021,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)
|
||||||
|
|||||||
+492
-64
@@ -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
|
||||||
@@ -406,6 +410,7 @@ void App::tryConnect()
|
|||||||
++connect_attempt;
|
++connect_attempt;
|
||||||
|
|
||||||
connection_in_progress_ = true;
|
connection_in_progress_ = true;
|
||||||
|
rpc_auth_failed_ = false; // re-arm: a fresh connect attempt clears any prior auth failure
|
||||||
connection_status_ = TR("sb_loading_config");
|
connection_status_ = TR("sb_loading_config");
|
||||||
|
|
||||||
// Auto-detect configuration (file I/O — fast, safe on main thread)
|
// Auto-detect configuration (file I/O — fast, safe on main thread)
|
||||||
@@ -558,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) {
|
||||||
@@ -595,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;
|
||||||
@@ -608,6 +636,7 @@ void App::tryConnect()
|
|||||||
} else {
|
} else {
|
||||||
state_.connected = false;
|
state_.connected = false;
|
||||||
connection_status_ = TR("sb_auth_failed");
|
connection_status_ = TR("sb_auth_failed");
|
||||||
|
rpc_auth_failed_ = true;
|
||||||
VERBOSE_LOGF("[connect #%d] .cookie auth also failed\n", attempt);
|
VERBOSE_LOGF("[connect #%d] .cookie auth also failed\n", attempt);
|
||||||
ui::Notifications::instance().error(
|
ui::Notifications::instance().error(
|
||||||
"RPC authentication failed (HTTP 401). "
|
"RPC authentication failed (HTTP 401). "
|
||||||
@@ -621,6 +650,7 @@ void App::tryConnect()
|
|||||||
state_.connected = false;
|
state_.connected = false;
|
||||||
std::string confPath = rpc::Connection::getDefaultConfPath();
|
std::string confPath = rpc::Connection::getDefaultConfPath();
|
||||||
connection_status_ = TR("sb_auth_failed");
|
connection_status_ = TR("sb_auth_failed");
|
||||||
|
rpc_auth_failed_ = true;
|
||||||
VERBOSE_LOGF("[connect #%d] HTTP 401 — rpcuser/rpcpassword in %s don't match the daemon. "
|
VERBOSE_LOGF("[connect #%d] HTTP 401 — rpcuser/rpcpassword in %s don't match the daemon. "
|
||||||
"Edit the file or restart the daemon to regenerate credentials.\n",
|
"Edit the file or restart the daemon to regenerate credentials.\n",
|
||||||
attempt, confPath.c_str());
|
attempt, confPath.c_str());
|
||||||
@@ -738,6 +768,7 @@ void App::tryConnect()
|
|||||||
void App::onConnected()
|
void App::onConnected()
|
||||||
{
|
{
|
||||||
maint_abort_.store(false); // maintenance calls queued before this connect have already failed (FIFO)
|
maint_abort_.store(false); // maintenance calls queued before this connect have already failed (FIFO)
|
||||||
|
sync_eta_reset_pending_.store(true, std::memory_order_relaxed); // fresh connect → recompute sync ETA
|
||||||
++rpc_session_;
|
++rpc_session_;
|
||||||
if (consolidation_.abort()) ui::Notifications::instance().warning(TR("merge_run_aborted_toast"));
|
if (consolidation_.abort()) ui::Notifications::instance().warning(TR("merge_run_aborted_toast"));
|
||||||
state_.connected = true;
|
state_.connected = true;
|
||||||
@@ -828,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,
|
||||||
@@ -851,6 +883,7 @@ void App::onConnected()
|
|||||||
|
|
||||||
void App::onDisconnected(const std::string& reason)
|
void App::onDisconnected(const std::string& reason)
|
||||||
{
|
{
|
||||||
|
sync_eta_reset_pending_.store(true, std::memory_order_relaxed);
|
||||||
// Persist what the history snapshot is still missing while the rows, the address set (the cache identity),
|
// Persist what the history snapshot is still missing while the rows, the address set (the cache identity),
|
||||||
// the pending-send filter and the cache key are all live: state_.clear(), pending_opids_.clear() and the
|
// the pending-send filter and the cache key are all live: state_.clear(), pending_opids_.clear() and the
|
||||||
// session reset below empty them, which would turn a later flush into a silent no-op.
|
// session reset below empty them, which would turn a later flush into a silent no-op.
|
||||||
@@ -886,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;
|
||||||
@@ -945,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();
|
||||||
@@ -999,7 +1041,7 @@ std::string App::applyDaemonInitStatus(bool reachableButBusy)
|
|||||||
|
|
||||||
std::string title = wt.title;
|
std::string title = wt.title;
|
||||||
const int h = daemon_controller_ ? daemon_controller_->lastBlockHeight() : -1;
|
const int h = daemon_controller_ ? daemon_controller_->lastBlockHeight() : -1;
|
||||||
if (h > 0) title += " (Block " + std::to_string(h) + ")";
|
if (h > 0) { char hb[48]; std::snprintf(hb, sizeof(hb), TR("warmup_block_fmt"), h); title += " "; title += hb; }
|
||||||
|
|
||||||
state_.warmup_status = title;
|
state_.warmup_status = title;
|
||||||
state_.warmup_description = wt.description ? wt.description : "";
|
state_.warmup_description = wt.description ? wt.description : "";
|
||||||
@@ -1023,7 +1065,7 @@ void App::applyWarmupText(const std::string& rpcMessage)
|
|||||||
}
|
}
|
||||||
// Append current block height from daemon output
|
// Append current block height from daemon output
|
||||||
const int h = daemon_controller_ ? daemon_controller_->lastBlockHeight() : -1;
|
const int h = daemon_controller_ ? daemon_controller_->lastBlockHeight() : -1;
|
||||||
if (h > 0) state_.warmup_status += " (Block " + std::to_string(h) + ")";
|
if (h > 0) { char hb[48]; std::snprintf(hb, sizeof(hb), TR("warmup_block_fmt"), h); state_.warmup_status += " "; state_.warmup_status += hb; }
|
||||||
connection_status_ = state_.warmup_status;
|
connection_status_ = state_.warmup_status;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1106,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
|
||||||
@@ -1668,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;
|
||||||
@@ -3526,6 +3572,7 @@ void App::startMining(int threads)
|
|||||||
const int maxThreads = std::max(1, (int)std::thread::hardware_concurrency());
|
const int maxThreads = std::max(1, (int)std::thread::hardware_concurrency());
|
||||||
threads = std::clamp(threads, 1, maxThreads);
|
threads = std::clamp(threads, 1, maxThreads);
|
||||||
if (mining_toggle_in_progress_.exchange(true)) return; // already in progress
|
if (mining_toggle_in_progress_.exchange(true)) return; // already in progress
|
||||||
|
clearMiningError(); // a fresh attempt wipes any stale error
|
||||||
|
|
||||||
worker_->post([this, threads]() -> rpc::RPCWorker::MainCb {
|
worker_->post([this, threads]() -> rpc::RPCWorker::MainCb {
|
||||||
bool ok = false;
|
bool ok = false;
|
||||||
@@ -3545,7 +3592,9 @@ void App::startMining(int threads)
|
|||||||
DEBUG_LOGF("Mining started with %d threads\n", threads);
|
DEBUG_LOGF("Mining started with %d threads\n", threads);
|
||||||
} else {
|
} else {
|
||||||
DEBUG_LOGF("Failed to start mining: %s\n", errMsg.c_str());
|
DEBUG_LOGF("Failed to start mining: %s\n", errMsg.c_str());
|
||||||
ui::Notifications::instance().error(std::string(TR("toast_mining_failed")) + util::friendlyRpcError(errMsg));
|
const std::string merr = std::string(TR("toast_mining_failed")) + util::friendlyRpcError(errMsg);
|
||||||
|
setMiningError(merr);
|
||||||
|
ui::Notifications::instance().error(merr);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -3556,6 +3605,7 @@ void App::stopMining()
|
|||||||
if (!supportsSoloMining()) return;
|
if (!supportsSoloMining()) return;
|
||||||
if (!state_.connected || !rpc_ || !worker_) return;
|
if (!state_.connected || !rpc_ || !worker_) return;
|
||||||
if (mining_toggle_in_progress_.exchange(true)) return; // already in progress
|
if (mining_toggle_in_progress_.exchange(true)) return; // already in progress
|
||||||
|
clearMiningError();
|
||||||
|
|
||||||
worker_->post([this]() -> rpc::RPCWorker::MainCb {
|
worker_->post([this]() -> rpc::RPCWorker::MainCb {
|
||||||
bool ok = false;
|
bool ok = false;
|
||||||
@@ -3577,7 +3627,9 @@ void App::stopMining()
|
|||||||
} else {
|
} else {
|
||||||
// Don't silently leave generate=true as if it worked: tell the user and let the next
|
// Don't silently leave generate=true as if it worked: tell the user and let the next
|
||||||
// getmininginfo refresh reconcile the true daemon state. (M-07)
|
// getmininginfo refresh reconcile the true daemon state. (M-07)
|
||||||
ui::Notifications::instance().error(std::string(TR("toast_stop_mining_failed")) + util::friendlyRpcError(errMsg));
|
const std::string merr = std::string(TR("toast_stop_mining_failed")) + util::friendlyRpcError(errMsg);
|
||||||
|
setMiningError(merr);
|
||||||
|
ui::Notifications::instance().error(merr);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -3631,9 +3683,11 @@ void App::postPoolMinerJob(int threads, bool retargetOnly)
|
|||||||
// A pool pays with ordinary transactions, so only a shielded address can be paid (private chain).
|
// A pool pays with ordinary transactions, so only a shielded address can be paid (private chain).
|
||||||
if (!cfg.worker_name.empty() && cfg.worker_name != "x" &&
|
if (!cfg.worker_name.empty() && cfg.worker_name != "x" &&
|
||||||
!util::isValidRecipientAddress(cfg.worker_name)) {
|
!util::isValidRecipientAddress(cfg.worker_name)) {
|
||||||
ui::Notifications::instance().error(util::isTransparentAddress(cfg.worker_name)
|
const std::string merr = util::isTransparentAddress(cfg.worker_name)
|
||||||
? TR("mining_payout_needs_shielded")
|
? std::string(TR("mining_payout_needs_shielded"))
|
||||||
: "Pool payout address is not a valid DragonX address — mining not started.");
|
: std::string(TR("toast_pool_payout_invalid"));
|
||||||
|
setMiningError(merr);
|
||||||
|
ui::Notifications::instance().error(merr);
|
||||||
if (retargetOnly) stopPoolMining(); // never keep mining on a config that no longer validates
|
if (retargetOnly) stopPoolMining(); // never keep mining on a config that no longer validates
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -3664,6 +3718,7 @@ void App::postPoolMinerJob(int threads, bool retargetOnly)
|
|||||||
|
|
||||||
if (cfg.wallet_address.empty()) {
|
if (cfg.wallet_address.empty()) {
|
||||||
DEBUG_LOGF("[ERROR] Pool mining: No wallet address available\n");
|
DEBUG_LOGF("[ERROR] Pool mining: No wallet address available\n");
|
||||||
|
setMiningError(TR("toast_no_address_generate_z"));
|
||||||
ui::Notifications::instance().error(TR("toast_no_address_generate_z"));
|
ui::Notifications::instance().error(TR("toast_no_address_generate_z"));
|
||||||
if (retargetOnly) stopPoolMining();
|
if (retargetOnly) stopPoolMining();
|
||||||
return;
|
return;
|
||||||
@@ -3712,13 +3767,15 @@ void App::postPoolMinerJob(int threads, bool retargetOnly)
|
|||||||
DEBUG_LOGF("[ERROR] Pool mining: %s\n", err.c_str());
|
DEBUG_LOGF("[ERROR] Pool mining: %s\n", err.c_str());
|
||||||
// Windows Defender blocking (error 225 = ERROR_VIRUS_INFECTED)
|
// Windows Defender blocking (error 225 = ERROR_VIRUS_INFECTED)
|
||||||
if (err.find("error 225") != std::string::npos || err.find("virus") != std::string::npos) {
|
if (err.find("error 225") != std::string::npos || err.find("virus") != std::string::npos) {
|
||||||
ui::Notifications::instance().error(
|
setMiningError(TR("toast_av_blocked_xmrig"), /*av=*/true);
|
||||||
"Windows Defender blocked xmrig. Add exclusion for %APPDATA%\\ObsidianDragon");
|
ui::Notifications::instance().error(TR("toast_av_blocked_xmrig"));
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
pending_antivirus_dialog_ = true;
|
pending_antivirus_dialog_ = true;
|
||||||
#endif
|
#endif
|
||||||
} else {
|
} else {
|
||||||
ui::Notifications::instance().error(std::string(TR("toast_pool_start_failed")) + util::friendlyRpcError(err));
|
const std::string merr = std::string(TR("toast_pool_start_failed")) + util::friendlyRpcError(err);
|
||||||
|
setMiningError(merr);
|
||||||
|
ui::Notifications::instance().error(merr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -3755,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(); }
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -3773,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(); }
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -3792,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();
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -3899,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;
|
||||||
@@ -4142,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
|
||||||
@@ -4578,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;
|
||||||
@@ -4600,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;
|
||||||
@@ -5381,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
|
||||||
@@ -5395,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;
|
||||||
@@ -5607,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
|
||||||
@@ -5617,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;
|
||||||
}
|
}
|
||||||
@@ -5629,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;
|
||||||
}
|
}
|
||||||
@@ -5647,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();
|
||||||
|
|
||||||
@@ -5708,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) {
|
||||||
@@ -5741,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());
|
||||||
@@ -5749,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.
|
||||||
@@ -6265,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
|
||||||
@@ -6343,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
|
||||||
@@ -6367,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.
|
||||||
@@ -6391,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");
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -6543,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.
|
||||||
@@ -6550,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;
|
||||||
@@ -6604,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6899,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.
|
||||||
@@ -7015,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_) {
|
||||||
@@ -7086,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)) {
|
||||||
@@ -7170,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);
|
||||||
@@ -7178,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/
|
||||||
@@ -7206,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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -7391,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);
|
||||||
|
|||||||
+85
-34
@@ -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,12 +1567,12 @@ 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;
|
||||||
ImGui::BeginDisabled(!valid || encrypt_in_progress_);
|
ImGui::BeginDisabled(!valid || encrypt_in_progress_);
|
||||||
if (ui::material::TactileButton(TR("settings_encrypt_wallet"), ImVec2(btnW, 40))) {
|
if (ui::material::TactileButton(TR("settings_encrypt_wallet"), ImVec2(btnW, 40.0f * ui::Layout::dpiScale()))) {
|
||||||
std::string pass(encrypt_pass_buf_);
|
std::string pass(encrypt_pass_buf_);
|
||||||
enc_dlg_saved_passphrase_ = pass;
|
enc_dlg_saved_passphrase_ = pass;
|
||||||
memset(encrypt_pass_buf_, 0, sizeof(encrypt_pass_buf_));
|
memset(encrypt_pass_buf_, 0, sizeof(encrypt_pass_buf_));
|
||||||
@@ -1533,7 +1584,7 @@ void App::renderEncryptWalletDialog() {
|
|||||||
ImGui::EndDisabled();
|
ImGui::EndDisabled();
|
||||||
|
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
if (ui::material::TactileButton(TR("cancel"), ImVec2(btnW, 40))) {
|
if (ui::material::TactileButton(TR("cancel"), ImVec2(btnW, 40.0f * ui::Layout::dpiScale()))) {
|
||||||
memset(encrypt_pass_buf_, 0, sizeof(encrypt_pass_buf_));
|
memset(encrypt_pass_buf_, 0, sizeof(encrypt_pass_buf_));
|
||||||
memset(encrypt_confirm_buf_, 0, sizeof(encrypt_confirm_buf_));
|
memset(encrypt_confirm_buf_, 0, sizeof(encrypt_confirm_buf_));
|
||||||
show_encrypt_dialog_ = false;
|
show_encrypt_dialog_ = false;
|
||||||
@@ -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();
|
||||||
@@ -1612,7 +1663,7 @@ void App::renderEncryptWalletDialog() {
|
|||||||
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
|
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
|
||||||
|
|
||||||
ImGui::BeginDisabled(!pinValid || !hasPassphrase || pin_in_progress_);
|
ImGui::BeginDisabled(!pinValid || !hasPassphrase || pin_in_progress_);
|
||||||
if (ui::material::TactileButton(TR("settings_set_pin"), ImVec2(btnW, 40))) {
|
if (ui::material::TactileButton(TR("settings_set_pin"), ImVec2(btnW, 40.0f * ui::Layout::dpiScale()))) {
|
||||||
pin_in_progress_ = true;
|
pin_in_progress_ = true;
|
||||||
enc_dlg_pin_status_.clear();
|
enc_dlg_pin_status_.clear();
|
||||||
std::string savedPass = enc_dlg_saved_passphrase_;
|
std::string savedPass = enc_dlg_saved_passphrase_;
|
||||||
@@ -1655,7 +1706,7 @@ void App::renderEncryptWalletDialog() {
|
|||||||
ImGui::EndDisabled();
|
ImGui::EndDisabled();
|
||||||
|
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
if (ui::material::TactileButton(TR("wiz_skip"), ImVec2(btnW, 40))) {
|
if (ui::material::TactileButton(TR("wiz_skip"), ImVec2(btnW, 40.0f * ui::Layout::dpiScale()))) {
|
||||||
if (!enc_dlg_saved_passphrase_.empty()) {
|
if (!enc_dlg_saved_passphrase_.empty()) {
|
||||||
util::SecureVault::secureZero(&enc_dlg_saved_passphrase_[0],
|
util::SecureVault::secureZero(&enc_dlg_saved_passphrase_[0],
|
||||||
enc_dlg_saved_passphrase_.size());
|
enc_dlg_saved_passphrase_.size());
|
||||||
@@ -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();
|
||||||
@@ -1730,7 +1781,7 @@ void App::renderEncryptWalletDialog() {
|
|||||||
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
|
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
|
||||||
// Not while a PIN operation runs: PIN setup could verify and seal the OLD passphrase.
|
// Not while a PIN operation runs: PIN setup could verify and seal the OLD passphrase.
|
||||||
ImGui::BeginDisabled(!valid || encrypt_in_progress_ || pin_in_progress_);
|
ImGui::BeginDisabled(!valid || encrypt_in_progress_ || pin_in_progress_);
|
||||||
if (ui::material::TactileButton(TR("change_pass_title"), ImVec2(btnW, 40))) {
|
if (ui::material::TactileButton(TR("change_pass_title"), ImVec2(btnW, 40.0f * ui::Layout::dpiScale()))) {
|
||||||
changePassphrase(std::string(change_old_pass_buf_),
|
changePassphrase(std::string(change_old_pass_buf_),
|
||||||
std::string(change_new_pass_buf_));
|
std::string(change_new_pass_buf_));
|
||||||
}
|
}
|
||||||
@@ -1739,7 +1790,7 @@ void App::renderEncryptWalletDialog() {
|
|||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
// Cancel does what Esc/close does — dismiss without applying. Buffers are wiped by
|
// Cancel does what Esc/close does — dismiss without applying. Buffers are wiped by
|
||||||
// the !show_change_passphrase_ cleanup block below.
|
// the !show_change_passphrase_ cleanup block below.
|
||||||
if (ui::material::TactileButton(TR("cancel"), ImVec2(btnW, 40))) {
|
if (ui::material::TactileButton(TR("cancel"), ImVec2(btnW, 40.0f * ui::Layout::dpiScale()))) {
|
||||||
show_change_passphrase_ = false;
|
show_change_passphrase_ = false;
|
||||||
}
|
}
|
||||||
EndOverlayDialog();
|
EndOverlayDialog();
|
||||||
@@ -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();
|
||||||
@@ -1808,7 +1859,7 @@ void App::renderDecryptWalletDialog() {
|
|||||||
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
|
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
|
||||||
ImGui::BeginDisabled(!valid || decryptState.inProgress);
|
ImGui::BeginDisabled(!valid || decryptState.inProgress);
|
||||||
// Decrypting stops and restarts the node: not while it is busy (refuseIfNodeBusy() only on a click).
|
// Decrypting stops and restarts the node: not while it is busy (refuseIfNodeBusy() only on a click).
|
||||||
if ((ui::material::TactileButton(TR("settings_remove_encryption"), ImVec2(btnW, 40)) || (enterPressed && valid)) &&
|
if ((ui::material::TactileButton(TR("settings_remove_encryption"), ImVec2(btnW, 40.0f * ui::Layout::dpiScale())) || (enterPressed && valid)) &&
|
||||||
!refuseIfNodeBusy()) {
|
!refuseIfNodeBusy()) {
|
||||||
std::string passphrase(decrypt_pass_buf_);
|
std::string passphrase(decrypt_pass_buf_);
|
||||||
memset(decrypt_pass_buf_, 0, sizeof(decrypt_pass_buf_));
|
memset(decrypt_pass_buf_, 0, sizeof(decrypt_pass_buf_));
|
||||||
@@ -2017,7 +2068,7 @@ void App::renderDecryptWalletDialog() {
|
|||||||
ImGui::EndDisabled();
|
ImGui::EndDisabled();
|
||||||
|
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
if (ui::material::TactileButton(TR("cancel"), ImVec2(btnW, 40))) {
|
if (ui::material::TactileButton(TR("cancel"), ImVec2(btnW, 40.0f * ui::Layout::dpiScale()))) {
|
||||||
memset(decrypt_pass_buf_, 0, sizeof(decrypt_pass_buf_));
|
memset(decrypt_pass_buf_, 0, sizeof(decrypt_pass_buf_));
|
||||||
show_decrypt_dialog_ = false;
|
show_decrypt_dialog_ = false;
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
@@ -2132,7 +2183,7 @@ void App::renderDecryptWalletDialog() {
|
|||||||
ImGui::TextWrapped("%s", TR("decrypt_success_desc"));
|
ImGui::TextWrapped("%s", TR("decrypt_success_desc"));
|
||||||
|
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
if (ui::material::TactileButton(TR("close"), ImVec2(-1, 40))) {
|
if (ui::material::TactileButton(TR("close"), ImVec2(-1, 40.0f * ui::Layout::dpiScale()))) {
|
||||||
show_decrypt_dialog_ = false;
|
show_decrypt_dialog_ = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2149,11 +2200,11 @@ void App::renderDecryptWalletDialog() {
|
|||||||
|
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
|
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
|
||||||
if (ui::material::TactileButton(TR("try_again"), ImVec2(btnW, 40))) {
|
if (ui::material::TactileButton(TR("try_again"), ImVec2(btnW, 40.0f * ui::Layout::dpiScale()))) {
|
||||||
wallet_security_workflow_.reset();
|
wallet_security_workflow_.reset();
|
||||||
}
|
}
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
if (ui::material::TactileButton(TR("close"), ImVec2(btnW, 40))) {
|
if (ui::material::TactileButton(TR("close"), ImVec2(btnW, 40.0f * ui::Layout::dpiScale()))) {
|
||||||
show_decrypt_dialog_ = false;
|
show_decrypt_dialog_ = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
@@ -2219,7 +2270,7 @@ void App::renderPinDialogs() {
|
|||||||
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
|
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
|
||||||
// Not while a passphrase change runs: it could land after the old passphrase is verified.
|
// Not while a passphrase change runs: it could land after the old passphrase is verified.
|
||||||
ImGui::BeginDisabled(!valid || pin_in_progress_ || encrypt_in_progress_);
|
ImGui::BeginDisabled(!valid || pin_in_progress_ || encrypt_in_progress_);
|
||||||
if (ui::material::TactileButton(TR("settings_set_pin"), ImVec2(btnW, 40))) {
|
if (ui::material::TactileButton(TR("settings_set_pin"), ImVec2(btnW, 40.0f * ui::Layout::dpiScale()))) {
|
||||||
pin_in_progress_ = true;
|
pin_in_progress_ = true;
|
||||||
pin_status_ = TR("sec_verifying_passphrase");
|
pin_status_ = TR("sec_verifying_passphrase");
|
||||||
|
|
||||||
@@ -2292,7 +2343,7 @@ void App::renderPinDialogs() {
|
|||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
// Cancel does what Esc/close does — dismiss without applying. Buffers are wiped by
|
// Cancel does what Esc/close does — dismiss without applying. Buffers are wiped by
|
||||||
// the !show_pin_setup_ cleanup block below.
|
// the !show_pin_setup_ cleanup block below.
|
||||||
if (ui::material::TactileButton(TR("cancel"), ImVec2(btnW, 40))) {
|
if (ui::material::TactileButton(TR("cancel"), ImVec2(btnW, 40.0f * ui::Layout::dpiScale()))) {
|
||||||
show_pin_setup_ = false;
|
show_pin_setup_ = false;
|
||||||
}
|
}
|
||||||
EndOverlayDialog();
|
EndOverlayDialog();
|
||||||
@@ -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();
|
||||||
@@ -2346,7 +2397,7 @@ void App::renderPinDialogs() {
|
|||||||
strcmp(pin_buf_, pin_confirm_buf_) == 0;
|
strcmp(pin_buf_, pin_confirm_buf_) == 0;
|
||||||
|
|
||||||
ImGui::BeginDisabled(!valid || pin_in_progress_);
|
ImGui::BeginDisabled(!valid || pin_in_progress_);
|
||||||
if (ui::material::TactileButton(TR("settings_change_pin"), ImVec2(-1, 40))) {
|
if (ui::material::TactileButton(TR("settings_change_pin"), ImVec2(-1, 40.0f * ui::Layout::dpiScale()))) {
|
||||||
pin_in_progress_ = true;
|
pin_in_progress_ = true;
|
||||||
pin_status_ = TR("sec_changing_pin");
|
pin_status_ = TR("sec_changing_pin");
|
||||||
std::string oldPin(pin_old_buf_);
|
std::string oldPin(pin_old_buf_);
|
||||||
@@ -2405,13 +2456,13 @@ 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();
|
||||||
bool valid = strlen(pin_old_buf_) >= 4;
|
bool valid = strlen(pin_old_buf_) >= 4;
|
||||||
ImGui::BeginDisabled(!valid || pin_in_progress_);
|
ImGui::BeginDisabled(!valid || pin_in_progress_);
|
||||||
if (ui::material::TactileButton(TR("settings_remove_pin"), ImVec2(-1, 40))) {
|
if (ui::material::TactileButton(TR("settings_remove_pin"), ImVec2(-1, 40.0f * ui::Layout::dpiScale()))) {
|
||||||
pin_in_progress_ = true;
|
pin_in_progress_ = true;
|
||||||
pin_status_ = TR("sec_verifying_pin");
|
pin_status_ = TR("sec_verifying_pin");
|
||||||
std::string oldPin(pin_old_buf_);
|
std::string oldPin(pin_old_buf_);
|
||||||
|
|||||||
+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,
|
||||||
|
|||||||
+65
-29
@@ -800,21 +800,22 @@ void App::renderFirstRunWizard() {
|
|||||||
// Daemon status indicator
|
// Daemon status indicator
|
||||||
{
|
{
|
||||||
bool daemonUp = isEmbeddedDaemonRunning();
|
bool daemonUp = isEmbeddedDaemonRunning();
|
||||||
const std::string& dStatus = getDaemonStatus();
|
// Key the dot + label off the daemon state ENUM, not an English substring of the status
|
||||||
ImU32 dotCol = daemonUp ? IM_COL32(76, 175, 80, 200) // green
|
// text — the substring match silently failed under every non-English language.
|
||||||
: IM_COL32(120, 120, 120, 160); // gray
|
const bool daemonStopping = daemon_controller_ &&
|
||||||
if (dStatus.find("Stopping") != std::string::npos)
|
daemon_controller_->state() == daemon::EmbeddedDaemon::State::Stopping;
|
||||||
dotCol = IM_COL32(255, 167, 38, 200); // orange
|
ImU32 dotCol = daemonStopping ? IM_COL32(255, 167, 38, 230) // orange
|
||||||
|
: daemonUp ? IM_COL32(76, 175, 80, 230) // green
|
||||||
|
: IM_COL32(120, 120, 120, 180); // gray
|
||||||
float dotR = 3.5f * dp;
|
float dotR = 3.5f * dp;
|
||||||
dl->AddCircleFilled(ImVec2(cx + dotR, cy + captionFont->LegacySize * 0.5f),
|
dl->AddCircleFilled(ImVec2(cx + dotR, cy + captionFont->LegacySize * 0.5f),
|
||||||
dotR, dotCol);
|
dotR, dotCol);
|
||||||
const char* label = daemonUp ? (dStatus.find("Stopping") != std::string::npos
|
const char* label = daemonStopping ? TR("bootstrap_daemon_stopping")
|
||||||
? TR("bootstrap_daemon_stopping")
|
: daemonUp ? TR("bootstrap_daemon_running")
|
||||||
: TR("bootstrap_daemon_running"))
|
: TR("bootstrap_daemon_stopped");
|
||||||
: TR("bootstrap_daemon_stopped");
|
|
||||||
dl->AddText(captionFont, captionFont->LegacySize,
|
dl->AddText(captionFont, captionFont->LegacySize,
|
||||||
ImVec2(cx + dotR * 2.0f + 6.0f * dp, cy),
|
ImVec2(cx + dotR * 2.0f + 6.0f * dp, cy),
|
||||||
(dimCol & 0x00FFFFFF) | IM_COL32(0,0,0,140), label);
|
(dimCol & 0x00FFFFFF) | IM_COL32(0,0,0,220), label);
|
||||||
cy += captionFont->LegacySize + 6.0f * dp;
|
cy += captionFont->LegacySize + 6.0f * dp;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -862,14 +863,19 @@ void App::renderFirstRunWizard() {
|
|||||||
ui::material::Error(), TR("wiz_download_failed"));
|
ui::material::Error(), TR("wiz_download_failed"));
|
||||||
cy += bodyFont->LegacySize + 8.0f * dp;
|
cy += bodyFont->LegacySize + 8.0f * dp;
|
||||||
|
|
||||||
|
// Wrap the (often long) error within the card instead of letting it clip off the edge.
|
||||||
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx, cy), textCol,
|
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx, cy), textCol,
|
||||||
errMsg.c_str());
|
errMsg.c_str(), nullptr, contentW);
|
||||||
cy += captionFont->LegacySize + 16.0f * dp;
|
ImVec2 es = captionFont->CalcTextSizeA(captionFont->LegacySize, FLT_MAX, contentW, errMsg.c_str());
|
||||||
|
cy += es.y + 16.0f * dp;
|
||||||
|
|
||||||
// Retry / Skip
|
// Retry / (Try mirror) / Skip. Offer the mirror unless the last attempt already used it.
|
||||||
|
const std::string mirrorUrl = std::string(util::Bootstrap::kMirrorUrl) + "/" + util::Bootstrap::kZipName;
|
||||||
|
const bool showMirror = (wizard_bootstrap_url_ != mirrorUrl);
|
||||||
float btnW2 = 120.0f * dp;
|
float btnW2 = 120.0f * dp;
|
||||||
float btnH2 = 40.0f * dp;
|
float btnH2 = 40.0f * dp;
|
||||||
float totalBW = btnW2 * 2 + 12.0f * dp;
|
const int nBtn = showMirror ? 3 : 2;
|
||||||
|
float totalBW = btnW2 * nBtn + 12.0f * dp * (nBtn - 1);
|
||||||
float bx = rightX + (colW - totalBW) * 0.5f;
|
float bx = rightX + (colW - totalBW) * 0.5f;
|
||||||
|
|
||||||
ImGui::SetCursorScreenPos(ImVec2(bx, cy));
|
ImGui::SetCursorScreenPos(ImVec2(bx, cy));
|
||||||
@@ -883,14 +889,33 @@ void App::renderFirstRunWizard() {
|
|||||||
stopDaemonForBootstrap();
|
stopDaemonForBootstrap();
|
||||||
bootstrap_ = std::make_unique<util::Bootstrap>();
|
bootstrap_ = std::make_unique<util::Bootstrap>();
|
||||||
std::string dataDir = util::Platform::getDragonXDataDir();
|
std::string dataDir = util::Platform::getDragonXDataDir();
|
||||||
bootstrap_->start(dataDir);
|
if (wizard_bootstrap_url_.empty()) bootstrap_->start(dataDir);
|
||||||
|
else bootstrap_->start(dataDir, wizard_bootstrap_url_); // keep the user's chosen source
|
||||||
wizard_phase_ = WizardPhase::BootstrapInProgress;
|
wizard_phase_ = WizardPhase::BootstrapInProgress;
|
||||||
}
|
}
|
||||||
ImGui::EndDisabled();
|
ImGui::EndDisabled();
|
||||||
ImGui::PopStyleVar();
|
ImGui::PopStyleVar();
|
||||||
ImGui::PopStyleColor(3);
|
ImGui::PopStyleColor(3);
|
||||||
|
|
||||||
ImGui::SetCursorScreenPos(ImVec2(bx + btnW2 + 12.0f * dp, cy));
|
float nextBx = bx + btnW2 + 12.0f * dp;
|
||||||
|
if (showMirror) {
|
||||||
|
ImGui::SetCursorScreenPos(ImVec2(nextBx, cy));
|
||||||
|
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp);
|
||||||
|
ImGui::BeginDisabled(!supportsFullNodeLifecycleActions());
|
||||||
|
if (ui::material::TactileButton(TR("bootstrap_try_mirror"), ImVec2(btnW2, btnH2)) && !refuseIfNodeBusy()) {
|
||||||
|
stopDaemonForBootstrap();
|
||||||
|
bootstrap_ = std::make_unique<util::Bootstrap>();
|
||||||
|
bootstrap_->start(util::Platform::getDragonXDataDir(), mirrorUrl);
|
||||||
|
wizard_bootstrap_url_ = mirrorUrl;
|
||||||
|
wizard_phase_ = WizardPhase::BootstrapInProgress;
|
||||||
|
}
|
||||||
|
ImGui::EndDisabled();
|
||||||
|
if (ImGui::IsItemHovered()) ui::material::Tooltip(TR("bootstrap_use_mirror_tooltip"));
|
||||||
|
ImGui::PopStyleVar();
|
||||||
|
nextBx += btnW2 + 12.0f * dp;
|
||||||
|
}
|
||||||
|
|
||||||
|
ImGui::SetCursorScreenPos(ImVec2(nextBx, cy));
|
||||||
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp);
|
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp);
|
||||||
if (ui::material::TactileButton(TR("wiz_skip"), ImVec2(btnW2, btnH2))) {
|
if (ui::material::TactileButton(TR("wiz_skip"), ImVec2(btnW2, btnH2))) {
|
||||||
wizard_phase_ = WizardPhase::EncryptOffer;
|
wizard_phase_ = WizardPhase::EncryptOffer;
|
||||||
@@ -1014,6 +1039,15 @@ void App::renderFirstRunWizard() {
|
|||||||
cy += bsSize.y + 8.0f * dp;
|
cy += bsSize.y + 8.0f * dp;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Honest size/time hint (no number is known until the download HEADs the remote file).
|
||||||
|
{
|
||||||
|
const char* hintText = TR("wiz_bootstrap_size_hint");
|
||||||
|
ImVec2 hs = captionFont->CalcTextSizeA(captionFont->LegacySize, FLT_MAX, contentW, hintText);
|
||||||
|
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx, cy),
|
||||||
|
(dimCol & 0x00FFFFFF) | IM_COL32(0, 0, 0, 200), hintText, nullptr, contentW);
|
||||||
|
cy += hs.y + 8.0f * dp;
|
||||||
|
}
|
||||||
|
|
||||||
// Trust warning
|
// Trust warning
|
||||||
{
|
{
|
||||||
float warnOpacity = S.drawElement("screens.first-run", "trust-warning").opacity;
|
float warnOpacity = S.drawElement("screens.first-run", "trust-warning").opacity;
|
||||||
@@ -1031,21 +1065,20 @@ void App::renderFirstRunWizard() {
|
|||||||
// Daemon status indicator (subtle, before buttons)
|
// Daemon status indicator (subtle, before buttons)
|
||||||
{
|
{
|
||||||
bool daemonUp = isEmbeddedDaemonRunning();
|
bool daemonUp = isEmbeddedDaemonRunning();
|
||||||
const std::string& dStatus = getDaemonStatus();
|
const bool daemonStopping = daemon_controller_ &&
|
||||||
ImU32 dotCol = daemonUp ? IM_COL32(76, 175, 80, 200)
|
daemon_controller_->state() == daemon::EmbeddedDaemon::State::Stopping;
|
||||||
: IM_COL32(120, 120, 120, 160);
|
ImU32 dotCol = daemonStopping ? IM_COL32(255, 167, 38, 230)
|
||||||
if (dStatus.find("Stopping") != std::string::npos)
|
: daemonUp ? IM_COL32(76, 175, 80, 230)
|
||||||
dotCol = IM_COL32(255, 167, 38, 200);
|
: IM_COL32(120, 120, 120, 180);
|
||||||
float dotR = 3.5f * dp;
|
float dotR = 3.5f * dp;
|
||||||
dl->AddCircleFilled(ImVec2(cx + dotR, cy + captionFont->LegacySize * 0.5f),
|
dl->AddCircleFilled(ImVec2(cx + dotR, cy + captionFont->LegacySize * 0.5f),
|
||||||
dotR, dotCol);
|
dotR, dotCol);
|
||||||
const char* label = daemonUp ? (dStatus.find("Stopping") != std::string::npos
|
const char* label = daemonStopping ? TR("bootstrap_daemon_stopping")
|
||||||
? TR("bootstrap_daemon_stopping")
|
: daemonUp ? TR("bootstrap_daemon_running")
|
||||||
: TR("bootstrap_daemon_running"))
|
: TR("bootstrap_daemon_stopped");
|
||||||
: TR("bootstrap_daemon_stopped");
|
|
||||||
dl->AddText(captionFont, captionFont->LegacySize,
|
dl->AddText(captionFont, captionFont->LegacySize,
|
||||||
ImVec2(cx + dotR * 2.0f + 6.0f * dp, cy),
|
ImVec2(cx + dotR * 2.0f + 6.0f * dp, cy),
|
||||||
(dimCol & 0x00FFFFFF) | IM_COL32(0,0,0,140), label);
|
(dimCol & 0x00FFFFFF) | IM_COL32(0,0,0,220), label);
|
||||||
cy += captionFont->LegacySize + 10.0f * dp;
|
cy += captionFont->LegacySize + 10.0f * dp;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1071,6 +1104,7 @@ void App::renderFirstRunWizard() {
|
|||||||
bootstrap_ = std::make_unique<util::Bootstrap>();
|
bootstrap_ = std::make_unique<util::Bootstrap>();
|
||||||
std::string dataDir = util::Platform::getDragonXDataDir();
|
std::string dataDir = util::Platform::getDragonXDataDir();
|
||||||
bootstrap_->start(dataDir);
|
bootstrap_->start(dataDir);
|
||||||
|
wizard_bootstrap_url_.clear(); // default source; Retry won't force the mirror
|
||||||
wizard_phase_ = WizardPhase::BootstrapInProgress;
|
wizard_phase_ = WizardPhase::BootstrapInProgress;
|
||||||
}
|
}
|
||||||
ImGui::EndDisabled();
|
ImGui::EndDisabled();
|
||||||
@@ -1084,17 +1118,18 @@ void App::renderFirstRunWizard() {
|
|||||||
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::OnSurface()));
|
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::OnSurface()));
|
||||||
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp);
|
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp);
|
||||||
ImGui::BeginDisabled(!supportsFullNodeLifecycleActions());
|
ImGui::BeginDisabled(!supportsFullNodeLifecycleActions());
|
||||||
if (ui::material::TactileButton(TR("bootstrap_mirror"), ImVec2(mirrorW, btnH2)) && !refuseIfNodeBusy()) {
|
if (ui::material::TactileButton(TR("bootstrap_use_mirror"), ImVec2(mirrorW, btnH2)) && !refuseIfNodeBusy()) {
|
||||||
stopDaemonForBootstrap();
|
stopDaemonForBootstrap();
|
||||||
bootstrap_ = std::make_unique<util::Bootstrap>();
|
bootstrap_ = std::make_unique<util::Bootstrap>();
|
||||||
std::string dataDir = util::Platform::getDragonXDataDir();
|
std::string dataDir = util::Platform::getDragonXDataDir();
|
||||||
std::string mirrorUrl = std::string(util::Bootstrap::kMirrorUrl) + "/" + util::Bootstrap::kZipName;
|
std::string mirrorUrl = std::string(util::Bootstrap::kMirrorUrl) + "/" + util::Bootstrap::kZipName;
|
||||||
bootstrap_->start(dataDir, mirrorUrl);
|
bootstrap_->start(dataDir, mirrorUrl);
|
||||||
|
wizard_bootstrap_url_ = mirrorUrl; // Retry on the failed card keeps this source
|
||||||
wizard_phase_ = WizardPhase::BootstrapInProgress;
|
wizard_phase_ = WizardPhase::BootstrapInProgress;
|
||||||
}
|
}
|
||||||
ImGui::EndDisabled();
|
ImGui::EndDisabled();
|
||||||
if (ImGui::IsItemHovered()) {
|
if (ImGui::IsItemHovered()) {
|
||||||
ui::material::Tooltip(TR("bootstrap_mirror_tooltip"));
|
ui::material::Tooltip(TR("bootstrap_use_mirror_tooltip"));
|
||||||
}
|
}
|
||||||
ImGui::PopStyleVar();
|
ImGui::PopStyleVar();
|
||||||
ImGui::PopStyleColor(3);
|
ImGui::PopStyleColor(3);
|
||||||
@@ -1421,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
|
||||||
|
|||||||
+34
-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); }
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -254,6 +275,8 @@ struct PoolMiningState {
|
|||||||
int64_t uptime_sec = 0;
|
int64_t uptime_sec = 0;
|
||||||
double pool_diff = 0;
|
double pool_diff = 0;
|
||||||
bool connected = false;
|
bool connected = false;
|
||||||
|
std::time_t running_since = 0; // wall-clock the miner was first seen running this session (0 = not running)
|
||||||
|
std::string last_error; // newest xmrig log line that looks like a connection error ("" = none)
|
||||||
|
|
||||||
// Memory/thread usage (bytes for memory)
|
// Memory/thread usage (bytes for memory)
|
||||||
int64_t memory_used = 0;
|
int64_t memory_used = 0;
|
||||||
@@ -368,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 };
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
#include "history_scan_policy.h"
|
#include "history_scan_policy.h"
|
||||||
#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/amount_format.h" // util::toSats: integer-satoshi accumulation
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cctype>
|
#include <cctype>
|
||||||
@@ -33,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;
|
||||||
}
|
}
|
||||||
@@ -51,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
|
||||||
@@ -67,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1039,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) {
|
||||||
@@ -1072,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));
|
||||||
@@ -1459,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
|
||||||
@@ -1492,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);
|
||||||
@@ -1995,7 +2018,7 @@ void NetworkRefreshService::applyPriceRefreshFailure(WalletState& state,
|
|||||||
{
|
{
|
||||||
state.market.price_loading = false;
|
state.market.price_loading = false;
|
||||||
state.market.price_error = errorMessage.empty()
|
state.market.price_error = errorMessage.empty()
|
||||||
? std::string("Price fetch failed")
|
? std::string(util::tr("market_price_fetch_failed"))
|
||||||
: errorMessage;
|
: errorMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -19,9 +19,11 @@ enum class NodeBannerSeverity {
|
|||||||
|
|
||||||
// What the banner's action button does. App maps this to the concrete call.
|
// What the banner's action button does. App maps this to the concrete call.
|
||||||
enum class NodeBannerAction {
|
enum class NodeBannerAction {
|
||||||
None, // no button — nothing the user can usefully do from here
|
None, // no button — nothing the user can usefully do from here
|
||||||
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
|
||||||
|
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,
|
||||||
@@ -33,6 +35,9 @@ enum class NodeBannerReason {
|
|||||||
LiteOpenFailed, // lite build: the wallet failed to open
|
LiteOpenFailed, // lite build: the wallet failed to open
|
||||||
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)
|
||||||
|
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 {
|
||||||
@@ -71,6 +76,13 @@ struct NodeBannerInputs {
|
|||||||
bool node_busy = false;
|
bool node_busy = false;
|
||||||
std::string node_busy_detail;
|
std::string node_busy_detail;
|
||||||
|
|
||||||
|
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
|
||||||
@@ -130,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;
|
||||||
}
|
}
|
||||||
@@ -146,6 +168,21 @@ inline NodeBannerState evaluateNodeStatusBanner(const NodeBannerInputs& in) {
|
|||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The node rejected the wallet's RPC login — retrying won't help (the creds are wrong). Restarting
|
||||||
|
// the embedded node regenerates its cookie/creds; for an external node the user fixes DRAGONX.conf,
|
||||||
|
// so offer to open its folder. App renders a translated, actionable detail (connection_status here
|
||||||
|
// is the jargon "Auth failed…" line).
|
||||||
|
if (in.auth_failed) {
|
||||||
|
s.show = true;
|
||||||
|
s.severity = NodeBannerSeverity::Error;
|
||||||
|
s.reason = NodeBannerReason::AuthFailed;
|
||||||
|
s.action = (in.using_embedded_daemon && in.has_daemon_controller)
|
||||||
|
? NodeBannerAction::RestartNode
|
||||||
|
: NodeBannerAction::OpenConfigFolder;
|
||||||
|
s.detail.clear();
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
s.show = true;
|
s.show = true;
|
||||||
s.severity = NodeBannerSeverity::Warning;
|
s.severity = NodeBannerSeverity::Warning;
|
||||||
s.reason = NodeBannerReason::FullNodeOffline;
|
s.reason = NodeBannerReason::FullNodeOffline;
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -123,9 +132,16 @@ void Notifications::render()
|
|||||||
glassSpec.borderWidth = 1.0f;
|
glassSpec.borderWidth = 1.0f;
|
||||||
material::DrawGlassPanel(dl, pMin, pMax, glassSpec);
|
material::DrawGlassPanel(dl, pMin, pMax, glassSpec);
|
||||||
|
|
||||||
|
// Severity-tinted fill so the toast differs by more than hue (helps when color alone is hard to read).
|
||||||
|
{
|
||||||
|
ImVec4 tint = accent_color; // already premultiplied by fade `alpha`
|
||||||
|
tint.w *= nde("accent-fill-alpha", 0.22f);
|
||||||
|
dl->AddRectFilled(pMin, pMax, ImGui::ColorConvertFloat4ToU32(tint), pillRounding);
|
||||||
|
}
|
||||||
|
|
||||||
// Colored accent border on top of the glass border for type indication
|
// Colored accent border on top of the glass border for type indication
|
||||||
ImU32 accentCol = ImGui::ColorConvertFloat4ToU32(accent_color);
|
ImU32 accentCol = ImGui::ColorConvertFloat4ToU32(accent_color);
|
||||||
dl->AddRect(pMin, pMax, accentCol, pillRounding, 0, 1.0f);
|
dl->AddRect(pMin, pMax, accentCol, pillRounding, 0, 1.5f * dp);
|
||||||
|
|
||||||
// Progress bar at bottom of pill (accent-colored), clipped to pill rounded
|
// Progress bar at bottom of pill (accent-colored), clipped to pill rounded
|
||||||
// corners. Draw a full-pill-size rounded rect and clip it to just the
|
// corners. Draw a full-pill-size rounded rect and clip it to just the
|
||||||
@@ -156,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) {
|
||||||
@@ -176,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_{};
|
||||||
|
|||||||
+112
-56
@@ -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;
|
||||||
@@ -1548,9 +1551,13 @@ void RenderSettingsPage(App* app) {
|
|||||||
if (cardBtn(cx, cy, cw, "##exp_key", TR("settings_export_key"), ICON_MD_LOGOUT)) app->showExportKeyDialog();
|
if (cardBtn(cx, cy, cw, "##exp_key", TR("settings_export_key"), ICON_MD_LOGOUT)) app->showExportKeyDialog();
|
||||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_export_key"));
|
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_export_key"));
|
||||||
last = cy + bh; cy += bh + bgp;
|
last = cy + bh; cy += bh + bgp;
|
||||||
if (cardBtn(cx, cy, cw, "##exp_all", TR("settings_export_all"), ICON_MD_ARCHIVE)) ExportAllKeysDialog::show();
|
// Export-all only works over the full-node RPC; hide it in lite (which has its own key export
|
||||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_export_all"));
|
// in the lite wallet section) rather than dead-ending on "Not connected to the daemon".
|
||||||
last = cy + bh; cy += bh + bgp;
|
if (fullNode) {
|
||||||
|
if (cardBtn(cx, cy, cw, "##exp_all", TR("settings_export_all"), ICON_MD_ARCHIVE)) ExportAllKeysDialog::show();
|
||||||
|
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_export_all"));
|
||||||
|
last = cy + bh; cy += bh + bgp;
|
||||||
|
}
|
||||||
if (cardBtn(cx, cy, cw, "##exp_csv", TR("settings_export_csv"), ICON_MD_DESCRIPTION)) ExportTransactionsDialog::show();
|
if (cardBtn(cx, cy, cw, "##exp_csv", TR("settings_export_csv"), ICON_MD_DESCRIPTION)) ExportTransactionsDialog::show();
|
||||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_export_csv"));
|
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_export_csv"));
|
||||||
last = cy + bh;
|
last = cy + bh;
|
||||||
@@ -1645,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.
|
||||||
@@ -1704,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);
|
||||||
|
|
||||||
@@ -1752,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 {
|
||||||
@@ -2168,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 {
|
||||||
@@ -2327,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.
|
||||||
@@ -2591,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"));
|
||||||
@@ -2727,7 +2756,10 @@ void RenderSettingsPage(App* app) {
|
|||||||
ImGui::TextUnformatted(TR("transaction_url"));
|
ImGui::TextUnformatted(TR("transaction_url"));
|
||||||
ImGui::SameLine(urlRowX + urlLblW);
|
ImGui::SameLine(urlRowX + urlLblW);
|
||||||
ImGui::SetNextItemWidth(urlInputW);
|
ImGui::SetNextItemWidth(urlInputW);
|
||||||
|
// These Explorer-tab controls persist on edit — they were the only ones still relying on the
|
||||||
|
// About-only Save button, which a user on this tab never sees.
|
||||||
ImGui::InputText("##TxExplorer", s_settingsState.tx_explorer, sizeof(s_settingsState.tx_explorer));
|
ImGui::InputText("##TxExplorer", s_settingsState.tx_explorer, sizeof(s_settingsState.tx_explorer));
|
||||||
|
if (ImGui::IsItemDeactivatedAfterEdit()) saveSettingsPageState(app->settings());
|
||||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_tx_url"));
|
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_tx_url"));
|
||||||
|
|
||||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||||
@@ -2737,6 +2769,7 @@ void RenderSettingsPage(App* app) {
|
|||||||
ImGui::SameLine(urlRowX + urlLblW);
|
ImGui::SameLine(urlRowX + urlLblW);
|
||||||
ImGui::SetNextItemWidth(urlInputW);
|
ImGui::SetNextItemWidth(urlInputW);
|
||||||
ImGui::InputText("##AddrExplorer", s_settingsState.addr_explorer, sizeof(s_settingsState.addr_explorer));
|
ImGui::InputText("##AddrExplorer", s_settingsState.addr_explorer, sizeof(s_settingsState.addr_explorer));
|
||||||
|
if (ImGui::IsItemDeactivatedAfterEdit()) saveSettingsPageState(app->settings());
|
||||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_addr_url"));
|
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_addr_url"));
|
||||||
|
|
||||||
ImGui::PopFont();
|
ImGui::PopFont();
|
||||||
@@ -2754,10 +2787,12 @@ void RenderSettingsPage(App* app) {
|
|||||||
|
|
||||||
// Checkboxes + Block Explorer button (button wraps to its own row when it won't fit).
|
// Checkboxes + Block Explorer button (button wraps to its own row when it won't fit).
|
||||||
const float expRowRight = ImGui::GetCursorScreenPos().x + contentW;
|
const float expRowRight = ImGui::GetCursorScreenPos().x + contentW;
|
||||||
ImGui::Checkbox(TrId("custom_fees", "custom_fees").c_str(), &s_settingsState.allow_custom_fees);
|
if (ImGui::Checkbox(TrId("custom_fees", "custom_fees").c_str(), &s_settingsState.allow_custom_fees))
|
||||||
|
saveSettingsPageState(app->settings());
|
||||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_custom_fees"));
|
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_custom_fees"));
|
||||||
ImGui::SameLine(0, Layout::spacingLg());
|
ImGui::SameLine(0, Layout::spacingLg());
|
||||||
ImGui::Checkbox(TrId("fetch_prices", "fetch_prices").c_str(), &s_settingsState.fetch_prices);
|
if (ImGui::Checkbox(TrId("fetch_prices", "fetch_prices").c_str(), &s_settingsState.fetch_prices))
|
||||||
|
saveSettingsPageState(app->settings());
|
||||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_fetch_prices"));
|
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_fetch_prices"));
|
||||||
const float expBtnW = ImGui::CalcTextSize(TR("block_explorer")).x
|
const float expBtnW = ImGui::CalcTextSize(TR("block_explorer")).x
|
||||||
+ ImGui::GetStyle().FramePadding.x * 2.0f + Layout::spacingLg();
|
+ ImGui::GetStyle().FramePadding.x * 2.0f + Layout::spacingLg();
|
||||||
@@ -2962,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"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2991,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;
|
||||||
@@ -3006,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()));
|
||||||
|
|
||||||
@@ -3049,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[] = {
|
||||||
@@ -3109,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) ----
|
||||||
@@ -3277,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();
|
||||||
@@ -3324,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();
|
||||||
|
|
||||||
@@ -3343,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();
|
||||||
@@ -3379,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();
|
||||||
@@ -3407,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();
|
||||||
@@ -3436,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"));
|
||||||
@@ -3463,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"));
|
||||||
|
|||||||
@@ -141,6 +141,14 @@ public:
|
|||||||
double newFromBal = s_info.fromBalance - totalDeduct;
|
double newFromBal = s_info.fromBalance - totalDeduct;
|
||||||
double newToBal = s_info.toBalance + amount;
|
double newToBal = s_info.toBalance + amount;
|
||||||
bool amountValid = amount > 0 && totalDeduct <= s_info.fromBalance;
|
bool amountValid = amount > 0 && totalDeduct <= s_info.fromBalance;
|
||||||
|
// Mirror the Send tab's guards (this dialog previously enabled Confirm on amount alone, so a
|
||||||
|
// disconnected / syncing / locked wallet would fail only at submit). These are own-address
|
||||||
|
// moves, but gating is strictly more restrictive — the transfer logic below is unchanged.
|
||||||
|
const auto& ws = s_app->getWalletState();
|
||||||
|
const bool connected = s_app->isConnected();
|
||||||
|
const bool syncing = ws.sync.syncing;
|
||||||
|
const bool liteLocked = s_app->liteWallet() && ws.isLocked();
|
||||||
|
const bool canTransfer = amountValid && !s_sending && connected && !syncing && !liteLocked;
|
||||||
|
|
||||||
ImGui::Separator();
|
ImGui::Separator();
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
@@ -167,6 +175,19 @@ public:
|
|||||||
ImGui::TextWrapped("%s", TR("insufficient_funds"));
|
ImGui::TextWrapped("%s", TR("insufficient_funds"));
|
||||||
ImGui::PopStyleColor();
|
ImGui::PopStyleColor();
|
||||||
}
|
}
|
||||||
|
if (amount <= 0.0) {
|
||||||
|
ImGui::Spacing();
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(Error()));
|
||||||
|
ImGui::TextWrapped("%s", TR("send_tooltip_enter_amount"));
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
} else if (amountValid && (!connected || syncing || liteLocked)) {
|
||||||
|
ImGui::Spacing();
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(Warning()));
|
||||||
|
ImGui::TextWrapped("%s", !connected ? TR("send_tooltip_not_connected")
|
||||||
|
: syncing ? TR("send_tooltip_syncing")
|
||||||
|
: TR("transfer_tooltip_locked"));
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
}
|
||||||
if (amountValid && newFromBal < 1e-9) {
|
if (amountValid && newFromBal < 1e-9) {
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
// Full-balance send: same warning-icon treatment as the de-shielding header above,
|
// Full-balance send: same warning-icon treatment as the de-shielding header above,
|
||||||
@@ -204,13 +225,13 @@ public:
|
|||||||
bool outConfirm = false;
|
bool outConfirm = false;
|
||||||
bool outClose = false;
|
bool outClose = false;
|
||||||
DialogActionFooter(s_sending ? sendingLabel : confirmLabel,
|
DialogActionFooter(s_sending ? sendingLabel : confirmLabel,
|
||||||
amountValid && !s_sending,
|
canTransfer,
|
||||||
cancelLabel, outConfirm, outClose, btnW);
|
cancelLabel, outConfirm, outClose, btnW);
|
||||||
|
|
||||||
if (outClose) {
|
if (outClose) {
|
||||||
s_open = false;
|
s_open = false;
|
||||||
}
|
}
|
||||||
if (outConfirm) {
|
if (outConfirm && canTransfer) { // re-check live state (the dialog persists across frames)
|
||||||
s_sending = true;
|
s_sending = true;
|
||||||
s_app->sendTransaction(s_info.fromAddr, s_info.toAddr,
|
s_app->sendTransaction(s_info.fromAddr, s_info.toAddr,
|
||||||
amount, s_fee, "",
|
amount, s_fee, "",
|
||||||
@@ -296,12 +317,19 @@ private:
|
|||||||
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("transfer_sent_desc"));
|
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("transfer_sent_desc"));
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
{
|
{
|
||||||
char buf[128];
|
// The result is a TRANSACTION id, not an "Operation ID" — label it correctly and make
|
||||||
snprintf(buf, sizeof(buf), TR("shield_operation_id"),
|
// it copyable.
|
||||||
s_resultMsg.size() > 40
|
std::string disp = s_resultMsg.size() > 40
|
||||||
? (s_resultMsg.substr(0, 20) + "..." + s_resultMsg.substr(s_resultMsg.size() - 10)).c_str()
|
? (s_resultMsg.substr(0, 20) + "..." + s_resultMsg.substr(s_resultMsg.size() - 10))
|
||||||
: s_resultMsg.c_str());
|
: s_resultMsg;
|
||||||
|
char buf[160];
|
||||||
|
snprintf(buf, sizeof(buf), TR("transfer_txid_label"), disp.c_str());
|
||||||
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), buf);
|
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), buf);
|
||||||
|
ImGui::SameLine();
|
||||||
|
if (ImGui::SmallButton(TR("grpb_copy"))) {
|
||||||
|
ImGui::SetClipboardText(s_resultMsg.c_str());
|
||||||
|
Notifications::instance().info(TR("copied_to_clipboard"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
|
|||||||
@@ -97,6 +97,18 @@ bool FormatTotalEarnings(App* app, std::string& outText, ImU32& outCol) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Render compact hero line: logo + balance + USD + mining on one line
|
// Render compact hero line: logo + balance + USD + mining on one line
|
||||||
|
float DrawPendingChip(ImDrawList* dl, ImVec2 pos, float maxRight, ImFont* font, float fontSize) {
|
||||||
|
using namespace material;
|
||||||
|
if (s_dispUnconfirmed <= 1e-9) return 0.0f;
|
||||||
|
char buf[64];
|
||||||
|
snprintf(buf, sizeof(buf), TR("baltab_pending_chip"), s_dispUnconfirmed);
|
||||||
|
const float w = font->CalcTextSizeA(fontSize, 10000.0f, 0.0f, buf).x;
|
||||||
|
if (pos.x + w > maxRight) return 0.0f; // would overflow the card — skip rather than overlap
|
||||||
|
dl->AddText(font, fontSize, pos, WithAlpha(Warning(), 230), buf);
|
||||||
|
if (IsRectHovered(pos, ImVec2(pos.x + w, pos.y + fontSize))) Tooltip("%s", TR("baltab_pending_tip"));
|
||||||
|
return w;
|
||||||
|
}
|
||||||
|
|
||||||
void RenderCompactHero(App* app, ImDrawList* dl, float availW, float hs, float vs, float heroHeightOverride) {
|
void RenderCompactHero(App* app, ImDrawList* dl, float availW, float hs, float vs, float heroHeightOverride) {
|
||||||
using namespace material;
|
using namespace material;
|
||||||
char buf[64];
|
char buf[64];
|
||||||
@@ -136,6 +148,18 @@ void RenderCompactHero(App* app, ImDrawList* dl, float availW, float hs, float v
|
|||||||
ImGui::Dummy(ImVec2(capFont->CalcTextSizeA(tickerFontSize, 10000, 0, DRAGONX_TICKER).x + Layout::spacingLg(), lineH));
|
ImGui::Dummy(ImVec2(capFont->CalcTextSizeA(tickerFontSize, 10000, 0, DRAGONX_TICKER).x + Layout::spacingLg(), lineH));
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
|
|
||||||
|
// Pending (unconfirmed) chip — so these layouts show it too, labeled, not just the Classic card.
|
||||||
|
if (s_dispUnconfirmed > 1e-9) {
|
||||||
|
snprintf(buf, sizeof(buf), TR("baltab_pending_chip"), s_dispUnconfirmed);
|
||||||
|
ImVec2 pp = ImGui::GetCursorScreenPos();
|
||||||
|
dl->AddText(capFont, tickerFontSize, ImVec2(pp.x, tickerY), WithAlpha(Warning(), 230), buf);
|
||||||
|
float pw = capFont->CalcTextSizeA(tickerFontSize, 10000, 0, buf).x;
|
||||||
|
if (material::IsRectHovered(ImVec2(pp.x, tickerY), ImVec2(pp.x + pw, tickerY + tickerFontSize)))
|
||||||
|
material::Tooltip("%s", TR("baltab_pending_tip"));
|
||||||
|
ImGui::Dummy(ImVec2(pw + Layout::spacingLg(), lineH));
|
||||||
|
ImGui::SameLine();
|
||||||
|
}
|
||||||
|
|
||||||
// Total period-earnings (left of the USD) when the global earnings toggle is on.
|
// Total period-earnings (left of the USD) when the global earnings toggle is on.
|
||||||
{
|
{
|
||||||
std::string earnText; ImU32 earnCol;
|
std::string earnText; ImU32 earnCol;
|
||||||
@@ -1033,7 +1057,27 @@ void RenderSharedRecentTx(App* app, float recentH, float availW, float hs, float
|
|||||||
int count = std::min({4, maxRows, (int)txs.size()}); // show only the most recent (state.transactions is newest-first)
|
int count = std::min({4, maxRows, (int)txs.size()}); // show only the most recent (state.transactions is newest-first)
|
||||||
|
|
||||||
if (count == 0) {
|
if (count == 0) {
|
||||||
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("no_transactions_yet"));
|
const bool roomForIcon = ImGui::GetContentRegionAvail().y >= 100.0f * dp;
|
||||||
|
if (!app->isConnected()) {
|
||||||
|
// Disconnected: not "empty" and not "loading" — keep a plain caption.
|
||||||
|
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(),
|
||||||
|
TR(app->isLiteBuild() ? "lite_no_wallet" : "not_connected"));
|
||||||
|
} else {
|
||||||
|
const std::string txLoadingText = app->transactionRefreshProgressText();
|
||||||
|
// In lite, transactionRefreshProgressText() is empty and last_tx_update is stamped differently,
|
||||||
|
// so treat only an active sync as "loading" there.
|
||||||
|
const bool loadingOrSyncing = !txLoadingText.empty() ||
|
||||||
|
(state.sync.syncing && !state.sync.isSynced()) ||
|
||||||
|
(!app->isLiteBuild() && state.last_tx_update == 0);
|
||||||
|
if (loadingOrSyncing) {
|
||||||
|
char lbuf[192];
|
||||||
|
const char* base = !txLoadingText.empty() ? txLoadingText.c_str() : TR("loading_transactions");
|
||||||
|
snprintf(lbuf, sizeof(lbuf), "%s%s", base, material::LoadingDots());
|
||||||
|
material::DrawEmptyState(roomForIcon ? ICON_MD_HOURGLASS_EMPTY : nullptr, lbuf);
|
||||||
|
} else {
|
||||||
|
material::DrawEmptyState(roomForIcon ? ICON_MD_RECEIPT_LONG : nullptr, TR("no_transactions_yet"));
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||||
ImFont* capFont = Type().caption();
|
ImFont* capFont = Type().caption();
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ void RenderSharedAddressList(App* app, float listH, float availW, float glassRou
|
|||||||
void RenderSharedRecentTx(App* app, float recentH, float availW, float hs, float vs);
|
void RenderSharedRecentTx(App* app, float recentH, float availW, float hs, float vs);
|
||||||
void RenderSyncBar(App* app, ImDrawList* dl, float vs);
|
void RenderSyncBar(App* app, ImDrawList* dl, float vs);
|
||||||
|
|
||||||
|
// Draws a labeled "Pending +X" chip (unconfirmed balance) at `pos`, via the draw list only, when there
|
||||||
|
// is unconfirmed balance AND it fits left of `maxRight`. Returns the width drawn (0 if skipped). Adds a
|
||||||
|
// hover tooltip. Shared by the balance layouts so pending isn't visible in only one of them.
|
||||||
|
float DrawPendingChip(ImDrawList* dl, ImVec2 pos, float maxRight, ImFont* font, float fontSize);
|
||||||
|
|
||||||
// Total period-earnings for the whole wallet, formatted per the earnings settings. Returns false
|
// Total period-earnings for the whole wallet, formatted per the earnings settings. Returns false
|
||||||
// (and leaves out* untouched) when the global earnings toggle is off, so layouts show it only when
|
// (and leaves out* untouched) when the global earnings toggle is off, so layouts show it only when
|
||||||
// the user has opted into the earnings view. Shared by RenderCompactHero and the layout totals.
|
// the user has opted into the earnings view. Shared by RenderCompactHero and the layout totals.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#include "balance_recent_tx.h"
|
#include "balance_recent_tx.h"
|
||||||
#include "../../config/version.h"
|
#include "../../config/version.h"
|
||||||
#include "../../util/text_format.h"
|
#include "../../util/text_format.h"
|
||||||
|
#include "../../util/i18n.h"
|
||||||
|
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
@@ -23,12 +24,22 @@ std::string recentTimeAgo(int64_t timestamp)
|
|||||||
{
|
{
|
||||||
return util::formatTimeAgoShort(timestamp);
|
return util::formatTimeAgoShort(timestamp);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Translated type label (the data layer's getTypeDisplay() returns raw English). Never leak a raw RPC
|
||||||
|
// category: anything unrecognized (including "shield") falls back to a generic "Transaction".
|
||||||
|
std::string recentTypeLabel(const std::string& type)
|
||||||
|
{
|
||||||
|
if (type == "send") return TR("sent_type");
|
||||||
|
if (type == "receive") return TR("received_label");
|
||||||
|
if (type == "mined" || type == "generate" || type == "immature") return TR("mined_type");
|
||||||
|
return TR("tx_type_generic");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
RecentTxDisplay buildRecentTxDisplay(const TransactionInfo& tx, int addressMaxLen)
|
RecentTxDisplay buildRecentTxDisplay(const TransactionInfo& tx, int addressMaxLen)
|
||||||
{
|
{
|
||||||
return {
|
return {
|
||||||
tx.getTypeDisplay(),
|
recentTypeLabel(tx.type),
|
||||||
util::truncateMiddle(tx.address, addressMaxLen),
|
util::truncateMiddle(tx.address, addressMaxLen),
|
||||||
formatRecentAmount(tx.type, tx.amount),
|
formatRecentAmount(tx.type, tx.amount),
|
||||||
recentTimeAgo(tx.timestamp)
|
recentTimeAgo(tx.timestamp)
|
||||||
|
|||||||
@@ -525,9 +525,10 @@ static void RenderBalanceClassic(App* app)
|
|||||||
WithAlpha(Success(), 160), buf);
|
WithAlpha(Success(), 160), buf);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unconfirmed badge (top-right corner)
|
// Pending badge (top-right corner) — labeled so an unconfirmed "+X" doesn't read as a mystery.
|
||||||
|
bool pendingChipHovered = false;
|
||||||
if (state.unconfirmed_balance > 0.0) {
|
if (state.unconfirmed_balance > 0.0) {
|
||||||
snprintf(buf, sizeof(buf), "+%.4f", state.unconfirmed_balance);
|
snprintf(buf, sizeof(buf), TR("baltab_pending_chip"), state.unconfirmed_balance);
|
||||||
ImVec2 ts = capFont->CalcTextSizeA(
|
ImVec2 ts = capFont->CalcTextSizeA(
|
||||||
capFont->LegacySize, 10000, 0, buf);
|
capFont->LegacySize, 10000, 0, buf);
|
||||||
float bp = S.drawElement("tabs.balance.classic", "unconfirmed-badge-padding").sizeOr(4.0f) * dp;
|
float bp = S.drawElement("tabs.balance.classic", "unconfirmed-badge-padding").sizeOr(4.0f) * dp;
|
||||||
@@ -540,6 +541,7 @@ static void RenderBalanceClassic(App* app)
|
|||||||
dl->AddText(capFont, capFont->LegacySize,
|
dl->AddText(capFont, capFont->LegacySize,
|
||||||
ImVec2(bMin.x + bp, bMin.y + bp * 0.5f),
|
ImVec2(bMin.x + bp, bMin.y + bp * 0.5f),
|
||||||
amberCol, buf);
|
amberCol, buf);
|
||||||
|
if (material::IsRectHovered(bMin, bMax)) { pendingChipHovered = true; material::Tooltip("%s", TR("baltab_pending_tip")); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hover glow + click to Receive
|
// Hover glow + click to Receive
|
||||||
@@ -547,6 +549,7 @@ static void RenderBalanceClassic(App* app)
|
|||||||
dl->AddRect(cMin, cMax, IM_COL32(255, 255, 255, (int)S.drawElement("tabs.balance", "hover-glow-alpha").sizeOr(40.0f)),
|
dl->AddRect(cMin, cMax, IM_COL32(255, 255, 255, (int)S.drawElement("tabs.balance", "hover-glow-alpha").sizeOr(40.0f)),
|
||||||
cardSpec.rounding, 0, S.drawElement("tabs.balance", "hover-glow-thickness").sizeOr(1.5f) * dp);
|
cardSpec.rounding, 0, S.drawElement("tabs.balance", "hover-glow-thickness").sizeOr(1.5f) * dp);
|
||||||
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||||
|
if (!pendingChipHovered) material::Tooltip("%s", TR("shielded_card_tooltip"));
|
||||||
if (ImGui::IsMouseClicked(0))
|
if (ImGui::IsMouseClicked(0))
|
||||||
app->setCurrentPage(NavPage::Receive);
|
app->setCurrentPage(NavPage::Receive);
|
||||||
}
|
}
|
||||||
@@ -747,6 +750,11 @@ static void RenderBalanceDonut(App* app) {
|
|||||||
dl->AddText(capFont, capFont->LegacySize,
|
dl->AddText(capFont, capFont->LegacySize,
|
||||||
ImVec2(ImGui::GetCursorScreenPos().x, tickerY),
|
ImVec2(ImGui::GetCursorScreenPos().x, tickerY),
|
||||||
OnSurfaceMedium(), DRAGONX_TICKER);
|
OnSurfaceMedium(), DRAGONX_TICKER);
|
||||||
|
DrawPendingChip(dl, ImVec2(ImGui::GetCursorScreenPos().x
|
||||||
|
+ capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, DRAGONX_TICKER).x
|
||||||
|
+ Layout::spacingMd(), tickerY),
|
||||||
|
ImGui::GetCursorScreenPos().x + ImGui::GetContentRegionAvail().x,
|
||||||
|
capFont, capFont->LegacySize);
|
||||||
ImGui::NewLine();
|
ImGui::NewLine();
|
||||||
|
|
||||||
// USD value
|
// USD value
|
||||||
@@ -1662,6 +1670,11 @@ static void RenderBalanceShield(App* app) {
|
|||||||
dl->AddText(capFont, capFont->LegacySize,
|
dl->AddText(capFont, capFont->LegacySize,
|
||||||
ImVec2(ImGui::GetCursorScreenPos().x, pos.y + heroSz.y - capFont->LegacySize),
|
ImVec2(ImGui::GetCursorScreenPos().x, pos.y + heroSz.y - capFont->LegacySize),
|
||||||
OnSurfaceMedium(), DRAGONX_TICKER);
|
OnSurfaceMedium(), DRAGONX_TICKER);
|
||||||
|
DrawPendingChip(dl, ImVec2(ImGui::GetCursorScreenPos().x
|
||||||
|
+ capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, DRAGONX_TICKER).x
|
||||||
|
+ Layout::spacingMd(), pos.y + heroSz.y - capFont->LegacySize),
|
||||||
|
ImGui::GetCursorScreenPos().x + ImGui::GetContentRegionAvail().x,
|
||||||
|
capFont, capFont->LegacySize);
|
||||||
ImGui::NewLine();
|
ImGui::NewLine();
|
||||||
|
|
||||||
double usd_value = state.getBalanceUSD();
|
double usd_value = state.getBalanceUSD();
|
||||||
@@ -1851,6 +1864,11 @@ static void RenderBalanceTimeline(App* app) {
|
|||||||
dl->AddText(capFont, capFont->LegacySize,
|
dl->AddText(capFont, capFont->LegacySize,
|
||||||
ImVec2(ImGui::GetCursorScreenPos().x, pos.y + heroSz.y - capFont->LegacySize),
|
ImVec2(ImGui::GetCursorScreenPos().x, pos.y + heroSz.y - capFont->LegacySize),
|
||||||
OnSurfaceMedium(), DRAGONX_TICKER);
|
OnSurfaceMedium(), DRAGONX_TICKER);
|
||||||
|
DrawPendingChip(dl, ImVec2(ImGui::GetCursorScreenPos().x
|
||||||
|
+ capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, DRAGONX_TICKER).x
|
||||||
|
+ Layout::spacingMd(), pos.y + heroSz.y - capFont->LegacySize),
|
||||||
|
ImGui::GetCursorScreenPos().x + ImGui::GetContentRegionAvail().x,
|
||||||
|
capFont, capFont->LegacySize);
|
||||||
ImGui::NewLine();
|
ImGui::NewLine();
|
||||||
|
|
||||||
double usd_value = state.getBalanceUSD();
|
double usd_value = state.getBalanceUSD();
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ public:
|
|||||||
s_state = State::Confirm;
|
s_state = State::Confirm;
|
||||||
s_bootstrap.reset();
|
s_bootstrap.reset();
|
||||||
s_errorMsg.clear();
|
s_errorMsg.clear();
|
||||||
|
s_lastUrl.clear();
|
||||||
s_wasDaemonRunning = false;
|
s_wasDaemonRunning = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,13 +283,17 @@ private:
|
|||||||
|
|
||||||
Type().textColored(TypeStyle::H6, Success(), TR("bootstrap_success"));
|
Type().textColored(TypeStyle::H6, Success(), TR("bootstrap_success"));
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::TextWrapped("%s", TR("bootstrap_success_desc"));
|
// For the embedded daemon, tell the user the node will start (and offer the button) instead of
|
||||||
|
// instructing them to start it by hand. External-daemon users keep the manual-start wording.
|
||||||
|
const bool embedded = s_app->isUsingEmbeddedDaemon();
|
||||||
|
ImGui::TextWrapped("%s", TR(embedded ? "bootstrap_success_desc_start" : "bootstrap_success_desc"));
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
|
|
||||||
float btnW = 140.0f * dp;
|
float btnW = 150.0f * dp;
|
||||||
if (s_wasDaemonRunning) {
|
if (s_wasDaemonRunning || embedded) {
|
||||||
if (TactileButton(TR("bootstrap_restart_daemon"), ImVec2(btnW, 0))) {
|
if (TactileButton(s_wasDaemonRunning ? TR("bootstrap_restart_daemon") : TR("bootstrap_start_daemon"),
|
||||||
|
ImVec2(btnW, 0))) {
|
||||||
s_app->setBootstrapDownloading(false);
|
s_app->setBootstrapDownloading(false);
|
||||||
s_app->startEmbeddedDaemon();
|
s_app->startEmbeddedDaemon();
|
||||||
s_open = false;
|
s_open = false;
|
||||||
@@ -314,9 +319,15 @@ private:
|
|||||||
|
|
||||||
float btnW = 120.0f * dp;
|
float btnW = 120.0f * dp;
|
||||||
if (TactileButton(TR("retry"), ImVec2(btnW, 0))) {
|
if (TactileButton(TR("retry"), ImVec2(btnW, 0))) {
|
||||||
startDownload("");
|
startDownload(s_lastUrl); // keep the user's source/mirror choice (was startDownload(""))
|
||||||
}
|
}
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
|
{
|
||||||
|
const std::string mirrorUrl = std::string(util::Bootstrap::kMirrorUrl) + "/" + util::Bootstrap::kZipName;
|
||||||
|
if (s_lastUrl != mirrorUrl && TactileButton(TR("bootstrap_try_mirror"), ImVec2(130.0f * dp, 0)))
|
||||||
|
startDownload(mirrorUrl);
|
||||||
|
ImGui::SameLine();
|
||||||
|
}
|
||||||
if (TactileButton(TR("close"), ImVec2(90.0f * dp, 0))) {
|
if (TactileButton(TR("close"), ImVec2(90.0f * dp, 0))) {
|
||||||
s_app->setBootstrapDownloading(false);
|
s_app->setBootstrapDownloading(false);
|
||||||
s_open = false;
|
s_open = false;
|
||||||
@@ -336,6 +347,7 @@ private:
|
|||||||
s_bootstrap->start(dataDir);
|
s_bootstrap->start(dataDir);
|
||||||
else
|
else
|
||||||
s_bootstrap->start(dataDir, url);
|
s_bootstrap->start(dataDir, url);
|
||||||
|
s_lastUrl = url; // remember what we started (set only after the guards passed)
|
||||||
s_state = State::Downloading;
|
s_state = State::Downloading;
|
||||||
s_errorMsg.clear();
|
s_errorMsg.clear();
|
||||||
s_cancelling = false;
|
s_cancelling = false;
|
||||||
@@ -347,6 +359,7 @@ private:
|
|||||||
static inline std::unique_ptr<util::Bootstrap> s_bootstrap;
|
static inline std::unique_ptr<util::Bootstrap> s_bootstrap;
|
||||||
static inline bool s_wasDaemonRunning = false;
|
static inline bool s_wasDaemonRunning = false;
|
||||||
static inline bool s_cancelling = false; // cancel requested; disables the Cancel button until the worker stops
|
static inline bool s_cancelling = false; // cancel requested; disables the Cancel button until the worker stops
|
||||||
|
static inline std::string s_lastUrl; // last source started ("" = default); so Retry keeps the mirror choice
|
||||||
static inline std::string s_errorMsg;
|
static inline std::string s_errorMsg;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+624
-35
@@ -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
|
||||||
|
|
||||||
@@ -96,12 +99,35 @@ int composeInputCallback(ImGuiInputTextCallbackData* data) {
|
|||||||
bool s_show_new_convo = false;
|
bool s_show_new_convo = false;
|
||||||
char s_new_zaddr[128] = "";
|
char s_new_zaddr[128] = "";
|
||||||
char s_new_msg[256] = "";
|
char s_new_msg[256] = "";
|
||||||
|
char s_pending_new_zaddr[128] = ""; // cross-tab request from Contacts' "Message" action
|
||||||
|
bool s_pending_new_set = false;
|
||||||
char s_search[80] = ""; // conversation-list filter (Q8)
|
char s_search[80] = ""; // conversation-list filter (Q8)
|
||||||
bool s_show_hidden = false; // when on, the list also shows hidden conversations (with an Unhide action)
|
bool s_show_hidden = false; // when on, the list also shows hidden conversations (with an Unhide action)
|
||||||
bool s_show_delete_confirm = false; // "Delete conversation?" confirm overlay (revive vs block)
|
bool s_show_delete_confirm = false; // "Delete conversation?" confirm overlay (revive vs block)
|
||||||
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
|
||||||
|
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).
|
||||||
|
static std::string chatExportPathFor(const std::string& peerName) {
|
||||||
|
std::string safe;
|
||||||
|
for (char ch : peerName) safe += (std::isalnum(static_cast<unsigned char>(ch)) ? ch : '_');
|
||||||
|
if (safe.empty()) safe = "chat";
|
||||||
|
return util::Platform::getConfigDir() + "/dragonx-chat-" + safe + ".txt";
|
||||||
|
}
|
||||||
bool s_show_emoji_picker = false; // emoji picker overlay — fills the conversation-list pane while open
|
bool s_show_emoji_picker = false; // emoji picker overlay — fills the conversation-list pane while open
|
||||||
char s_emoji_search[48] = ""; // emoji picker keyword filter
|
char s_emoji_search[48] = ""; // emoji picker keyword filter
|
||||||
|
|
||||||
@@ -535,6 +561,7 @@ struct ConvSummary {
|
|||||||
std::int64_t lastTs = 0;
|
std::int64_t lastTs = 0;
|
||||||
int count = 0;
|
int count = 0;
|
||||||
bool hidden = false; // shown only while "Show hidden" is on
|
bool hidden = false; // shown only while "Show hidden" is on
|
||||||
|
bool pendingRequest = false; // unsolicited incoming contact request from a stranger: awaiting Accept/Block
|
||||||
};
|
};
|
||||||
|
|
||||||
// Per-frame memoization of the conversation-list build and the open-thread message list (both otherwise
|
// Per-frame memoization of the conversation-list build and the open-thread message list (both otherwise
|
||||||
@@ -576,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();
|
||||||
@@ -766,6 +804,17 @@ void RenderChatTab(App* app)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Consume a cross-tab "Message this contact" request (from Contacts): open the New-conversation
|
||||||
|
// dialog prefilled with the z-address. Runs every frame the tab renders, so it fires right after the
|
||||||
|
// Contacts tab switches us here.
|
||||||
|
if (s_pending_new_set) {
|
||||||
|
s_pending_new_set = false;
|
||||||
|
s_show_new_convo = true;
|
||||||
|
std::memcpy(s_new_zaddr, s_pending_new_zaddr, sizeof(s_new_zaddr));
|
||||||
|
s_new_msg[0] = '\0';
|
||||||
|
sodium_memzero(s_pending_new_zaddr, sizeof(s_pending_new_zaddr));
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve the Chat-tab clock once per frame: the chat override wins, else the global format.
|
// Resolve the Chat-tab clock once per frame: the chat override wins, else the global format.
|
||||||
if (auto* cst = app->settings()) {
|
if (auto* cst = app->settings()) {
|
||||||
const int ctf = cst->getChatTimeFormat(); // 0=follow global, 1=24h, 2=12h
|
const int ctf = cst->getChatTimeFormat(); // 0=follow global, 1=24h, 2=12h
|
||||||
@@ -795,9 +844,11 @@ void RenderChatTab(App* app)
|
|||||||
c.cid = cid;
|
c.cid = cid;
|
||||||
c.hidden = hidden;
|
c.hidden = hidden;
|
||||||
c.count = static_cast<int>(messages.size());
|
c.count = static_cast<int>(messages.size());
|
||||||
|
bool sentAny = false;
|
||||||
for (const auto& m : messages) { // pin to the EARLIEST (establishing) peer z-addr / key (B2 — the
|
for (const auto& m : messages) { // pin to the EARLIEST (establishing) peer z-addr / key (B2 — the
|
||||||
if (c.peerZaddr.empty() && !m.peer_zaddr.empty()) c.peerZaddr = m.peer_zaddr; // memo header rides
|
if (c.peerZaddr.empty() && !m.peer_zaddr.empty()) c.peerZaddr = m.peer_zaddr; // memo header rides
|
||||||
if (c.peerPubKey.empty() && !m.peer_public_key_hex.empty()) c.peerPubKey = m.peer_public_key_hex; // outside the AEAD)
|
if (c.peerPubKey.empty() && !m.peer_public_key_hex.empty()) c.peerPubKey = m.peer_public_key_hex; // outside the AEAD)
|
||||||
|
if (m.direction == chat::ChatDirection::Outgoing) sentAny = true;
|
||||||
}
|
}
|
||||||
const auto& last = messages.back();
|
const auto& last = messages.back();
|
||||||
c.lastBody = last.body;
|
c.lastBody = last.body;
|
||||||
@@ -805,6 +856,12 @@ void RenderChatTab(App* app)
|
|||||||
const int idx = c.peerZaddr.empty() ? -1 : book.findByAddress(c.peerZaddr);
|
const int idx = c.peerZaddr.empty() ? -1 : book.findByAddress(c.peerZaddr);
|
||||||
c.peerName = (idx >= 0) ? book.entries()[idx].label
|
c.peerName = (idx >= 0) ? book.entries()[idx].label
|
||||||
: shorten(!c.peerZaddr.empty() ? c.peerZaddr : cid);
|
: shorten(!c.peerZaddr.empty() ? c.peerZaddr : cid);
|
||||||
|
// Unsolicited: first message is an incoming contact request, we've never replied, and the peer
|
||||||
|
// isn't a saved contact → show an Accept/Block card instead of treating it as a normal chat.
|
||||||
|
const auto& first = messages.front();
|
||||||
|
c.pendingRequest = (idx < 0) && !sentAny && !c.peerZaddr.empty() &&
|
||||||
|
first.direction == chat::ChatDirection::Incoming &&
|
||||||
|
first.kind == chat::ChatMessageKind::ContactRequest;
|
||||||
s_convs.push_back(std::move(c));
|
s_convs.push_back(std::move(c));
|
||||||
}
|
}
|
||||||
std::sort(s_convs.begin(), s_convs.end(),
|
std::sort(s_convs.begin(), s_convs.end(),
|
||||||
@@ -916,6 +973,57 @@ 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
|
||||||
|
// 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).
|
||||||
|
{
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
|
||||||
|
// Text-only: SmallButton uses the body font, which has no Material icon glyph (would show "?").
|
||||||
|
if (ImGui::SmallButton(TR("chat_copy_my_address"))) copyMyChatAddress(app);
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("chat_my_address_tip"));
|
||||||
|
}
|
||||||
const std::string search = s_search;
|
const std::string search = s_search;
|
||||||
ImGui::Separator();
|
ImGui::Separator();
|
||||||
if (convs.empty()) {
|
if (convs.empty()) {
|
||||||
@@ -982,9 +1090,21 @@ void RenderChatTab(App* app)
|
|||||||
dl->AddText(nameFont, nameSz, ImVec2(textX, p.y + pad),
|
dl->AddText(nameFont, nameSz, ImVec2(textX, p.y + pad),
|
||||||
c.hidden ? material::OnSurfaceMedium() : material::OnSurface(), c.peerName.c_str());
|
c.hidden ? material::OnSurfaceMedium() : material::OnSurface(), c.peerName.c_str());
|
||||||
dl->PopClipRect();
|
dl->PopClipRect();
|
||||||
|
// Pending-request pill (bottom-right) for an unsolicited stranger; the preview is clipped to its left.
|
||||||
|
float previewRight = mx.x - pad;
|
||||||
|
if (c.pendingRequest) {
|
||||||
|
const char* rl = TR("chat_request_pill");
|
||||||
|
const ImVec2 rs = metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, rl);
|
||||||
|
const float px = 6.0f * dp, py = 1.0f * dp;
|
||||||
|
const ImVec2 pmax(mx.x - pad, p.y + rowH - pad + py);
|
||||||
|
const ImVec2 pmin(pmax.x - rs.x - 2.0f * px, pmax.y - rs.y - 2.0f * py);
|
||||||
|
dl->AddRectFilled(pmin, pmax, material::WithAlpha(material::Warning(), 60), (rs.y + 2.0f * py) * 0.5f);
|
||||||
|
dl->AddText(metaFont, metaSz, ImVec2(pmin.x + px, pmin.y + py), material::Warning(), rl);
|
||||||
|
previewRight = pmin.x - pad;
|
||||||
|
}
|
||||||
// Preview (bottom, clipped to the text column, muted).
|
// Preview (bottom, clipped to the text column, muted).
|
||||||
const std::string preview = previewOf(c.lastBody);
|
const std::string preview = previewOf(c.lastBody);
|
||||||
dl->PushClipRect(ImVec2(textX, p.y), ImVec2(mx.x - pad, mx.y), true);
|
dl->PushClipRect(ImVec2(textX, p.y), ImVec2(std::max(textX, previewRight), mx.y), true);
|
||||||
dl->AddText(metaFont, metaSz, ImVec2(textX, p.y + rowH - pad - metaSz),
|
dl->AddText(metaFont, metaSz, ImVec2(textX, p.y + rowH - pad - metaSz),
|
||||||
material::OnSurfaceMedium(), preview.c_str());
|
material::OnSurfaceMedium(), preview.c_str());
|
||||||
dl->PopClipRect();
|
dl->PopClipRect();
|
||||||
@@ -1089,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.
|
||||||
@@ -1189,23 +1309,9 @@ void RenderChatTab(App* app)
|
|||||||
ImGui::SetCursorScreenPos(ImVec2(bx, by));
|
ImGui::SetCursorScreenPos(ImVec2(bx, by));
|
||||||
material::IconButtonStyle a = base; a.tooltip = TR("chat_export_warn");
|
material::IconButtonStyle a = base; a.tooltip = TR("chat_export_warn");
|
||||||
if (material::IconButton("##hdr_export", ICON_MD_FILE_DOWNLOAD, ifont, ImVec2(ib, ib), a)) {
|
if (material::IconButton("##hdr_export", ICON_MD_FILE_DOWNLOAD, ifont, ImVec2(ib, ib), a)) {
|
||||||
std::string content = "DragonX chat export\n";
|
// The export writes DECRYPTED plaintext — confirm first instead of writing on one click.
|
||||||
content += sel->peerName + " <" + sel->peerZaddr + ">\n";
|
s_export_cid = sel->cid;
|
||||||
content += "cid: " + sel->cid + "\n\n";
|
s_show_export_confirm = true;
|
||||||
for (const auto& m : store.conversation(sel->cid)) {
|
|
||||||
const bool out = (m.direction == chat::ChatDirection::Outgoing);
|
|
||||||
content += "[" + formatTime(m.timestamp) + "] " +
|
|
||||||
(out ? std::string(TR("chat_you")) : sel->peerName) + ": " + m.body + "\n";
|
|
||||||
}
|
|
||||||
std::string safe;
|
|
||||||
for (char ch : sel->peerName)
|
|
||||||
safe += (std::isalnum(static_cast<unsigned char>(ch)) ? ch : '_');
|
|
||||||
if (safe.empty()) safe = "chat";
|
|
||||||
const std::string path = util::Platform::getConfigDir() + "/dragonx-chat-" + safe + ".txt";
|
|
||||||
if (util::Platform::writeFileAtomically(path, content, /*restrictPermissions=*/true))
|
|
||||||
Notifications::instance().success(std::string(TR("chat_export_done")) + ": " + path);
|
|
||||||
else
|
|
||||||
Notifications::instance().error(TR("chat_export_failed"));
|
|
||||||
}
|
}
|
||||||
bx += ib + gap;
|
bx += ib + gap;
|
||||||
}
|
}
|
||||||
@@ -1246,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.
|
||||||
{
|
{
|
||||||
@@ -1276,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.
|
||||||
@@ -1309,6 +1442,42 @@ void RenderChatTab(App* app)
|
|||||||
ImGui::SetCursorScreenPos(ImVec2(hp.x, hp.y + rowH + 4.0f * hdpi));
|
ImGui::SetCursorScreenPos(ImVec2(hp.x, hp.y + rowH + 4.0f * hdpi));
|
||||||
ImGui::Separator();
|
ImGui::Separator();
|
||||||
|
|
||||||
|
// Stranger contact-request card: Accept (add to contacts) or Block (delete + block). Normal
|
||||||
|
// ImGui flow, so ##ChatMessages below (sized from the remaining content height) shrinks to fit.
|
||||||
|
if (sel->pendingRequest) {
|
||||||
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(material::Warning()));
|
||||||
|
ImGui::TextUnformatted(TR("chat_request_title"));
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
ImGui::PushTextWrapPos(0.0f);
|
||||||
|
ImGui::TextWrapped("%s", TR("chat_request_body"));
|
||||||
|
ImGui::PopTextWrapPos();
|
||||||
|
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
||||||
|
if (ImGui::SmallButton(TR("chat_request_accept"))) {
|
||||||
|
data::AddressBookEntry e;
|
||||||
|
e.address = sel->peerZaddr;
|
||||||
|
e.label = sel->peerName;
|
||||||
|
book.addEntry(e); // book.revision() bumps → the request flag clears on the next rebuild
|
||||||
|
Notifications::instance().info(TR("chat_request_accepted"));
|
||||||
|
}
|
||||||
|
ImGui::SameLine();
|
||||||
|
if (ImGui::SmallButton(TR("chat_request_block"))) {
|
||||||
|
const std::string cid = sel->cid, nm = sel->peerName; // copy before deleteConversation
|
||||||
|
if (app->chatService().deleteConversation(cid, /*block=*/true)) {
|
||||||
|
if (app->settings()) {
|
||||||
|
app->settings()->setChatBlocked(cid, nm, true);
|
||||||
|
app->settings()->setChatHidden(cid, false);
|
||||||
|
app->settings()->save();
|
||||||
|
}
|
||||||
|
if (s_selected_cid == cid) s_selected_cid.clear();
|
||||||
|
Notifications::instance().info(TR("chat_blocked_toast"));
|
||||||
|
} else {
|
||||||
|
Notifications::instance().error(TR("chat_delete_failed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ImGui::Separator();
|
||||||
|
}
|
||||||
|
|
||||||
const float dp = Layout::dpiScale();
|
const float dp = Layout::dpiScale();
|
||||||
// Small message-list inset (the ##ChatThread WindowPadding above already holds content off
|
// Small message-list inset (the ##ChatThread WindowPadding above already holds content off
|
||||||
// the glass edge; keep a little here so bubbles don't hug the scrollbar).
|
// the glass edge; keep a little here so bubbles don't hug the scrollbar).
|
||||||
@@ -1640,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
|
||||||
@@ -1780,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();
|
||||||
@@ -2002,17 +2200,27 @@ void RenderChatTab(App* app)
|
|||||||
const std::string activeHash = app->activeWalletScopeId(); // per-wallet contact scope
|
const std::string activeHash = app->activeWalletScopeId(); // per-wallet contact scope
|
||||||
int shown = 0;
|
int shown = 0;
|
||||||
for (const auto& e : book.entries()) {
|
for (const auto& e : book.entries()) {
|
||||||
if (e.address.empty() || e.address[0] != 'z') continue; // chat requires a z-address
|
if (e.address.empty()) continue;
|
||||||
// Respect the same per-wallet scope the Contacts tab enforces — don't leak another
|
// Respect the same per-wallet scope the Contacts tab enforces — don't leak another
|
||||||
// wallet's scoped contact into this wallet's picker (global + legacy fail open).
|
// wallet's scoped contact into this wallet's picker (global + legacy fail open).
|
||||||
const bool visible = e.isGlobal() ||
|
const bool visible = e.isGlobal() ||
|
||||||
(e.scope.rfind("w:", 0) == 0 ? (!activeHash.empty() && e.scope == activeHash) : true);
|
(e.scope.rfind("w:", 0) == 0 ? (!activeHash.empty() && e.scope == activeHash) : true);
|
||||||
if (!visible) continue;
|
if (!visible) continue;
|
||||||
++shown;
|
|
||||||
const std::string item = e.label + " " + shorten(e.address, 16, 8);
|
const std::string item = e.label + " " + shorten(e.address, 16, 8);
|
||||||
if (ImGui::Selectable(item.c_str())) {
|
// Show transparent contacts too, but DISABLED with a reason — chat needs a shielded
|
||||||
std::strncpy(s_new_zaddr, e.address.c_str(), sizeof(s_new_zaddr) - 1);
|
// address, so silently hiding them left the user wondering where a contact went.
|
||||||
s_new_zaddr[sizeof(s_new_zaddr) - 1] = '\0';
|
if (dragonx::util::isShieldedAddress(e.address)) {
|
||||||
|
++shown;
|
||||||
|
if (ImGui::Selectable(item.c_str())) {
|
||||||
|
std::strncpy(s_new_zaddr, e.address.c_str(), sizeof(s_new_zaddr) - 1);
|
||||||
|
s_new_zaddr[sizeof(s_new_zaddr) - 1] = '\0';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ImGui::BeginDisabled();
|
||||||
|
ImGui::Selectable(item.c_str(), false);
|
||||||
|
ImGui::EndDisabled();
|
||||||
|
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
|
||||||
|
material::Tooltip("%s", TR("chat_contact_needs_z"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (shown == 0) {
|
if (shown == 0) {
|
||||||
@@ -2053,6 +2261,113 @@ void RenderChatTab(App* app)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Export-as-plaintext confirm (unencrypted file on disk) ----
|
||||||
|
if (s_show_export_confirm) {
|
||||||
|
const ConvSummary* ex = nullptr;
|
||||||
|
for (const auto& c : convs) if (c.cid == s_export_cid) { ex = &c; break; }
|
||||||
|
if (!ex) {
|
||||||
|
s_show_export_confirm = false;
|
||||||
|
s_export_cid.clear();
|
||||||
|
} else {
|
||||||
|
material::OverlayDialogSpec ov;
|
||||||
|
ov.title = TR("chat_export_confirm_title");
|
||||||
|
ov.p_open = &s_show_export_confirm;
|
||||||
|
ov.style = material::OverlayStyle::BlurFloat;
|
||||||
|
ov.cardWidth = 500.0f;
|
||||||
|
ov.idSuffix = "chatexport";
|
||||||
|
if (material::BeginOverlayDialog(ov)) {
|
||||||
|
const float edp = Layout::dpiScale();
|
||||||
|
const std::string path = chatExportPathFor(ex->peerName);
|
||||||
|
ImGui::PushTextWrapPos(0.0f);
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(material::Warning()));
|
||||||
|
ImGui::TextWrapped("%s", TR("chat_export_confirm_body"));
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
ImGui::Spacing();
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(material::OnSurfaceMedium()));
|
||||||
|
ImGui::TextUnformatted(TR("chat_export_confirm_path"));
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
ImGui::TextWrapped("%s", path.c_str());
|
||||||
|
ImGui::PopTextWrapPos();
|
||||||
|
ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
|
||||||
|
|
||||||
|
const float egap = ImGui::GetStyle().ItemSpacing.x;
|
||||||
|
float wExp = std::max(110.0f * edp, ImGui::CalcTextSize(TR("chat_export_confirm_btn")).x + 24.0f * edp);
|
||||||
|
float wCan = std::max(90.0f * edp, ImGui::CalcTextSize(TR("chat_cancel")).x + 24.0f * edp);
|
||||||
|
material::BeginOverlayDialogFooter(wExp + wCan + egap, false);
|
||||||
|
if (material::TactileButton(TR("chat_export_confirm_btn"), ImVec2(wExp, 0))) {
|
||||||
|
// Build + write exactly as the old inline export did (format byte-identical).
|
||||||
|
std::string content = "DragonX chat export\n";
|
||||||
|
content += ex->peerName + " <" + ex->peerZaddr + ">\n";
|
||||||
|
content += "cid: " + ex->cid + "\n\n";
|
||||||
|
for (const auto& m : store.conversation(ex->cid)) {
|
||||||
|
const bool out = (m.direction == chat::ChatDirection::Outgoing);
|
||||||
|
content += "[" + formatTime(m.timestamp) + "] " +
|
||||||
|
(out ? std::string(TR("chat_you")) : ex->peerName) + ": " + m.body + "\n";
|
||||||
|
}
|
||||||
|
if (util::Platform::writeFileAtomically(path, content, /*restrictPermissions=*/true))
|
||||||
|
Notifications::instance().success(std::string(TR("chat_export_done")) + ": " + path);
|
||||||
|
else
|
||||||
|
Notifications::instance().error(TR("chat_export_failed"));
|
||||||
|
s_show_export_confirm = false;
|
||||||
|
s_export_cid.clear();
|
||||||
|
}
|
||||||
|
ImGui::SameLine(0, egap);
|
||||||
|
if (material::TactileButton(TR("chat_cancel"), ImVec2(wCan, 0))) {
|
||||||
|
s_show_export_confirm = false;
|
||||||
|
s_export_cid.clear();
|
||||||
|
}
|
||||||
|
material::EndOverlayDialog();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 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();
|
||||||
@@ -2098,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;
|
||||||
};
|
};
|
||||||
@@ -2169,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;
|
||||||
@@ -2224,6 +2799,8 @@ void ResetChatTab()
|
|||||||
sodium_memzero(s_compose, sizeof(s_compose));
|
sodium_memzero(s_compose, sizeof(s_compose));
|
||||||
sodium_memzero(s_new_zaddr, sizeof(s_new_zaddr));
|
sodium_memzero(s_new_zaddr, sizeof(s_new_zaddr));
|
||||||
sodium_memzero(s_new_msg, sizeof(s_new_msg));
|
sodium_memzero(s_new_msg, sizeof(s_new_msg));
|
||||||
|
sodium_memzero(s_pending_new_zaddr, sizeof(s_pending_new_zaddr));
|
||||||
|
s_pending_new_set = false;
|
||||||
sodium_memzero(s_rename_buf, sizeof(s_rename_buf));
|
sodium_memzero(s_rename_buf, sizeof(s_rename_buf));
|
||||||
s_selected_cid.clear();
|
s_selected_cid.clear();
|
||||||
s_scroll_to_cid.clear();
|
s_scroll_to_cid.clear();
|
||||||
@@ -2238,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();
|
||||||
@@ -2250,6 +2832,13 @@ void ResetChatTab()
|
|||||||
s_msgmenu_index = -1;
|
s_msgmenu_index = -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void OpenChatNewConversation(const std::string& zaddr)
|
||||||
|
{
|
||||||
|
std::strncpy(s_pending_new_zaddr, zaddr.c_str(), sizeof(s_pending_new_zaddr) - 1);
|
||||||
|
s_pending_new_zaddr[sizeof(s_pending_new_zaddr) - 1] = '\0';
|
||||||
|
s_pending_new_set = true;
|
||||||
|
}
|
||||||
|
|
||||||
void RenderChatSettingsControls(App* app, float contentWidth, bool drawCards)
|
void RenderChatSettingsControls(App* app, float contentWidth, bool drawCards)
|
||||||
{
|
{
|
||||||
auto* st = app ? app->settings() : nullptr;
|
auto* st = app ? app->settings() : nullptr;
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
namespace dragonx {
|
namespace dragonx {
|
||||||
|
|
||||||
class App;
|
class App;
|
||||||
@@ -44,5 +46,11 @@ void RenderChatSettingsControls(App* app, float contentWidth = 0.0f, bool drawCa
|
|||||||
*/
|
*/
|
||||||
void ResetChatTab();
|
void ResetChatTab();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Ask the Chat tab to open its New-conversation dialog prefilled with this z-address (consumed
|
||||||
|
* on the next RenderChatTab frame). Used by the Contacts tab's "Message" action.
|
||||||
|
*/
|
||||||
|
void OpenChatNewConversation(const std::string& zaddr);
|
||||||
|
|
||||||
} // namespace ui
|
} // namespace ui
|
||||||
} // namespace dragonx
|
} // namespace dragonx
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
#include "../../util/platform.h"
|
#include "../../util/platform.h"
|
||||||
#include "image_picker.h"
|
#include "image_picker.h"
|
||||||
#include "transactions_tab.h"
|
#include "transactions_tab.h"
|
||||||
|
#include "chat_tab.h" // OpenChatNewConversation — Contacts "Message" action
|
||||||
#include "../sidebar.h"
|
#include "../sidebar.h"
|
||||||
#include "../layout.h"
|
#include "../layout.h"
|
||||||
#include "imgui.h"
|
#include "imgui.h"
|
||||||
@@ -537,6 +538,15 @@ void RenderContactsTab(App* app)
|
|||||||
SetTransactionsAddressFilter(book.entries()[s_selected_index].address);
|
SetTransactionsAddressFilter(book.entries()[s_selected_index].address);
|
||||||
app->setCurrentPage(ui::NavPage::History);
|
app->setCurrentPage(ui::NavPage::History);
|
||||||
};
|
};
|
||||||
|
// Message a contact in Chat — only z-contacts (chat needs a shielded address to carry encrypted
|
||||||
|
// memos). Mirrors doViewTransactions: hand the address to the Chat tab and switch there.
|
||||||
|
auto doMessage = [&]() {
|
||||||
|
if (!selValid()) return;
|
||||||
|
const auto& e = book.entries()[s_selected_index];
|
||||||
|
if (!isShieldedAddr(e.address)) return;
|
||||||
|
OpenChatNewConversation(e.address);
|
||||||
|
app->setCurrentPage(ui::NavPage::Chat);
|
||||||
|
};
|
||||||
|
|
||||||
// Add/edit form — a modal popup layered over the tab, with a live contact preview and an avatar
|
// Add/edit form — a modal popup layered over the tab, with a live contact preview and an avatar
|
||||||
// picker (default Z/T badge, a Material icon, or a custom image via the in-app image picker).
|
// picker (default Z/T badge, a Material icon, or a custom image via the in-app image picker).
|
||||||
@@ -1465,6 +1475,14 @@ void RenderContactsTab(App* app)
|
|||||||
if (ImGui::BeginPopup("##contactCtx")) {
|
if (ImGui::BeginPopup("##contactCtx")) {
|
||||||
if (ImGui::MenuItem(TR("copy_address"))) doCopy();
|
if (ImGui::MenuItem(TR("copy_address"))) doCopy();
|
||||||
if (ImGui::MenuItem(TR("view_transactions"))) doViewTransactions();
|
if (ImGui::MenuItem(TR("view_transactions"))) doViewTransactions();
|
||||||
|
#if DRAGONX_ENABLE_CHAT
|
||||||
|
{
|
||||||
|
const bool canMsg = selValid() && isShieldedAddr(book.entries()[s_selected_index].address);
|
||||||
|
if (ImGui::MenuItem(TR("contacts_message"), nullptr, false, canMsg)) doMessage();
|
||||||
|
if (!canMsg && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
|
||||||
|
material::Tooltip("%s", TR("contacts_message_needs_z"));
|
||||||
|
}
|
||||||
|
#endif
|
||||||
if (ImGui::MenuItem(TR("edit"))) openEdit();
|
if (ImGui::MenuItem(TR("edit"))) openEdit();
|
||||||
ImGui::Separator();
|
ImGui::Separator();
|
||||||
if (ImGui::MenuItem(TR("delete"))) doDelete();
|
if (ImGui::MenuItem(TR("delete"))) doDelete();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <ctime>
|
#include <ctime>
|
||||||
|
#include <chrono>
|
||||||
#include <unordered_set>
|
#include <unordered_set>
|
||||||
|
|
||||||
namespace dragonx {
|
namespace dragonx {
|
||||||
@@ -1430,7 +1431,7 @@ static void mktDrawPairSelector(App* app, const std::vector<data::ExchangeInfo>&
|
|||||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||||
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("market_attribution"));
|
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("market_attribution"));
|
||||||
if (!market.last_updated.empty()) {
|
if (!market.last_updated.empty()) {
|
||||||
ImGui::SameLine(0, 12);
|
ImGui::SameLine(0, 12.0f * Layout::dpiScale());
|
||||||
snprintf(buf, sizeof(buf), TR("market_updated"), market.last_updated.c_str());
|
snprintf(buf, sizeof(buf), TR("market_updated"), market.last_updated.c_str());
|
||||||
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), buf);
|
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), buf);
|
||||||
}
|
}
|
||||||
@@ -1496,7 +1497,18 @@ static void mktDrawPriceHero(const MktCtx& cx)
|
|||||||
// ---- HERO PRICE (large, prominent) ----
|
// ---- HERO PRICE (large, prominent) ----
|
||||||
ImFont* h3 = Type().h3();
|
ImFont* h3 = Type().h3();
|
||||||
std::string priceStr = FormatPrice(heroPrice);
|
std::string priceStr = FormatPrice(heroPrice);
|
||||||
ImU32 priceCol = Success();
|
// Stale-price cue: the aggregate price is kept on a failed/old refresh (it's never cleared), so a
|
||||||
|
// green number can be hours out of date. Flag it amber when the last refresh failed or the
|
||||||
|
// aggregate is >=5 min old. Only when the hero IS the aggregate (an exchange pair has its own feed).
|
||||||
|
const bool haveFetch = market.last_fetch_time.time_since_epoch().count() != 0;
|
||||||
|
const long ageMin = haveFetch
|
||||||
|
? (long)std::chrono::duration_cast<std::chrono::minutes>(
|
||||||
|
std::chrono::steady_clock::now() - market.last_fetch_time).count()
|
||||||
|
: 0;
|
||||||
|
const bool usingAggregate = (heroPrice == market.price_usd);
|
||||||
|
const bool priceStale = usingAggregate && haveFetch &&
|
||||||
|
(!market.price_error.empty() || ageMin >= 5);
|
||||||
|
ImU32 priceCol = priceStale ? Warning() : Success();
|
||||||
DrawTextShadow(dl, h3, h3->LegacySize, ImVec2(cx0, cy), priceCol, priceStr.c_str());
|
DrawTextShadow(dl, h3, h3->LegacySize, ImVec2(cx0, cy), priceCol, priceStr.c_str());
|
||||||
|
|
||||||
// Ticker label after price
|
// Ticker label after price
|
||||||
@@ -1519,6 +1531,29 @@ static void mktDrawPriceHero(const MktCtx& cx)
|
|||||||
dl->AddRectFilled(bMin, bMax, badgeBg, 4.0f * dp);
|
dl->AddRectFilled(bMin, bMax, badgeBg, 4.0f * dp);
|
||||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(bMin.x + badgePadH, bMin.y + badgePadV), chgCol, buf);
|
dl->AddText(capFont, capFont->LegacySize, ImVec2(bMin.x + badgePadH, bMin.y + badgePadV), chgCol, buf);
|
||||||
|
|
||||||
|
// Stale "as of Nh ago" caption + hover tooltip, right of the change badge (cursor saved/restored
|
||||||
|
// so the custom draw-list layout below is undisturbed).
|
||||||
|
if (priceStale) {
|
||||||
|
char age[64];
|
||||||
|
if (ageMin >= 60) snprintf(age, sizeof(age), TR("market_price_stale_hour"), (int)(ageMin / 60));
|
||||||
|
else snprintf(age, sizeof(age), TR("market_price_stale_min"), (int)std::max(1L, ageMin));
|
||||||
|
ImVec2 asz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, age);
|
||||||
|
float ax = bMax.x + Layout::spacingMd();
|
||||||
|
float limit = cardMax.x - pad - 260.0f * dp; // reserve the trade pill + chart toggles
|
||||||
|
if (ax + asz.x < limit) {
|
||||||
|
dl->AddText(capFont, capFont->LegacySize, ImVec2(ax, bMin.y + badgePadV), Warning(), age);
|
||||||
|
ImVec2 sv = ImGui::GetCursorScreenPos();
|
||||||
|
ImGui::SetCursorScreenPos(ImVec2(ax, bMin.y));
|
||||||
|
ImGui::InvisibleButton("##PriceStale", ImVec2(asz.x, bMax.y - bMin.y));
|
||||||
|
if (ImGui::IsItemHovered()) {
|
||||||
|
std::string tip = market.price_error.empty()
|
||||||
|
? std::string(TR("market_price_fetch_failed")) : market.price_error;
|
||||||
|
material::Tooltip(TR("market_price_stale_tip"), tip.c_str());
|
||||||
|
}
|
||||||
|
ImGui::SetCursorScreenPos(sv);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// (Divider is drawn in the chart block, below the interval buttons.)
|
// (Divider is drawn in the chart block, below the interval buttons.)
|
||||||
|
|
||||||
// ---- TRADE BUTTON (top-right of card) ----
|
// ---- TRADE BUTTON (top-right of card) ----
|
||||||
|
|||||||
@@ -957,6 +957,8 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo&
|
|||||||
material::Tooltip("%s", isMiningActive ? TR("mining_stopping_tooltip") : TR("mining_starting_tooltip"));
|
material::Tooltip("%s", isMiningActive ? TR("mining_stopping_tooltip") : TR("mining_starting_tooltip"));
|
||||||
else if (isSyncing && !s_pool_mode)
|
else if (isSyncing && !s_pool_mode)
|
||||||
material::Tooltip(TR("mining_syncing_tooltip"), state.sync.verification_progress * 100.0);
|
material::Tooltip(TR("mining_syncing_tooltip"), state.sync.verification_progress * 100.0);
|
||||||
|
else if (!s_pool_mode && !poolStillRunning && !app->isConnected())
|
||||||
|
material::Tooltip("%s", TR("mining_not_connected_tooltip")); // was falling through to "Start Mining" on a dead button
|
||||||
else if (poolBlockedBySolo)
|
else if (poolBlockedBySolo)
|
||||||
material::Tooltip("%s", TR("mining_stop_solo_for_pool"));
|
material::Tooltip("%s", TR("mining_stop_solo_for_pool"));
|
||||||
else if (poolNeedsPayout)
|
else if (poolNeedsPayout)
|
||||||
@@ -989,6 +991,30 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo&
|
|||||||
|
|
||||||
ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMax.y));
|
ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMax.y));
|
||||||
ImGui::Dummy(ImVec2(availWidth, 0));
|
ImGui::Dummy(ImVec2(availWidth, 0));
|
||||||
|
|
||||||
|
// Persistent error caption (a crashed/AV-blocked/failed miner otherwise leaves a button that
|
||||||
|
// "did nothing" once its toast faded). Normal ImGui flow, OUTSIDE the hand-drawn button card.
|
||||||
|
const std::string& merr = app->miningLastError();
|
||||||
|
if (!merr.empty() && !app->isMiningToggleInProgress()) {
|
||||||
|
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
||||||
|
ImGui::PushTextWrapPos(cardMin.x + availWidth);
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(Error()));
|
||||||
|
ImGui::TextWrapped("%s", merr.c_str());
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(OnSurfaceMedium()));
|
||||||
|
ImGui::TextUnformatted(TR("mining_error_retry_hint"));
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
ImGui::PopTextWrapPos();
|
||||||
|
#ifdef _WIN32
|
||||||
|
if (app->miningErrorIsAntivirus()) {
|
||||||
|
ImGui::SameLine(0, Layout::spacingSm());
|
||||||
|
if (ImGui::SmallButton(TR("mining_error_av_help"))) app->showAntivirusHelp();
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
ImGui::SameLine(0, Layout::spacingSm());
|
||||||
|
if (ImGui::SmallButton(TR("mining_error_dismiss"))) app->clearMiningError();
|
||||||
|
}
|
||||||
|
|
||||||
ImGui::Dummy(ImVec2(0, gap));
|
ImGui::Dummy(ImVec2(0, gap));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -191,18 +191,18 @@ void RenderMiningEarnings(App* app, const WalletState& state, const MiningInfo&
|
|||||||
};
|
};
|
||||||
|
|
||||||
snprintf(valBuf, sizeof(valBuf), "+%.4f", minedToday);
|
snprintf(valBuf, sizeof(valBuf), "+%.4f", minedToday);
|
||||||
snprintf(subBuf2, sizeof(subBuf2), "(%d txn)", minedTodayCount);
|
snprintf(subBuf2, sizeof(subBuf2), TR("mining_txn_count"), minedTodayCount);
|
||||||
char todayVal[64], todaySub[64];
|
char todayVal[64], todaySub[64];
|
||||||
strncpy(todayVal, valBuf, sizeof(todayVal));
|
strncpy(todayVal, valBuf, sizeof(todayVal));
|
||||||
strncpy(todaySub, subBuf2, sizeof(todaySub));
|
strncpy(todaySub, subBuf2, sizeof(todaySub));
|
||||||
|
|
||||||
char yesterdayVal[64], yesterdaySub[64];
|
char yesterdayVal[64], yesterdaySub[64];
|
||||||
snprintf(yesterdayVal, sizeof(yesterdayVal), "+%.4f", minedYesterday);
|
snprintf(yesterdayVal, sizeof(yesterdayVal), "+%.4f", minedYesterday);
|
||||||
snprintf(yesterdaySub, sizeof(yesterdaySub), "(%d txn)", minedYesterdayCount);
|
snprintf(yesterdaySub, sizeof(yesterdaySub), TR("mining_txn_count"), minedYesterdayCount);
|
||||||
|
|
||||||
char allVal[64], allSub[64];
|
char allVal[64], allSub[64];
|
||||||
snprintf(allVal, sizeof(allVal), "+%.4f", minedAllTime);
|
snprintf(allVal, sizeof(allVal), "+%.4f", minedAllTime);
|
||||||
snprintf(allSub, sizeof(allSub), "(%d txn)", minedAllTimeCount);
|
snprintf(allSub, sizeof(allSub), TR("mining_txn_count"), minedAllTimeCount);
|
||||||
|
|
||||||
char estVal[64];
|
char estVal[64];
|
||||||
if (estActive)
|
if (estActive)
|
||||||
@@ -324,6 +324,11 @@ void RenderMiningEarnings(App* app, const WalletState& state, const MiningInfo&
|
|||||||
|
|
||||||
// -- Mining Address --
|
// -- Mining Address --
|
||||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(col3X, cy), OnSurfaceMedium(), TR("mining_mining_addr"));
|
dl->AddText(capFont, capFont->LegacySize, ImVec2(col3X, cy), OnSurfaceMedium(), TR("mining_mining_addr"));
|
||||||
|
{
|
||||||
|
ImVec2 lblSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, TR("mining_mining_addr"));
|
||||||
|
if (material::IsRectHovered(ImVec2(col3X, cy), ImVec2(col3X + lblSz.x, cy + lblSz.y)))
|
||||||
|
material::Tooltip("%s", TR("mining_addr_explainer"));
|
||||||
|
}
|
||||||
std::string mining_address = "";
|
std::string mining_address = "";
|
||||||
for (const auto& addr : state.addresses) {
|
for (const auto& addr : state.addresses) {
|
||||||
if (addr.type == "transparent" && !addr.address.empty()) {
|
if (addr.type == "transparent" && !addr.address.empty()) {
|
||||||
@@ -351,6 +356,12 @@ void RenderMiningEarnings(App* app, const WalletState& state, const MiningInfo&
|
|||||||
ImVec2(Layout::spacingMd(), 4 * dp))) {
|
ImVec2(Layout::spacingMd(), 4 * dp))) {
|
||||||
Notifications::instance().info(TR("mining_address_copied"));
|
Notifications::instance().info(TR("mining_address_copied"));
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// No mining (transparent) address yet — explain where rewards go instead of a blank.
|
||||||
|
dl->AddText(capFont, capFont->LegacySize, ImVec2(col3X + valOffX, cy), OnSurfaceMedium(), TR("mining_addr_none"));
|
||||||
|
ImVec2 emSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, TR("mining_addr_none"));
|
||||||
|
if (material::IsRectHovered(ImVec2(col3X + valOffX, cy), ImVec2(col3X + valOffX + emSz.x, cy + emSz.y)))
|
||||||
|
material::Tooltip("%s", TR("mining_addr_none_tip"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -146,6 +146,8 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo
|
|||||||
ImGui::SetCursorScreenPos(ImVec2(tMin.x, tMax.y));
|
ImGui::SetCursorScreenPos(ImVec2(tMin.x, tMax.y));
|
||||||
ImGui::Dummy(ImVec2(totalW, 0));
|
ImGui::Dummy(ImVec2(totalW, 0));
|
||||||
|
|
||||||
|
bool payoutBadThisFrame = false; // set when the inline payout error is drawn; reserves a row below
|
||||||
|
|
||||||
// Pool URL + Worker inputs inline next to toggle (pool mode only)
|
// Pool URL + Worker inputs inline next to toggle (pool mode only)
|
||||||
if (s_pool_mode && soloMiningActive) {
|
if (s_pool_mode && soloMiningActive) {
|
||||||
// Solo mining is active — show disabled message instead of inputs
|
// Solo mining is active — show disabled message instead of inputs
|
||||||
@@ -427,6 +429,24 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Persistent inline payout error — a hover-only tooltip is missed by anyone who doesn't hover,
|
||||||
|
// so outline the field in red and draw a short reason below it (draw-list only; no layout calls
|
||||||
|
// here, the cursor is hand-positioned). The field height is reserved at the end of the function.
|
||||||
|
{
|
||||||
|
const ImVec2 wMin = ImGui::GetItemRectMin();
|
||||||
|
const ImVec2 wMax = ImGui::GetItemRectMax();
|
||||||
|
const std::string wk(s_pool_worker);
|
||||||
|
if (!wk.empty() && wk != "x" && !util::isValidRecipientAddress(wk)) {
|
||||||
|
payoutBadThisFrame = true;
|
||||||
|
dl->AddRect(wMin, wMax, Error(), 4.0f * dp, 0, 1.5f * dp);
|
||||||
|
const char* msg = util::isTransparentAddress(wk)
|
||||||
|
? TR("mining_payout_needs_shielded_short") : TR("mining_payout_invalid_short");
|
||||||
|
std::string shown = material::TruncateToWidth(msg, capFont, capFont->LegacySize, wrkGroupW);
|
||||||
|
dl->AddText(capFont, capFont->LegacySize, ImVec2(wMin.x, wMax.y + 1.0f * dp),
|
||||||
|
ReadableError(), shown.c_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Worker: Dropdown arrow button ---
|
// --- Worker: Dropdown arrow button ---
|
||||||
ImGui::SameLine(0, 0);
|
ImGui::SameLine(0, 0);
|
||||||
{
|
{
|
||||||
@@ -642,7 +662,9 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo
|
|||||||
// Ensure cursor Y is at toggle bottom regardless of pool input widgets,
|
// Ensure cursor Y is at toggle bottom regardless of pool input widgets,
|
||||||
// so the cards below stay at the same position in both solo and pool modes.
|
// so the cards below stay at the same position in both solo and pool modes.
|
||||||
ImGui::SetCursorScreenPos(ImVec2(tMin.x, tMax.y));
|
ImGui::SetCursorScreenPos(ImVec2(tMin.x, tMax.y));
|
||||||
ImGui::Dummy(ImVec2(0, gap * 0.5f));
|
// Reserve a row for the inline payout error so it doesn't overlap the card below (cards shift down
|
||||||
|
// transiently while the error is shown).
|
||||||
|
ImGui::Dummy(ImVec2(0, gap * 0.5f + (payoutBadThisFrame ? capFont->LegacySize + 2.0f * dp : 0.0f)));
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace ui
|
} // namespace ui
|
||||||
|
|||||||
@@ -243,11 +243,26 @@ static void RenderLeftPoolCard(App* app, const WalletState& state, ImDrawList* d
|
|||||||
{
|
{
|
||||||
char uptBuf[64];
|
char uptBuf[64];
|
||||||
int64_t up = pm.uptime_sec;
|
int64_t up = pm.uptime_sec;
|
||||||
if (up <= 0) snprintf(uptBuf, sizeof(uptBuf), "N/A");
|
if (up <= 0) snprintf(uptBuf, sizeof(uptBuf), "%s", TR("grpc_na"));
|
||||||
else if (up < 3600) snprintf(uptBuf, sizeof(uptBuf), "%lldm %llds", (long long)(up / 60), (long long)(up % 60));
|
else if (up < 3600) snprintf(uptBuf, sizeof(uptBuf), TR("mining_uptime_ms"), (long long)(up / 60), (long long)(up % 60));
|
||||||
else snprintf(uptBuf, sizeof(uptBuf), "%lldh %lldm", (long long)(up / 3600), (long long)((up % 3600) / 60));
|
else snprintf(uptBuf, sizeof(uptBuf), TR("mining_uptime_hm"), (long long)(up / 3600), (long long)((up % 3600) / 60));
|
||||||
metricRow(TR("mining_uptime"), uptBuf, OnSurface());
|
metricRow(TR("mining_uptime"), uptBuf, OnSurface());
|
||||||
}
|
}
|
||||||
|
// Connection status — replaces the old transient "Connecting" banner, with a timeout so a bad URL /
|
||||||
|
// blocked port flips from "Connecting" to "Can't reach pool" instead of spinning on red forever.
|
||||||
|
{
|
||||||
|
static constexpr int kPoolConnectTimeoutSec = 30;
|
||||||
|
const bool shares = pm.accepted + pm.rejected > 0;
|
||||||
|
const bool ok = pm.connected || shares || pm.hashrate_10s > 0;
|
||||||
|
const bool failed = pm.xmrig_running && !ok && pm.running_since > 0 &&
|
||||||
|
(std::time(nullptr) - pm.running_since) >= kPoolConnectTimeoutSec;
|
||||||
|
const char* stateText; ImU32 col;
|
||||||
|
if (ok) { stateText = TR("mining_status_connected"); col = Success(); }
|
||||||
|
else if (failed) { stateText = TR("mining_pool_unreachable"); col = Error(); }
|
||||||
|
else if (pm.xmrig_running) { stateText = TR("mining_status_connecting"); col = Warning(); }
|
||||||
|
else { stateText = TR("mining_status_stopped"); col = OnSurfaceDisabled(); }
|
||||||
|
metricRow(TR("mining_status_label"), stateText, col);
|
||||||
|
}
|
||||||
y += gap * 0.5f;
|
y += gap * 0.5f;
|
||||||
|
|
||||||
// The pool list = official pools ∪ user-saved favorites ∪ the current custom pool.
|
// The pool list = official pools ∪ user-saved favorites ∪ the current custom pool.
|
||||||
@@ -397,7 +412,7 @@ void RenderMiningStats(const WalletState& state, const MiningInfo& mining,
|
|||||||
const float mRowH = sub1->LegacySize + 7 * dp;
|
const float mRowH = sub1->LegacySize + 7 * dp;
|
||||||
const float listMinH = (capFont->LegacySize + 10 * dp) * 2.6f; // ~2–3 rows before scroll
|
const float listMinH = (capFont->LegacySize + 10 * dp) * 2.6f; // ~2–3 rows before scroll
|
||||||
float leftContentH = pad * 0.75f + toggleH + gap
|
float leftContentH = pad * 0.75f + toggleH + gap
|
||||||
+ mRowH * 4.0f + gap * 0.5f
|
+ mRowH * 5.0f + gap * 0.5f // 4 metric rows + the connection-status row
|
||||||
+ (ovFont->LegacySize + 4 * dp) + listMinH + pad * 0.75f;
|
+ (ovFont->LegacySize + 4 * dp) + listMinH + pad * 0.75f;
|
||||||
float cardH = std::max(leftContentH, statRowH + chartBudgetH + pad);
|
float cardH = std::max(leftContentH, statRowH + chartBudgetH + pad);
|
||||||
|
|
||||||
|
|||||||
@@ -335,24 +335,9 @@ static void RenderMiningTabContent(App* app)
|
|||||||
isMiningActive, s_pool_mode, availWidth, glassSpec, sHdr, gapOver);
|
isMiningActive, s_pool_mode, availWidth, glassSpec, sHdr, gapOver);
|
||||||
|
|
||||||
// ================================================================
|
// ================================================================
|
||||||
// POOL CONNECTION STATUS — inline indicator (pool mode, no log)
|
// (Pool connection status now lives as a persistent, timeout-aware row in RenderLeftPoolCard
|
||||||
// ================================================================
|
// (mining_stats.cpp) — the old transient "Connecting" banner that vanished once logs arrived and
|
||||||
if (s_pool_mode && state.pool_mining.log_lines.empty() && state.pool_mining.xmrig_running) {
|
// sat on red forever on failure was removed.)
|
||||||
ImFont* iconFont = Type().iconSmall();
|
|
||||||
const char* dotIcon = ICON_MD_CIRCLE;
|
|
||||||
ImU32 dotCol = state.pool_mining.connected ? WithAlpha(Success(), 200) : WithAlpha(Error(), 200);
|
|
||||||
const char* statusText = state.pool_mining.connected
|
|
||||||
? (state.pool_mining.pool_url.empty() ? TR("mining_connected") : state.pool_mining.pool_url.c_str())
|
|
||||||
: TR("mining_connecting");
|
|
||||||
|
|
||||||
ImVec2 pos = ImGui::GetCursorScreenPos();
|
|
||||||
ImVec2 dotSz = iconFont->CalcTextSizeA(iconFont->LegacySize, 1000.0f, 0.0f, dotIcon);
|
|
||||||
dl->AddText(iconFont, iconFont->LegacySize, pos, dotCol, dotIcon);
|
|
||||||
dl->AddText(capFont, capFont->LegacySize,
|
|
||||||
ImVec2(pos.x + dotSz.x + 4 * dp, pos.y + (dotSz.y - capFont->LegacySize) * 0.5f),
|
|
||||||
OnSurfaceMedium(), statusText);
|
|
||||||
ImGui::Dummy(ImVec2(availWidth, capFont->LegacySize + 4 * dp));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace ui
|
} // namespace ui
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -74,7 +74,9 @@ void QRPopupDialog::render(App* app)
|
|||||||
const float addrBoxW = ImGui::CalcTextSize(chunkedAddr.c_str()).x + addrPadX * 2.0f + 2.0f;
|
const float addrBoxW = ImGui::CalcTextSize(chunkedAddr.c_str()).x + addrPadX * 2.0f + 2.0f;
|
||||||
// The BlurFloat overlay card insets content by 28px per side (draw_helpers BeginOverlayDialog); add
|
// The BlurFloat overlay card insets content by 28px per side (draw_helpers BeginOverlayDialog); add
|
||||||
// that (+ a little slack) so the address box fits on one line under its label instead of wrapping.
|
// that (+ a little slack) so the address box fits on one line under its label instead of wrapping.
|
||||||
const float wantW = addrBoxW + 28.0f * 2.0f + 12.0f;
|
// Scale the overlay inset (BeginOverlayDialog insets 28 LOGICAL px/side) into the same scaled space
|
||||||
|
// as addrBoxW before the /dp conversion below, so the address box doesn't wrap at HiDPI.
|
||||||
|
const float wantW = addrBoxW + (28.0f * 2.0f + 12.0f) * dp;
|
||||||
const float maxW = 0.85f * ImGui::GetMainViewport()->Size.x;
|
const float maxW = 0.85f * ImGui::GetMainViewport()->Size.x;
|
||||||
const float cardW = (wantW < maxW ? wantW : maxW) / dp;
|
const float cardW = (wantW < maxW ? wantW : maxW) / dp;
|
||||||
material::OverlayDialogSpec ov;
|
material::OverlayDialogSpec ov;
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ static uintptr_t s_qr_texture = 0;
|
|||||||
static int s_qr_width = 0;
|
static int s_qr_width = 0;
|
||||||
static int s_qr_height = 0;
|
static int s_qr_height = 0;
|
||||||
static bool s_uri_dirty = true;
|
static bool s_uri_dirty = true;
|
||||||
|
static bool s_creating_address = false; // true while a Create-shielded-address RPC is in flight
|
||||||
|
|
||||||
// Helper to build payment URI
|
// Helper to build payment URI
|
||||||
static std::string buildPaymentUri()
|
static std::string buildPaymentUri()
|
||||||
@@ -48,7 +49,8 @@ void RequestPaymentDialog::show(const std::string& address)
|
|||||||
s_label[0] = '\0';
|
s_label[0] = '\0';
|
||||||
s_payment_uri.clear();
|
s_payment_uri.clear();
|
||||||
s_uri_dirty = true;
|
s_uri_dirty = true;
|
||||||
|
s_creating_address = false;
|
||||||
|
|
||||||
// Only a shielded address can be paid (DragonX is a private chain): never prefill a mining address.
|
// Only a shielded address can be paid (DragonX is a private chain): never prefill a mining address.
|
||||||
if (!address.empty() && !util::isTransparentAddress(address)) {
|
if (!address.empty() && !util::isTransparentAddress(address)) {
|
||||||
strncpy(s_address, address.c_str(), sizeof(s_address) - 1);
|
strncpy(s_address, address.c_str(), sizeof(s_address) - 1);
|
||||||
@@ -67,6 +69,7 @@ void RequestPaymentDialog::show(const std::string& address)
|
|||||||
void RequestPaymentDialog::hide()
|
void RequestPaymentDialog::hide()
|
||||||
{
|
{
|
||||||
s_open = false;
|
s_open = false;
|
||||||
|
s_creating_address = false;
|
||||||
s_payment_uri.clear();
|
s_payment_uri.clear();
|
||||||
if (s_qr_texture != 0) {
|
if (s_qr_texture != 0) {
|
||||||
FreeQRTexture(s_qr_texture);
|
FreeQRTexture(s_qr_texture);
|
||||||
@@ -100,17 +103,55 @@ void RequestPaymentDialog::render(App* app)
|
|||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
|
|
||||||
// Address selection
|
// Address selection
|
||||||
ImGui::Text("%s", TR("request_receive_address"));
|
const bool noShielded = state.z_addresses.empty();
|
||||||
|
if (noShielded) {
|
||||||
std::string addr_display = s_address[0] ? s_address : TR("request_select_address");
|
// Nothing to request to yet — offer to create a shielded address instead of dead-ending.
|
||||||
if (addr_display.length() > static_cast<size_t>(zAddrLbl.truncate)) {
|
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(material::OnSurfaceMedium()));
|
||||||
addr_display = addr_display.substr(0, zAddrFrontLbl.truncate) + "..." + addr_display.substr(addr_display.length() - zAddrBackLbl.truncate);
|
ImGui::TextWrapped("%s", TR("request_no_shielded"));
|
||||||
}
|
ImGui::PopStyleColor();
|
||||||
|
ImGui::Spacing();
|
||||||
ImGui::SetNextItemWidth(-1);
|
if (s_creating_address) {
|
||||||
if (ImGui::BeginCombo("##Address", addr_display.c_str())) {
|
ImGui::BeginDisabled();
|
||||||
// Z-addresses
|
material::TactileButton(TR("generating"), ImVec2(0, 0), S.resolveFont(actionBtn.font));
|
||||||
if (!state.z_addresses.empty()) {
|
ImGui::EndDisabled();
|
||||||
|
} else {
|
||||||
|
ImGui::BeginDisabled(!app->isConnected());
|
||||||
|
if (material::TactileButton(TR("request_create_shielded"), ImVec2(0, 0), S.resolveFont(actionBtn.font))) {
|
||||||
|
if (app->isConnected()) {
|
||||||
|
s_creating_address = true;
|
||||||
|
app->createNewZAddress([app](const std::string& addr) {
|
||||||
|
s_creating_address = false;
|
||||||
|
if (addr.empty()) {
|
||||||
|
Notifications::instance().error(app->lastAddressError().empty()
|
||||||
|
? std::string(TR("failed_create_shielded")) : app->lastAddressError());
|
||||||
|
} else {
|
||||||
|
Notifications::instance().success(TR("new_shielded_created"));
|
||||||
|
// Pre-select the new address if the dialog is still open and none chosen.
|
||||||
|
if (s_open && s_address[0] == '\0') {
|
||||||
|
strncpy(s_address, addr.c_str(), sizeof(s_address) - 1);
|
||||||
|
s_address[sizeof(s_address) - 1] = '\0';
|
||||||
|
s_uri_dirty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ImGui::EndDisabled();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ImGui::Text("%s", TR("request_receive_address"));
|
||||||
|
|
||||||
|
std::string addr_display = s_address[0] ? s_address : std::string(TR("request_select_address"));
|
||||||
|
if (addr_display.length() > static_cast<size_t>(zAddrLbl.truncate)) {
|
||||||
|
addr_display = addr_display.substr(0, zAddrFrontLbl.truncate) + "..." + addr_display.substr(addr_display.length() - zAddrBackLbl.truncate);
|
||||||
|
}
|
||||||
|
if (s_address[0]) {
|
||||||
|
std::string l = app->getAddressLabel(s_address);
|
||||||
|
if (!l.empty()) addr_display = l + " " + addr_display;
|
||||||
|
}
|
||||||
|
|
||||||
|
ImGui::SetNextItemWidth(-1);
|
||||||
|
if (ImGui::BeginCombo("##Address", addr_display.c_str())) {
|
||||||
ImGui::TextDisabled("%s", TR("request_shielded_addrs"));
|
ImGui::TextDisabled("%s", TR("request_shielded_addrs"));
|
||||||
for (size_t i = 0; i < state.z_addresses.size(); i++) {
|
for (size_t i = 0; i < state.z_addresses.size(); i++) {
|
||||||
const auto& addr = state.z_addresses[i];
|
const auto& addr = state.z_addresses[i];
|
||||||
@@ -118,19 +159,20 @@ void RequestPaymentDialog::render(App* app)
|
|||||||
if (label.length() > static_cast<size_t>(zAddrLbl.truncate)) {
|
if (label.length() > static_cast<size_t>(zAddrLbl.truncate)) {
|
||||||
label = label.substr(0, zAddrFrontLbl.truncate) + "..." + label.substr(label.length() - zAddrBackLbl.truncate);
|
label = label.substr(0, zAddrFrontLbl.truncate) + "..." + label.substr(label.length() - zAddrBackLbl.truncate);
|
||||||
}
|
}
|
||||||
|
std::string clbl = app->getAddressLabel(addr.address);
|
||||||
if (ImGui::Selectable(label.c_str(), addr.address == s_address)) {
|
std::string shown = (clbl.empty() ? label : (clbl + " " + label)) + "##" + addr.address;
|
||||||
|
|
||||||
|
if (ImGui::Selectable(shown.c_str(), addr.address == s_address)) {
|
||||||
strncpy(s_address, addr.address.c_str(), sizeof(s_address) - 1);
|
strncpy(s_address, addr.address.c_str(), sizeof(s_address) - 1);
|
||||||
s_uri_dirty = true;
|
s_uri_dirty = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// No mining (transparent) addresses: they only receive mining rewards, so a request to one
|
||||||
|
// could never be paid.
|
||||||
|
ImGui::EndCombo();
|
||||||
}
|
}
|
||||||
|
|
||||||
// No mining (transparent) addresses: they only receive mining rewards, so a request to one
|
|
||||||
// could never be paid.
|
|
||||||
ImGui::EndCombo();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
|
|
||||||
// Amount (optional)
|
// Amount (optional)
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+91
-50
@@ -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"
|
||||||
@@ -77,7 +79,6 @@ static constexpr double FEE_LOW = DRAGONX_DEFAULT_FEE * 0.5;
|
|||||||
static constexpr double FEE_NORMAL = DRAGONX_DEFAULT_FEE;
|
static constexpr double FEE_NORMAL = DRAGONX_DEFAULT_FEE;
|
||||||
static constexpr double FEE_HIGH = DRAGONX_DEFAULT_FEE * 2.0;
|
static constexpr double FEE_HIGH = DRAGONX_DEFAULT_FEE * 2.0;
|
||||||
|
|
||||||
static bool s_clear_confirm_pending = false;
|
|
||||||
|
|
||||||
// Source dropdown preview string
|
// Source dropdown preview string
|
||||||
static std::string s_source_preview;
|
static std::string s_source_preview;
|
||||||
@@ -268,9 +269,13 @@ static void RenderSourceDropdown(App* app, float width) {
|
|||||||
const char* tag = isZ ? "[Z]" : "[T]";
|
const char* tag = isZ ? "[Z]" : "[T]";
|
||||||
std::string trunc = util::truncateMiddle(addr.address,
|
std::string trunc = util::truncateMiddle(addr.address,
|
||||||
static_cast<int>(std::max(S.drawElement("tabs.send", "addr-preview-trunc-min").size, width / S.drawElement("tabs.send", "addr-preview-trunc-divisor").size)));
|
static_cast<int>(std::max(S.drawElement("tabs.send", "addr-preview-trunc-min").size, width / S.drawElement("tabs.send", "addr-preview-trunc-divisor").size)));
|
||||||
snprintf(buf, sizeof(buf), "%s %s — %s",
|
std::string lbl = app->getAddressLabel(addr.address);
|
||||||
tag, trunc.c_str(),
|
if (!lbl.empty())
|
||||||
FormatSourceBalance(addr.balance, addr.spendableBalance).c_str());
|
snprintf(buf, sizeof(buf), "%s %s (%s) — %s", tag, lbl.c_str(), trunc.c_str(),
|
||||||
|
FormatSourceBalance(addr.balance, addr.spendableBalance).c_str());
|
||||||
|
else
|
||||||
|
snprintf(buf, sizeof(buf), "%s %s — %s", tag, trunc.c_str(),
|
||||||
|
FormatSourceBalance(addr.balance, addr.spendableBalance).c_str());
|
||||||
s_source_preview = buf;
|
s_source_preview = buf;
|
||||||
} else if (s_from_address[0] != '\0') {
|
} else if (s_from_address[0] != '\0') {
|
||||||
// A pre-filled source that the dropdown list doesn't carry (zero-balance / view-only / list still
|
// A pre-filled source that the dropdown list doesn't carry (zero-balance / view-only / list still
|
||||||
@@ -313,9 +318,13 @@ static void RenderSourceDropdown(App* app, float width) {
|
|||||||
const char* tag = isZ ? "[Z]" : "[T]";
|
const char* tag = isZ ? "[Z]" : "[T]";
|
||||||
|
|
||||||
std::string trunc = util::truncateMiddle(addr.address, (int)addrTruncLen);
|
std::string trunc = util::truncateMiddle(addr.address, (int)addrTruncLen);
|
||||||
snprintf(buf, sizeof(buf), "%s %s — %s",
|
std::string lbl = app->getAddressLabel(addr.address);
|
||||||
tag, trunc.c_str(),
|
if (!lbl.empty())
|
||||||
FormatSourceBalance(addr.balance, addr.spendableBalance).c_str());
|
snprintf(buf, sizeof(buf), "%s %s (%s) — %s", tag, lbl.c_str(), trunc.c_str(),
|
||||||
|
FormatSourceBalance(addr.balance, addr.spendableBalance).c_str());
|
||||||
|
else
|
||||||
|
snprintf(buf, sizeof(buf), "%s %s — %s", tag, trunc.c_str(),
|
||||||
|
FormatSourceBalance(addr.balance, addr.spendableBalance).c_str());
|
||||||
|
|
||||||
ImGui::PushID(static_cast<int>(i));
|
ImGui::PushID(static_cast<int>(i));
|
||||||
if (ImGui::Selectable(buf, isCurrent)) {
|
if (ImGui::Selectable(buf, isCurrent)) {
|
||||||
@@ -324,9 +333,12 @@ static void RenderSourceDropdown(App* app, float width) {
|
|||||||
addr.address.c_str());
|
addr.address.c_str());
|
||||||
}
|
}
|
||||||
if (ImGui::IsItemHovered()) {
|
if (ImGui::IsItemHovered()) {
|
||||||
material::Tooltip("%s\n%s",
|
if (!lbl.empty())
|
||||||
addr.address.c_str(),
|
material::Tooltip("%s\n%s\n%s", lbl.c_str(), addr.address.c_str(),
|
||||||
FormatSourceBalance(addr.balance, addr.spendableBalance).c_str());
|
FormatSourceBalance(addr.balance, addr.spendableBalance).c_str());
|
||||||
|
else
|
||||||
|
material::Tooltip("%s\n%s", addr.address.c_str(),
|
||||||
|
FormatSourceBalance(addr.balance, addr.spendableBalance).c_str());
|
||||||
}
|
}
|
||||||
ImGui::PopID();
|
ImGui::PopID();
|
||||||
}
|
}
|
||||||
@@ -335,6 +347,20 @@ static void RenderSourceDropdown(App* app, float width) {
|
|||||||
ImGui::EndCombo();
|
ImGui::EndCombo();
|
||||||
}
|
}
|
||||||
ImGui::PopFont();
|
ImGui::PopFont();
|
||||||
|
|
||||||
|
// Warn when the chosen source is a transparent address: spending from it is not private on this
|
||||||
|
// ac_private chain (sender, amount and recipient become visible on-chain).
|
||||||
|
if (app->isConnected() && s_from_address[0] != '\0') {
|
||||||
|
bool srcT = (s_selected_from_idx >= 0 && s_selected_from_idx < (int)state.addresses.size())
|
||||||
|
? (state.addresses[s_selected_from_idx].type != "shielded")
|
||||||
|
: IsValidTransparentAddr(s_from_address);
|
||||||
|
if (srcT) {
|
||||||
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(Warning()));
|
||||||
|
Type().textWrapped(TypeStyle::Caption, TR("send_transparent_source_warn"));
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -891,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();
|
||||||
@@ -930,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.
|
||||||
@@ -1010,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;
|
||||||
|
|
||||||
@@ -1033,6 +1073,12 @@ static void RenderActionButtons(App* app, float width, float vScale,
|
|||||||
float sendBtnW = width - cancelBtnW - btnGap;
|
float sendBtnW = width - cancelBtnW - btnGap;
|
||||||
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).
|
||||||
|
if (state.sync.syncing || (app->isConnected() && !state.sync.sendTipReady(app->isLiteBuild()))) {
|
||||||
|
Type().textColored(TypeStyle::Caption, Warning(), TR("send_available_when_synced"));
|
||||||
|
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Send button ----
|
// ---- Send button ----
|
||||||
if (!can_send) {
|
if (!can_send) {
|
||||||
// The disabled fill must contrast the surface: a white overlay reads on dark skins but vanished
|
// The disabled fill must contrast the surface: a white overlay reads on dark skins but vanished
|
||||||
@@ -1057,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)
|
||||||
@@ -1065,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"));
|
||||||
@@ -1081,38 +1129,20 @@ static void RenderActionButtons(App* app, float width, float vScale,
|
|||||||
ImGui::PushStyleColor(ImGuiCol_Border, ImGui::ColorConvertU32ToFloat4(OnSurfaceDisabled()));
|
ImGui::PushStyleColor(ImGuiCol_Border, ImGui::ColorConvertU32ToFloat4(OnSurfaceDisabled()));
|
||||||
ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, S.drawElement("tabs.send", "cancel-btn-border-size").size);
|
ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, S.drawElement("tabs.send", "cancel-btn-border-size").size);
|
||||||
char clearId[64];
|
char clearId[64];
|
||||||
snprintf(clearId, sizeof(clearId), "%s##Cancel%s", TR("cancel"), suffix);
|
// This button never cancels an in-progress send (there is none here) — it clears the form. Relabel it
|
||||||
|
// honestly, disable it when the form is empty, and drop the redundant confirm (ClearFormWithUndo
|
||||||
|
// already offers a timed Undo). The "##Cancel" id suffix is kept so the widget id stays stable.
|
||||||
|
snprintf(clearId, sizeof(clearId), "%s##Cancel%s", TR("send_clear_form"), suffix);
|
||||||
|
ImGui::BeginDisabled(!FormHasData());
|
||||||
if (TactileButton(clearId, ImVec2(cancelBtnW, btnH), S.resolveFont(S.button("tabs.send", "clear-button").font))) {
|
if (TactileButton(clearId, ImVec2(cancelBtnW, btnH), S.resolveFont(S.button("tabs.send", "clear-button").font))) {
|
||||||
if (FormHasData()) {
|
ClearFormWithUndo();
|
||||||
s_clear_confirm_pending = true;
|
|
||||||
} else {
|
|
||||||
ClearFormWithUndo();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
ImGui::EndDisabled();
|
||||||
|
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
|
||||||
|
material::Tooltip("%s", TR("send_clear_form_tip"));
|
||||||
ImGui::PopStyleVar(); // FrameBorderSize
|
ImGui::PopStyleVar(); // FrameBorderSize
|
||||||
ImGui::PopStyleColor(3);
|
ImGui::PopStyleColor(3);
|
||||||
|
|
||||||
// Clear confirmation popup
|
|
||||||
char confirmClearId[32];
|
|
||||||
snprintf(confirmClearId, sizeof(confirmClearId), "##ConfirmClear%s", suffix);
|
|
||||||
if (s_clear_confirm_pending) {
|
|
||||||
ImGui::OpenPopup(confirmClearId);
|
|
||||||
s_clear_confirm_pending = false;
|
|
||||||
}
|
|
||||||
if (ImGui::BeginPopup(confirmClearId)) {
|
|
||||||
ImGui::Text("%s", TR("send_clear_fields"));
|
|
||||||
ImGui::Spacing();
|
|
||||||
if (TactileButton(TR("send_yes_clear"), ImVec2(schema::UI().drawElement("tabs.send", "clear-confirm-yes-width").size * dp, 0), S.resolveFont("button"))) {
|
|
||||||
ClearFormWithUndo();
|
|
||||||
ImGui::CloseCurrentPopup();
|
|
||||||
}
|
|
||||||
ImGui::SameLine();
|
|
||||||
if (TactileButton(TR("send_keep"), ImVec2(schema::UI().drawElement("tabs.send", "clear-confirm-keep-width").size * dp, 0), S.resolveFont("button"))) {
|
|
||||||
ImGui::CloseCurrentPopup();
|
|
||||||
}
|
|
||||||
ImGui::EndPopup();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Undo button (shown briefly after clear)
|
// Undo button (shown briefly after clear)
|
||||||
if (s_undo_timestamp > 0.0) {
|
if (s_undo_timestamp > 0.0) {
|
||||||
double undoElapsed = ImGui::GetTime() - s_undo_timestamp;
|
double undoElapsed = ImGui::GetTime() - s_undo_timestamp;
|
||||||
@@ -1270,9 +1300,9 @@ void RenderSendTab(App* app)
|
|||||||
|
|
||||||
RenderSyncBanner(state);
|
RenderSyncBanner(state);
|
||||||
|
|
||||||
bool sendSyncing = state.sync.syncing && !state.sync.isSynced();
|
// The form stays EDITABLE while syncing so the user can pre-fill it; only the Review/Send button is
|
||||||
ImGui::BeginDisabled(sendSyncing);
|
// gated (can_send includes !syncing, and the confirm step re-checks live state), so nothing can be
|
||||||
|
// sent until the wallet is synced.
|
||||||
ImVec2 sendAvail = ImGui::GetContentRegionAvail();
|
ImVec2 sendAvail = ImGui::GetContentRegionAvail();
|
||||||
float hs = Layout::hScale(sendAvail.x);
|
float hs = Layout::hScale(sendAvail.x);
|
||||||
float vScale = Layout::vScale(sendAvail.y);
|
float vScale = Layout::vScale(sendAvail.y);
|
||||||
@@ -1329,7 +1359,6 @@ void RenderSendTab(App* app)
|
|||||||
// Zero balance CTA
|
// Zero balance CTA
|
||||||
if (RenderZeroBalanceCTA(app, dl, formW)) {
|
if (RenderZeroBalanceCTA(app, dl, formW)) {
|
||||||
ImGui::EndGroup();
|
ImGui::EndGroup();
|
||||||
ImGui::EndDisabled(); // sendSyncing guard
|
|
||||||
ImGui::EndChild();
|
ImGui::EndChild();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1404,7 +1433,9 @@ void RenderSendTab(App* app)
|
|||||||
Type().textColored(TypeStyle::Caption, Error(), TR("send_mining_addr_short"));
|
Type().textColored(TypeStyle::Caption, Error(), TR("send_mining_addr_short"));
|
||||||
if (vt && ImGui::IsItemHovered())
|
if (vt && ImGui::IsItemHovered())
|
||||||
material::Tooltip("%s", TR("send_mining_addr_blocked"));
|
material::Tooltip("%s", TR("send_mining_addr_blocked"));
|
||||||
} else if (!checkPreview) {
|
} else if (!checkPreview && ImGui::GetActiveID() != ImGui::GetID("##ToAddr")) {
|
||||||
|
// Defer the "invalid" error until the field loses focus, so it doesn't flash red
|
||||||
|
// from the first keystroke while the user is still typing the address.
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
Type().textColored(TypeStyle::Caption, Error(), TR("invalid_address"));
|
Type().textColored(TypeStyle::Caption, Error(), TR("invalid_address"));
|
||||||
}
|
}
|
||||||
@@ -1517,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)
|
||||||
{
|
{
|
||||||
@@ -1542,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) {
|
||||||
@@ -1606,14 +1639,17 @@ 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- MEMO (shielded only) ----
|
// ---- MEMO (shielded only; shown disabled + explained for non-shielded recipients) ----
|
||||||
if (is_valid_z || s_to_address[0] == '\0') {
|
{
|
||||||
|
// Keep the memo visible (and the typed text intact) even for a transparent recipient, with an
|
||||||
|
// explainer, instead of silently hiding the field and dropping what the user wrote.
|
||||||
|
const bool memoDisabled = (s_to_address[0] != '\0' && !is_valid_z);
|
||||||
ImGui::Dummy(ImVec2(0, innerGap * 0.5f));
|
ImGui::Dummy(ImVec2(0, innerGap * 0.5f));
|
||||||
{
|
{
|
||||||
ImVec2 divPos = ImGui::GetCursorScreenPos();
|
ImVec2 divPos = ImGui::GetCursorScreenPos();
|
||||||
@@ -1628,8 +1664,10 @@ void RenderSendTab(App* app)
|
|||||||
|
|
||||||
float memoInputH = std::max(S.drawElement("tabs.send", "memo-min-height").size, S.drawElement("tabs.send", "memo-base-height").size * vScale);
|
float memoInputH = std::max(S.drawElement("tabs.send", "memo-min-height").size, S.drawElement("tabs.send", "memo-base-height").size * vScale);
|
||||||
ImGui::PushItemWidth(colW);
|
ImGui::PushItemWidth(colW);
|
||||||
|
if (memoDisabled) ImGui::BeginDisabled();
|
||||||
ImGui::InputTextMultiline("##Memo", s_memo, sizeof(s_memo),
|
ImGui::InputTextMultiline("##Memo", s_memo, sizeof(s_memo),
|
||||||
ImVec2(colW, memoInputH));
|
ImVec2(colW, memoInputH));
|
||||||
|
if (memoDisabled) ImGui::EndDisabled();
|
||||||
ImGui::PopItemWidth();
|
ImGui::PopItemWidth();
|
||||||
|
|
||||||
// The Sapling memo limit is in BYTES (multi-byte UTF-8 fills it faster than visible chars);
|
// The Sapling memo limit is in BYTES (multi-byte UTF-8 fills it faster than visible chars);
|
||||||
@@ -1639,6 +1677,10 @@ void RenderSendTab(App* app)
|
|||||||
bool memoAtCap = memo_len + 1 >= memoMax;
|
bool memoAtCap = memo_len + 1 >= memoMax;
|
||||||
snprintf(buf, sizeof(buf), TR("byte_count_fmt"), memo_len, memoMax);
|
snprintf(buf, sizeof(buf), TR("byte_count_fmt"), memo_len, memoMax);
|
||||||
Type().textColored(TypeStyle::Caption, memoAtCap ? Warning() : OnSurfaceDisabled(), buf);
|
Type().textColored(TypeStyle::Caption, memoAtCap ? Warning() : OnSurfaceDisabled(), buf);
|
||||||
|
if (memoDisabled) {
|
||||||
|
const char* mk = s_memo[0] ? "send_memo_will_not_be_sent" : "send_memo_requires_shielded";
|
||||||
|
Type().textColored(TypeStyle::Caption, Warning(), TR(mk));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Divider before action buttons
|
// Divider before action buttons
|
||||||
@@ -1706,7 +1748,6 @@ void RenderSendTab(App* app)
|
|||||||
RenderRecentSends(state, formW, capFont, app);
|
RenderRecentSends(state, formW, capFont, app);
|
||||||
|
|
||||||
ImGui::EndGroup();
|
ImGui::EndGroup();
|
||||||
ImGui::EndDisabled(); // sendSyncing guard
|
|
||||||
// Round to nearest pixel to prevent sub-pixel oscillation (vibration)
|
// Round to nearest pixel to prevent sub-pixel oscillation (vibration)
|
||||||
// when content like "recent sends" elapsed-time text changes width each frame
|
// when content like "recent sends" elapsed-time text changes width each frame
|
||||||
float measuredH = ImGui::GetCursorPosY() - contentStartY;
|
float measuredH = ImGui::GetCursorPosY() - contentStartY;
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -41,6 +41,8 @@ static bool s_advanced = false; // Advanced (fee + batch size)
|
|||||||
static bool s_confirm = false; // inline "confirm before moving funds" phase
|
static bool s_confirm = false; // inline "confirm before moving funds" phase
|
||||||
static bool s_operation_pending = false;
|
static bool s_operation_pending = false;
|
||||||
static bool s_op_terminal = false; // async op reached success/failed — freeze inputs
|
static bool s_op_terminal = false; // async op reached success/failed — freeze inputs
|
||||||
|
enum class OpOutcome { None, Success, Failed, Uncertain };
|
||||||
|
static OpOutcome s_op_outcome = OpOutcome::None; // tags the terminal status for colour (display only)
|
||||||
static std::string s_operation_id;
|
static std::string s_operation_id;
|
||||||
static std::string s_status_message;
|
static std::string s_status_message;
|
||||||
static double s_last_poll = 0.0; // live-progress self-poll timer (ImGui::GetTime seconds)
|
static double s_last_poll = 0.0; // live-progress self-poll timer (ImGui::GetTime seconds)
|
||||||
@@ -68,6 +70,7 @@ static void resetTransient()
|
|||||||
++s_poll_session;
|
++s_poll_session;
|
||||||
s_operation_pending = false;
|
s_operation_pending = false;
|
||||||
s_op_terminal = false;
|
s_op_terminal = false;
|
||||||
|
s_op_outcome = OpOutcome::None;
|
||||||
s_confirm = false;
|
s_confirm = false;
|
||||||
s_status_message.clear();
|
s_status_message.clear();
|
||||||
s_operation_id.clear();
|
s_operation_id.clear();
|
||||||
@@ -201,6 +204,25 @@ static double srcAmount() { return s_src == 0 ? s_t_amount : s_src == 1 ? s_z_am
|
|||||||
|
|
||||||
static std::string fmtAmt(double v) { char b[48]; std::snprintf(b, sizeof(b), "%.4f", v); return b; }
|
static std::string fmtAmt(double v) { char b[48]; std::snprintf(b, sizeof(b), "%.4f", v); return b; }
|
||||||
|
|
||||||
|
// Shield-coinbase source helpers. "*" = all spendable mining (transparent) addresses; else one address.
|
||||||
|
// Balances are DISPLAY totals (may include immature/non-coinbase), so the UI wording says "up to".
|
||||||
|
static double miningBalance(const WalletState& st, const char* from) {
|
||||||
|
if (std::strcmp(from, "*") == 0) {
|
||||||
|
double sum = 0.0;
|
||||||
|
for (const auto& a : st.t_addresses) if (a.isSpendable()) sum += a.balance;
|
||||||
|
return sum;
|
||||||
|
}
|
||||||
|
const int i = addressIndexByString(st.t_addresses, from);
|
||||||
|
return i >= 0 ? st.t_addresses[i].balance : 0.0;
|
||||||
|
}
|
||||||
|
static int miningAddrCount(const WalletState& st, const char* from) {
|
||||||
|
if (std::strcmp(from, "*") == 0) {
|
||||||
|
int n = 0; for (const auto& a : st.t_addresses) if (a.isSpendable() && a.balance > 0.0) ++n; return n;
|
||||||
|
}
|
||||||
|
const int i = addressIndexByString(st.t_addresses, from);
|
||||||
|
return (i >= 0 && st.t_addresses[i].balance > 0.0) ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
// A caption-style line that wraps at the dialog edge (Type().textColored doesn't wrap).
|
// A caption-style line that wraps at the dialog edge (Type().textColored doesn't wrap).
|
||||||
static void wrappedCaption(ImU32 color, const char* text)
|
static void wrappedCaption(ImU32 color, const char* text)
|
||||||
{
|
{
|
||||||
@@ -232,6 +254,7 @@ static void submitOperation(App* app)
|
|||||||
if (!app->worker()) return; // before the pending state, so a missing worker can't wedge it
|
if (!app->worker()) return; // before the pending state, so a missing worker can't wedge it
|
||||||
s_operation_pending = true;
|
s_operation_pending = true;
|
||||||
s_op_terminal = false;
|
s_op_terminal = false;
|
||||||
|
s_op_outcome = OpOutcome::None;
|
||||||
s_status_message = TR("shield_submitting");
|
s_status_message = TR("shield_submitting");
|
||||||
s_last_poll = ImGui::GetTime();
|
s_last_poll = ImGui::GetTime();
|
||||||
|
|
||||||
@@ -253,8 +276,9 @@ static void submitOperation(App* app)
|
|||||||
app->trackOperation(s_operation_id);
|
app->trackOperation(s_operation_id);
|
||||||
} else {
|
} else {
|
||||||
s_operation_pending = false; s_op_terminal = true;
|
s_operation_pending = false; s_op_terminal = true;
|
||||||
s_status_message = std::string(TR("shield_error_prefix")) + error;
|
s_op_outcome = OpOutcome::Failed;
|
||||||
Notifications::instance().error(std::string(TR("shield_send_failed")) + error);
|
s_status_message = std::string(TR("shield_error_prefix")) + util::friendlyRpcError(error);
|
||||||
|
Notifications::instance().error(std::string(TR("shield_send_failed")) + util::friendlyRpcError(error));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -309,13 +333,15 @@ static void submitOperation(App* app)
|
|||||||
app->trackOperation(s_operation_id);
|
app->trackOperation(s_operation_id);
|
||||||
} else if (uncertain) {
|
} else if (uncertain) {
|
||||||
s_operation_pending = false; s_op_terminal = true;
|
s_operation_pending = false; s_op_terminal = true;
|
||||||
|
s_op_outcome = OpOutcome::Uncertain;
|
||||||
s_confirm = false; // don't leave Confirm armed for a merge that may still be running
|
s_confirm = false; // don't leave Confirm armed for a merge that may still be running
|
||||||
s_status_message = TR("merge_submit_uncertain");
|
s_status_message = TR("merge_submit_uncertain");
|
||||||
Notifications::instance().warning(TR("merge_submit_uncertain"));
|
Notifications::instance().warning(TR("merge_submit_uncertain"));
|
||||||
} else {
|
} else {
|
||||||
s_operation_pending = false; s_op_terminal = true;
|
s_operation_pending = false; s_op_terminal = true;
|
||||||
s_status_message = std::string(TR("shield_error_prefix")) + error;
|
s_op_outcome = OpOutcome::Failed;
|
||||||
Notifications::instance().error(std::string(TR("merge_send_failed")) + error);
|
s_status_message = std::string(TR("shield_error_prefix")) + util::friendlyRpcError(error);
|
||||||
|
Notifications::instance().error(std::string(TR("merge_send_failed")) + util::friendlyRpcError(error));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -340,6 +366,7 @@ static void submitPerAddress(App* app)
|
|||||||
s_confirm = false;
|
s_confirm = false;
|
||||||
s_status_message.clear(); // an earlier "Into one" error / uncertain text belongs to that op
|
s_status_message.clear(); // an earlier "Into one" error / uncertain text belongs to that op
|
||||||
s_op_terminal = false;
|
s_op_terminal = false;
|
||||||
|
s_op_outcome = OpOutcome::None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,6 +411,7 @@ static void pollOperation(App* app)
|
|||||||
// client popped it with z_getoperationresult). It registers an op before returning its id,
|
// client popped it with z_getoperationresult). It registers an op before returning its id,
|
||||||
// so this is no race. The outcome can't be confirmed; stop polling.
|
// so this is no race. The outcome can't be confirmed; stop polling.
|
||||||
s_operation_pending = false; s_op_terminal = true;
|
s_operation_pending = false; s_op_terminal = true;
|
||||||
|
s_op_outcome = OpOutcome::Uncertain;
|
||||||
s_status_message = TR("send_status_unconfirmed");
|
s_status_message = TR("send_status_unconfirmed");
|
||||||
Notifications::instance().warning(TR("send_status_unconfirmed"));
|
Notifications::instance().warning(TR("send_status_unconfirmed"));
|
||||||
return;
|
return;
|
||||||
@@ -393,6 +421,7 @@ static void pollOperation(App* app)
|
|||||||
? op["status"].get<std::string>() : std::string();
|
? op["status"].get<std::string>() : std::string();
|
||||||
if (status == "success") {
|
if (status == "success") {
|
||||||
s_operation_pending = false; s_op_terminal = true;
|
s_operation_pending = false; s_op_terminal = true;
|
||||||
|
s_op_outcome = OpOutcome::Success;
|
||||||
s_status_message = TR("shield_completed");
|
s_status_message = TR("shield_completed");
|
||||||
Notifications::instance().success(TR("shield_merge_done"));
|
Notifications::instance().success(TR("shield_merge_done"));
|
||||||
} else if (status == "failed" || status == "cancelled" || status == "canceled") {
|
} else if (status == "failed" || status == "cancelled" || status == "canceled") {
|
||||||
@@ -401,6 +430,7 @@ static void pollOperation(App* app)
|
|||||||
op["error"]["message"].is_string())
|
op["error"]["message"].is_string())
|
||||||
msg = op["error"]["message"].get<std::string>();
|
msg = op["error"]["message"].get<std::string>();
|
||||||
s_operation_pending = false; s_op_terminal = true;
|
s_operation_pending = false; s_op_terminal = true;
|
||||||
|
s_op_outcome = OpOutcome::Failed;
|
||||||
s_status_message = std::string(TR("shield_op_failed")) + msg;
|
s_status_message = std::string(TR("shield_op_failed")) + msg;
|
||||||
// No toast: the op is tracked (App::trackOperation) and the shared opid poller raises the
|
// No toast: the op is tracked (App::trackOperation) and the shared opid poller raises the
|
||||||
// one failure toast, whether this dialog is open or not.
|
// one failure toast, whether this dialog is open or not.
|
||||||
@@ -556,8 +586,32 @@ void ShieldDialog::render(App* app)
|
|||||||
|
|
||||||
// ── Shield coinbase: from address ───────────────────────────────────────────────────────────
|
// ── Shield coinbase: from address ───────────────────────────────────────────────────────────
|
||||||
if (!isMerge && !opInFlight) {
|
if (!isMerge && !opInFlight) {
|
||||||
material::LabeledInput(TR("shield_from_address"), "##FromAddr", s_from_address, sizeof(s_from_address));
|
// Pick the mining (transparent) address to shield from a dropdown with balances, instead of a
|
||||||
ImGui::TextDisabled("%s", TR("shield_wildcard_hint"));
|
// free-text box with a "*" wildcard. "*" shields all of them.
|
||||||
|
ImGui::TextUnformatted(TR("shield_from_address"));
|
||||||
|
const bool allFrom = std::strcmp(s_from_address, "*") == 0;
|
||||||
|
const std::string fromLbl = allFrom ? std::string(TR("shield_all_mining_addrs"))
|
||||||
|
: (app->getAddressLabel(s_from_address).empty() ? shortAddr(s_from_address)
|
||||||
|
: app->getAddressLabel(s_from_address));
|
||||||
|
ImGui::SetNextItemWidth(-1);
|
||||||
|
if (ImGui::BeginCombo("##FromAddr", fromLbl.c_str())) {
|
||||||
|
if (ImGui::Selectable(TR("shield_all_mining_addrs"), allFrom))
|
||||||
|
strncpy(s_from_address, "*", sizeof(s_from_address));
|
||||||
|
for (const auto& a : state.t_addresses) {
|
||||||
|
if (!a.isSpendable()) continue;
|
||||||
|
const std::string lbl = app->getAddressLabel(a.address);
|
||||||
|
char item[192];
|
||||||
|
std::snprintf(item, sizeof(item), "%s (%s DRGX)",
|
||||||
|
lbl.empty() ? shortAddr(a.address).c_str() : lbl.c_str(),
|
||||||
|
fmtAmt(a.balance).c_str());
|
||||||
|
const bool sel = std::strcmp(s_from_address, a.address.c_str()) == 0;
|
||||||
|
if (ImGui::Selectable(item, sel)) {
|
||||||
|
strncpy(s_from_address, a.address.c_str(), sizeof(s_from_address) - 1);
|
||||||
|
s_from_address[sizeof(s_from_address) - 1] = '\0';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ImGui::EndCombo();
|
||||||
|
}
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -746,10 +800,18 @@ void ShieldDialog::render(App* app)
|
|||||||
|
|
||||||
// ── Live progress / status ──────────────────────────────────────────────────────────────────
|
// ── Live progress / status ──────────────────────────────────────────────────────────────────
|
||||||
if (!s_status_message.empty() && !(isMerge && run.hasRun())) { // the single-op path's message, not the run's
|
if (!s_status_message.empty() && !(isMerge && run.hasRun())) { // the single-op path's message, not the run's
|
||||||
if (s_operation_pending && !s_op_terminal)
|
const bool inFlight = s_operation_pending && !s_op_terminal;
|
||||||
ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.0f, 1.0f), "%s", s_status_message.c_str());
|
const ImU32 stCol = inFlight ? material::Warning()
|
||||||
|
: s_op_outcome == OpOutcome::Success ? material::Success()
|
||||||
|
: s_op_outcome == OpOutcome::Failed ? material::Error()
|
||||||
|
: s_op_outcome == OpOutcome::Uncertain ? material::Warning()
|
||||||
|
: material::OnSurfaceMedium();
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(stCol));
|
||||||
|
if (inFlight)
|
||||||
|
ImGui::Text("%s%s", s_status_message.c_str(), material::LoadingDots()); // spinner while scanning
|
||||||
else
|
else
|
||||||
ImGui::TextWrapped("%s", s_status_message.c_str());
|
ImGui::TextWrapped("%s", s_status_message.c_str());
|
||||||
|
ImGui::PopStyleColor();
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -814,9 +876,24 @@ void ShieldDialog::render(App* app)
|
|||||||
fmtAmt(srcAmount()).c_str(), srcCount(), shortAddr(s_to_address).c_str());
|
fmtAmt(srcAmount()).c_str(), srcCount(), shortAddr(s_to_address).c_str());
|
||||||
ImGui::TextWrapped("%s", cb);
|
ImGui::TextWrapped("%s", cb);
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
|
} else if (s_confirm && !isMerge) {
|
||||||
|
// Shield-coinbase preview: amount / address count / fee / destination (no amount/count was shown before).
|
||||||
|
const bool allFrom = std::strcmp(s_from_address, "*") == 0;
|
||||||
|
const std::string toLbl = app->getAddressLabel(s_to_address);
|
||||||
|
char cb[360];
|
||||||
|
std::snprintf(cb, sizeof(cb), TR("shield_confirm_fmt"),
|
||||||
|
fmtAmt(miningBalance(state, s_from_address)).c_str(),
|
||||||
|
allFrom ? miningAddrCount(state, s_from_address) : 1,
|
||||||
|
s_utxo_limit,
|
||||||
|
toLbl.empty() ? shortAddr(s_to_address).c_str() : toLbl.c_str(),
|
||||||
|
fmtAmt(s_fee).c_str());
|
||||||
|
ImGui::TextWrapped("%s", cb);
|
||||||
|
ImGui::Spacing();
|
||||||
}
|
}
|
||||||
|
|
||||||
bool can_submit = haveDest && connected && !syncing;
|
bool can_submit = haveDest && connected && !syncing;
|
||||||
|
// Block a shield from an empty source or one with no funds (the free-text box allowed both).
|
||||||
|
if (!isMerge && (s_from_address[0] == '\0' || miningBalance(state, s_from_address) <= 0.0)) can_submit = false;
|
||||||
const bool scopeLoaded = s_scan.phase == services::ScopePhase::Loaded;
|
const bool scopeLoaded = s_scan.phase == services::ScopePhase::Loaded;
|
||||||
if (isMerge && scopeLoaded && srcCount() == 0) can_submit = false;
|
if (isMerge && scopeLoaded && srcCount() == 0) can_submit = false;
|
||||||
if (perAddress) can_submit = connected && !syncing && scopeLoaded && !selectedGroups().empty();
|
if (perAddress) can_submit = connected && !syncing && scopeLoaded && !selectedGroups().empty();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -91,7 +91,8 @@ void ValidateAddressDialog::render(App* app)
|
|||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
|
|
||||||
// Validate button
|
// Validate button
|
||||||
bool can_validate = strlen(s_address_input) > 0 && !s_validating && app->isConnected();
|
// Classification + checksum are LOCAL, so allow validating offline (the is-mine check needs the node).
|
||||||
|
bool can_validate = strlen(s_address_input) > 0 && !s_validating;
|
||||||
|
|
||||||
if (!can_validate) {
|
if (!can_validate) {
|
||||||
ImGui::BeginDisabled();
|
ImGui::BeginDisabled();
|
||||||
@@ -123,8 +124,9 @@ void ValidateAddressDialog::render(App* app)
|
|||||||
s_address_type.clear();
|
s_address_type.clear();
|
||||||
s_validated = true;
|
s_validated = true;
|
||||||
s_validating = false;
|
s_validating = false;
|
||||||
} else if (app->isLiteBuild()) {
|
} else if (app->isLiteBuild() || !app->isConnected()) {
|
||||||
// Lite has no node to ask: the checksum verdict is the answer, ownership is unknown (hidden).
|
// No node to ask (lite, or full-node offline): the checksum verdict is the answer; ownership
|
||||||
|
// is unknown. The result block shows why.
|
||||||
s_is_valid = true;
|
s_is_valid = true;
|
||||||
s_is_mine = false;
|
s_is_mine = false;
|
||||||
s_ownership_known = false;
|
s_ownership_known = false;
|
||||||
@@ -181,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();
|
||||||
@@ -192,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) {
|
||||||
@@ -209,15 +211,17 @@ void ValidateAddressDialog::render(App* app)
|
|||||||
ImGui::SameLine(lbl.position);
|
ImGui::SameLine(lbl.position);
|
||||||
ImGui::Text("%s", s_address_type.c_str());
|
ImGui::Text("%s", s_address_type.c_str());
|
||||||
|
|
||||||
// Is mine? (not shown when no node was asked - lite)
|
// Is mine? When no node was asked (lite / offline) say why ownership is unknown rather
|
||||||
if (s_ownership_known) {
|
// than silently omitting it.
|
||||||
ImGui::Text("%s", TR("validate_ownership"));
|
ImGui::Text("%s", TR("validate_ownership"));
|
||||||
ImGui::SameLine(lbl.position);
|
ImGui::SameLine(lbl.position);
|
||||||
if (s_is_mine) {
|
if (!s_ownership_known) {
|
||||||
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s", TR("validate_is_mine"));
|
ImGui::TextDisabled("%s", TR(app->isLiteBuild() ? "validate_ownership_unknown_lite"
|
||||||
} else {
|
: "validate_ownership_unknown_offline"));
|
||||||
ImGui::TextDisabled("%s", TR("validate_not_mine"));
|
} else if (s_is_mine) {
|
||||||
}
|
ImGui::TextColored(material::SuccessVec4(), "%s", TR("validate_is_mine"));
|
||||||
|
} else {
|
||||||
|
ImGui::TextDisabled("%s", TR("validate_not_mine"));
|
||||||
}
|
}
|
||||||
if (s_is_mining) { // valid, but not for payments
|
if (s_is_mining) { // valid, but not for payments
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
@@ -227,8 +231,6 @@ void ValidateAddressDialog::render(App* app)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (!app->isConnected()) {
|
|
||||||
ImGui::TextColored(ImVec4(1.0f, 0.6f, 0.0f, 1.0f), "%s", TR("not_connected"));
|
|
||||||
} else {
|
} else {
|
||||||
// Pre-interaction placeholder: keeps the results area from reading as a large
|
// Pre-interaction placeholder: keeps the results area from reading as a large
|
||||||
// dead gap between the Validate/Paste row and the Close button until a result exists.
|
// dead gap between the Validate/Paste row and the Close button until a result exists.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
+211
-2
@@ -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();
|
||||||
@@ -232,6 +237,12 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["chat_empty_hint"] = "No conversations yet. Messages you receive will appear here.";
|
strings_["chat_empty_hint"] = "No conversations yet. Messages you receive will appear here.";
|
||||||
strings_["chat_you"] = "You";
|
strings_["chat_you"] = "You";
|
||||||
strings_["chat_contact_request"] = "contact request";
|
strings_["chat_contact_request"] = "contact request";
|
||||||
|
strings_["chat_request_pill"] = "Request";
|
||||||
|
strings_["chat_request_title"] = "New contact request";
|
||||||
|
strings_["chat_request_body"] = "This person isn't in your contacts. Accept to add them, or block to delete this conversation and ignore them.";
|
||||||
|
strings_["chat_request_accept"] = "Accept";
|
||||||
|
strings_["chat_request_block"] = "Block";
|
||||||
|
strings_["chat_request_accepted"] = "Contact added.";
|
||||||
strings_["chat_send_failed"] = "not sent";
|
strings_["chat_send_failed"] = "not sent";
|
||||||
strings_["chat_status_sending"] = "Sending… waiting for your wallet to broadcast it";
|
strings_["chat_status_sending"] = "Sending… waiting for your wallet to broadcast it";
|
||||||
strings_["chat_status_sent"] = "Sent to the network — it arrives after it confirms (about a minute)";
|
strings_["chat_status_sent"] = "Sent to the network — it arrives after it confirms (about a minute)";
|
||||||
@@ -254,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).";
|
||||||
@@ -262,13 +274,24 @@ 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_my_address_tip"] = "Share this address so others can message you";
|
||||||
|
strings_["chat_no_my_address"] = "No chat address yet";
|
||||||
strings_["chat_search"] = "Search conversations";
|
strings_["chat_search"] = "Search conversations";
|
||||||
strings_["chat_no_matches"] = "No conversations match your search.";
|
strings_["chat_no_matches"] = "No conversations match your search.";
|
||||||
strings_["chat_export"] = "Export chat\xE2\x80\xA6";
|
strings_["chat_export"] = "Export chat\xE2\x80\xA6";
|
||||||
strings_["chat_export_warn"] = "Saves the decrypted messages as plain text. Store the file securely.";
|
strings_["chat_export_warn"] = "Saves the decrypted messages as plain text. Store the file securely.";
|
||||||
strings_["chat_export_done"] = "Conversation exported";
|
strings_["chat_export_done"] = "Conversation exported";
|
||||||
strings_["chat_export_failed"] = "Could not write the export file.";
|
strings_["chat_export_failed"] = "Could not write the export file.";
|
||||||
|
strings_["chat_export_confirm_title"] = "Export chat as plain text?";
|
||||||
|
strings_["chat_export_confirm_body"] = "This saves the decrypted messages as an UNENCRYPTED plain-text file. Anyone who can open the file can read the whole conversation. Store it securely and delete it when you no longer need it.";
|
||||||
|
strings_["chat_export_confirm_path"] = "Will be saved to:";
|
||||||
|
strings_["chat_export_confirm_btn"] = "Export";
|
||||||
strings_["chat_len_over"] = "Message too long";
|
strings_["chat_len_over"] = "Message too long";
|
||||||
strings_["chat_mute"] = "Mute";
|
strings_["chat_mute"] = "Mute";
|
||||||
strings_["chat_unmute"] = "Unmute";
|
strings_["chat_unmute"] = "Unmute";
|
||||||
@@ -290,15 +313,65 @@ 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_["contacts_message"] = "Message";
|
||||||
|
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";
|
||||||
@@ -458,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";
|
||||||
@@ -466,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.";
|
||||||
@@ -475,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.";
|
||||||
@@ -576,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…";
|
||||||
@@ -682,6 +789,7 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["wiz_bootstrap_desc"] = "Download a blockchain bootstrap to dramatically speed up initial sync.\n\nYour existing wallet.dat will NOT be modified or replaced.";
|
strings_["wiz_bootstrap_desc"] = "Download a blockchain bootstrap to dramatically speed up initial sync.\n\nYour existing wallet.dat will NOT be modified or replaced.";
|
||||||
strings_["wiz_skip"] = "Skip";
|
strings_["wiz_skip"] = "Skip";
|
||||||
strings_["wiz_bootstrap_failed"] = "Bootstrap failed";
|
strings_["wiz_bootstrap_failed"] = "Bootstrap failed";
|
||||||
|
strings_["bootstrap_try_mirror"] = "Try mirror";
|
||||||
strings_["wiz_download_failed"] = "Download Failed";
|
strings_["wiz_download_failed"] = "Download Failed";
|
||||||
strings_["wiz_ext_daemon_running"] = "External daemon running";
|
strings_["wiz_ext_daemon_running"] = "External daemon running";
|
||||||
strings_["wiz_ext_daemon_warning"] = "It must be stopped before downloading a bootstrap, otherwise chain data could be corrupted.";
|
strings_["wiz_ext_daemon_warning"] = "It must be stopped before downloading a bootstrap, otherwise chain data could be corrupted.";
|
||||||
@@ -750,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):";
|
||||||
@@ -886,6 +996,9 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["bootstrap_trust_warning"] = "Only use bootstrap.dragonx.is or bootstrap2.dragonx.is. Using files from untrusted sources could compromise your node.";
|
strings_["bootstrap_trust_warning"] = "Only use bootstrap.dragonx.is or bootstrap2.dragonx.is. Using files from untrusted sources could compromise your node.";
|
||||||
strings_["bootstrap_mirror"] = "Mirror";
|
strings_["bootstrap_mirror"] = "Mirror";
|
||||||
strings_["bootstrap_mirror_tooltip"] = "Download from mirror (bootstrap2.dragonx.is).\nUse this if the main download is slow or failing.";
|
strings_["bootstrap_mirror_tooltip"] = "Download from mirror (bootstrap2.dragonx.is).\nUse this if the main download is slow or failing.";
|
||||||
|
strings_["bootstrap_use_mirror"] = "Use mirror";
|
||||||
|
strings_["bootstrap_use_mirror_tooltip"] = "Download the same bootstrap from the backup server (bootstrap2.dragonx.is) instead of the main one (bootstrap.dragonx.is).\nChoose this if the main download is slow or failing.";
|
||||||
|
strings_["wiz_bootstrap_size_hint"] = "This is a large download. The exact size and a time estimate appear once it starts, and it can be resumed if interrupted.";
|
||||||
strings_["bootstrap_downloading"] = "Downloading bootstrap...";
|
strings_["bootstrap_downloading"] = "Downloading bootstrap...";
|
||||||
strings_["bootstrap_verifying"] = "Verifying checksums...";
|
strings_["bootstrap_verifying"] = "Verifying checksums...";
|
||||||
strings_["bootstrap_extracting"] = "Extracting blockchain data...";
|
strings_["bootstrap_extracting"] = "Extracting blockchain data...";
|
||||||
@@ -896,6 +1009,8 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["bootstrap_success"] = "Bootstrap Complete";
|
strings_["bootstrap_success"] = "Bootstrap Complete";
|
||||||
strings_["bootstrap_success_desc"] = "Blockchain data has been extracted successfully. Start the daemon to begin syncing from the bootstrap point.";
|
strings_["bootstrap_success_desc"] = "Blockchain data has been extracted successfully. Start the daemon to begin syncing from the bootstrap point.";
|
||||||
strings_["bootstrap_restart_daemon"] = "Restart Daemon";
|
strings_["bootstrap_restart_daemon"] = "Restart Daemon";
|
||||||
|
strings_["bootstrap_start_daemon"] = "Start Daemon";
|
||||||
|
strings_["bootstrap_success_desc_start"] = "Blockchain data was extracted successfully. Click Start Daemon to begin syncing from the bootstrap point.";
|
||||||
strings_["bootstrap_failed"] = "Bootstrap Failed";
|
strings_["bootstrap_failed"] = "Bootstrap Failed";
|
||||||
strings_["tt_open_dir"] = "Click to open in file explorer";
|
strings_["tt_open_dir"] = "Click to open in file explorer";
|
||||||
strings_["settings_open_data_dir"] = "Open data folder";
|
strings_["settings_open_data_dir"] = "Open data folder";
|
||||||
@@ -1030,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";
|
||||||
@@ -1112,6 +1231,8 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["confirm_transfer"] = "Confirm Transfer";
|
strings_["confirm_transfer"] = "Confirm Transfer";
|
||||||
strings_["transfer_sent"] = "Transfer Sent";
|
strings_["transfer_sent"] = "Transfer Sent";
|
||||||
strings_["transfer_sent_desc"] = "Your transfer has been submitted to the network.";
|
strings_["transfer_sent_desc"] = "Your transfer has been submitted to the network.";
|
||||||
|
strings_["transfer_txid_label"] = "Transaction ID: %s";
|
||||||
|
strings_["transfer_tooltip_locked"] = "Wallet is locked — unlock it to transfer";
|
||||||
strings_["transfer_failed"] = "Transfer Failed";
|
strings_["transfer_failed"] = "Transfer Failed";
|
||||||
|
|
||||||
// Misc dialog strings
|
// Misc dialog strings
|
||||||
@@ -1131,6 +1252,8 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["recent_sends"] = "RECENT SENDS";
|
strings_["recent_sends"] = "RECENT SENDS";
|
||||||
strings_["recipient"] = "RECIPIENT";
|
strings_["recipient"] = "RECIPIENT";
|
||||||
strings_["memo_optional"] = "MEMO (OPTIONAL)";
|
strings_["memo_optional"] = "MEMO (OPTIONAL)";
|
||||||
|
strings_["send_memo_requires_shielded"] = "Memos require a shielded (z) recipient address.";
|
||||||
|
strings_["send_memo_will_not_be_sent"] = "This memo will not be sent: memos only work with shielded (z) recipients.";
|
||||||
strings_["no_recent_sends"] = "No recent sends";
|
strings_["no_recent_sends"] = "No recent sends";
|
||||||
strings_["network_fee"] = "NETWORK FEE";
|
strings_["network_fee"] = "NETWORK FEE";
|
||||||
strings_["amount_details"] = "AMOUNT DETAILS";
|
strings_["amount_details"] = "AMOUNT DETAILS";
|
||||||
@@ -1217,6 +1340,7 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["shielded_self_type"] = "Shielded";
|
strings_["shielded_self_type"] = "Shielded";
|
||||||
strings_["immature_type"] = "Immature";
|
strings_["immature_type"] = "Immature";
|
||||||
strings_["mined_type"] = "Mined";
|
strings_["mined_type"] = "Mined";
|
||||||
|
strings_["tx_type_generic"] = "Transaction";
|
||||||
strings_["mature"] = "Mature";
|
strings_["mature"] = "Mature";
|
||||||
strings_["copy_txid"] = "Copy TxID";
|
strings_["copy_txid"] = "Copy TxID";
|
||||||
strings_["view_details"] = "View Details";
|
strings_["view_details"] = "View Details";
|
||||||
@@ -1291,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";
|
||||||
@@ -1442,6 +1567,10 @@ 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_tip"] = "Awaiting confirmations — not spendable yet; it moves to your available balance once confirmed.";
|
||||||
strings_["sent"] = "sent";
|
strings_["sent"] = "sent";
|
||||||
strings_["received"] = "received";
|
strings_["received"] = "received";
|
||||||
strings_["mined"] = "mined";
|
strings_["mined"] = "mined";
|
||||||
@@ -1532,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%%";
|
||||||
@@ -1544,7 +1674,7 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["sb_no_conf"] = "No DRAGONX.conf found";
|
strings_["sb_no_conf"] = "No DRAGONX.conf found";
|
||||||
strings_["sb_starting_daemon"] = "Starting dragonxd...";
|
strings_["sb_starting_daemon"] = "Starting dragonxd...";
|
||||||
strings_["sb_connecting_daemon"] = "Connecting to dragonxd...";
|
strings_["sb_connecting_daemon"] = "Connecting to dragonxd...";
|
||||||
strings_["sb_auth_failed"] = "Auth failed — check rpcuser/rpcpassword";
|
strings_["sb_auth_failed"] = "Couldn't sign in to the node";
|
||||||
strings_["sb_waiting_daemon"] = "Waiting for dragonxd to start...";
|
strings_["sb_waiting_daemon"] = "Waiting for dragonxd to start...";
|
||||||
strings_["sb_waiting_daemon_err"] = "Waiting for dragonxd — %s";
|
strings_["sb_waiting_daemon_err"] = "Waiting for dragonxd — %s";
|
||||||
strings_["sb_connecting_external"] = "Connecting to external daemon...";
|
strings_["sb_connecting_external"] = "Connecting to external daemon...";
|
||||||
@@ -1562,6 +1692,12 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["node_banner_lite_open_failed"] = "Couldn't open your wallet";
|
strings_["node_banner_lite_open_failed"] = "Couldn't open your wallet";
|
||||||
strings_["node_banner_reconnect"] = "Reconnect";
|
strings_["node_banner_reconnect"] = "Reconnect";
|
||||||
strings_["node_banner_restart"] = "Restart node";
|
strings_["node_banner_restart"] = "Restart node";
|
||||||
|
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_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"] =
|
||||||
@@ -1938,15 +2074,24 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["toast_finish_send_first"] = "Finish or cancel the pending send before switching wallets.";
|
strings_["toast_finish_send_first"] = "Finish or cancel the pending send before switching wallets.";
|
||||||
strings_["toast_rescan_complete"] = "Blockchain rescan complete.";
|
strings_["toast_rescan_complete"] = "Blockchain rescan complete.";
|
||||||
strings_["toast_rescan_from_fmt"] = "Rescanning the blockchain from block %d — this can take a while.";
|
strings_["toast_rescan_from_fmt"] = "Rescanning the blockchain from block %d — this can take a while.";
|
||||||
|
strings_["warmup_block_fmt"] = "(Block %d)";
|
||||||
strings_["toast_solo_unavailable_lite"] = "Solo mining isn't available in the lite wallet.";
|
strings_["toast_solo_unavailable_lite"] = "Solo mining isn't available in the lite wallet.";
|
||||||
strings_["toast_pool_unavailable"] = "Pool mining isn't available in this build.";
|
strings_["toast_pool_unavailable"] = "Pool mining isn't available in this build.";
|
||||||
strings_["toast_no_address_generate_z"] = "No wallet address yet — generate a shielded (Z) address in the Receive tab.";
|
strings_["toast_no_address_generate_z"] = "No wallet address yet — generate a shielded (Z) address in the Receive tab.";
|
||||||
strings_["toast_pool_connecting"] = "Starting pool miner — connecting to the pool…";
|
strings_["toast_pool_connecting"] = "Starting pool miner — connecting to the pool…";
|
||||||
strings_["toast_not_connected_daemon"] = "Not connected to the node.";
|
strings_["toast_not_connected_daemon"] = "Not connected to the node.";
|
||||||
strings_["toast_mining_failed"] = "Couldn't start mining: ";
|
strings_["toast_mining_failed"] = "Couldn't start mining: ";
|
||||||
|
strings_["toast_pool_payout_invalid"] = "Pool payout address is not a valid DragonX address — mining not started.";
|
||||||
|
strings_["toast_av_blocked_xmrig"] = "Windows Defender blocked the miner. Add an exclusion for %APPDATA%\\ObsidianDragon.";
|
||||||
|
strings_["mining_error_retry_hint"] = "Press Mine to try again.";
|
||||||
|
strings_["mining_error_av_help"] = "How to fix";
|
||||||
|
strings_["mining_error_dismiss"] = "Dismiss";
|
||||||
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: ";
|
||||||
@@ -1956,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";
|
||||||
@@ -2070,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 ---
|
||||||
@@ -2121,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.";
|
||||||
@@ -2222,6 +2385,10 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["market_24h_change"] = "24h";
|
strings_["market_24h_change"] = "24h";
|
||||||
strings_["market_price_loading"] = "Loading price data...";
|
strings_["market_price_loading"] = "Loading price data...";
|
||||||
strings_["market_price_unavailable"] = "Price data unavailable";
|
strings_["market_price_unavailable"] = "Price data unavailable";
|
||||||
|
strings_["market_price_fetch_failed"] = "Price fetch failed";
|
||||||
|
strings_["market_price_stale_min"] = "as of %d min ago";
|
||||||
|
strings_["market_price_stale_hour"] = "as of %dh ago";
|
||||||
|
strings_["market_price_stale_tip"] = "Showing the last known price. The latest refresh failed: %s";
|
||||||
strings_["market_refresh_price"] = "Refresh price data";
|
strings_["market_refresh_price"] = "Refresh price data";
|
||||||
strings_["market_trade_on"] = "Trade on %s";
|
strings_["market_trade_on"] = "Trade on %s";
|
||||||
|
|
||||||
@@ -2241,6 +2408,11 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["mining_click_copy_difficulty"] = "Click to copy difficulty";
|
strings_["mining_click_copy_difficulty"] = "Click to copy difficulty";
|
||||||
strings_["mining_connected"] = "Connected";
|
strings_["mining_connected"] = "Connected";
|
||||||
strings_["mining_connecting"] = "Connecting...";
|
strings_["mining_connecting"] = "Connecting...";
|
||||||
|
strings_["mining_status_label"] = "Status";
|
||||||
|
strings_["mining_status_connected"] = "Connected";
|
||||||
|
strings_["mining_status_connecting"] = "Connecting…";
|
||||||
|
strings_["mining_status_stopped"] = "Stopped";
|
||||||
|
strings_["mining_pool_unreachable"] = "Can't reach pool — check the URL";
|
||||||
strings_["mining_difficulty_copied"] = "Difficulty copied";
|
strings_["mining_difficulty_copied"] = "Difficulty copied";
|
||||||
strings_["mining_est_block"] = "Est. Block";
|
strings_["mining_est_block"] = "Est. Block";
|
||||||
strings_["mining_est_daily"] = "Est. Daily";
|
strings_["mining_est_daily"] = "Est. Daily";
|
||||||
@@ -2276,6 +2448,8 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["send_mining_addr_short"] = "Mining address — can't be paid";
|
strings_["send_mining_addr_short"] = "Mining address — can't be paid";
|
||||||
strings_["send_mining_addr_blocked"] = "This is a mining (transparent) address. On DragonX these can only receive mining rewards — the network rejects payments to them. Ask the recipient for a shielded address (it starts with zs).";
|
strings_["send_mining_addr_blocked"] = "This is a mining (transparent) address. On DragonX these can only receive mining rewards — the network rejects payments to them. Ask the recipient for a shielded address (it starts with zs).";
|
||||||
strings_["mining_payout_needs_shielded"] = "Pools pay out with ordinary transactions, which can't reach a mining (transparent, R…) address on DragonX. Use a shielded address (it starts with zs) as your payout address.";
|
strings_["mining_payout_needs_shielded"] = "Pools pay out with ordinary transactions, which can't reach a mining (transparent, R…) address on DragonX. Use a shielded address (it starts with zs) as your payout address.";
|
||||||
|
strings_["mining_payout_invalid_short"] = "Not a valid DragonX address";
|
||||||
|
strings_["mining_payout_needs_shielded_short"] = "Use a shielded (zs…) address, not a mining address";
|
||||||
strings_["new_mining_address"] = "New mining address";
|
strings_["new_mining_address"] = "New mining address";
|
||||||
strings_["tt_new_mining_address"] = "Creates a transparent (R…) address for solo-mining rewards. It can't receive payments — share a shielded address for those.";
|
strings_["tt_new_mining_address"] = "Creates a transparent (R…) address for solo-mining rewards. It can't receive payments — share a shielded address for those.";
|
||||||
strings_["mining_address"] = "Mining address";
|
strings_["mining_address"] = "Mining address";
|
||||||
@@ -2287,10 +2461,13 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["balance_mining_fmt"] = "Mining rewards: %.8f";
|
strings_["balance_mining_fmt"] = "Mining rewards: %.8f";
|
||||||
strings_["mining_card_tooltip"] = "Solo-mining rewards arrive at your mining (transparent, R…) addresses. Those addresses can't receive payments. Rewards are shielded automatically once they mature.";
|
strings_["mining_card_tooltip"] = "Solo-mining rewards arrive at your mining (transparent, R…) addresses. Those addresses can't receive payments. Rewards are shielded automatically once they mature.";
|
||||||
strings_["mining_card_click_to_shield"] = "Click to shield matured rewards now.";
|
strings_["mining_card_click_to_shield"] = "Click to shield matured rewards now.";
|
||||||
|
strings_["shielded_card_tooltip"] = "Shielded (private) balance: funds held in z-addresses. Amounts, senders and recipients are hidden on the blockchain. Click to open Receive.";
|
||||||
strings_["contact_chip_mining"] = "Mining only";
|
strings_["contact_chip_mining"] = "Mining only";
|
||||||
strings_["failed_create_mining_address"] = "Failed to create mining address";
|
strings_["failed_create_mining_address"] = "Failed to create mining address";
|
||||||
strings_["new_mining_address_created"] = "New mining address created";
|
strings_["new_mining_address_created"] = "New mining address created";
|
||||||
strings_["mining_addr_explainer"] = "Receives solo-mining rewards only — the network rejects payments to it. Rewards are shielded automatically once they mature.";
|
strings_["mining_addr_explainer"] = "Receives solo-mining rewards only — the network rejects payments to it. Rewards are shielded automatically once they mature.";
|
||||||
|
strings_["mining_addr_none"] = "None yet";
|
||||||
|
strings_["mining_addr_none_tip"] = "No mining address yet. Create one in the Receive tab (New mining address). Solo-mining rewards are paid to a transparent (R...) mining address, then shielded automatically once they mature.";
|
||||||
strings_["receive_use_shielded"] = "Use a shielded address";
|
strings_["receive_use_shielded"] = "Use a shielded address";
|
||||||
strings_["receive_not_for_payments"] = "Not for payments";
|
strings_["receive_not_for_payments"] = "Not for payments";
|
||||||
strings_["validate_mining_type"] = "Mining address (transparent)";
|
strings_["validate_mining_type"] = "Mining address (transparent)";
|
||||||
@@ -2344,9 +2521,13 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["mining_stopping"] = "Stopping...";
|
strings_["mining_stopping"] = "Stopping...";
|
||||||
strings_["mining_stopping_tooltip"] = "Miner is stopping...";
|
strings_["mining_stopping_tooltip"] = "Miner is stopping...";
|
||||||
strings_["mining_syncing_tooltip"] = "Blockchain is syncing...";
|
strings_["mining_syncing_tooltip"] = "Blockchain is syncing...";
|
||||||
|
strings_["mining_not_connected_tooltip"] = "Not connected to the node. Wait for the daemon to connect before mining.";
|
||||||
strings_["mining_to_save"] = "to save";
|
strings_["mining_to_save"] = "to save";
|
||||||
strings_["mining_today"] = "Today";
|
strings_["mining_today"] = "Today";
|
||||||
strings_["mining_uptime"] = "Uptime";
|
strings_["mining_uptime"] = "Uptime";
|
||||||
|
strings_["mining_txn_count"] = "(%d txn)";
|
||||||
|
strings_["mining_uptime_ms"] = "%lldm %llds";
|
||||||
|
strings_["mining_uptime_hm"] = "%lldh %lldm";
|
||||||
strings_["mining_yesterday"] = "Yesterday";
|
strings_["mining_yesterday"] = "Yesterday";
|
||||||
|
|
||||||
// --- Miner (xmrig) updater ---
|
// --- Miner (xmrig) updater ---
|
||||||
@@ -2420,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.";
|
||||||
@@ -2476,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";
|
||||||
@@ -2532,6 +2719,8 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["request_payment_uri"] = "Payment URI:";
|
strings_["request_payment_uri"] = "Payment URI:";
|
||||||
strings_["request_receive_address"] = "Receive Address:";
|
strings_["request_receive_address"] = "Receive Address:";
|
||||||
strings_["request_select_address"] = "Select address...";
|
strings_["request_select_address"] = "Select address...";
|
||||||
|
strings_["request_no_shielded"] = "You don't have a shielded address yet. Create one to receive payments.";
|
||||||
|
strings_["request_create_shielded"] = "Create a shielded address";
|
||||||
strings_["request_shielded_addrs"] = "-- Shielded Addresses --";
|
strings_["request_shielded_addrs"] = "-- Shielded Addresses --";
|
||||||
strings_["request_title"] = "Request Payment";
|
strings_["request_title"] = "Request Payment";
|
||||||
strings_["request_uri_copied"] = "Payment URI copied to clipboard";
|
strings_["request_uri_copied"] = "Payment URI copied to clipboard";
|
||||||
@@ -2541,6 +2730,8 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["send_amount_details"] = "AMOUNT DETAILS";
|
strings_["send_amount_details"] = "AMOUNT DETAILS";
|
||||||
strings_["send_amount_upper"] = "AMOUNT";
|
strings_["send_amount_upper"] = "AMOUNT";
|
||||||
strings_["send_clear_fields"] = "Clear all form fields?";
|
strings_["send_clear_fields"] = "Clear all form fields?";
|
||||||
|
strings_["send_clear_form"] = "Clear form";
|
||||||
|
strings_["send_clear_form_tip"] = "Clear the recipient, amount and memo. You can undo for a few seconds.";
|
||||||
strings_["send_copy_error"] = "Copy Error";
|
strings_["send_copy_error"] = "Copy Error";
|
||||||
strings_["send_dismiss"] = "Dismiss";
|
strings_["send_dismiss"] = "Dismiss";
|
||||||
strings_["send_error_copied"] = "Error copied to clipboard";
|
strings_["send_error_copied"] = "Error copied to clipboard";
|
||||||
@@ -2557,6 +2748,7 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["send_network_fee_label"] = "Network fee:";
|
strings_["send_network_fee_label"] = "Network fee:";
|
||||||
strings_["send_total_label"] = "Total:";
|
strings_["send_total_label"] = "Total:";
|
||||||
strings_["send_no_balance"] = "No balance";
|
strings_["send_no_balance"] = "No balance";
|
||||||
|
strings_["send_transparent_source_warn"] = "Sending from a transparent address is not private: the sender, amount and recipient are visible on-chain. Use a shielded (Z) address for privacy.";
|
||||||
strings_["send_no_recent"] = "No recent sends";
|
strings_["send_no_recent"] = "No recent sends";
|
||||||
strings_["send_recent_sends"] = "RECENT SENDS";
|
strings_["send_recent_sends"] = "RECENT SENDS";
|
||||||
strings_["send_recipient"] = "RECIPIENT";
|
strings_["send_recipient"] = "RECIPIENT";
|
||||||
@@ -2572,9 +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_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!";
|
||||||
@@ -2590,7 +2785,9 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["shield_check_status"] = "Check Status";
|
strings_["shield_check_status"] = "Check Status";
|
||||||
strings_["shield_completed"] = "Operation completed successfully!";
|
strings_["shield_completed"] = "Operation completed successfully!";
|
||||||
strings_["shield_description"] = "Shield your mining rewards by sending coinbase outputs from transparent addresses to a shielded address. This improves privacy by hiding your mining income.";
|
strings_["shield_description"] = "Shield your mining rewards by sending coinbase outputs from transparent addresses to a shielded address. This improves privacy by hiding your mining income.";
|
||||||
strings_["shield_from_address"] = "From Address:";
|
strings_["shield_from_address"] = "From mining address:";
|
||||||
|
strings_["shield_all_mining_addrs"] = "All mining addresses";
|
||||||
|
strings_["shield_confirm_fmt"] = "Shield up to %s DRGX from %d mining address(es) (at most %d rewards per run) to %s? Network fee: %s DRGX.";
|
||||||
strings_["shield_funds"] = "Shield Funds";
|
strings_["shield_funds"] = "Shield Funds";
|
||||||
strings_["shield_in_progress"] = "Operation in progress...";
|
strings_["shield_in_progress"] = "Operation in progress...";
|
||||||
strings_["shield_max_utxos"] = "Max UTXOs per operation";
|
strings_["shield_max_utxos"] = "Max UTXOs per operation";
|
||||||
@@ -2695,6 +2892,8 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["validate_invalid"] = "INVALID";
|
strings_["validate_invalid"] = "INVALID";
|
||||||
strings_["validate_is_mine"] = "This wallet owns this address";
|
strings_["validate_is_mine"] = "This wallet owns this address";
|
||||||
strings_["validate_not_mine"] = "Not owned by this wallet";
|
strings_["validate_not_mine"] = "Not owned by this wallet";
|
||||||
|
strings_["validate_ownership_unknown_offline"] = "Unknown — node offline";
|
||||||
|
strings_["validate_ownership_unknown_lite"] = "Unknown — not checkable in the lite wallet";
|
||||||
strings_["validate_ownership"] = "Ownership:";
|
strings_["validate_ownership"] = "Ownership:";
|
||||||
strings_["validate_results"] = "Results:";
|
strings_["validate_results"] = "Results:";
|
||||||
strings_["validate_results_placeholder"] = "Results will appear here";
|
strings_["validate_results_placeholder"] = "Results will appear here";
|
||||||
@@ -2756,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)";
|
||||||
@@ -2983,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
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// DragonX Wallet - ImGui Edition
|
||||||
|
// Copyright 2024-2026 The Hush Developers
|
||||||
|
// Released under the GPLv3
|
||||||
|
//
|
||||||
|
// A tiny, pure block-sync rate estimator for the status-bar / peers-card ETA. Pure (no ImGui, no App):
|
||||||
|
// App owns one instance, feeds it the current height + a monotonic clock once per frame while syncing,
|
||||||
|
// and resets it on connect / disconnect / rescan / restart so a height drop never produces a stale or
|
||||||
|
// absurd ("412h") ETA.
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdio>
|
||||||
|
|
||||||
|
namespace dragonx {
|
||||||
|
namespace util {
|
||||||
|
|
||||||
|
class SyncRateEstimator {
|
||||||
|
public:
|
||||||
|
void reset() { prevBlocks_ = -1; prevTime_ = 0.0; rate_ = 0.0; }
|
||||||
|
|
||||||
|
// Call once per frame while syncing. `now` is monotonic seconds (e.g. ImGui::GetTime()).
|
||||||
|
void update(int blocks, double now) {
|
||||||
|
// First sample, a rescan/restart height drop, or a clock reset → re-baseline, don't compute.
|
||||||
|
if (prevBlocks_ < 0 || blocks < prevBlocks_ || now < prevTime_) {
|
||||||
|
prevBlocks_ = blocks; prevTime_ = now; rate_ = 0.0; return;
|
||||||
|
}
|
||||||
|
const double dt = now - prevTime_;
|
||||||
|
if (blocks > prevBlocks_) {
|
||||||
|
if (dt > 0.5) {
|
||||||
|
const double raw = (blocks - prevBlocks_) / dt;
|
||||||
|
rate_ = rate_ > 0.0 ? rate_ * 0.7 + raw * 0.3 : raw; // smooth (same alpha as the old code)
|
||||||
|
prevBlocks_ = blocks; prevTime_ = now;
|
||||||
|
}
|
||||||
|
} else if (dt > 10.0) { // stalled: decay and re-baseline the clock
|
||||||
|
rate_ *= 0.5; prevTime_ = now;
|
||||||
|
if (rate_ < 0.1) rate_ = 0.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
double rate() const { return rate_; } // blocks/sec, 0 when unknown
|
||||||
|
|
||||||
|
// <= 0 when unknown. Capped at ~1 year so a tiny rate can't overflow.
|
||||||
|
long long etaSeconds(int blocksLeft) const {
|
||||||
|
if (rate_ <= 0.1 || blocksLeft <= 0) return -1;
|
||||||
|
const double s = blocksLeft / rate_;
|
||||||
|
return s > 3.15e7 ? 31500000LL : static_cast<long long>(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
int prevBlocks_ = -1; // -1 = unset (distinct from a legitimate height 0)
|
||||||
|
double prevTime_ = 0.0;
|
||||||
|
double rate_ = 0.0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Human ETA with days, e.g. "3d 4h", "5h 12m", "7m 9s", "42s". English unit letters (as the old code).
|
||||||
|
inline void formatEta(long long s, char* out, std::size_t n) {
|
||||||
|
if (s >= 86400) std::snprintf(out, n, "%lldd %lldh", s / 86400, (s % 86400) / 3600);
|
||||||
|
else if (s >= 3600) std::snprintf(out, n, "%lldh %lldm", s / 3600, (s % 3600) / 60);
|
||||||
|
else if (s >= 60) std::snprintf(out, n, "%lldm %llds", s / 60, s % 60);
|
||||||
|
else std::snprintf(out, n, "%llds", s);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace util
|
||||||
|
} // namespace dragonx
|
||||||
@@ -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
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user