Compare commits
27 Commits
3a9edf2200
...
49b5ef6b20
| Author | SHA1 | Date | |
|---|---|---|---|
| 49b5ef6b20 | |||
| 6aaab9e01f | |||
| b7da6335d1 | |||
| b765d62e00 | |||
| 3dc4d2d860 | |||
| fe40d38f0d | |||
| 1d3a919ac6 | |||
| d549127e41 | |||
| 54a14485a5 | |||
| 8eefab99ec | |||
| 4fdf281230 | |||
| c4f69b5a85 | |||
| 171b705429 | |||
| 175950a2ec | |||
| 36efe99a0f | |||
| fadbd6a879 | |||
| 112c581247 | |||
| 57147fd06b | |||
| aa60188665 | |||
| cff958c21e | |||
| c50125523c | |||
| 063f1fa4be | |||
| ad7a150ad6 | |||
| 433c7ae46f | |||
| a5efbaf83d | |||
| 46fb0029b8 | |||
| aa587d1d84 |
270
overview_mockup.html
Normal file
270
overview_mockup.html
Normal file
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -1323,31 +1323,47 @@
|
|||||||
"wallets_add": "Ordner hinzufügen",
|
"wallets_add": "Ordner hinzufügen",
|
||||||
"wallets_add_folder": "Auch einen weiteren Ordner nach Wallet-Dateien durchsuchen:",
|
"wallets_add_folder": "Auch einen weiteren Ordner nach Wallet-Dateien durchsuchen:",
|
||||||
"wallets_add_folder_toggle": "+ Weiteren Ordner nach Wallets durchsuchen…",
|
"wallets_add_folder_toggle": "+ Weiteren Ordner nach Wallets durchsuchen…",
|
||||||
|
"wallets_badge_encrypted": "Verschlüsselt (passphrasengeschützt)",
|
||||||
|
"wallets_badge_encrypted_short": "Verschlüsselt",
|
||||||
|
"wallets_badge_legacy": "Legacy-Wallet (keine Seed-Phrase)",
|
||||||
|
"wallets_badge_legacy_short": "Legacy",
|
||||||
|
"wallets_badge_seed": "Seed-Phrase-Wallet (HD)",
|
||||||
|
"wallets_badge_seed_short": "Seed-Phrase",
|
||||||
|
"wallets_badge_unknown": "Wallet-Typ nicht vollständig ermittelt (große Datei — zum Bestätigen öffnen)",
|
||||||
|
"wallets_badge_unknown_short": "Unbekannt",
|
||||||
"wallets_button": "Wallets…",
|
"wallets_button": "Wallets…",
|
||||||
"wallets_col_addresses": "Adressen",
|
"wallets_col_addresses": "Adressen",
|
||||||
"wallets_col_balance": "Guthaben",
|
"wallets_col_balance": "Guthaben",
|
||||||
|
"wallets_col_keys": "Schlüssel",
|
||||||
"wallets_col_name": "Wallet",
|
"wallets_col_name": "Wallet",
|
||||||
"wallets_col_opened": "Zuletzt geöffnet",
|
"wallets_col_opened": "Zuletzt geöffnet",
|
||||||
"wallets_col_size": "Größe",
|
"wallets_col_size": "Größe",
|
||||||
|
"wallets_col_txs": "Tx",
|
||||||
"wallets_create": "Wallet erstellen",
|
"wallets_create": "Wallet erstellen",
|
||||||
|
"wallets_created": "erstellt",
|
||||||
"wallets_creating": "Wallet wird erstellt — der Node wird neu gestartet…",
|
"wallets_creating": "Wallet wird erstellt — der Node wird neu gestartet…",
|
||||||
"wallets_current": "aktuell",
|
"wallets_current": "aktuell",
|
||||||
"wallets_exists": "Eine Wallet mit diesem Namen existiert bereits.",
|
"wallets_exists": "Eine Wallet mit diesem Namen existiert bereits.",
|
||||||
"wallets_external_tt": "Außerhalb deines Datenverzeichnisses — mit Import hineinkopieren.",
|
"wallets_external_tt": "Außerhalb deines Datenverzeichnisses — Öffnen verlinkt sie an ihrem Ort (keine Kopie).",
|
||||||
"wallets_folder_hint": "/pfad/zum/ordner mit .dat-Wallet-Dateien",
|
"wallets_folder_hint": "/pfad/zum/ordner mit .dat-Wallet-Dateien",
|
||||||
"wallets_folder_invalid": "Dieser Ordner existiert nicht.",
|
"wallets_folder_invalid": "Dieser Ordner existiert nicht.",
|
||||||
"wallets_import": "Importieren",
|
|
||||||
"wallets_import_failed": "Diese Wallet-Datei konnte nicht importiert werden.",
|
|
||||||
"wallets_import_hint": "Zum Öffnen importieren",
|
|
||||||
"wallets_import_tt": "Diese Wallet befindet sich außerhalb des Datenverzeichnisses. Beim Öffnen wird sie zuerst hineinkopiert (demnächst verfügbar).",
|
|
||||||
"wallets_imported": "Wallet importiert — wird gewechselt…",
|
|
||||||
"wallets_intro": "Wallet-Dateien in Ihrem Datenverzeichnis (und in beliebigen Ordnern, die Sie hinzufügen). Öffnen Sie eine, um zu wechseln — der Node wird neu gestartet, um sie zu laden, und jede Wallet behält ihre eigenen Daten.",
|
"wallets_intro": "Wallet-Dateien in Ihrem Datenverzeichnis (und in beliebigen Ordnern, die Sie hinzufügen). Öffnen Sie eine, um zu wechseln — der Node wird neu gestartet, um sie zu laden, und jede Wallet behält ihre eigenen Daten.",
|
||||||
"wallets_name_invalid": "Bitte geben Sie einen gültigen Wallet-Namen ein.",
|
"wallets_name_invalid": "Bitte geben Sie einen gültigen Wallet-Namen ein.",
|
||||||
"wallets_never": "Nie",
|
"wallets_never": "Nie geöffnet",
|
||||||
"wallets_new_hint": "Name (z. B. Ersparnisse)",
|
"wallets_new_hint": "Name (z. B. Ersparnisse)",
|
||||||
"wallets_new_label": "Eine neue Wallet erstellen:",
|
"wallets_new_label": "Eine neue Wallet erstellen:",
|
||||||
"wallets_open": "Öffnen",
|
"wallets_open": "Öffnen",
|
||||||
|
"wallets_open_failed": "Diese Wallet konnte nicht an ihrem Ort geöffnet werden. Sie liegt vermutlich auf einem anderen Laufwerk als dein Datenverzeichnis — verschiebe sie auf dasselbe Laufwerk (unter Windows erlaubt auch der aktivierte Entwicklermodus laufwerkübergreifende Verknüpfungen).",
|
||||||
|
"wallets_open_folder": "Ordnerpfad öffnen",
|
||||||
|
"wallets_open_inplace_tt": "Öffnet diese Wallet an ihrem Ort — im Datenverzeichnis verlinkt (keine Kopie)",
|
||||||
"wallets_reveal": "Ordner anzeigen",
|
"wallets_reveal": "Ordner anzeigen",
|
||||||
|
"wallets_sort_addresses": "Adressen",
|
||||||
|
"wallets_sort_asc": "Aufsteigend (ältestes / wenigste / kleinstes zuerst)",
|
||||||
|
"wallets_sort_by": "Sortieren:",
|
||||||
|
"wallets_sort_created": "Erstellt",
|
||||||
|
"wallets_sort_desc": "Absteigend (neuestes / meiste / größtes zuerst)",
|
||||||
|
"wallets_sort_size": "Größe",
|
||||||
|
"wallets_sort_txs": "Txs",
|
||||||
"wallets_title": "Wallets",
|
"wallets_title": "Wallets",
|
||||||
"warning": "Warnung",
|
"warning": "Warnung",
|
||||||
"warning_upper": "WARNUNG!",
|
"warning_upper": "WARNUNG!",
|
||||||
|
|||||||
@@ -1323,31 +1323,47 @@
|
|||||||
"wallets_add": "Agregar carpeta",
|
"wallets_add": "Agregar carpeta",
|
||||||
"wallets_add_folder": "Escanear también otra carpeta en busca de archivos de cartera:",
|
"wallets_add_folder": "Escanear también otra carpeta en busca de archivos de cartera:",
|
||||||
"wallets_add_folder_toggle": "+ Buscar wallets en otra carpeta…",
|
"wallets_add_folder_toggle": "+ Buscar wallets en otra carpeta…",
|
||||||
|
"wallets_badge_encrypted": "Cifrada (protegida con contraseña)",
|
||||||
|
"wallets_badge_encrypted_short": "Cifrada",
|
||||||
|
"wallets_badge_legacy": "Billetera heredada (sin frase semilla)",
|
||||||
|
"wallets_badge_legacy_short": "Heredada",
|
||||||
|
"wallets_badge_seed": "Billetera con frase semilla (HD)",
|
||||||
|
"wallets_badge_seed_short": "Frase semilla",
|
||||||
|
"wallets_badge_unknown": "Tipo de billetera no determinado por completo (archivo grande — abra para confirmar)",
|
||||||
|
"wallets_badge_unknown_short": "Desconocido",
|
||||||
"wallets_button": "Carteras…",
|
"wallets_button": "Carteras…",
|
||||||
"wallets_col_addresses": "Direcciones",
|
"wallets_col_addresses": "Direcciones",
|
||||||
"wallets_col_balance": "Saldo",
|
"wallets_col_balance": "Saldo",
|
||||||
|
"wallets_col_keys": "claves",
|
||||||
"wallets_col_name": "Cartera",
|
"wallets_col_name": "Cartera",
|
||||||
"wallets_col_opened": "Última apertura",
|
"wallets_col_opened": "Última apertura",
|
||||||
"wallets_col_size": "Tamaño",
|
"wallets_col_size": "Tamaño",
|
||||||
|
"wallets_col_txs": "tx",
|
||||||
"wallets_create": "Crear cartera",
|
"wallets_create": "Crear cartera",
|
||||||
|
"wallets_created": "creada",
|
||||||
"wallets_creating": "Creando cartera — el nodo se reiniciará…",
|
"wallets_creating": "Creando cartera — el nodo se reiniciará…",
|
||||||
"wallets_current": "actual",
|
"wallets_current": "actual",
|
||||||
"wallets_exists": "Ya existe una cartera con ese nombre.",
|
"wallets_exists": "Ya existe una cartera con ese nombre.",
|
||||||
"wallets_external_tt": "Fuera de tu directorio de datos — usa Importar para copiarlo.",
|
"wallets_external_tt": "Fuera de tu directorio de datos — Abrir lo enlaza en su lugar (sin copiar).",
|
||||||
"wallets_folder_hint": "/ruta/a/carpeta con archivos .dat de cartera",
|
"wallets_folder_hint": "/ruta/a/carpeta con archivos .dat de cartera",
|
||||||
"wallets_folder_invalid": "Esa carpeta no existe.",
|
"wallets_folder_invalid": "Esa carpeta no existe.",
|
||||||
"wallets_import": "Importar",
|
|
||||||
"wallets_import_failed": "No se pudo importar ese archivo de cartera.",
|
|
||||||
"wallets_import_hint": "Importar para abrir",
|
|
||||||
"wallets_import_tt": "Esta cartera está fuera del directorio de datos. Al abrirla se copiará primero dentro (próximamente).",
|
|
||||||
"wallets_imported": "Cartera importada — cambiando…",
|
|
||||||
"wallets_intro": "Archivos de cartera en tu directorio de datos (y en las carpetas que agregues). Abre uno para cambiar: el nodo se reinicia para cargarlo y cada cartera conserva sus propios datos.",
|
"wallets_intro": "Archivos de cartera en tu directorio de datos (y en las carpetas que agregues). Abre uno para cambiar: el nodo se reinicia para cargarlo y cada cartera conserva sus propios datos.",
|
||||||
"wallets_name_invalid": "Introduce un nombre de cartera válido.",
|
"wallets_name_invalid": "Introduce un nombre de cartera válido.",
|
||||||
"wallets_never": "Nunca",
|
"wallets_never": "Nunca abierta",
|
||||||
"wallets_new_hint": "Nombre (p. ej. ahorros)",
|
"wallets_new_hint": "Nombre (p. ej. ahorros)",
|
||||||
"wallets_new_label": "Crear una nueva cartera:",
|
"wallets_new_label": "Crear una nueva cartera:",
|
||||||
"wallets_open": "Abrir",
|
"wallets_open": "Abrir",
|
||||||
|
"wallets_open_failed": "No se pudo abrir esta cartera en su ubicación. Probablemente está en una unidad distinta a tu directorio de datos: muévela a la misma unidad (en Windows, activar el Modo de desarrollador también permite enlazar entre unidades).",
|
||||||
|
"wallets_open_folder": "Abrir ubicación de la carpeta",
|
||||||
|
"wallets_open_inplace_tt": "Abre esta cartera donde está, enlazándola al directorio de datos (sin copiar)",
|
||||||
"wallets_reveal": "Mostrar carpeta",
|
"wallets_reveal": "Mostrar carpeta",
|
||||||
|
"wallets_sort_addresses": "Direcciones",
|
||||||
|
"wallets_sort_asc": "Ascendente (más antiguo / menos / más pequeño primero)",
|
||||||
|
"wallets_sort_by": "Ordenar:",
|
||||||
|
"wallets_sort_created": "Creado",
|
||||||
|
"wallets_sort_desc": "Descendente (más reciente / más / más grande primero)",
|
||||||
|
"wallets_sort_size": "Tamaño",
|
||||||
|
"wallets_sort_txs": "Txs",
|
||||||
"wallets_title": "Carteras",
|
"wallets_title": "Carteras",
|
||||||
"warning": "Advertencia",
|
"warning": "Advertencia",
|
||||||
"warning_upper": "¡ADVERTENCIA!",
|
"warning_upper": "¡ADVERTENCIA!",
|
||||||
|
|||||||
@@ -1323,31 +1323,47 @@
|
|||||||
"wallets_add": "Ajouter le dossier",
|
"wallets_add": "Ajouter le dossier",
|
||||||
"wallets_add_folder": "Analyser aussi un autre dossier à la recherche de fichiers de portefeuille :",
|
"wallets_add_folder": "Analyser aussi un autre dossier à la recherche de fichiers de portefeuille :",
|
||||||
"wallets_add_folder_toggle": "+ Analyser un autre dossier pour les portefeuilles…",
|
"wallets_add_folder_toggle": "+ Analyser un autre dossier pour les portefeuilles…",
|
||||||
|
"wallets_badge_encrypted": "Chiffré (protégé par phrase secrète)",
|
||||||
|
"wallets_badge_encrypted_short": "Chiffré",
|
||||||
|
"wallets_badge_legacy": "Portefeuille hérité (sans phrase de récupération)",
|
||||||
|
"wallets_badge_legacy_short": "Hérité",
|
||||||
|
"wallets_badge_seed": "Portefeuille à phrase de récupération (HD)",
|
||||||
|
"wallets_badge_seed_short": "Phrase secrète",
|
||||||
|
"wallets_badge_unknown": "Type de portefeuille non entièrement déterminé (fichier volumineux — ouvrir pour confirmer)",
|
||||||
|
"wallets_badge_unknown_short": "Inconnu",
|
||||||
"wallets_button": "Portefeuilles…",
|
"wallets_button": "Portefeuilles…",
|
||||||
"wallets_col_addresses": "Adresses",
|
"wallets_col_addresses": "Adresses",
|
||||||
"wallets_col_balance": "Solde",
|
"wallets_col_balance": "Solde",
|
||||||
|
"wallets_col_keys": "clés",
|
||||||
"wallets_col_name": "Portefeuille",
|
"wallets_col_name": "Portefeuille",
|
||||||
"wallets_col_opened": "Dernière ouverture",
|
"wallets_col_opened": "Dernière ouverture",
|
||||||
"wallets_col_size": "Taille",
|
"wallets_col_size": "Taille",
|
||||||
|
"wallets_col_txs": "tx",
|
||||||
"wallets_create": "Créer le portefeuille",
|
"wallets_create": "Créer le portefeuille",
|
||||||
|
"wallets_created": "créé",
|
||||||
"wallets_creating": "Création du portefeuille — le nœud va redémarrer…",
|
"wallets_creating": "Création du portefeuille — le nœud va redémarrer…",
|
||||||
"wallets_current": "actuel",
|
"wallets_current": "actuel",
|
||||||
"wallets_exists": "Un portefeuille portant ce nom existe déjà.",
|
"wallets_exists": "Un portefeuille portant ce nom existe déjà.",
|
||||||
"wallets_external_tt": "Hors de votre répertoire de données — Importer pour le copier.",
|
"wallets_external_tt": "Hors de votre répertoire de données — Ouvrir le lie sur place (sans copie).",
|
||||||
"wallets_folder_hint": "/chemin/vers/le/dossier contenant les fichiers .dat",
|
"wallets_folder_hint": "/chemin/vers/le/dossier contenant les fichiers .dat",
|
||||||
"wallets_folder_invalid": "Ce dossier n'existe pas.",
|
"wallets_folder_invalid": "Ce dossier n'existe pas.",
|
||||||
"wallets_import": "Importer",
|
|
||||||
"wallets_import_failed": "Impossible d'importer ce fichier de portefeuille.",
|
|
||||||
"wallets_import_hint": "Importer pour ouvrir",
|
|
||||||
"wallets_import_tt": "Ce portefeuille se trouve hors du répertoire de données. L'ouvrir le copiera d'abord dedans (bientôt disponible).",
|
|
||||||
"wallets_imported": "Portefeuille importé — changement en cours…",
|
|
||||||
"wallets_intro": "Fichiers de portefeuille dans votre répertoire de données (et tout dossier que vous ajoutez). Ouvrez-en un pour changer — le nœud redémarre pour le charger, et chaque portefeuille conserve ses propres données.",
|
"wallets_intro": "Fichiers de portefeuille dans votre répertoire de données (et tout dossier que vous ajoutez). Ouvrez-en un pour changer — le nœud redémarre pour le charger, et chaque portefeuille conserve ses propres données.",
|
||||||
"wallets_name_invalid": "Veuillez saisir un nom de portefeuille valide.",
|
"wallets_name_invalid": "Veuillez saisir un nom de portefeuille valide.",
|
||||||
"wallets_never": "Jamais",
|
"wallets_never": "Jamais ouvert",
|
||||||
"wallets_new_hint": "Nom (par ex. épargne)",
|
"wallets_new_hint": "Nom (par ex. épargne)",
|
||||||
"wallets_new_label": "Créer un nouveau portefeuille :",
|
"wallets_new_label": "Créer un nouveau portefeuille :",
|
||||||
"wallets_open": "Ouvrir",
|
"wallets_open": "Ouvrir",
|
||||||
|
"wallets_open_failed": "Impossible d'ouvrir ce portefeuille à son emplacement. Il se trouve probablement sur un lecteur différent de votre répertoire de données — déplacez-le sur le même lecteur (sous Windows, activer le mode développeur permet aussi de créer des liens entre lecteurs).",
|
||||||
|
"wallets_open_folder": "Ouvrir l'emplacement du dossier",
|
||||||
|
"wallets_open_inplace_tt": "Ouvre ce portefeuille à son emplacement — lié au répertoire de données (sans copie)",
|
||||||
"wallets_reveal": "Afficher le dossier",
|
"wallets_reveal": "Afficher le dossier",
|
||||||
|
"wallets_sort_addresses": "Adresses",
|
||||||
|
"wallets_sort_asc": "Croissant (plus ancien / moins / plus petit d'abord)",
|
||||||
|
"wallets_sort_by": "Trier :",
|
||||||
|
"wallets_sort_created": "Créé",
|
||||||
|
"wallets_sort_desc": "Décroissant (plus récent / plus / plus grand d'abord)",
|
||||||
|
"wallets_sort_size": "Taille",
|
||||||
|
"wallets_sort_txs": "Txs",
|
||||||
"wallets_title": "Portefeuilles",
|
"wallets_title": "Portefeuilles",
|
||||||
"warning": "Attention",
|
"warning": "Attention",
|
||||||
"warning_upper": "ATTENTION !",
|
"warning_upper": "ATTENTION !",
|
||||||
|
|||||||
@@ -1323,31 +1323,47 @@
|
|||||||
"wallets_add": "フォルダを追加",
|
"wallets_add": "フォルダを追加",
|
||||||
"wallets_add_folder": "ウォレットファイルを検索する別のフォルダを追加:",
|
"wallets_add_folder": "ウォレットファイルを検索する別のフォルダを追加:",
|
||||||
"wallets_add_folder_toggle": "+ 別のフォルダーをウォレット検索…",
|
"wallets_add_folder_toggle": "+ 別のフォルダーをウォレット検索…",
|
||||||
|
"wallets_badge_encrypted": "暗号化済み(パスフレーズ保護)",
|
||||||
|
"wallets_badge_encrypted_short": "暗号化",
|
||||||
|
"wallets_badge_legacy": "レガシーウォレット(シードフレーズなし)",
|
||||||
|
"wallets_badge_legacy_short": "レガシー",
|
||||||
|
"wallets_badge_seed": "シードフレーズウォレット (HD)",
|
||||||
|
"wallets_badge_seed_short": "シードフレーズ",
|
||||||
|
"wallets_badge_unknown": "ウォレットの種類を完全に判定できません(大きなファイル — 開いて確認)",
|
||||||
|
"wallets_badge_unknown_short": "不明",
|
||||||
"wallets_button": "ウォレット…",
|
"wallets_button": "ウォレット…",
|
||||||
"wallets_col_addresses": "アドレス",
|
"wallets_col_addresses": "アドレス",
|
||||||
"wallets_col_balance": "残高",
|
"wallets_col_balance": "残高",
|
||||||
|
"wallets_col_keys": "個の鍵",
|
||||||
"wallets_col_name": "ウォレット",
|
"wallets_col_name": "ウォレット",
|
||||||
"wallets_col_opened": "最終使用",
|
"wallets_col_opened": "最終使用",
|
||||||
"wallets_col_size": "サイズ",
|
"wallets_col_size": "サイズ",
|
||||||
|
"wallets_col_txs": "件",
|
||||||
"wallets_create": "ウォレットを作成",
|
"wallets_create": "ウォレットを作成",
|
||||||
|
"wallets_created": "作成",
|
||||||
"wallets_creating": "ウォレットを作成中 — ノードが再起動します…",
|
"wallets_creating": "ウォレットを作成中 — ノードが再起動します…",
|
||||||
"wallets_current": "現在",
|
"wallets_current": "現在",
|
||||||
"wallets_exists": "その名前のウォレットは既に存在します。",
|
"wallets_exists": "その名前のウォレットは既に存在します。",
|
||||||
"wallets_external_tt": "データディレクトリの外にあります — インポートでコピーします。",
|
"wallets_external_tt": "データディレクトリの外 —「開く」はその場でリンクします(コピーなし)。",
|
||||||
"wallets_folder_hint": "wallet .dat ファイルのあるフォルダのパス",
|
"wallets_folder_hint": "wallet .dat ファイルのあるフォルダのパス",
|
||||||
"wallets_folder_invalid": "そのフォルダは存在しません。",
|
"wallets_folder_invalid": "そのフォルダは存在しません。",
|
||||||
"wallets_import": "インポート",
|
|
||||||
"wallets_import_failed": "そのウォレットファイルをインポートできませんでした。",
|
|
||||||
"wallets_import_hint": "インポートして開く",
|
|
||||||
"wallets_import_tt": "このウォレットはデータディレクトリの外にあります。開くには先にコピーが必要です(近日対応予定)。",
|
|
||||||
"wallets_imported": "ウォレットをインポートしました — 切り替え中…",
|
|
||||||
"wallets_intro": "データディレクトリ(および追加したフォルダ)内のウォレットファイルです。開いて切り替えると、それを読み込むためにノードが再起動します。各ウォレットは個別のデータを保持します。",
|
"wallets_intro": "データディレクトリ(および追加したフォルダ)内のウォレットファイルです。開いて切り替えると、それを読み込むためにノードが再起動します。各ウォレットは個別のデータを保持します。",
|
||||||
"wallets_name_invalid": "有効なウォレット名を入力してください。",
|
"wallets_name_invalid": "有効なウォレット名を入力してください。",
|
||||||
"wallets_never": "なし",
|
"wallets_never": "未使用",
|
||||||
"wallets_new_hint": "名前(例:savings)",
|
"wallets_new_hint": "名前(例:savings)",
|
||||||
"wallets_new_label": "新しいウォレットを作成:",
|
"wallets_new_label": "新しいウォレットを作成:",
|
||||||
"wallets_open": "開く",
|
"wallets_open": "開く",
|
||||||
|
"wallets_open_failed": "このウォレットをその場で開けませんでした。データディレクトリとは別のドライブにある可能性があります — 同じドライブに移動してください(Windows では開発者モードを有効にするとドライブ間のリンクも可能になります)。",
|
||||||
|
"wallets_open_folder": "フォルダーの場所を開く",
|
||||||
|
"wallets_open_inplace_tt": "このウォレットをその場で開きます — データディレクトリにリンク(コピーなし)",
|
||||||
"wallets_reveal": "フォルダを開く",
|
"wallets_reveal": "フォルダを開く",
|
||||||
|
"wallets_sort_addresses": "アドレス",
|
||||||
|
"wallets_sort_asc": "昇順(古い/少ない/小さい順)",
|
||||||
|
"wallets_sort_by": "並べ替え:",
|
||||||
|
"wallets_sort_created": "作成",
|
||||||
|
"wallets_sort_desc": "降順(新しい/多い/大きい順)",
|
||||||
|
"wallets_sort_size": "サイズ",
|
||||||
|
"wallets_sort_txs": "取引",
|
||||||
"wallets_title": "ウォレット",
|
"wallets_title": "ウォレット",
|
||||||
"warning": "警告",
|
"warning": "警告",
|
||||||
"warning_upper": "警告!",
|
"warning_upper": "警告!",
|
||||||
|
|||||||
@@ -1323,31 +1323,47 @@
|
|||||||
"wallets_add": "폴더 추가",
|
"wallets_add": "폴더 추가",
|
||||||
"wallets_add_folder": "지갑 파일을 검색할 폴더를 추가로 지정:",
|
"wallets_add_folder": "지갑 파일을 검색할 폴더를 추가로 지정:",
|
||||||
"wallets_add_folder_toggle": "+ 다른 폴더에서 지갑 검색…",
|
"wallets_add_folder_toggle": "+ 다른 폴더에서 지갑 검색…",
|
||||||
|
"wallets_badge_encrypted": "암호화됨 (암호로 보호됨)",
|
||||||
|
"wallets_badge_encrypted_short": "암호화됨",
|
||||||
|
"wallets_badge_legacy": "레거시 지갑 (시드 문구 없음)",
|
||||||
|
"wallets_badge_legacy_short": "레거시",
|
||||||
|
"wallets_badge_seed": "시드 문구 지갑 (HD)",
|
||||||
|
"wallets_badge_seed_short": "시드 문구",
|
||||||
|
"wallets_badge_unknown": "지갑 유형을 완전히 확인하지 못함 (큰 파일 — 열어서 확인)",
|
||||||
|
"wallets_badge_unknown_short": "알 수 없음",
|
||||||
"wallets_button": "지갑…",
|
"wallets_button": "지갑…",
|
||||||
"wallets_col_addresses": "주소",
|
"wallets_col_addresses": "주소",
|
||||||
"wallets_col_balance": "잔액",
|
"wallets_col_balance": "잔액",
|
||||||
|
"wallets_col_keys": "개 키",
|
||||||
"wallets_col_name": "지갑",
|
"wallets_col_name": "지갑",
|
||||||
"wallets_col_opened": "마지막 열람",
|
"wallets_col_opened": "마지막 열람",
|
||||||
"wallets_col_size": "크기",
|
"wallets_col_size": "크기",
|
||||||
|
"wallets_col_txs": "건",
|
||||||
"wallets_create": "지갑 만들기",
|
"wallets_create": "지갑 만들기",
|
||||||
|
"wallets_created": "생성",
|
||||||
"wallets_creating": "지갑을 만드는 중 — 노드가 재시작됩니다…",
|
"wallets_creating": "지갑을 만드는 중 — 노드가 재시작됩니다…",
|
||||||
"wallets_current": "현재",
|
"wallets_current": "현재",
|
||||||
"wallets_exists": "같은 이름의 지갑이 이미 존재합니다.",
|
"wallets_exists": "같은 이름의 지갑이 이미 존재합니다.",
|
||||||
"wallets_external_tt": "데이터 디렉터리 외부에 있음 — 가져오기로 복사합니다.",
|
"wallets_external_tt": "데이터 디렉터리 밖 — '열기'는 제자리에 링크합니다 (복사 없음).",
|
||||||
"wallets_folder_hint": "wallet-*.dat 파일이 있는 폴더 경로",
|
"wallets_folder_hint": "wallet-*.dat 파일이 있는 폴더 경로",
|
||||||
"wallets_folder_invalid": "해당 폴더가 존재하지 않습니다.",
|
"wallets_folder_invalid": "해당 폴더가 존재하지 않습니다.",
|
||||||
"wallets_import": "가져오기",
|
|
||||||
"wallets_import_failed": "해당 지갑 파일을 가져올 수 없습니다.",
|
|
||||||
"wallets_import_hint": "가져와서 열기",
|
|
||||||
"wallets_import_tt": "이 지갑은 데이터 디렉터리 밖에 있습니다. 열면 먼저 안으로 복사됩니다(출시 예정).",
|
|
||||||
"wallets_imported": "지갑을 가져왔습니다 — 전환 중…",
|
|
||||||
"wallets_intro": "데이터 디렉터리(및 추가한 폴더)에 있는 지갑 파일입니다. 하나를 열면 전환됩니다 — 노드가 재시작되어 해당 지갑을 불러오며, 각 지갑은 고유한 데이터를 유지합니다.",
|
"wallets_intro": "데이터 디렉터리(및 추가한 폴더)에 있는 지갑 파일입니다. 하나를 열면 전환됩니다 — 노드가 재시작되어 해당 지갑을 불러오며, 각 지갑은 고유한 데이터를 유지합니다.",
|
||||||
"wallets_name_invalid": "유효한 지갑 이름을 입력하세요.",
|
"wallets_name_invalid": "유효한 지갑 이름을 입력하세요.",
|
||||||
"wallets_never": "없음",
|
"wallets_never": "미사용",
|
||||||
"wallets_new_hint": "이름(예: savings)",
|
"wallets_new_hint": "이름(예: savings)",
|
||||||
"wallets_new_label": "새 지갑 만들기:",
|
"wallets_new_label": "새 지갑 만들기:",
|
||||||
"wallets_open": "열기",
|
"wallets_open": "열기",
|
||||||
|
"wallets_open_failed": "이 지갑을 제자리에서 열 수 없습니다. 데이터 디렉터리와 다른 드라이브에 있을 가능성이 높습니다 — 같은 드라이브로 옮기세요 (Windows에서는 개발자 모드를 켜면 드라이브 간 링크도 가능합니다).",
|
||||||
|
"wallets_open_folder": "폴더 위치 열기",
|
||||||
|
"wallets_open_inplace_tt": "이 지갑을 있는 자리에서 엽니다 — 데이터 디렉터리에 링크 (복사 없음)",
|
||||||
"wallets_reveal": "폴더 열기",
|
"wallets_reveal": "폴더 열기",
|
||||||
|
"wallets_sort_addresses": "주소",
|
||||||
|
"wallets_sort_asc": "오름차순 (오래된/적은/작은 순)",
|
||||||
|
"wallets_sort_by": "정렬:",
|
||||||
|
"wallets_sort_created": "생성",
|
||||||
|
"wallets_sort_desc": "내림차순 (최신/많은/큰 순)",
|
||||||
|
"wallets_sort_size": "크기",
|
||||||
|
"wallets_sort_txs": "거래",
|
||||||
"wallets_title": "지갑",
|
"wallets_title": "지갑",
|
||||||
"warning": "경고",
|
"warning": "경고",
|
||||||
"warning_upper": "경고!",
|
"warning_upper": "경고!",
|
||||||
|
|||||||
@@ -1323,31 +1323,47 @@
|
|||||||
"wallets_add": "Adicionar pasta",
|
"wallets_add": "Adicionar pasta",
|
||||||
"wallets_add_folder": "Verificar também outra pasta em busca de arquivos de carteira:",
|
"wallets_add_folder": "Verificar também outra pasta em busca de arquivos de carteira:",
|
||||||
"wallets_add_folder_toggle": "+ Procurar carteiras noutra pasta…",
|
"wallets_add_folder_toggle": "+ Procurar carteiras noutra pasta…",
|
||||||
|
"wallets_badge_encrypted": "Encriptada (protegida por senha)",
|
||||||
|
"wallets_badge_encrypted_short": "Encriptada",
|
||||||
|
"wallets_badge_legacy": "Carteira legada (sem frase semente)",
|
||||||
|
"wallets_badge_legacy_short": "Legada",
|
||||||
|
"wallets_badge_seed": "Carteira com frase semente (HD)",
|
||||||
|
"wallets_badge_seed_short": "Frase semente",
|
||||||
|
"wallets_badge_unknown": "Tipo de carteira não totalmente determinado (arquivo grande — abra para confirmar)",
|
||||||
|
"wallets_badge_unknown_short": "Desconhecido",
|
||||||
"wallets_button": "Carteiras…",
|
"wallets_button": "Carteiras…",
|
||||||
"wallets_col_addresses": "Endereços",
|
"wallets_col_addresses": "Endereços",
|
||||||
"wallets_col_balance": "Saldo",
|
"wallets_col_balance": "Saldo",
|
||||||
|
"wallets_col_keys": "chaves",
|
||||||
"wallets_col_name": "Carteira",
|
"wallets_col_name": "Carteira",
|
||||||
"wallets_col_opened": "Aberta pela última vez",
|
"wallets_col_opened": "Aberta pela última vez",
|
||||||
"wallets_col_size": "Tamanho",
|
"wallets_col_size": "Tamanho",
|
||||||
|
"wallets_col_txs": "tx",
|
||||||
"wallets_create": "Criar carteira",
|
"wallets_create": "Criar carteira",
|
||||||
|
"wallets_created": "criada",
|
||||||
"wallets_creating": "Criando carteira — o nó será reiniciado…",
|
"wallets_creating": "Criando carteira — o nó será reiniciado…",
|
||||||
"wallets_current": "atual",
|
"wallets_current": "atual",
|
||||||
"wallets_exists": "Já existe uma carteira com esse nome.",
|
"wallets_exists": "Já existe uma carteira com esse nome.",
|
||||||
"wallets_external_tt": "Fora do seu diretório de dados — use Importar para copiá-lo.",
|
"wallets_external_tt": "Fora do seu diretório de dados — Abrir o vincula no lugar (sem cópia).",
|
||||||
"wallets_folder_hint": "/caminho/para/pasta com arquivos .dat de carteira",
|
"wallets_folder_hint": "/caminho/para/pasta com arquivos .dat de carteira",
|
||||||
"wallets_folder_invalid": "Essa pasta não existe.",
|
"wallets_folder_invalid": "Essa pasta não existe.",
|
||||||
"wallets_import": "Importar",
|
|
||||||
"wallets_import_failed": "Não foi possível importar esse arquivo de carteira.",
|
|
||||||
"wallets_import_hint": "Importar para abrir",
|
|
||||||
"wallets_import_tt": "Esta carteira está fora do diretório de dados. Ao abri-la, ela será copiada para lá primeiro (em breve).",
|
|
||||||
"wallets_imported": "Carteira importada — alternando…",
|
|
||||||
"wallets_intro": "Arquivos de carteira no seu diretório de dados (e em quaisquer pastas que você adicionar). Abra um para alternar — o nó reinicia para carregá-lo, e cada carteira mantém seus próprios dados.",
|
"wallets_intro": "Arquivos de carteira no seu diretório de dados (e em quaisquer pastas que você adicionar). Abra um para alternar — o nó reinicia para carregá-lo, e cada carteira mantém seus próprios dados.",
|
||||||
"wallets_name_invalid": "Insira um nome de carteira válido.",
|
"wallets_name_invalid": "Insira um nome de carteira válido.",
|
||||||
"wallets_never": "Nunca",
|
"wallets_never": "Nunca aberta",
|
||||||
"wallets_new_hint": "Nome (ex.: poupança)",
|
"wallets_new_hint": "Nome (ex.: poupança)",
|
||||||
"wallets_new_label": "Criar uma nova carteira:",
|
"wallets_new_label": "Criar uma nova carteira:",
|
||||||
"wallets_open": "Abrir",
|
"wallets_open": "Abrir",
|
||||||
|
"wallets_open_failed": "Não foi possível abrir esta carteira no lugar. Provavelmente está em uma unidade diferente do seu diretório de dados — mova-a para a mesma unidade (no Windows, ativar o Modo de Desenvolvedor também permite vincular entre unidades).",
|
||||||
|
"wallets_open_folder": "Abrir local da pasta",
|
||||||
|
"wallets_open_inplace_tt": "Abre esta carteira onde está — vinculada ao diretório de dados (sem cópia)",
|
||||||
"wallets_reveal": "Mostrar pasta",
|
"wallets_reveal": "Mostrar pasta",
|
||||||
|
"wallets_sort_addresses": "Endereços",
|
||||||
|
"wallets_sort_asc": "Crescente (mais antigo / menos / menor primeiro)",
|
||||||
|
"wallets_sort_by": "Ordenar:",
|
||||||
|
"wallets_sort_created": "Criado",
|
||||||
|
"wallets_sort_desc": "Decrescente (mais recente / mais / maior primeiro)",
|
||||||
|
"wallets_sort_size": "Tamanho",
|
||||||
|
"wallets_sort_txs": "Txs",
|
||||||
"wallets_title": "Carteiras",
|
"wallets_title": "Carteiras",
|
||||||
"warning": "Aviso",
|
"warning": "Aviso",
|
||||||
"warning_upper": "AVISO!",
|
"warning_upper": "AVISO!",
|
||||||
|
|||||||
@@ -1323,31 +1323,47 @@
|
|||||||
"wallets_add": "Добавить папку",
|
"wallets_add": "Добавить папку",
|
||||||
"wallets_add_folder": "Также сканировать другую папку на наличие файлов кошельков:",
|
"wallets_add_folder": "Также сканировать другую папку на наличие файлов кошельков:",
|
||||||
"wallets_add_folder_toggle": "+ Искать кошельки в другой папке…",
|
"wallets_add_folder_toggle": "+ Искать кошельки в другой папке…",
|
||||||
|
"wallets_badge_encrypted": "Зашифрован (защищён паролем)",
|
||||||
|
"wallets_badge_encrypted_short": "Зашифрован",
|
||||||
|
"wallets_badge_legacy": "Устаревший кошелёк (без seed-фразы)",
|
||||||
|
"wallets_badge_legacy_short": "Устаревший",
|
||||||
|
"wallets_badge_seed": "Кошелёк с seed-фразой (HD)",
|
||||||
|
"wallets_badge_seed_short": "Seed-фраза",
|
||||||
|
"wallets_badge_unknown": "Тип кошелька определён не полностью (большой файл — откройте для подтверждения)",
|
||||||
|
"wallets_badge_unknown_short": "Неизвестно",
|
||||||
"wallets_button": "Кошельки…",
|
"wallets_button": "Кошельки…",
|
||||||
"wallets_col_addresses": "Адреса",
|
"wallets_col_addresses": "Адреса",
|
||||||
"wallets_col_balance": "Баланс",
|
"wallets_col_balance": "Баланс",
|
||||||
|
"wallets_col_keys": "ключей",
|
||||||
"wallets_col_name": "Кошелёк",
|
"wallets_col_name": "Кошелёк",
|
||||||
"wallets_col_opened": "Последнее открытие",
|
"wallets_col_opened": "Последнее открытие",
|
||||||
"wallets_col_size": "Размер",
|
"wallets_col_size": "Размер",
|
||||||
|
"wallets_col_txs": "трз",
|
||||||
"wallets_create": "Создать кошелёк",
|
"wallets_create": "Создать кошелёк",
|
||||||
|
"wallets_created": "создан",
|
||||||
"wallets_creating": "Создание кошелька — узел будет перезапущен…",
|
"wallets_creating": "Создание кошелька — узел будет перезапущен…",
|
||||||
"wallets_current": "текущий",
|
"wallets_current": "текущий",
|
||||||
"wallets_exists": "Кошелёк с таким именем уже существует.",
|
"wallets_exists": "Кошелёк с таким именем уже существует.",
|
||||||
"wallets_external_tt": "Вне вашего каталога данных — «Импорт» скопирует его.",
|
"wallets_external_tt": "Вне каталога данных — «Открыть» создаёт ссылку на месте (без копирования).",
|
||||||
"wallets_folder_hint": "/путь/к/папке с файлами кошельков .dat",
|
"wallets_folder_hint": "/путь/к/папке с файлами кошельков .dat",
|
||||||
"wallets_folder_invalid": "Такой папки не существует.",
|
"wallets_folder_invalid": "Такой папки не существует.",
|
||||||
"wallets_import": "Импорт",
|
|
||||||
"wallets_import_failed": "Не удалось импортировать этот файл кошелька.",
|
|
||||||
"wallets_import_hint": "Импортировать для открытия",
|
|
||||||
"wallets_import_tt": "Этот кошелёк находится вне папки данных. При открытии он сначала будет скопирован в неё (скоро).",
|
|
||||||
"wallets_imported": "Кошелёк импортирован — переключение…",
|
|
||||||
"wallets_intro": "Файлы кошельков в вашей папке данных (и в любых добавленных вами папках). Откройте один, чтобы переключиться — узел перезапустится, чтобы загрузить его, и у каждого кошелька свои данные.",
|
"wallets_intro": "Файлы кошельков в вашей папке данных (и в любых добавленных вами папках). Откройте один, чтобы переключиться — узел перезапустится, чтобы загрузить его, и у каждого кошелька свои данные.",
|
||||||
"wallets_name_invalid": "Введите допустимое имя кошелька.",
|
"wallets_name_invalid": "Введите допустимое имя кошелька.",
|
||||||
"wallets_never": "Никогда",
|
"wallets_never": "Не открывался",
|
||||||
"wallets_new_hint": "Имя (например, savings)",
|
"wallets_new_hint": "Имя (например, savings)",
|
||||||
"wallets_new_label": "Создать новый кошелёк:",
|
"wallets_new_label": "Создать новый кошелёк:",
|
||||||
"wallets_open": "Открыть",
|
"wallets_open": "Открыть",
|
||||||
|
"wallets_open_failed": "Не удалось открыть этот кошелёк на месте. Вероятно, он на другом диске, чем каталог данных — переместите его на тот же диск (в Windows включённый режим разработчика также позволяет создавать ссылки между дисками).",
|
||||||
|
"wallets_open_folder": "Открыть расположение папки",
|
||||||
|
"wallets_open_inplace_tt": "Открывает этот кошелёк на месте — по ссылке в каталоге данных (без копирования)",
|
||||||
"wallets_reveal": "Открыть папку",
|
"wallets_reveal": "Открыть папку",
|
||||||
|
"wallets_sort_addresses": "Адреса",
|
||||||
|
"wallets_sort_asc": "По возрастанию (сначала старые / меньше / меньший)",
|
||||||
|
"wallets_sort_by": "Сортировка:",
|
||||||
|
"wallets_sort_created": "Создан",
|
||||||
|
"wallets_sort_desc": "По убыванию (сначала новые / больше / больший)",
|
||||||
|
"wallets_sort_size": "Размер",
|
||||||
|
"wallets_sort_txs": "Транз.",
|
||||||
"wallets_title": "Кошельки",
|
"wallets_title": "Кошельки",
|
||||||
"warning": "Предупреждение",
|
"warning": "Предупреждение",
|
||||||
"warning_upper": "ПРЕДУПРЕЖДЕНИЕ!",
|
"warning_upper": "ПРЕДУПРЕЖДЕНИЕ!",
|
||||||
|
|||||||
@@ -1323,31 +1323,47 @@
|
|||||||
"wallets_add": "添加文件夹",
|
"wallets_add": "添加文件夹",
|
||||||
"wallets_add_folder": "同时扫描另一个文件夹中的钱包文件:",
|
"wallets_add_folder": "同时扫描另一个文件夹中的钱包文件:",
|
||||||
"wallets_add_folder_toggle": "+ 扫描其他文件夹中的钱包…",
|
"wallets_add_folder_toggle": "+ 扫描其他文件夹中的钱包…",
|
||||||
|
"wallets_badge_encrypted": "已加密(密码保护)",
|
||||||
|
"wallets_badge_encrypted_short": "已加密",
|
||||||
|
"wallets_badge_legacy": "旧版钱包(无助记词)",
|
||||||
|
"wallets_badge_legacy_short": "旧版",
|
||||||
|
"wallets_badge_seed": "助记词钱包 (HD)",
|
||||||
|
"wallets_badge_seed_short": "助记词",
|
||||||
|
"wallets_badge_unknown": "钱包类型未完全确定(文件较大——打开以确认)",
|
||||||
|
"wallets_badge_unknown_short": "未知",
|
||||||
"wallets_button": "钱包…",
|
"wallets_button": "钱包…",
|
||||||
"wallets_col_addresses": "地址",
|
"wallets_col_addresses": "地址",
|
||||||
"wallets_col_balance": "余额",
|
"wallets_col_balance": "余额",
|
||||||
|
"wallets_col_keys": "个密钥",
|
||||||
"wallets_col_name": "钱包",
|
"wallets_col_name": "钱包",
|
||||||
"wallets_col_opened": "上次打开",
|
"wallets_col_opened": "上次打开",
|
||||||
"wallets_col_size": "大小",
|
"wallets_col_size": "大小",
|
||||||
|
"wallets_col_txs": "笔交易",
|
||||||
"wallets_create": "创建钱包",
|
"wallets_create": "创建钱包",
|
||||||
|
"wallets_created": "创建于",
|
||||||
"wallets_creating": "正在创建钱包——节点将重启…",
|
"wallets_creating": "正在创建钱包——节点将重启…",
|
||||||
"wallets_current": "当前",
|
"wallets_current": "当前",
|
||||||
"wallets_exists": "已存在同名钱包。",
|
"wallets_exists": "已存在同名钱包。",
|
||||||
"wallets_external_tt": "位于数据目录之外 — 导入以将其复制进来。",
|
"wallets_external_tt": "在数据目录之外 —「打开」会就地链接(不复制)。",
|
||||||
"wallets_folder_hint": "/含 .dat 钱包文件的文件夹路径",
|
"wallets_folder_hint": "/含 .dat 钱包文件的文件夹路径",
|
||||||
"wallets_folder_invalid": "该文件夹不存在。",
|
"wallets_folder_invalid": "该文件夹不存在。",
|
||||||
"wallets_import": "导入",
|
|
||||||
"wallets_import_failed": "无法导入该钱包文件。",
|
|
||||||
"wallets_import_hint": "导入以打开",
|
|
||||||
"wallets_import_tt": "此钱包位于数据目录之外。打开它会先将其复制进来(即将推出)。",
|
|
||||||
"wallets_imported": "钱包已导入——正在切换…",
|
|
||||||
"wallets_intro": "位于您数据目录(以及您添加的任何文件夹)中的钱包文件。打开一个即可切换——节点将重启以加载它,且每个钱包各自保留其数据。",
|
"wallets_intro": "位于您数据目录(以及您添加的任何文件夹)中的钱包文件。打开一个即可切换——节点将重启以加载它,且每个钱包各自保留其数据。",
|
||||||
"wallets_name_invalid": "请输入有效的钱包名称。",
|
"wallets_name_invalid": "请输入有效的钱包名称。",
|
||||||
"wallets_never": "从未",
|
"wallets_never": "从未打开",
|
||||||
"wallets_new_hint": "名称(例如 savings)",
|
"wallets_new_hint": "名称(例如 savings)",
|
||||||
"wallets_new_label": "创建新钱包:",
|
"wallets_new_label": "创建新钱包:",
|
||||||
"wallets_open": "打开",
|
"wallets_open": "打开",
|
||||||
|
"wallets_open_failed": "无法在原位置打开此钱包。它可能与数据目录位于不同的驱动器上 — 请将其移动到同一驱动器(在 Windows 上,启用开发者模式也可跨驱动器链接)。",
|
||||||
|
"wallets_open_folder": "打开文件夹位置",
|
||||||
|
"wallets_open_inplace_tt": "在原位置打开此钱包 — 链接到数据目录(不复制)",
|
||||||
"wallets_reveal": "打开文件夹",
|
"wallets_reveal": "打开文件夹",
|
||||||
|
"wallets_sort_addresses": "地址",
|
||||||
|
"wallets_sort_asc": "升序(最早/最少/最小优先)",
|
||||||
|
"wallets_sort_by": "排序:",
|
||||||
|
"wallets_sort_created": "创建",
|
||||||
|
"wallets_sort_desc": "降序(最新/最多/最大优先)",
|
||||||
|
"wallets_sort_size": "大小",
|
||||||
|
"wallets_sort_txs": "交易",
|
||||||
"wallets_title": "钱包",
|
"wallets_title": "钱包",
|
||||||
"warning": "警告",
|
"warning": "警告",
|
||||||
"warning_upper": "警告!",
|
"warning_upper": "警告!",
|
||||||
|
|||||||
@@ -2100,6 +2100,138 @@ TRANSLATIONS = {
|
|||||||
"pt": "EXPLORADOR", "ru": "ОБОЗРЕВАТЕЛЬ", "zh": "浏览器",
|
"pt": "EXPLORADOR", "ru": "ОБОЗРЕВАТЕЛЬ", "zh": "浏览器",
|
||||||
"ja": "エクスプローラー", "ko": "탐색기"
|
"ja": "エクスプローラー", "ko": "탐색기"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
# --- Wallets dialog: metadata counts + status badges ---
|
||||||
|
"wallets_col_txs": {
|
||||||
|
"es": "tx", "de": "Tx", "fr": "tx", "pt": "tx",
|
||||||
|
"ru": "трз", "zh": "笔交易", "ja": "件", "ko": "건"
|
||||||
|
},
|
||||||
|
"wallets_col_keys": {
|
||||||
|
"es": "claves", "de": "Schlüssel", "fr": "clés", "pt": "chaves",
|
||||||
|
"ru": "ключей", "zh": "个密钥", "ja": "個の鍵", "ko": "개 키"
|
||||||
|
},
|
||||||
|
"wallets_badge_encrypted": {
|
||||||
|
"es": "Cifrada (protegida con contraseña)", "de": "Verschlüsselt (passphrasengeschützt)",
|
||||||
|
"fr": "Chiffré (protégé par phrase secrète)", "pt": "Encriptada (protegida por senha)",
|
||||||
|
"ru": "Зашифрован (защищён паролем)", "zh": "已加密(密码保护)",
|
||||||
|
"ja": "暗号化済み(パスフレーズ保護)", "ko": "암호화됨 (암호로 보호됨)"
|
||||||
|
},
|
||||||
|
"wallets_badge_seed": {
|
||||||
|
"es": "Billetera con frase semilla (HD)", "de": "Seed-Phrase-Wallet (HD)",
|
||||||
|
"fr": "Portefeuille à phrase de récupération (HD)", "pt": "Carteira com frase semente (HD)",
|
||||||
|
"ru": "Кошелёк с seed-фразой (HD)", "zh": "助记词钱包 (HD)",
|
||||||
|
"ja": "シードフレーズウォレット (HD)", "ko": "시드 문구 지갑 (HD)"
|
||||||
|
},
|
||||||
|
"wallets_badge_legacy": {
|
||||||
|
"es": "Billetera heredada (sin frase semilla)", "de": "Legacy-Wallet (keine Seed-Phrase)",
|
||||||
|
"fr": "Portefeuille hérité (sans phrase de récupération)", "pt": "Carteira legada (sem frase semente)",
|
||||||
|
"ru": "Устаревший кошелёк (без seed-фразы)", "zh": "旧版钱包(无助记词)",
|
||||||
|
"ja": "レガシーウォレット(シードフレーズなし)", "ko": "레거시 지갑 (시드 문구 없음)"
|
||||||
|
},
|
||||||
|
"wallets_badge_unknown": {
|
||||||
|
"es": "Tipo de billetera no determinado por completo (archivo grande — abra para confirmar)",
|
||||||
|
"de": "Wallet-Typ nicht vollständig ermittelt (große Datei — zum Bestätigen öffnen)",
|
||||||
|
"fr": "Type de portefeuille non entièrement déterminé (fichier volumineux — ouvrir pour confirmer)",
|
||||||
|
"pt": "Tipo de carteira não totalmente determinado (arquivo grande — abra para confirmar)",
|
||||||
|
"ru": "Тип кошелька определён не полностью (большой файл — откройте для подтверждения)",
|
||||||
|
"zh": "钱包类型未完全确定(文件较大——打开以确认)",
|
||||||
|
"ja": "ウォレットの種類を完全に判定できません(大きなファイル — 開いて確認)",
|
||||||
|
"ko": "지갑 유형을 완전히 확인하지 못함 (큰 파일 — 열어서 확인)"
|
||||||
|
},
|
||||||
|
"wallets_badge_seed_short": {
|
||||||
|
"es": "Frase semilla", "de": "Seed-Phrase", "fr": "Phrase secrète", "pt": "Frase semente",
|
||||||
|
"ru": "Seed-фраза", "zh": "助记词", "ja": "シードフレーズ", "ko": "시드 문구"
|
||||||
|
},
|
||||||
|
"wallets_badge_encrypted_short": {
|
||||||
|
"es": "Cifrada", "de": "Verschlüsselt", "fr": "Chiffré", "pt": "Encriptada",
|
||||||
|
"ru": "Зашифрован", "zh": "已加密", "ja": "暗号化", "ko": "암호화됨"
|
||||||
|
},
|
||||||
|
"wallets_badge_legacy_short": {
|
||||||
|
"es": "Heredada", "de": "Legacy", "fr": "Hérité", "pt": "Legada",
|
||||||
|
"ru": "Устаревший", "zh": "旧版", "ja": "レガシー", "ko": "레거시"
|
||||||
|
},
|
||||||
|
"wallets_badge_unknown_short": {
|
||||||
|
"es": "Desconocido", "de": "Unbekannt", "fr": "Inconnu", "pt": "Desconhecido",
|
||||||
|
"ru": "Неизвестно", "zh": "未知", "ja": "不明", "ko": "알 수 없음"
|
||||||
|
},
|
||||||
|
|
||||||
|
# --- Wallets dialog: created date + sort control ---
|
||||||
|
"wallets_created": {
|
||||||
|
"es": "creada", "de": "erstellt", "fr": "créé", "pt": "criada",
|
||||||
|
"ru": "создан", "zh": "创建于", "ja": "作成", "ko": "생성"
|
||||||
|
},
|
||||||
|
"wallets_sort_by": {
|
||||||
|
"es": "Ordenar:", "de": "Sortieren:", "fr": "Trier :", "pt": "Ordenar:",
|
||||||
|
"ru": "Сортировка:", "zh": "排序:", "ja": "並べ替え:", "ko": "정렬:"
|
||||||
|
},
|
||||||
|
"wallets_sort_created": {
|
||||||
|
"es": "Creado", "de": "Erstellt", "fr": "Créé", "pt": "Criado",
|
||||||
|
"ru": "Создан", "zh": "创建", "ja": "作成", "ko": "생성"
|
||||||
|
},
|
||||||
|
"wallets_sort_addresses": {
|
||||||
|
"es": "Direcciones", "de": "Adressen", "fr": "Adresses", "pt": "Endereços",
|
||||||
|
"ru": "Адреса", "zh": "地址", "ja": "アドレス", "ko": "주소"
|
||||||
|
},
|
||||||
|
"wallets_sort_txs": {
|
||||||
|
"es": "Txs", "de": "Txs", "fr": "Txs", "pt": "Txs",
|
||||||
|
"ru": "Транз.", "zh": "交易", "ja": "取引", "ko": "거래"
|
||||||
|
},
|
||||||
|
"wallets_sort_size": {
|
||||||
|
"es": "Tamaño", "de": "Größe", "fr": "Taille", "pt": "Tamanho",
|
||||||
|
"ru": "Размер", "zh": "大小", "ja": "サイズ", "ko": "크기"
|
||||||
|
},
|
||||||
|
"wallets_sort_asc": {
|
||||||
|
"es": "Ascendente (más antiguo / menos / más pequeño primero)",
|
||||||
|
"de": "Aufsteigend (ältestes / wenigste / kleinstes zuerst)",
|
||||||
|
"fr": "Croissant (plus ancien / moins / plus petit d'abord)",
|
||||||
|
"pt": "Crescente (mais antigo / menos / menor primeiro)",
|
||||||
|
"ru": "По возрастанию (сначала старые / меньше / меньший)",
|
||||||
|
"zh": "升序(最早/最少/最小优先)", "ja": "昇順(古い/少ない/小さい順)", "ko": "오름차순 (오래된/적은/작은 순)"
|
||||||
|
},
|
||||||
|
"wallets_sort_desc": {
|
||||||
|
"es": "Descendente (más reciente / más / más grande primero)",
|
||||||
|
"de": "Absteigend (neuestes / meiste / größtes zuerst)",
|
||||||
|
"fr": "Décroissant (plus récent / plus / plus grand d'abord)",
|
||||||
|
"pt": "Decrescente (mais recente / mais / maior primeiro)",
|
||||||
|
"ru": "По убыванию (сначала новые / больше / больший)",
|
||||||
|
"zh": "降序(最新/最多/最大优先)", "ja": "降順(新しい/多い/大きい順)", "ko": "내림차순 (최신/많은/큰 순)"
|
||||||
|
},
|
||||||
|
"wallets_open_folder": {
|
||||||
|
"es": "Abrir ubicación de la carpeta", "de": "Ordnerpfad öffnen",
|
||||||
|
"fr": "Ouvrir l'emplacement du dossier", "pt": "Abrir local da pasta",
|
||||||
|
"ru": "Открыть расположение папки", "zh": "打开文件夹位置",
|
||||||
|
"ja": "フォルダーの場所を開く", "ko": "폴더 위치 열기"
|
||||||
|
},
|
||||||
|
"wallets_open_inplace_tt": {
|
||||||
|
"es": "Abre esta cartera donde está, enlazándola al directorio de datos (sin copiar)",
|
||||||
|
"de": "Öffnet diese Wallet an ihrem Ort — im Datenverzeichnis verlinkt (keine Kopie)",
|
||||||
|
"fr": "Ouvre ce portefeuille à son emplacement — lié au répertoire de données (sans copie)",
|
||||||
|
"pt": "Abre esta carteira onde está — vinculada ao diretório de dados (sem cópia)",
|
||||||
|
"ru": "Открывает этот кошелёк на месте — по ссылке в каталоге данных (без копирования)",
|
||||||
|
"zh": "在原位置打开此钱包 — 链接到数据目录(不复制)",
|
||||||
|
"ja": "このウォレットをその場で開きます — データディレクトリにリンク(コピーなし)",
|
||||||
|
"ko": "이 지갑을 있는 자리에서 엽니다 — 데이터 디렉터리에 링크 (복사 없음)"
|
||||||
|
},
|
||||||
|
"wallets_open_failed": {
|
||||||
|
"es": "No se pudo abrir esta cartera en su ubicación. Probablemente está en una unidad distinta a tu directorio de datos: muévela a la misma unidad (en Windows, activar el Modo de desarrollador también permite enlazar entre unidades).",
|
||||||
|
"de": "Diese Wallet konnte nicht an ihrem Ort geöffnet werden. Sie liegt vermutlich auf einem anderen Laufwerk als dein Datenverzeichnis — verschiebe sie auf dasselbe Laufwerk (unter Windows erlaubt auch der aktivierte Entwicklermodus laufwerkübergreifende Verknüpfungen).",
|
||||||
|
"fr": "Impossible d'ouvrir ce portefeuille à son emplacement. Il se trouve probablement sur un lecteur différent de votre répertoire de données — déplacez-le sur le même lecteur (sous Windows, activer le mode développeur permet aussi de créer des liens entre lecteurs).",
|
||||||
|
"pt": "Não foi possível abrir esta carteira no lugar. Provavelmente está em uma unidade diferente do seu diretório de dados — mova-a para a mesma unidade (no Windows, ativar o Modo de Desenvolvedor também permite vincular entre unidades).",
|
||||||
|
"ru": "Не удалось открыть этот кошелёк на месте. Вероятно, он на другом диске, чем каталог данных — переместите его на тот же диск (в Windows включённый режим разработчика также позволяет создавать ссылки между дисками).",
|
||||||
|
"zh": "无法在原位置打开此钱包。它可能与数据目录位于不同的驱动器上 — 请将其移动到同一驱动器(在 Windows 上,启用开发者模式也可跨驱动器链接)。",
|
||||||
|
"ja": "このウォレットをその場で開けませんでした。データディレクトリとは別のドライブにある可能性があります — 同じドライブに移動してください(Windows では開発者モードを有効にするとドライブ間のリンクも可能になります)。",
|
||||||
|
"ko": "이 지갑을 제자리에서 열 수 없습니다. 데이터 디렉터리와 다른 드라이브에 있을 가능성이 높습니다 — 같은 드라이브로 옮기세요 (Windows에서는 개발자 모드를 켜면 드라이브 간 링크도 가능합니다)."
|
||||||
|
},
|
||||||
|
"wallets_external_tt": {
|
||||||
|
"es": "Fuera de tu directorio de datos — Abrir lo enlaza en su lugar (sin copiar).",
|
||||||
|
"de": "Außerhalb deines Datenverzeichnisses — Öffnen verlinkt sie an ihrem Ort (keine Kopie).",
|
||||||
|
"fr": "Hors de votre répertoire de données — Ouvrir le lie sur place (sans copie).",
|
||||||
|
"pt": "Fora do seu diretório de dados — Abrir o vincula no lugar (sem cópia).",
|
||||||
|
"ru": "Вне каталога данных — «Открыть» создаёт ссылку на месте (без копирования).",
|
||||||
|
"zh": "在数据目录之外 —「打开」会就地链接(不复制)。",
|
||||||
|
"ja": "データディレクトリの外 —「開く」はその場でリンクします(コピーなし)。",
|
||||||
|
"ko": "데이터 디렉터리 밖 — '열기'는 제자리에 링크합니다 (복사 없음)."
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|||||||
45
src/app.cpp
45
src/app.cpp
@@ -742,6 +742,10 @@ void App::update()
|
|||||||
// One-time reminder to back up the wallet's seed phrase (mnemonic wallets only).
|
// One-time reminder to back up the wallet's seed phrase (mnemonic wallets only).
|
||||||
maybeRemindSeedBackup();
|
maybeRemindSeedBackup();
|
||||||
|
|
||||||
|
// Classify the wallet's mnemonic status (once per connect) so the Migrate-to-seed button can
|
||||||
|
// glow for a legacy, pre-seed-phrase wallet.
|
||||||
|
probeWalletSeedStatus();
|
||||||
|
|
||||||
// Pick up progress/result from a running seed-wallet migration (create/sweep/adopt).
|
// Pick up progress/result from a running seed-wallet migration (create/sweep/adopt).
|
||||||
pumpSeedMigration();
|
pumpSeedMigration();
|
||||||
// While confirming the sweep, poll the tx confirmations + legacy balance every ~5s.
|
// While confirming the sweep, poll the tx confirmations + legacy balance every ~5s.
|
||||||
@@ -4727,6 +4731,47 @@ bool App::hasPinVault() const {
|
|||||||
return vault_ && vault_->hasVault() && settings_ && settings_->getPinEnabled();
|
return vault_ && vault_->hasVault() && settings_ && settings_->getPinEnabled();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool App::debugGateRequiresAuth() const {
|
||||||
|
return hasPinVault() || state_.encrypted;
|
||||||
|
}
|
||||||
|
|
||||||
|
void App::verifyDebugCredential(const std::string& secret, std::function<void(bool)> cb) {
|
||||||
|
if (!cb) return;
|
||||||
|
// A quick-unlock PIN vault → verify the PIN by deriving it (pure crypto, no wallet side effects).
|
||||||
|
if (hasPinVault()) {
|
||||||
|
auto* w = worker_.get();
|
||||||
|
if (!w) { cb(false); return; }
|
||||||
|
w->post([this, s = secret, cb]() mutable -> rpc::RPCWorker::MainCb {
|
||||||
|
std::string pass;
|
||||||
|
bool ok = vault_ && vault_->retrieve(s, pass);
|
||||||
|
if (!pass.empty()) util::SecureVault::secureZero(pass.data(), pass.size());
|
||||||
|
if (!s.empty()) util::SecureVault::secureZero(&s[0], s.size());
|
||||||
|
return [cb, ok]() { cb(ok); };
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Encrypted wallet, no PIN → verify the passphrase via walletpassphrase (re-unlocks with the
|
||||||
|
// usual timeout; a wrong passphrase throws).
|
||||||
|
if (state_.encrypted) {
|
||||||
|
auto* w = worker_.get();
|
||||||
|
auto* r = rpc_.get();
|
||||||
|
if (!w || !r) { cb(false); return; }
|
||||||
|
int autoLock = settings_ ? settings_->getAutoLockTimeout() : 300;
|
||||||
|
int timeout = (autoLock > 0) ? std::max(600, autoLock * 2) : 86400;
|
||||||
|
w->post([r, s = secret, timeout, cb]() mutable -> rpc::RPCWorker::MainCb {
|
||||||
|
bool ok = false;
|
||||||
|
try {
|
||||||
|
if (r && r->isConnected()) { r->call("walletpassphrase", {s, timeout}); ok = true; }
|
||||||
|
} catch (...) { ok = false; }
|
||||||
|
if (!s.empty()) util::SecureVault::secureZero(&s[0], s.size());
|
||||||
|
return [cb, ok]() { cb(ok); };
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// No credential set — nothing to verify.
|
||||||
|
cb(true);
|
||||||
|
}
|
||||||
|
|
||||||
bool App::hasPendingRPCResults() const {
|
bool App::hasPendingRPCResults() const {
|
||||||
return (worker_ && worker_->hasPendingResults())
|
return (worker_ && worker_->hasPendingResults())
|
||||||
|| (fast_worker_ && fast_worker_->hasPendingResults());
|
|| (fast_worker_ && fast_worker_->hasPendingResults());
|
||||||
|
|||||||
19
src/app.h
19
src/app.h
@@ -368,6 +368,9 @@ public:
|
|||||||
void showBackupDialog() { show_backup_ = true; }
|
void showBackupDialog() { show_backup_ = true; }
|
||||||
void showSeedBackupDialog() { show_seed_backup_ = true; }
|
void showSeedBackupDialog() { show_seed_backup_ = true; }
|
||||||
void showSeedMigrationDialog(); // opens the migration modal (resumes a pending one at Sweep)
|
void showSeedMigrationDialog(); // opens the migration modal (resumes a pending one at Sweep)
|
||||||
|
// True when the current full-node wallet is a legacy, pre-seed-phrase wallet (no BIP39 mnemonic)
|
||||||
|
// that a capable daemon could migrate — the Migrate-to-seed button glows to nudge the user.
|
||||||
|
bool isPreSeedWallet() const { return wallet_seed_status_ == WalletSeedStatus::NoMnemonic; }
|
||||||
void showAboutDialog() { show_about_ = true; }
|
void showAboutDialog() { show_about_ = true; }
|
||||||
|
|
||||||
// Legacy tab compat — maps int to NavPage
|
// Legacy tab compat — maps int to NavPage
|
||||||
@@ -492,6 +495,12 @@ public:
|
|||||||
void showPinRemoveDialog() { show_pin_remove_ = true; pin_status_.clear(); }
|
void showPinRemoveDialog() { show_pin_remove_ = true; pin_status_.clear(); }
|
||||||
bool hasPinVault() const;
|
bool hasPinVault() const;
|
||||||
|
|
||||||
|
// Debug-options gate: does revealing the debug dropdown need re-authentication (a PIN vault or
|
||||||
|
// an encrypted wallet)? And verify the entered PIN/passphrase — cb(ok) is invoked on the main
|
||||||
|
// thread (PIN via the vault, else the wallet passphrase via RPC).
|
||||||
|
bool debugGateRequiresAuth() const;
|
||||||
|
void verifyDebugCredential(const std::string& secret, std::function<void(bool)> cb);
|
||||||
|
|
||||||
/// @brief Check if RPC worker has queued results waiting to be processed
|
/// @brief Check if RPC worker has queued results waiting to be processed
|
||||||
bool hasPendingRPCResults() const;
|
bool hasPendingRPCResults() const;
|
||||||
bool hasTransactionSendProgress() const { return send_progress_active_ || send_submissions_in_flight_ > 0 || !pending_opids_.empty(); }
|
bool hasTransactionSendProgress() const { return send_progress_active_ || send_submissions_in_flight_ > 0 || !pending_opids_.empty(); }
|
||||||
@@ -711,6 +720,16 @@ private:
|
|||||||
bool seed_backup_no_mnemonic_ = false;
|
bool seed_backup_no_mnemonic_ = false;
|
||||||
bool seed_backup_reminder_in_flight_ = false; // guards the one-time backup nudge probe
|
bool seed_backup_reminder_in_flight_ = false; // guards the one-time backup nudge probe
|
||||||
|
|
||||||
|
// Cached mnemonic status of the current wallet, driving the Migrate-to-seed button glow. Probed
|
||||||
|
// once per connect (probeWalletSeedStatus, via exportSeedPhrase); NoMnemonic = a legacy wallet a
|
||||||
|
// capable daemon can migrate; Incapable = the daemon lacks z_exportmnemonic (can't tell / can't
|
||||||
|
// migrate). Reset to Unknown on wallet switch so it re-probes the new wallet.
|
||||||
|
enum class WalletSeedStatus { Unknown, HasMnemonic, NoMnemonic, Incapable };
|
||||||
|
WalletSeedStatus wallet_seed_status_ = WalletSeedStatus::Unknown;
|
||||||
|
bool wallet_seed_status_in_flight_ = false;
|
||||||
|
int wallet_seed_status_attempts_ = 0; // give up (Incapable) after a few transient probe failures
|
||||||
|
void probeWalletSeedStatus(); // one-shot per connect; classifies the wallet's mnemonic status
|
||||||
|
|
||||||
// --- Seed-wallet migration (Phase 1: create; Phase 2: sweep + adopt) ---
|
// --- Seed-wallet migration (Phase 1: create; Phase 2: sweep + adopt) ---
|
||||||
enum class SeedMigrationStep { Intro, Working, ShowSeed, Sweep, Sweeping, Confirming, Adopting, Done, Error };
|
enum class SeedMigrationStep { Intro, Working, ShowSeed, Sweep, Sweeping, Confirming, Adopting, Done, Error };
|
||||||
bool show_seed_migration_ = false;
|
bool show_seed_migration_ = false;
|
||||||
|
|||||||
@@ -579,6 +579,11 @@ void App::onDisconnected(const std::string& reason)
|
|||||||
state_.clear();
|
state_.clear();
|
||||||
connection_status_ = reason;
|
connection_status_ = reason;
|
||||||
|
|
||||||
|
// Re-classify the wallet's mnemonic status on the next connect (the active wallet may have
|
||||||
|
// changed — a switch or a post-migration adopt both disconnect here).
|
||||||
|
wallet_seed_status_ = WalletSeedStatus::Unknown;
|
||||||
|
wallet_seed_status_attempts_ = 0;
|
||||||
|
|
||||||
// Clear RPC result caches
|
// Clear RPC result caches
|
||||||
viewtx_cache_.clear();
|
viewtx_cache_.clear();
|
||||||
confirmed_tx_cache_.clear();
|
confirmed_tx_cache_.clear();
|
||||||
@@ -2936,6 +2941,35 @@ void App::maybeRemindSeedBackup()
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One-shot (per connect) probe of the current wallet's mnemonic status, so the Settings
|
||||||
|
// Migrate-to-seed button can glow for a legacy wallet without opening the migration dialog. Same
|
||||||
|
// classification as the migration Intro pre-flight, but proactive and cached. Reads no secret past
|
||||||
|
// the exportSeedPhrase callback (which wipes the phrase). Retries next tick on a transient failure.
|
||||||
|
void App::probeWalletSeedStatus()
|
||||||
|
{
|
||||||
|
if (capture_mode_) return; // no live ops during a UI sweep
|
||||||
|
if (lite_wallet_) return; // lite has its own seed UX
|
||||||
|
if (wallet_seed_status_ != WalletSeedStatus::Unknown) return; // already classified
|
||||||
|
if (!state_.connected || !state_.encryption_state_known) return;
|
||||||
|
if (state_.isLocked()) return; // needs an unlocked wallet to read it
|
||||||
|
if (wallet_seed_status_in_flight_) return;
|
||||||
|
wallet_seed_status_in_flight_ = true;
|
||||||
|
exportSeedPhrase([this](bool ok, bool noMnemonic, const std::string& /*phrase*/,
|
||||||
|
const std::string& error) {
|
||||||
|
wallet_seed_status_in_flight_ = false;
|
||||||
|
if (ok)
|
||||||
|
wallet_seed_status_ = WalletSeedStatus::HasMnemonic;
|
||||||
|
else if (noMnemonic)
|
||||||
|
wallet_seed_status_ = WalletSeedStatus::NoMnemonic; // legacy → migratable → glow
|
||||||
|
else if (error.find("Method not found") != std::string::npos ||
|
||||||
|
error.find("-32601") != std::string::npos)
|
||||||
|
wallet_seed_status_ = WalletSeedStatus::Incapable; // old daemon: can't migrate
|
||||||
|
else if (++wallet_seed_status_attempts_ >= 3)
|
||||||
|
wallet_seed_status_ = WalletSeedStatus::Incapable; // give up after a few transient errors
|
||||||
|
// else: transient error → stay Unknown, retry next tick
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
void App::beginCreateSeedWallet()
|
void App::beginCreateSeedWallet()
|
||||||
{
|
{
|
||||||
if (seed_migration_in_flight_) return;
|
if (seed_migration_in_flight_) return;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
#include "ui/windows/wallets_dialog.h"
|
#include "ui/windows/wallets_dialog.h"
|
||||||
#include "ui/windows/daemon_download_dialog.h"
|
#include "ui/windows/daemon_download_dialog.h"
|
||||||
#include "ui/windows/xmrig_download_dialog.h"
|
#include "ui/windows/xmrig_download_dialog.h"
|
||||||
|
#include "ui/pages/settings_page.h"
|
||||||
#include "util/platform.h"
|
#include "util/platform.h"
|
||||||
#include "wallet/wallet_capabilities.h"
|
#include "wallet/wallet_capabilities.h"
|
||||||
|
|
||||||
@@ -113,6 +114,8 @@ void App::installDemoWalletData()
|
|||||||
applyHealthyDemoState();
|
applyHealthyDemoState();
|
||||||
state_.sync.blocks = state_.sync.headers = 3124322;
|
state_.sync.blocks = state_.sync.headers = 3124322;
|
||||||
state_.sync.verification_progress = 1.0; state_.sync.syncing = false;
|
state_.sync.verification_progress = 1.0; state_.sync.syncing = false;
|
||||||
|
// Legacy (pre-seed) wallet so the Settings "Migrate to seed" button glows in the sweep.
|
||||||
|
wallet_seed_status_ = WalletSeedStatus::NoMnemonic;
|
||||||
|
|
||||||
state_.privateBalance = 12.50000000; state_.transparentBalance = 3.25000000;
|
state_.privateBalance = 12.50000000; state_.transparentBalance = 3.25000000;
|
||||||
state_.totalBalance = 15.75000000; state_.unconfirmedBalance = 0.50000000;
|
state_.totalBalance = 15.75000000; state_.unconfirmedBalance = 0.50000000;
|
||||||
@@ -210,6 +213,7 @@ void App::clearDemoWalletData()
|
|||||||
{
|
{
|
||||||
auto& s = sweep_state_snapshot_;
|
auto& s = sweep_state_snapshot_;
|
||||||
if (!s.valid) return;
|
if (!s.valid) return;
|
||||||
|
wallet_seed_status_ = WalletSeedStatus::Unknown; // re-probe on the next real connect
|
||||||
state_.connected = s.connected; state_.warming_up = s.warming_up;
|
state_.connected = s.connected; state_.warming_up = s.warming_up;
|
||||||
state_.daemon_initializing = s.daemon_initializing;
|
state_.daemon_initializing = s.daemon_initializing;
|
||||||
state_.encrypted = s.encrypted; state_.locked = s.locked;
|
state_.encrypted = s.encrypted; state_.locked = s.locked;
|
||||||
@@ -439,6 +443,11 @@ void App::buildSweepCatalog()
|
|||||||
[](App& a) { a.console_tab_.sweepSetCommandsPopup(true); },
|
[](App& a) { a.console_tab_.sweepSetCommandsPopup(true); },
|
||||||
[](App& a) { a.console_tab_.sweepSetCommandsPopup(false); });
|
[](App& a) { a.console_tab_.sweepSetCommandsPopup(false); });
|
||||||
|
|
||||||
|
// Debug-options gate: confirmation + warning, with the passphrase re-auth field (encrypted).
|
||||||
|
add("modal-debug-gate", ui::NavPage::Settings,
|
||||||
|
[](App& a) { a.state_.encrypted = true; ui::SweepOpenDebugGate(true); },
|
||||||
|
[](App& a) { ui::SweepOpenDebugGate(false); a.applyHealthyDemoState(); });
|
||||||
|
|
||||||
// Daemon updater — the two-pane version picker (versions left, selected-version detail right).
|
// Daemon updater — the two-pane version picker (versions left, selected-version detail right).
|
||||||
// Seeds fake releases so the offline sweep renders it without a network fetch / live updater.
|
// Seeds fake releases so the offline sweep renders it without a network fetch / live updater.
|
||||||
add("modal-daemon-update", ui::NavPage::Settings,
|
add("modal-daemon-update", ui::NavPage::Settings,
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
#include "daemon_controller.h"
|
#include "daemon_controller.h"
|
||||||
#include "../config/settings.h"
|
#include "../config/settings.h"
|
||||||
|
#include "../util/platform.h"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <system_error>
|
||||||
|
|
||||||
namespace dragonx {
|
namespace dragonx {
|
||||||
namespace daemon {
|
namespace daemon {
|
||||||
@@ -23,7 +26,18 @@ void DaemonController::syncSettings(const config::Settings* settings)
|
|||||||
if (!settings) return;
|
if (!settings) return;
|
||||||
daemon_->setDebugCategories(settings->getDebugCategories());
|
daemon_->setDebugCategories(settings->getDebugCategories());
|
||||||
daemon_->setMaxConnections(settings->getMaxConnections());
|
daemon_->setMaxConnections(settings->getMaxConnections());
|
||||||
daemon_->setWalletFile(settings->getActiveWalletFile());
|
|
||||||
|
std::string walletFile = settings->getActiveWalletFile();
|
||||||
|
// The Wallets dialog opens an out-of-datadir wallet by linking it into the datadir under a
|
||||||
|
// "wallet-ip-<hash>.dat" name. If that link went dangling (the external file was moved / a USB was
|
||||||
|
// unplugged between sessions), don't let the daemon create a fresh EMPTY wallet at that name — fall
|
||||||
|
// back to the default this launch. fs::exists follows the link, so it's false for a dangling one.
|
||||||
|
if (walletFile.rfind("wallet-ip-", 0) == 0) {
|
||||||
|
std::error_code ec;
|
||||||
|
if (!std::filesystem::exists(util::Platform::getDragonXDataDir() + "/" + walletFile, ec))
|
||||||
|
walletFile = "wallet.dat";
|
||||||
|
}
|
||||||
|
daemon_->setWalletFile(walletFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool DaemonController::start(const config::Settings* settings)
|
bool DaemonController::start(const config::Settings* settings)
|
||||||
|
|||||||
@@ -586,12 +586,14 @@ void AcrylicMaterial::drawRect(ImDrawList* drawList, const ImVec2& pMin, const I
|
|||||||
float u1 = localX1 / viewportWidth_;
|
float u1 = localX1 / viewportWidth_;
|
||||||
float v1 = 1.0f - localY1 / viewportHeight_; // V at bottom-right (low)
|
float v1 = 1.0f - localY1 / viewportHeight_; // V at bottom-right (low)
|
||||||
|
|
||||||
// Draw the blurred background. Scale by both the glass preset
|
// Draw the blurred background at (near) full strength so the panel is actually FROSTED — the
|
||||||
// opacity (fallbackColor.w) AND the user's UI opacity slider so
|
// blurred capture replaces the sharp background in the card. UI opacity is applied only to the
|
||||||
// that lowering card opacity lets the sharp background through.
|
// tint overlay (in DrawGlassPanel), NOT here: scaling the blur by uiOpacity used to let the
|
||||||
|
// SHARP background bleed straight through at low opacity, which made the acrylic slider look
|
||||||
|
// like a no-op. A small floor keeps it frosted even for low-alpha presets.
|
||||||
ImTextureID blurTex = getBlurredTexture();
|
ImTextureID blurTex = getBlurredTexture();
|
||||||
uint8_t glassAlpha = static_cast<uint8_t>(
|
uint8_t glassAlpha = static_cast<uint8_t>(
|
||||||
std::min(255.0f, std::max(0.0f, params.fallbackColor.w * settings_.uiOpacity * 255.0f)));
|
std::min(255.0f, std::max(0.0f, std::max(params.fallbackColor.w, 0.90f) * 255.0f)));
|
||||||
|
|
||||||
if (blurTex) {
|
if (blurTex) {
|
||||||
drawList->AddImageRounded(
|
drawList->AddImageRounded(
|
||||||
@@ -1562,11 +1564,12 @@ void AcrylicMaterial::drawRect(ImDrawList* drawList, const ImVec2& pMin, const I
|
|||||||
(void*)blurTex, u0, v0, u1, v1);
|
(void*)blurTex, u0, v0, u1, v1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw the blurred background. Scale by both the glass preset
|
// Draw the blurred background at (near) full strength so the panel is actually FROSTED — UI
|
||||||
// opacity (fallbackColor.w) AND the user's UI opacity slider so
|
// opacity is applied only to the tint overlay, NOT here (scaling the blur by uiOpacity let the
|
||||||
// that lowering card opacity lets the sharp background through.
|
// SHARP background bleed through at low opacity, making the acrylic slider a no-op). Mirrors the
|
||||||
|
// GL drawRect fix above.
|
||||||
uint8_t glassAlpha = (uint8_t)std::min(255.f,
|
uint8_t glassAlpha = (uint8_t)std::min(255.f,
|
||||||
std::max(0.f, params.fallbackColor.w * settings_.uiOpacity * 255.f));
|
std::max(0.f, std::max(params.fallbackColor.w, 0.90f) * 255.f));
|
||||||
|
|
||||||
if (blurTex) {
|
if (blurTex) {
|
||||||
drawList->AddImageRounded(
|
drawList->AddImageRounded(
|
||||||
|
|||||||
@@ -545,10 +545,75 @@ inline bool& FullWindowBlurOverlayActiveRef() { static bool v = false; return v;
|
|||||||
inline void SetFullWindowBlurOverlayActive(bool v) { FullWindowBlurOverlayActiveRef() = v; }
|
inline void SetFullWindowBlurOverlayActive(bool v) { FullWindowBlurOverlayActiveRef() = v; }
|
||||||
inline bool IsFullWindowBlurOverlayActive() { return FullWindowBlurOverlayActiveRef(); }
|
inline bool IsFullWindowBlurOverlayActive() { return FullWindowBlurOverlayActiveRef(); }
|
||||||
|
|
||||||
|
// Light-surface themes (Light / Marble) want cards to lift off the pale background with a subtle
|
||||||
|
// drop shadow. Cached per theme-generation so the string compare runs once per theme load, not per
|
||||||
|
// panel. (Data-driven later if more themes want it.)
|
||||||
|
inline bool CardDropShadowWanted()
|
||||||
|
{
|
||||||
|
static uint32_t s_gen = ~0u;
|
||||||
|
static bool s_want = false;
|
||||||
|
uint32_t g = schema::UI().generation();
|
||||||
|
if (g != s_gen) {
|
||||||
|
s_gen = g;
|
||||||
|
const std::string& tn = schema::UI().themeName();
|
||||||
|
s_want = (tn == "Light" || tn == "Marble");
|
||||||
|
}
|
||||||
|
return s_want;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft, UNIFORM shadow around a rounded rect — no directional offset, equal on all sides. Drawn as
|
||||||
|
// fading rounded-rect STROKES (not fills), so it never paints across the card body — only the outer
|
||||||
|
// blur remains once the card fill covers the interior. The "render then mask out the UI area" result
|
||||||
|
// without an FBO.
|
||||||
|
inline void DrawCardDropShadow(ImDrawList* dl, const ImVec2& pMin, const ImVec2& pMax, float rounding)
|
||||||
|
{
|
||||||
|
const float dp = Layout::dpiScale();
|
||||||
|
// Matches the design mockup's `box-shadow: 0 0 4px rgb(50 53 58 / 26%)`: a tight, darker, colored
|
||||||
|
// uniform shadow. Sampled as a stack of rounded-rect strokes stepping OUTWARD from the card edge
|
||||||
|
// with a Gaussian alpha falloff (darkest at the edge, ~0 by the tail). ~1px-spaced, near-1px-thick
|
||||||
|
// rings keep overlap (and thus alpha accumulation) low, so the peak reads close to the target.
|
||||||
|
const float blur = 4.0f * dp; // CSS-like blur radius
|
||||||
|
const float reach = blur * 1.5f; // how far the shadow extends beyond the card edge (all sides)
|
||||||
|
const float sigma = blur * 0.6f;
|
||||||
|
const float peak = 34.0f; // edge alpha for rgb(50,53,58) (~13% after the card fill masks the inner half)
|
||||||
|
const int steps = std::max(6, (int)(reach / dp + 0.5f));
|
||||||
|
|
||||||
|
// The shadow rides on the card's OWN draw list (so it keeps the right z-order in EVERY context —
|
||||||
|
// main content, modals, foreground popups, notifications; a global background draw list would drop
|
||||||
|
// a modal/popup card's shadow behind its scrim). But cards often live inside clipping child windows
|
||||||
|
// whose clip rect chops the shadow wherever a card sits flush with the child's top/bottom/side edge.
|
||||||
|
// Clip instead to the shadow's own bounding box (card ± reach), so all four sides show in full —
|
||||||
|
// bounded on each side to at most `reach` beyond the child clip (and the viewport) so a card scrolled
|
||||||
|
// partly out of a scroll area still can't smear its shadow more than the halo width into neighbours.
|
||||||
|
ImGuiViewport* vp = ImGui::GetMainViewport();
|
||||||
|
const ImVec2 cur0 = dl->GetClipRectMin();
|
||||||
|
const ImVec2 cur1 = dl->GetClipRectMax();
|
||||||
|
const float x0 = std::max(vp->Pos.x, std::max(pMin.x - reach, cur0.x - reach));
|
||||||
|
const float y0 = std::max(vp->Pos.y, std::max(pMin.y - reach, cur0.y - reach));
|
||||||
|
const float x1 = std::min(vp->Pos.x + vp->Size.x, std::min(pMax.x + reach, cur1.x + reach));
|
||||||
|
const float y1 = std::min(vp->Pos.y + vp->Size.y, std::min(pMax.y + reach, cur1.y + reach));
|
||||||
|
dl->PushClipRect(ImVec2(x0, y0), ImVec2(x1, y1), false);
|
||||||
|
for (int i = 0; i < steps; ++i) {
|
||||||
|
float d = reach * (float)i / (float)(steps - 1); // 0 (card edge) .. reach (outer tail)
|
||||||
|
float g = std::exp(-(d * d) / (2.0f * sigma * sigma));
|
||||||
|
int a = (int)(peak * g + 0.5f);
|
||||||
|
if (a < 1) continue;
|
||||||
|
ImVec2 smn(pMin.x - d, pMin.y - d);
|
||||||
|
ImVec2 smx(pMax.x + d, pMax.y + d);
|
||||||
|
dl->AddRect(smn, smx, IM_COL32(50, 53, 58, a), rounding + d, 0, 1.3f * dp);
|
||||||
|
}
|
||||||
|
dl->PopClipRect();
|
||||||
|
}
|
||||||
|
|
||||||
inline void DrawGlassPanel(ImDrawList* dl, const ImVec2& pMin,
|
inline void DrawGlassPanel(ImDrawList* dl, const ImVec2& pMin,
|
||||||
const ImVec2& pMax,
|
const ImVec2& pMax,
|
||||||
const GlassPanelSpec& spec = GlassPanelSpec())
|
const GlassPanelSpec& spec = GlassPanelSpec())
|
||||||
{
|
{
|
||||||
|
// Subtle drop shadow behind the card (light themes only). Drawn first so the card fill below
|
||||||
|
// covers the interior, leaving only the outer soft blur — the card body is never contaminated.
|
||||||
|
if (CardDropShadowWanted())
|
||||||
|
DrawCardDropShadow(dl, pMin, pMax, spec.rounding);
|
||||||
|
|
||||||
if (IsBackdropActive() && !dragonx::ui::effects::isLowSpecMode() && !IsFullWindowBlurOverlayActive()) {
|
if (IsBackdropActive() && !dragonx::ui::effects::isLowSpecMode() && !IsFullWindowBlurOverlayActive()) {
|
||||||
// --- Cached color lookups (invalidated on theme change) ---
|
// --- Cached color lookups (invalidated on theme change) ---
|
||||||
// These 3 resolveColor() calls do string parsing + map lookup
|
// These 3 resolveColor() calls do string parsing + map lookup
|
||||||
|
|||||||
@@ -57,6 +57,11 @@ namespace ui {
|
|||||||
|
|
||||||
using namespace material;
|
using namespace material;
|
||||||
|
|
||||||
|
// Top of the Acrylic slider's range (the blur-radius multiplier at 100%). The panels frost at full
|
||||||
|
// alpha now, so this only controls how BLURRY the max is — kept gentle (the old max of 4.0 was way
|
||||||
|
// too strong). The slider still reads 0–100%; this is just what 100% maps to.
|
||||||
|
static constexpr float kAcrylicMaxBlur = 1.25f;
|
||||||
|
|
||||||
// Helper: build "TranslatedLabel##id" for ImGui widgets that use label as ID
|
// Helper: build "TranslatedLabel##id" for ImGui widgets that use label as ID
|
||||||
static std::string TrId(const char* tr_key, const char* id) {
|
static std::string TrId(const char* tr_key, const char* id) {
|
||||||
std::string s = TR(tr_key);
|
std::string s = TR(tr_key);
|
||||||
@@ -144,6 +149,15 @@ struct SettingsPageState {
|
|||||||
std::set<std::string> debug_categories;
|
std::set<std::string> debug_categories;
|
||||||
bool debug_cats_dirty = false;
|
bool debug_cats_dirty = false;
|
||||||
bool debug_expanded = false;
|
bool debug_expanded = false;
|
||||||
|
// Debug-options gate: a confirmation + warning (and, when a PIN/passphrase is set, re-auth) is
|
||||||
|
// required before the debug dropdown reveals its options — once per session (passing it once
|
||||||
|
// unlocks the dropdown until the app restarts).
|
||||||
|
bool debug_gate_open = false;
|
||||||
|
bool debug_gate_passed = false;
|
||||||
|
bool debug_gate_verifying = false;
|
||||||
|
char debug_gate_buf[128] = {0};
|
||||||
|
std::string debug_gate_err;
|
||||||
|
float debug_gate_err_timer = 0.0f;
|
||||||
bool effects_expanded = false;
|
bool effects_expanded = false;
|
||||||
bool tools_expanded = false;
|
bool tools_expanded = false;
|
||||||
bool rpc_expanded = false; // Node & Security: reveal the RPC connection fields
|
bool rpc_expanded = false; // Node & Security: reveal the RPC connection fields
|
||||||
@@ -382,8 +396,9 @@ static void loadSettingsPageState(config::Settings* settings) {
|
|||||||
idx++;
|
idx++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load blur amount directly from saved multiplier
|
// Load blur amount directly from saved multiplier, clamped to the (now gentler) slider max so a
|
||||||
s_settingsState.blur_amount = settings->getBlurMultiplier();
|
// value saved under the old 0–4 range maps into 0–100% instead of pinning far past the top.
|
||||||
|
s_settingsState.blur_amount = std::min(settings->getBlurMultiplier(), kAcrylicMaxBlur);
|
||||||
s_settingsState.acrylic_enabled = (s_settingsState.blur_amount > 0.001f);
|
s_settingsState.acrylic_enabled = (s_settingsState.blur_amount > 0.001f);
|
||||||
s_settingsState.ui_opacity = settings->getUIOpacity();
|
s_settingsState.ui_opacity = settings->getUIOpacity();
|
||||||
s_settingsState.window_opacity = settings->getWindowOpacity();
|
s_settingsState.window_opacity = settings->getWindowOpacity();
|
||||||
@@ -874,10 +889,10 @@ void RenderSettingsPage(App* app) {
|
|||||||
if (s_settingsState.blur_amount < 0.01f)
|
if (s_settingsState.blur_amount < 0.01f)
|
||||||
snprintf(blur_fmt, sizeof(blur_fmt), "%s", TR("slider_off"));
|
snprintf(blur_fmt, sizeof(blur_fmt), "%s", TR("slider_off"));
|
||||||
else
|
else
|
||||||
snprintf(blur_fmt, sizeof(blur_fmt), "%.0f%%%%", s_settingsState.blur_amount * 25.0f);
|
snprintf(blur_fmt, sizeof(blur_fmt), "%.0f%%%%", s_settingsState.blur_amount / kAcrylicMaxBlur * 100.0f);
|
||||||
if (ImGui::SliderFloat("##AcrylicBlur", &s_settingsState.blur_amount, 0.0f, 4.0f, blur_fmt,
|
if (ImGui::SliderFloat("##AcrylicBlur", &s_settingsState.blur_amount, 0.0f, kAcrylicMaxBlur, blur_fmt,
|
||||||
ImGuiSliderFlags_AlwaysClamp)) {
|
ImGuiSliderFlags_AlwaysClamp)) {
|
||||||
if (s_settingsState.blur_amount > 0.0f && s_settingsState.blur_amount < 0.15f) s_settingsState.blur_amount = 0.0f;
|
if (s_settingsState.blur_amount > 0.0f && s_settingsState.blur_amount < kAcrylicMaxBlur * 0.04f) s_settingsState.blur_amount = 0.0f;
|
||||||
s_settingsState.acrylic_enabled = (s_settingsState.blur_amount > 0.001f);
|
s_settingsState.acrylic_enabled = (s_settingsState.blur_amount > 0.001f);
|
||||||
effects::ImGuiAcrylic::ApplyBlurAmount(s_settingsState.blur_amount);
|
effects::ImGuiAcrylic::ApplyBlurAmount(s_settingsState.blur_amount);
|
||||||
}
|
}
|
||||||
@@ -1120,10 +1135,10 @@ void RenderSettingsPage(App* app) {
|
|||||||
if (s_settingsState.blur_amount < 0.01f)
|
if (s_settingsState.blur_amount < 0.01f)
|
||||||
snprintf(blur_fmt, sizeof(blur_fmt), "%s", TR("slider_off"));
|
snprintf(blur_fmt, sizeof(blur_fmt), "%s", TR("slider_off"));
|
||||||
else
|
else
|
||||||
snprintf(blur_fmt, sizeof(blur_fmt), "%.0f%%%%", s_settingsState.blur_amount * 25.0f);
|
snprintf(blur_fmt, sizeof(blur_fmt), "%.0f%%%%", s_settingsState.blur_amount / kAcrylicMaxBlur * 100.0f);
|
||||||
if (ImGui::SliderFloat("##AcrylicBlur", &s_settingsState.blur_amount, 0.0f, 4.0f, blur_fmt,
|
if (ImGui::SliderFloat("##AcrylicBlur", &s_settingsState.blur_amount, 0.0f, kAcrylicMaxBlur, blur_fmt,
|
||||||
ImGuiSliderFlags_AlwaysClamp)) {
|
ImGuiSliderFlags_AlwaysClamp)) {
|
||||||
if (s_settingsState.blur_amount > 0.0f && s_settingsState.blur_amount < 0.15f) s_settingsState.blur_amount = 0.0f;
|
if (s_settingsState.blur_amount > 0.0f && s_settingsState.blur_amount < kAcrylicMaxBlur * 0.04f) s_settingsState.blur_amount = 0.0f;
|
||||||
s_settingsState.acrylic_enabled = (s_settingsState.blur_amount > 0.001f);
|
s_settingsState.acrylic_enabled = (s_settingsState.blur_amount > 0.001f);
|
||||||
effects::ImGuiAcrylic::ApplyBlurAmount(s_settingsState.blur_amount);
|
effects::ImGuiAcrylic::ApplyBlurAmount(s_settingsState.blur_amount);
|
||||||
}
|
}
|
||||||
@@ -1336,10 +1351,24 @@ void RenderSettingsPage(App* app) {
|
|||||||
if (TactileButton(TR("seed_backup_button"), ImVec2(0, 0), btnFont))
|
if (TactileButton(TR("seed_backup_button"), ImVec2(0, 0), btnFont))
|
||||||
app->showSeedBackupDialog();
|
app->showSeedBackupDialog();
|
||||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_seed_backup"));
|
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_seed_backup"));
|
||||||
// Migrate to a new seed-phrase wallet (create in isolation, then sweep funds).
|
// Migrate to a new seed-phrase wallet (create in isolation, then sweep funds). A
|
||||||
|
// legacy (pre-seed-phrase) wallet glows the button to nudge the user toward migrating.
|
||||||
ImGui::SameLine(0, scaledSp);
|
ImGui::SameLine(0, scaledSp);
|
||||||
|
const bool migrateGlow = app->isPreSeedWallet();
|
||||||
if (TactileButton(TR("seed_migrate_button"), ImVec2(0, 0), btnFont))
|
if (TactileButton(TR("seed_migrate_button"), ImVec2(0, 0), btnFont))
|
||||||
app->showSeedMigrationDialog();
|
app->showSeedMigrationDialog();
|
||||||
|
if (migrateGlow) {
|
||||||
|
const ImVec2 gmn = ImGui::GetItemRectMin(), gmx = ImGui::GetItemRectMax();
|
||||||
|
const float gdp = Layout::dpiScale();
|
||||||
|
const float pulse = 0.5f + 0.5f * std::sin((float)ImGui::GetTime() * 3.2f); // 0..1
|
||||||
|
ImDrawList* gdl = ImGui::GetWindowDrawList();
|
||||||
|
for (int g = 3; g >= 1; --g) { // soft, pulsing outward halo in the accent color
|
||||||
|
const float e = (float)g * 2.2f * gdp;
|
||||||
|
const int a = (int)((70.0f + pulse * 95.0f) / (float)g);
|
||||||
|
gdl->AddRect(ImVec2(gmn.x - e, gmn.y - e), ImVec2(gmx.x + e, gmx.y + e),
|
||||||
|
material::WithAlpha(material::Primary(), a), 6.0f * gdp + e, 0, 1.6f * gdp);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_seed_migrate"));
|
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_seed_migrate"));
|
||||||
// Multi-wallet: list wallet files + switch the active one.
|
// Multi-wallet: list wallet files + switch the active one.
|
||||||
ImGui::SameLine(0, scaledSp);
|
ImGui::SameLine(0, scaledSp);
|
||||||
@@ -2507,7 +2536,17 @@ void RenderSettingsPage(App* app) {
|
|||||||
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(1,1,1,0.05f));
|
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(1,1,1,0.05f));
|
||||||
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(1,1,1,0.08f));
|
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(1,1,1,0.08f));
|
||||||
if (ImGui::Button("##DebugToggle", ImVec2(availWidth, ImGui::GetFrameHeight()))) {
|
if (ImGui::Button("##DebugToggle", ImVec2(availWidth, ImGui::GetFrameHeight()))) {
|
||||||
s_settingsState.debug_expanded = !s_settingsState.debug_expanded;
|
if (s_settingsState.debug_expanded) {
|
||||||
|
s_settingsState.debug_expanded = false; // collapsing needs no gate
|
||||||
|
} else if (s_settingsState.debug_gate_passed) {
|
||||||
|
s_settingsState.debug_expanded = true; // already unlocked this session
|
||||||
|
} else {
|
||||||
|
// First expand this session is gated: confirmation + (if secured) re-auth.
|
||||||
|
s_settingsState.debug_gate_open = true;
|
||||||
|
s_settingsState.debug_gate_buf[0] = '\0';
|
||||||
|
s_settingsState.debug_gate_err.clear();
|
||||||
|
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") : TR("tt_debug_expand"));
|
||||||
ImGui::PopStyleColor(3);
|
ImGui::PopStyleColor(3);
|
||||||
@@ -2633,6 +2672,77 @@ void RenderSettingsPage(App* app) {
|
|||||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_restart_daemon"));
|
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_restart_daemon"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Debug-options gate: confirmation + warning (+ re-auth if a PIN/passphrase is set) ----
|
||||||
|
if (s_settingsState.debug_gate_open) {
|
||||||
|
auto& st = s_settingsState;
|
||||||
|
if (st.debug_gate_err_timer > 0.0f) {
|
||||||
|
st.debug_gate_err_timer -= ImGui::GetIO().DeltaTime;
|
||||||
|
if (st.debug_gate_err_timer <= 0.0f) st.debug_gate_err.clear();
|
||||||
|
}
|
||||||
|
const bool needAuth = app->debugGateRequiresAuth();
|
||||||
|
const bool hasPin = app->hasPinVault();
|
||||||
|
const float gdp = Layout::dpiScale();
|
||||||
|
material::OverlayDialogSpec ov;
|
||||||
|
ov.title = TR("debug_gate_title");
|
||||||
|
ov.p_open = &st.debug_gate_open;
|
||||||
|
ov.style = material::OverlayStyle::BlurFloat;
|
||||||
|
ov.cardWidth = 480.0f;
|
||||||
|
ov.idSuffix = "debuggate";
|
||||||
|
if (material::BeginOverlayDialog(ov)) {
|
||||||
|
if (ImGui::IsKeyPressed(ImGuiKey_Escape)) st.debug_gate_open = false;
|
||||||
|
ImGui::TextWrapped("%s", TR("debug_gate_warning"));
|
||||||
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||||
|
|
||||||
|
bool submit = false;
|
||||||
|
if (needAuth) {
|
||||||
|
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(),
|
||||||
|
hasPin ? TR("debug_gate_pin_prompt") : TR("debug_gate_pass_prompt"));
|
||||||
|
ImGui::SetNextItemWidth(-FLT_MIN);
|
||||||
|
ImGuiInputTextFlags f = ImGuiInputTextFlags_Password | ImGuiInputTextFlags_EnterReturnsTrue;
|
||||||
|
if (hasPin) f |= ImGuiInputTextFlags_CharsDecimal; // PIN is numeric
|
||||||
|
if (ImGui::InputText("##debugGateSecret", st.debug_gate_buf, sizeof(st.debug_gate_buf), f))
|
||||||
|
submit = true;
|
||||||
|
if (!st.debug_gate_err.empty()) {
|
||||||
|
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
||||||
|
Type().textColored(TypeStyle::Caption, Error(), st.debug_gate_err.c_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||||
|
ImGui::BeginDisabled(st.debug_gate_verifying);
|
||||||
|
if (TactileButton(TR("debug_gate_confirm"), ImVec2(170.0f * gdp, 0)) || submit) {
|
||||||
|
if (!needAuth) {
|
||||||
|
st.debug_expanded = true;
|
||||||
|
st.debug_gate_passed = true;
|
||||||
|
st.debug_gate_open = false;
|
||||||
|
} else if (strlen(st.debug_gate_buf) > 0) {
|
||||||
|
st.debug_gate_verifying = true;
|
||||||
|
std::string secret = st.debug_gate_buf;
|
||||||
|
memset(st.debug_gate_buf, 0, sizeof(st.debug_gate_buf));
|
||||||
|
app->verifyDebugCredential(secret, [](bool ok) {
|
||||||
|
auto& s = s_settingsState;
|
||||||
|
s.debug_gate_verifying = false;
|
||||||
|
if (ok) { s.debug_expanded = true; s.debug_gate_passed = true; s.debug_gate_open = false; }
|
||||||
|
else { s.debug_gate_err = TR("debug_gate_incorrect"); s.debug_gate_err_timer = 4.0f; }
|
||||||
|
});
|
||||||
|
if (!secret.empty()) memset(&secret[0], 0, secret.size());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ImGui::EndDisabled();
|
||||||
|
ImGui::SameLine();
|
||||||
|
if (TactileButton(TR("cancel"), ImVec2(120.0f * gdp, 0))) {
|
||||||
|
memset(st.debug_gate_buf, 0, sizeof(st.debug_gate_buf));
|
||||||
|
st.debug_gate_open = false;
|
||||||
|
}
|
||||||
|
if (st.debug_gate_verifying) {
|
||||||
|
ImGui::SameLine();
|
||||||
|
ImGui::AlignTextToFramePadding();
|
||||||
|
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("debug_gate_verifying"));
|
||||||
|
}
|
||||||
|
material::EndOverlayDialog();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Shader-based scroll fade: unbind (restore ImGui's default shader) ---
|
// --- Shader-based scroll fade: unbind (restore ImGui's default shader) ---
|
||||||
@@ -2909,5 +3019,14 @@ void RenderSettingsPage(App* app) {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void SweepOpenDebugGate(bool open) {
|
||||||
|
s_settingsState.debug_gate_open = open;
|
||||||
|
if (open) {
|
||||||
|
s_settingsState.debug_gate_buf[0] = '\0';
|
||||||
|
s_settingsState.debug_gate_err.clear();
|
||||||
|
s_settingsState.debug_gate_verifying = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace ui
|
} // namespace ui
|
||||||
} // namespace dragonx
|
} // namespace dragonx
|
||||||
|
|||||||
@@ -15,5 +15,9 @@ namespace ui {
|
|||||||
*/
|
*/
|
||||||
void RenderSettingsPage(App* app);
|
void RenderSettingsPage(App* app);
|
||||||
|
|
||||||
|
// Sweep-only: force the debug-options confirmation gate open/closed so the offline UI sweep can
|
||||||
|
// capture it (it is otherwise opened only by clicking the DEBUG OPTIONS header).
|
||||||
|
void SweepOpenDebugGate(bool open);
|
||||||
|
|
||||||
} // namespace ui
|
} // namespace ui
|
||||||
} // namespace dragonx
|
} // namespace dragonx
|
||||||
|
|||||||
@@ -90,12 +90,78 @@ bool isResultBodyChannel(ConsoleChannel ch)
|
|||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// ── WCAG contrast helpers — keep console text high-contrast while following each theme's palette. ──
|
||||||
|
inline float srgbToLinear(float c8) {
|
||||||
|
float c = c8 / 255.0f;
|
||||||
|
return c <= 0.04045f ? c / 12.92f : std::pow((c + 0.055f) / 1.055f, 2.4f);
|
||||||
|
}
|
||||||
|
inline float relLuminance(float r, float g, float b) {
|
||||||
|
return 0.2126f * srgbToLinear(r) + 0.7152f * srgbToLinear(g) + 0.0722f * srgbToLinear(b);
|
||||||
|
}
|
||||||
|
inline float contrastRatio(float lumA, float lumB) {
|
||||||
|
float hi = std::max(lumA, lumB), lo = std::min(lumA, lumB);
|
||||||
|
return (hi + 0.05f) / (lo + 0.05f);
|
||||||
|
}
|
||||||
|
// Nudge `fg` toward black (light surface) or white (dark surface), preserving hue + alpha, until its
|
||||||
|
// WCAG contrast against `bg` reaches `minRatio`. Colors already passing come back essentially unchanged.
|
||||||
|
inline ImU32 EnsureContrast(ImU32 fg, ImU32 bg, float minRatio) {
|
||||||
|
const float bgL = relLuminance((float)((bg >> IM_COL32_R_SHIFT) & 0xFF),
|
||||||
|
(float)((bg >> IM_COL32_G_SHIFT) & 0xFF),
|
||||||
|
(float)((bg >> IM_COL32_B_SHIFT) & 0xFF));
|
||||||
|
float r = (float)((fg >> IM_COL32_R_SHIFT) & 0xFF);
|
||||||
|
float g = (float)((fg >> IM_COL32_G_SHIFT) & 0xFF);
|
||||||
|
float b = (float)((fg >> IM_COL32_B_SHIFT) & 0xFF);
|
||||||
|
const int a = (int)((fg >> IM_COL32_A_SHIFT) & 0xFF);
|
||||||
|
const bool darken = bgL > 0.5f; // light surface -> darken the text; dark surface -> lighten it
|
||||||
|
for (int i = 0; i < 40 && contrastRatio(relLuminance(r, g, b), bgL) < minRatio; ++i) {
|
||||||
|
if (darken) { r *= 0.90f; g *= 0.90f; b *= 0.90f; }
|
||||||
|
else { r += (255.0f - r) * 0.12f; g += (255.0f - g) * 0.12f; b += (255.0f - b) * 0.12f; }
|
||||||
|
}
|
||||||
|
return IM_COL32((int)(r + 0.5f), (int)(g + 0.5f), (int)(b + 0.5f), a);
|
||||||
|
}
|
||||||
|
// Floor a dynamically-derived channel color to a readable contrast on light terminals; no-op on dark.
|
||||||
|
inline ImU32 FloorLight(ImU32 c) {
|
||||||
|
return material::IsLightTheme() ? EnsureContrast(c, IM_COL32(255, 255, 255, 255), 4.5f) : c;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
void ConsoleTab::refreshColors()
|
void ConsoleTab::refreshColors()
|
||||||
{
|
{
|
||||||
|
using namespace material;
|
||||||
auto& S = schema::UI();
|
auto& S = schema::UI();
|
||||||
bool dark = material::IsDarkTheme();
|
// Derive light/dark from the SAME live background-luminance predicate the terminal surface uses
|
||||||
|
// (the overlay at line ~274 is chosen by IsLightTheme()). Using the stored IsDarkTheme() flag here
|
||||||
|
// let the two disagree — baking dark-theme (pale) text onto the light near-white console surface.
|
||||||
|
bool dark = !IsLightTheme();
|
||||||
|
|
||||||
// Try schema overrides first, then use sensible per-theme defaults
|
// Per-theme channel defaults.
|
||||||
|
ImU32 defCmd, defRes, defErr, defDmn, defInf, defRpc;
|
||||||
|
if (dark) {
|
||||||
|
// Dark terminal: keep the authored light-on-dark palette (schema overrides honored below).
|
||||||
|
defCmd = IM_COL32(191, 209, 229, 255);
|
||||||
|
defRes = IM_COL32(200, 200, 200, 255);
|
||||||
|
defErr = IM_COL32(246, 71, 64, 255);
|
||||||
|
defDmn = IM_COL32(160, 160, 160, 180);
|
||||||
|
defInf = IM_COL32(191, 209, 229, 255);
|
||||||
|
defRpc = IM_COL32(120, 180, 255, 210);
|
||||||
|
} else {
|
||||||
|
// Light terminal: follow THIS theme's own palette (Marble slate/taupe, Dune sand, Light blue, …)
|
||||||
|
// so the console reads on-theme — each color nudged toward black until it clears a WCAG contrast
|
||||||
|
// floor on the near-white console surface. High-contrast AND theme-colored.
|
||||||
|
const ImU32 W = IM_COL32(255, 255, 255, 255); // overlay is white(205); target pure white = safe floor
|
||||||
|
defCmd = EnsureContrast(Primary(), W, 4.5f); // command echo — theme primary
|
||||||
|
defRes = EnsureContrast(OnSurface(), W, 7.0f); // result body — theme text (already dark)
|
||||||
|
defErr = EnsureContrast(Error(), W, 4.5f); // errors — theme error
|
||||||
|
defDmn = WithAlpha(EnsureContrast(OnSurfaceMedium(), W, 4.5f), 235); // node log — dimmer, still legible
|
||||||
|
defInf = EnsureContrast(Primary(), W, 4.5f); // info / app log — theme primary
|
||||||
|
defRpc = EnsureContrast(Secondary(), W, 4.5f); // rpc trace — theme secondary
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schema console colors are authored for the dark terminal (light-on-dark); honor them only in dark
|
||||||
|
// themes. Light themes always use the palette-derived, contrast-floored defaults above.
|
||||||
if (S.isLoaded()) {
|
if (S.isLoaded()) {
|
||||||
auto cmd = S.drawElement("console", "color-command");
|
auto cmd = S.drawElement("console", "color-command");
|
||||||
auto res = S.drawElement("console", "color-result");
|
auto res = S.drawElement("console", "color-result");
|
||||||
@@ -103,17 +169,6 @@ void ConsoleTab::refreshColors()
|
|||||||
auto dmn = S.drawElement("console", "color-daemon");
|
auto dmn = S.drawElement("console", "color-daemon");
|
||||||
auto inf = S.drawElement("console", "color-info");
|
auto inf = S.drawElement("console", "color-info");
|
||||||
auto rpc = S.drawElement("console", "color-rpc");
|
auto rpc = S.drawElement("console", "color-rpc");
|
||||||
|
|
||||||
ImU32 defCmd = dark ? IM_COL32(191, 209, 229, 255) : IM_COL32(21, 101, 192, 255);
|
|
||||||
ImU32 defRes = dark ? IM_COL32(200, 200, 200, 255) : IM_COL32(50, 50, 50, 255);
|
|
||||||
ImU32 defErr = dark ? IM_COL32(246, 71, 64, 255) : IM_COL32(198, 40, 40, 255);
|
|
||||||
ImU32 defDmn = dark ? IM_COL32(160, 160, 160, 180) : IM_COL32(90, 90, 90, 200);
|
|
||||||
ImU32 defInf = dark ? IM_COL32(191, 209, 229, 255) : IM_COL32(21, 101, 192, 255);
|
|
||||||
ImU32 defRpc = dark ? IM_COL32(120, 180, 255, 210) : IM_COL32(25, 118, 210, 220);
|
|
||||||
|
|
||||||
// The schema console colors are authored for the dark terminal (light-on-dark); honor them only
|
|
||||||
// in dark themes. Light themes always use the dark-text defaults so log text stays legible on the
|
|
||||||
// now near-white console surface (otherwise the schema's light-blue text sat pale-on-white ~1.2:1).
|
|
||||||
COLOR_COMMAND = (dark && !cmd.color.empty()) ? S.resolveColor(cmd.color, defCmd) : defCmd;
|
COLOR_COMMAND = (dark && !cmd.color.empty()) ? S.resolveColor(cmd.color, defCmd) : defCmd;
|
||||||
COLOR_RESULT = (dark && !res.color.empty()) ? S.resolveColor(res.color, defRes) : defRes;
|
COLOR_RESULT = (dark && !res.color.empty()) ? S.resolveColor(res.color, defRes) : defRes;
|
||||||
COLOR_ERROR = (dark && !err.color.empty()) ? S.resolveColor(err.color, defErr) : defErr;
|
COLOR_ERROR = (dark && !err.color.empty()) ? S.resolveColor(err.color, defErr) : defErr;
|
||||||
@@ -121,13 +176,12 @@ void ConsoleTab::refreshColors()
|
|||||||
COLOR_INFO = (dark && !inf.color.empty()) ? S.resolveColor(inf.color, defInf) : defInf;
|
COLOR_INFO = (dark && !inf.color.empty()) ? S.resolveColor(inf.color, defInf) : defInf;
|
||||||
COLOR_RPC = (dark && !rpc.color.empty()) ? S.resolveColor(rpc.color, defRpc) : defRpc;
|
COLOR_RPC = (dark && !rpc.color.empty()) ? S.resolveColor(rpc.color, defRpc) : defRpc;
|
||||||
} else {
|
} else {
|
||||||
// No schema — use hardcoded defaults per theme
|
COLOR_COMMAND = defCmd;
|
||||||
COLOR_COMMAND = dark ? IM_COL32(191, 209, 229, 255) : IM_COL32(21, 101, 192, 255);
|
COLOR_RESULT = defRes;
|
||||||
COLOR_RESULT = dark ? IM_COL32(200, 200, 200, 255) : IM_COL32(50, 50, 50, 255);
|
COLOR_ERROR = defErr;
|
||||||
COLOR_ERROR = dark ? IM_COL32(246, 71, 64, 255) : IM_COL32(198, 40, 40, 255);
|
COLOR_DAEMON = defDmn;
|
||||||
COLOR_DAEMON = dark ? IM_COL32(160, 160, 160, 180) : IM_COL32(90, 90, 90, 200);
|
COLOR_INFO = defInf;
|
||||||
COLOR_INFO = dark ? IM_COL32(191, 209, 229, 255) : IM_COL32(21, 101, 192, 255);
|
COLOR_RPC = defRpc;
|
||||||
COLOR_RPC = dark ? IM_COL32(120, 180, 255, 210) : IM_COL32(25, 118, 210, 220);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,19 +189,22 @@ ImU32 ConsoleTab::channelTextColor(ConsoleChannel channel) const
|
|||||||
{
|
{
|
||||||
using namespace material;
|
using namespace material;
|
||||||
switch (channel) {
|
switch (channel) {
|
||||||
|
// COLOR_* channels are already palette-derived + contrast-floored in refreshColors(). The
|
||||||
|
// roles below are computed live from the theme palette, so floor them to a readable contrast on
|
||||||
|
// light terminals here (FloorLight is a no-op on dark themes, preserving the authored look).
|
||||||
case ConsoleChannel::Command: return COLOR_COMMAND;
|
case ConsoleChannel::Command: return COLOR_COMMAND;
|
||||||
case ConsoleChannel::Info: return COLOR_INFO;
|
case ConsoleChannel::Info: return COLOR_INFO;
|
||||||
case ConsoleChannel::Success: return WithAlpha(Success(), 255);
|
case ConsoleChannel::Success: return FloorLight(WithAlpha(Success(), 255));
|
||||||
case ConsoleChannel::Warning: return Warning();
|
case ConsoleChannel::Warning: return FloorLight(Warning());
|
||||||
case ConsoleChannel::Error: return COLOR_ERROR;
|
case ConsoleChannel::Error: return COLOR_ERROR;
|
||||||
case ConsoleChannel::Rpc: return COLOR_RPC;
|
case ConsoleChannel::Rpc: return COLOR_RPC;
|
||||||
case ConsoleChannel::Daemon: return COLOR_DAEMON;
|
case ConsoleChannel::Daemon: return COLOR_DAEMON;
|
||||||
case ConsoleChannel::Xmrig: return COLOR_DAEMON;
|
case ConsoleChannel::Xmrig: return COLOR_DAEMON;
|
||||||
case ConsoleChannel::App: return COLOR_INFO;
|
case ConsoleChannel::App: return COLOR_INFO;
|
||||||
// JSON syntax roles — highlight against the plain-result body.
|
// JSON syntax roles — highlight against the plain-result body.
|
||||||
case ConsoleChannel::JsonKey: return WithAlpha(Secondary(), 255);
|
case ConsoleChannel::JsonKey: return FloorLight(WithAlpha(Secondary(), 255));
|
||||||
case ConsoleChannel::JsonString: return WithAlpha(Success(), 255);
|
case ConsoleChannel::JsonString: return FloorLight(WithAlpha(Success(), 255));
|
||||||
case ConsoleChannel::JsonNumber: return WithAlpha(Warning(), 255);
|
case ConsoleChannel::JsonNumber: return FloorLight(WithAlpha(Warning(), 255));
|
||||||
case ConsoleChannel::JsonBrace: return IsLightTheme() ? IM_COL32(90, 90, 90, 180) : IM_COL32(200, 200, 200, 150);
|
case ConsoleChannel::JsonBrace: return IsLightTheme() ? IM_COL32(90, 90, 90, 180) : IM_COL32(200, 200, 200, 150);
|
||||||
case ConsoleChannel::None:
|
case ConsoleChannel::None:
|
||||||
default: return COLOR_RESULT;
|
default: return COLOR_RESULT;
|
||||||
@@ -208,14 +265,16 @@ void ConsoleTab::render(ConsoleCommandExecutor& exec)
|
|||||||
{
|
{
|
||||||
using namespace material;
|
using namespace material;
|
||||||
|
|
||||||
// Refresh the console theme colors on a dark/light switch. Line colors are derived
|
// Refresh the console theme colors whenever the theme changes. Keyed on the schema generation
|
||||||
// from each line's channel at draw time, so no per-line remap is needed.
|
// (bumped on every theme/skin load) rather than a dark/light toggle: the constructor's refreshColors()
|
||||||
|
// can run before the theme is applied (s_isDarkTheme still defaults true), and a toggle-only trigger
|
||||||
|
// would never correct that on startup. Line colors are derived from each line's channel at draw time.
|
||||||
{
|
{
|
||||||
static bool s_lastDark = IsDarkTheme();
|
static uint32_t s_lastGen = ~0u;
|
||||||
bool nowDark = IsDarkTheme();
|
uint32_t gen = schema::UI().generation();
|
||||||
if (nowDark != s_lastDark) {
|
if (gen != s_lastGen) {
|
||||||
refreshColors();
|
refreshColors();
|
||||||
s_lastDark = nowDark;
|
s_lastGen = gen;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,10 +9,18 @@
|
|||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <atomic>
|
||||||
#include <cctype>
|
#include <cctype>
|
||||||
|
#include <cstddef>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
#include <ctime>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
|
#include <memory>
|
||||||
|
#include <mutex>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <thread>
|
||||||
|
#include <unordered_set>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include "imgui.h"
|
#include "imgui.h"
|
||||||
@@ -23,6 +31,7 @@
|
|||||||
#include "../../util/i18n.h"
|
#include "../../util/i18n.h"
|
||||||
#include "../../util/platform.h"
|
#include "../../util/platform.h"
|
||||||
#include "../../util/text_format.h"
|
#include "../../util/text_format.h"
|
||||||
|
#include "../../util/wallet_file_probe.h"
|
||||||
#include "../../embedded/IconsMaterialDesign.h"
|
#include "../../embedded/IconsMaterialDesign.h"
|
||||||
#include "../notifications.h"
|
#include "../notifications.h"
|
||||||
#include "../layout.h"
|
#include "../layout.h"
|
||||||
@@ -79,7 +88,8 @@ public:
|
|||||||
+ ctrlRow // footer buttons
|
+ ctrlRow // footer buttons
|
||||||
+ 6.0f * style.ItemSpacing.y; // uncounted inter-item gaps
|
+ 6.0f * style.ItemSpacing.y; // uncounted inter-item gaps
|
||||||
const float headH = Type().h6()->LegacySize + Layout::spacingXs() // framework h6 title
|
const float headH = Type().h6()->LegacySize + Layout::spacingXs() // framework h6 title
|
||||||
+ Type().caption()->LegacySize + Layout::spacingSm(); // intro + gap
|
+ Type().caption()->LegacySize + Layout::spacingSm() // intro + gap
|
||||||
|
+ ctrlRow + Layout::spacingSm(); // sort control row + gap
|
||||||
const float padV = 48.0f; // content-child padding (top+bottom) + margin
|
const float padV = 48.0f; // content-child padding (top+bottom) + margin
|
||||||
// Size to content, but cap at a viewport fraction: with many wallets on a small / HiDPI
|
// Size to content, but cap at a viewport fraction: with many wallets on a small / HiDPI
|
||||||
// screen the content-sized card could exceed the window (the framework would clip a too-tall
|
// screen the content-sized card could exceed the window (the framework would clip a too-tall
|
||||||
@@ -94,7 +104,7 @@ public:
|
|||||||
ov.title = TR("wallets_title");
|
ov.title = TR("wallets_title");
|
||||||
ov.p_open = &s_open;
|
ov.p_open = &s_open;
|
||||||
ov.style = OverlayStyle::BlurFloat;
|
ov.style = OverlayStyle::BlurFloat;
|
||||||
ov.cardWidth = 780.0f;
|
ov.cardWidth = 860.0f; // roomier — makes space for the per-row "open folder" button
|
||||||
ov.cardHeight = cardH;
|
ov.cardHeight = cardH;
|
||||||
ov.idSuffix = "wallets";
|
ov.idSuffix = "wallets";
|
||||||
if (BeginOverlayDialog(ov)) {
|
if (BeginOverlayDialog(ov)) {
|
||||||
@@ -102,6 +112,80 @@ public:
|
|||||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||||
|
|
||||||
const std::string active = app->settings() ? app->settings()->getActiveWalletFile() : "wallet.dat";
|
const std::string active = app->settings() ? app->settings()->getActiveWalletFile() : "wallet.dat";
|
||||||
|
// When an external wallet is open in place, the active file is its per-target link name; that row
|
||||||
|
// is the external wallet whose path hashes to it. Precompute per row so the sort stays cheap.
|
||||||
|
const bool linkMode = isLinkName(active);
|
||||||
|
auto rowIsActive = [&](const WalletRow& row) {
|
||||||
|
return linkMode ? (!row.inDatadir && linkNameFor(row.canonPath) == active)
|
||||||
|
: (row.inDatadir && row.fileName == active);
|
||||||
|
};
|
||||||
|
std::vector<char> rowActive(s_rows.size(), 0);
|
||||||
|
for (std::size_t j = 0; j < s_rows.size(); ++j) rowActive[j] = rowIsActive(s_rows[j]) ? 1 : 0;
|
||||||
|
|
||||||
|
// ---- Frame-consistent snapshot of the async probe results (badges / counts / sort all read
|
||||||
|
// the same view; one lock instead of one-per-row). ----------------------------------
|
||||||
|
std::vector<ProbeResult> probeSnap;
|
||||||
|
if (s_probe) { std::lock_guard<std::mutex> lk(s_probe->mtx); probeSnap = s_probe->results; }
|
||||||
|
auto probeAt = [&](std::size_t idx) -> ProbeResult {
|
||||||
|
return idx < probeSnap.size() ? probeSnap[idx] : ProbeResult{};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Sort controls: a segmented key picker + an arrow that flips ascending/descending ----
|
||||||
|
{
|
||||||
|
ImGui::AlignTextToFramePadding();
|
||||||
|
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("wallets_sort_by"));
|
||||||
|
ImGui::SameLine();
|
||||||
|
ImFont* segFont = Type().caption();
|
||||||
|
const char* segLabels[] = { TR("wallets_sort_created"), TR("wallets_sort_addresses"),
|
||||||
|
TR("wallets_sort_txs"), TR("wallets_sort_size") };
|
||||||
|
float maxLW = 0.0f;
|
||||||
|
for (const char* l : segLabels)
|
||||||
|
maxLW = std::max(maxLW, segFont->CalcTextSizeA(segFont->LegacySize, FLT_MAX, 0, l).x);
|
||||||
|
const float segH = ImGui::GetFrameHeight();
|
||||||
|
const float segTW = (maxLW + 20.0f * dp) * 4.0f;
|
||||||
|
const ImVec2 segOrigin = ImGui::GetCursorScreenPos();
|
||||||
|
const int clk = material::SegmentedControl(ImGui::GetWindowDrawList(), segOrigin, segTW, segH,
|
||||||
|
segLabels, 4, s_sortMode, segFont, "##walletSortSeg", dp);
|
||||||
|
if (clk >= 0) s_sortMode = clk;
|
||||||
|
// SegmentedControl is draw-list based and doesn't move the layout cursor, so place the
|
||||||
|
// direction toggle explicitly just to its right on the same baseline (a real item advances
|
||||||
|
// the cursor past this row for the list below). A circular IconButton (square size →
|
||||||
|
// bgRounding = radius) with the arrow glyph centered.
|
||||||
|
ImGui::SetCursorScreenPos(ImVec2(segOrigin.x + segTW + Layout::spacingSm(), segOrigin.y));
|
||||||
|
IconButtonStyle dirStyle;
|
||||||
|
dirStyle.restBg = WithAlpha(OnSurface(), 24);
|
||||||
|
dirStyle.hoverBg = WithAlpha(OnSurface(), 44);
|
||||||
|
dirStyle.color = OnSurfaceMedium();
|
||||||
|
dirStyle.tooltip = TR(s_sortDesc ? "wallets_sort_desc" : "wallets_sort_asc");
|
||||||
|
if (IconButton("##walletSortDir", s_sortDesc ? ICON_MD_ARROW_DOWNWARD : ICON_MD_ARROW_UPWARD,
|
||||||
|
Type().iconSmall(), ImVec2(segH, segH), dirStyle))
|
||||||
|
s_sortDesc = !s_sortDesc;
|
||||||
|
}
|
||||||
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||||
|
|
||||||
|
// ---- Compute the display order for the active sort key (stable, so ties keep scan order).
|
||||||
|
s_order.resize(s_rows.size());
|
||||||
|
for (std::size_t k = 0; k < s_rows.size(); ++k) s_order[k] = k;
|
||||||
|
{
|
||||||
|
std::vector<long long> sortVal(s_rows.size(), 0);
|
||||||
|
for (std::size_t k = 0; k < s_rows.size(); ++k) {
|
||||||
|
const WalletRow& r = s_rows[k];
|
||||||
|
const ProbeResult pr = probeAt(k);
|
||||||
|
switch (s_sortMode) {
|
||||||
|
case 1: { const auto* m = r.inDatadir ? app->walletIndex().find(r.fileName) : nullptr;
|
||||||
|
sortVal[k] = (m && m->cachedAddressCount >= 0) ? (long long)m->cachedAddressCount
|
||||||
|
: pr.keyCount; break; }
|
||||||
|
case 2: sortVal[k] = pr.txCount; break;
|
||||||
|
case 3: sortVal[k] = r.sizeBytes; break;
|
||||||
|
default: sortVal[k] = pr.createdEpoch; break; // 0 = date created
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::stable_sort(s_order.begin(), s_order.end(), [&](std::size_t a, std::size_t b) {
|
||||||
|
const bool aa = rowActive[a] != 0, ba = rowActive[b] != 0;
|
||||||
|
if (aa != ba) return aa; // the active wallet is always pinned to the top
|
||||||
|
return s_sortDesc ? sortVal[a] > sortVal[b] : sortVal[a] < sortVal[b];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Wallet cards (Material-style rows; no table) -------------------------------------
|
// ---- Wallet cards (Material-style rows; no table) -------------------------------------
|
||||||
const float listW = ImGui::GetContentRegionAvail().x;
|
const float listW = ImGui::GetContentRegionAvail().x;
|
||||||
@@ -111,8 +195,11 @@ public:
|
|||||||
float listHFit = listH;
|
float listHFit = listH;
|
||||||
if (capped) listHFit = std::max(walRowH, ImGui::GetContentRegionAvail().y - belowH);
|
if (capped) listHFit = std::max(walRowH, ImGui::GetContentRegionAvail().y - belowH);
|
||||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0));
|
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0));
|
||||||
ImGui::BeginChild("##walletList", ImVec2(listW, listHFit), false);
|
// NoScrollWithMouse + ApplySmoothScroll gives the wheel the same eased scrolling as the
|
||||||
|
// Settings page (ApplySmoothScroll handles the wheel itself, so let it own that input).
|
||||||
|
ImGui::BeginChild("##walletList", ImVec2(listW, listHFit), false, ImGuiWindowFlags_NoScrollWithMouse);
|
||||||
ImGui::PopStyleVar();
|
ImGui::PopStyleVar();
|
||||||
|
ApplySmoothScroll();
|
||||||
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0, 0)); // inter-card gaps are explicit
|
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0, 0)); // inter-card gaps are explicit
|
||||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||||
const float rowW = ImGui::GetContentRegionAvail().x;
|
const float rowW = ImGui::GetContentRegionAvail().x;
|
||||||
@@ -131,11 +218,28 @@ public:
|
|||||||
if (t) s += "\xE2\x80\xA6";
|
if (t) s += "\xE2\x80\xA6";
|
||||||
return s;
|
return s;
|
||||||
};
|
};
|
||||||
|
// The containing sub-directory as its last 1-2 components (e.g. "…/Backups/2021"), so several
|
||||||
|
// same-named wallet.dat files surfaced by the recursive scan are distinguishable at a glance.
|
||||||
|
auto shortSubdir = [](std::string d) {
|
||||||
|
while (d.size() > 1 && (d.back() == '/' || d.back() == '\\')) d.pop_back();
|
||||||
|
const auto p2 = d.find_last_of("/\\");
|
||||||
|
if (p2 == std::string::npos) return d; // no separator: whole thing
|
||||||
|
std::string leaf = d.substr(p2 + 1);
|
||||||
|
const auto p1 = (p2 > 0) ? d.find_last_of("/\\", p2 - 1) : std::string::npos;
|
||||||
|
std::string parent = (p1 == std::string::npos) ? d.substr(0, p2) : d.substr(p1 + 1, p2 - p1 - 1);
|
||||||
|
std::string out = parent.empty() ? leaf : (parent + "/" + leaf);
|
||||||
|
return (p1 != std::string::npos && p1 > 0) ? ("\xE2\x80\xA6/" + out) : out; // "…/" if deeper
|
||||||
|
};
|
||||||
|
|
||||||
for (std::size_t i = 0; i < s_rows.size(); ++i) {
|
for (std::size_t k = 0; k < s_order.size(); ++k) {
|
||||||
|
const std::size_t i = s_order[k]; // sorted display order → actual s_rows index
|
||||||
const WalletRow& r = s_rows[i];
|
const WalletRow& r = s_rows[i];
|
||||||
const data::WalletIndexEntry* meta = app->walletIndex().find(r.fileName);
|
// The wallet index is keyed by bare filename and is only ever written for the active
|
||||||
const bool isCurrent = r.inDatadir && r.fileName == active;
|
// DATADIR wallet — so an external row (esp. a same-named one surfaced by the recursive
|
||||||
|
// subdir scan) must NOT borrow a datadir wallet's cached balance/addresses. Only look up
|
||||||
|
// metadata for datadir rows; external rows show size + "never opened" (accurate).
|
||||||
|
const data::WalletIndexEntry* meta = r.inDatadir ? app->walletIndex().find(r.fileName) : nullptr;
|
||||||
|
const bool isCurrent = rowActive[i] != 0;
|
||||||
|
|
||||||
ImVec2 rMin = ImGui::GetCursorScreenPos();
|
ImVec2 rMin = ImGui::GetCursorScreenPos();
|
||||||
ImVec2 rMax(rMin.x + rowW, rMin.y + walRowH);
|
ImVec2 rMax(rMin.x + rowW, rMin.y + walRowH);
|
||||||
@@ -159,15 +263,41 @@ public:
|
|||||||
isCurrent ? Success() : OnSurfaceMedium(), ICON_MD_ACCOUNT_BALANCE_WALLET);
|
isCurrent ? Success() : OnSurfaceMedium(), ICON_MD_ACCOUNT_BALANCE_WALLET);
|
||||||
x += iconSz + Layout::spacingMd();
|
x += iconSz + Layout::spacingMd();
|
||||||
|
|
||||||
// Action-button slot on the right — reserve room so text truncates before it.
|
// Action-button + folder-button slots on the right — reserve room so text truncates first.
|
||||||
const float btnW = 96.0f * dp, btnH = ImGui::GetFrameHeight();
|
const float btnW = 96.0f * dp, btnH = ImGui::GetFrameHeight();
|
||||||
const float btnX = rMax.x - padX - btnW;
|
const float btnX = rMax.x - padX - btnW; // Open button / Active chip zone
|
||||||
const float textR = btnX - Layout::spacingMd();
|
const float folderW = btnH; // circular "open folder" button
|
||||||
|
const float folderX = btnX - folderW - Layout::spacingSm();
|
||||||
|
const float textR = folderX - Layout::spacingMd();
|
||||||
|
|
||||||
// Name (+ info icon for external files)
|
// Name (+ info icon for external files). Status badges render as a right-aligned vertical
|
||||||
|
// stack of "label icon" rows (e.g. "Seed phrase 🌱" over "Encrypted 🔒") to the left of the
|
||||||
|
// action button — reserve their width first so name/metadata truncate before them.
|
||||||
const float topY = rMin.y + cardPadY;
|
const float topY = rMin.y + cardPadY;
|
||||||
float extra = (!r.inDatadir) ? (metaFont->LegacySize + Layout::spacingSm()) : 0.0f;
|
const float badgeSz = metaFont->LegacySize;
|
||||||
std::string nm = fit(r.fileName, nameFont, std::max(24.0f * dp, textR - x - extra));
|
// Positive detections are definitive; a MISSING marker is trustworthy only when the whole
|
||||||
|
// file was scanned (probeComplete): seed iff hdSeed; "legacy" only when complete & no seed;
|
||||||
|
// "encrypted" iff mkey found; and an "unknown" row when a truncated scan couldn't confirm
|
||||||
|
// encryption — so absence of a lock never falsely reads as "unencrypted" on a huge wallet.
|
||||||
|
const ProbeResult pres = probeAt(i); // from the frame-consistent snapshot above
|
||||||
|
const bool bLock = pres.probed && pres.encrypted;
|
||||||
|
const bool bSeed = pres.probed && pres.hdSeed;
|
||||||
|
const bool bLegacy = pres.probed && pres.complete && !pres.hdSeed;
|
||||||
|
const bool bUnknown = pres.probed && !pres.complete && !pres.encrypted;
|
||||||
|
struct Badge { const char* glyph; ImU32 col; const char* label; const char* tip; };
|
||||||
|
Badge bl[4]; int nb = 0;
|
||||||
|
if (bSeed) bl[nb++] = { ICON_MD_ECO, WithAlpha(Success(), 235), TR("wallets_badge_seed_short"), TR("wallets_badge_seed") };
|
||||||
|
if (bLock) bl[nb++] = { ICON_MD_LOCK, WithAlpha(Warning(), 240), TR("wallets_badge_encrypted_short"), TR("wallets_badge_encrypted") };
|
||||||
|
if (bLegacy) bl[nb++] = { ICON_MD_HISTORY, WithAlpha(OnSurfaceMedium(), 220), TR("wallets_badge_legacy_short"), TR("wallets_badge_legacy") };
|
||||||
|
if (bUnknown) bl[nb++] = { ICON_MD_HELP_OUTLINE, WithAlpha(OnSurfaceMedium(), 185), TR("wallets_badge_unknown_short"), TR("wallets_badge_unknown") };
|
||||||
|
const float bGap = Layout::spacingSm();
|
||||||
|
float maxLabelW = 0.0f;
|
||||||
|
for (int k = 0; k < nb; ++k) maxLabelW = std::max(maxLabelW, tw(metaFont, bl[k].label));
|
||||||
|
const float stackW = nb > 0 ? maxLabelW + bGap + badgeSz : 0.0f;
|
||||||
|
const float stackLeft = nb > 0 ? textR - stackW - Layout::spacingMd() : textR; // text stops here
|
||||||
|
const float infoReserve = (!r.inDatadir) ? (metaFont->LegacySize + Layout::spacingSm()) : 0.0f;
|
||||||
|
|
||||||
|
std::string nm = fit(r.fileName, nameFont, std::max(24.0f * dp, stackLeft - x - infoReserve));
|
||||||
dl->AddText(nameFont, nameFont->LegacySize, ImVec2(x, topY), OnSurface(), nm.c_str());
|
dl->AddText(nameFont, nameFont->LegacySize, ImVec2(x, topY), OnSurface(), nm.c_str());
|
||||||
if (!r.inDatadir) {
|
if (!r.inDatadir) {
|
||||||
ImVec2 ip(x + tw(nameFont, nm) + Layout::spacingSm(), topY + (nameFont->LegacySize - metaFont->LegacySize));
|
ImVec2 ip(x + tw(nameFont, nm) + Layout::spacingSm(), topY + (nameFont->LegacySize - metaFont->LegacySize));
|
||||||
@@ -175,37 +305,70 @@ public:
|
|||||||
if (ImGui::IsMouseHoveringRect(ip, ImVec2(ip.x + metaFont->LegacySize, ip.y + metaFont->LegacySize)))
|
if (ImGui::IsMouseHoveringRect(ip, ImVec2(ip.x + metaFont->LegacySize, ip.y + metaFont->LegacySize)))
|
||||||
Tooltip("%s\n%s", TR("wallets_external_tt"), r.dir.c_str());
|
Tooltip("%s\n%s", TR("wallets_external_tt"), r.dir.c_str());
|
||||||
}
|
}
|
||||||
|
if (nb > 0) {
|
||||||
|
const float lineH = metaFont->LegacySize;
|
||||||
|
const float vGap = Layout::spacingXs();
|
||||||
|
const float totalH = nb * lineH + (nb - 1) * vGap;
|
||||||
|
const float sy = midY - totalH * 0.5f;
|
||||||
|
for (int k = 0; k < nb; ++k) {
|
||||||
|
const float ey = sy + k * (lineH + vGap);
|
||||||
|
const float lw = tw(metaFont, bl[k].label);
|
||||||
|
dl->AddText(metaFont, lineH, ImVec2(textR - badgeSz - bGap - lw, ey), bl[k].col, bl[k].label);
|
||||||
|
dl->AddText(icoFont, badgeSz, ImVec2(textR - badgeSz, ey), bl[k].col, bl[k].glyph);
|
||||||
|
if (ImGui::IsMouseHoveringRect(ImVec2(textR - badgeSz - bGap - lw, ey), ImVec2(textR, ey + lineH)))
|
||||||
|
Tooltip("%s", bl[k].tip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Metadata line (size · N addresses · balance DRGX · last opened)
|
// Metadata line (size · N addresses/keys · M txs · balance DRGX · last opened). The cached
|
||||||
std::string ms = util::Platform::formatFileSize((uint64_t)r.sizeBytes);
|
// wallet index gives the authoritative user-facing ADDRESS count; the btree walk gives an
|
||||||
char b[64];
|
// exact tx count and a spendable-KEY count (labeled "keys", not "addresses", since it counts
|
||||||
|
// change keys the daemon's address list omits — a different figure for the same wallet).
|
||||||
|
std::string ms;
|
||||||
|
if (!r.inDatadir) ms = shortSubdir(r.dir) + dot; // which sub-directory this wallet is in
|
||||||
|
ms += util::Platform::formatFileSize((uint64_t)r.sizeBytes);
|
||||||
|
char b[80];
|
||||||
if (meta && meta->cachedAddressCount >= 0) { snprintf(b, sizeof(b), "%s%lld %s", dot, (long long)meta->cachedAddressCount, TR("wallets_col_addresses")); ms += b; }
|
if (meta && meta->cachedAddressCount >= 0) { snprintf(b, sizeof(b), "%s%lld %s", dot, (long long)meta->cachedAddressCount, TR("wallets_col_addresses")); ms += b; }
|
||||||
|
else if (pres.hasCounts && pres.keyCount > 0) { snprintf(b, sizeof(b), "%s%d %s", dot, pres.keyCount, TR("wallets_col_keys")); ms += b; }
|
||||||
|
if (pres.hasCounts) { snprintf(b, sizeof(b), "%s%d %s", dot, pres.txCount, TR("wallets_col_txs")); ms += b; }
|
||||||
|
if (pres.hasCounts && pres.createdEpoch > 0) { // wallet birthday, e.g. "created Aug 2025"
|
||||||
|
std::time_t ct = static_cast<std::time_t>(pres.createdEpoch);
|
||||||
|
char cd[24]; std::strftime(cd, sizeof(cd), "%b %Y", std::localtime(&ct));
|
||||||
|
snprintf(b, sizeof(b), "%s%s %s", dot, TR("wallets_created"), cd); ms += b;
|
||||||
|
}
|
||||||
if (meta && meta->cachedBalance >= 0.0) { snprintf(b, sizeof(b), "%s%.4f %s", dot, meta->cachedBalance, DRAGONX_TICKER); ms += b; }
|
if (meta && meta->cachedBalance >= 0.0) { snprintf(b, sizeof(b), "%s%.4f %s", dot, meta->cachedBalance, DRAGONX_TICKER); ms += b; }
|
||||||
ms += dot;
|
ms += dot;
|
||||||
ms += (meta && meta->lastOpenedEpoch > 0) ? util::formatTimeAgo(meta->lastOpenedEpoch) : std::string(TR("wallets_never"));
|
ms += (meta && meta->lastOpenedEpoch > 0) ? util::formatTimeAgo(meta->lastOpenedEpoch) : std::string(TR("wallets_never"));
|
||||||
ms = fit(ms, metaFont, std::max(24.0f * dp, textR - x));
|
ms = fit(ms, metaFont, std::max(24.0f * dp, stackLeft - x));
|
||||||
dl->AddText(metaFont, metaFont->LegacySize, ImVec2(x, topY + nameFont->LegacySize + Layout::spacingXs()),
|
dl->AddText(metaFont, metaFont->LegacySize, ImVec2(x, topY + nameFont->LegacySize + Layout::spacingXs()),
|
||||||
OnSurfaceMedium(), ms.c_str());
|
OnSurfaceMedium(), ms.c_str());
|
||||||
|
|
||||||
// Action: Active chip (current) / Open / Import
|
// Action: circular "open folder" button, then the Active chip (current) or an Open button.
|
||||||
|
// External wallets Open IN PLACE (link, no copy); datadir wallets switch directly.
|
||||||
ImGui::PushID(static_cast<int>(i));
|
ImGui::PushID(static_cast<int>(i));
|
||||||
|
{
|
||||||
|
ImGui::SetCursorScreenPos(ImVec2(folderX, midY - folderW * 0.5f));
|
||||||
|
IconButtonStyle fs;
|
||||||
|
fs.restBg = WithAlpha(OnSurface(), 20);
|
||||||
|
fs.hoverBg = WithAlpha(OnSurface(), 44);
|
||||||
|
fs.color = OnSurfaceMedium();
|
||||||
|
fs.tooltip = TR("wallets_open_folder");
|
||||||
|
if (IconButton("##walletFolder", ICON_MD_FOLDER_OPEN, Type().iconSmall(), ImVec2(folderW, folderW), fs))
|
||||||
|
util::Platform::openFolder(r.dir);
|
||||||
|
}
|
||||||
if (isCurrent) {
|
if (isCurrent) {
|
||||||
const char* al = TR("wallets_active");
|
const char* al = TR("wallets_active");
|
||||||
float aw = tw(metaFont, al), cw = aw + 16.0f * dp, ch = metaFont->LegacySize + 6.0f * dp;
|
float aw = tw(metaFont, al), cw = aw + 16.0f * dp, ch = metaFont->LegacySize + 6.0f * dp;
|
||||||
ImVec2 cp(rMax.x - padX - cw, midY - ch * 0.5f);
|
ImVec2 cp(rMax.x - padX - cw, midY - ch * 0.5f);
|
||||||
dl->AddRectFilled(cp, ImVec2(cp.x + cw, cp.y + ch), WithAlpha(Success(), 34), 5.0f * dp);
|
dl->AddRectFilled(cp, ImVec2(cp.x + cw, cp.y + ch), WithAlpha(Success(), 34), 5.0f * dp);
|
||||||
dl->AddText(metaFont, metaFont->LegacySize, ImVec2(cp.x + 8.0f * dp, cp.y + 3.0f * dp), Success(), al);
|
dl->AddText(metaFont, metaFont->LegacySize, ImVec2(cp.x + 8.0f * dp, cp.y + 3.0f * dp), Success(), al);
|
||||||
} else if (r.inDatadir) {
|
|
||||||
ImGui::SetCursorScreenPos(ImVec2(btnX, midY - btnH * 0.5f));
|
|
||||||
if (StyledButton(TR("wallets_open"), ImVec2(btnW, btnH))) {
|
|
||||||
app->switchToWallet(r.fileName);
|
|
||||||
s_open = false;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
ImGui::SetCursorScreenPos(ImVec2(btnX, midY - btnH * 0.5f));
|
ImGui::SetCursorScreenPos(ImVec2(btnX, midY - btnH * 0.5f));
|
||||||
if (StyledButton(TR("wallets_import"), ImVec2(btnW, btnH)))
|
if (StyledButton(TR("wallets_open"), ImVec2(btnW, btnH))) {
|
||||||
importAndOpen(app, r);
|
if (r.inDatadir) { app->switchToWallet(r.fileName); s_open = false; }
|
||||||
if (ImGui::IsItemHovered()) Tooltip("%s", TR("wallets_import_tt"));
|
else { openInPlace(app, r); }
|
||||||
|
}
|
||||||
|
if (!r.inDatadir && ImGui::IsItemHovered()) Tooltip("%s", TR("wallets_open_inplace_tt"));
|
||||||
}
|
}
|
||||||
ImGui::PopID();
|
ImGui::PopID();
|
||||||
|
|
||||||
@@ -230,7 +393,7 @@ public:
|
|||||||
if (StyledButton(TR("wallets_create"), ImVec2(createBtnW, 0))) {
|
if (StyledButton(TR("wallets_create"), ImVec2(createBtnW, 0))) {
|
||||||
std::string name = normalizeWalletName(s_newName);
|
std::string name = normalizeWalletName(s_newName);
|
||||||
std::error_code ec;
|
std::error_code ec;
|
||||||
if (name.empty()) {
|
if (name.empty() || isLinkName(name)) { // reserve the in-place-link prefix
|
||||||
Notifications::instance().warning(TR("wallets_name_invalid"));
|
Notifications::instance().warning(TR("wallets_name_invalid"));
|
||||||
} else if (std::filesystem::exists(util::Platform::getDragonXDataDir() + "/" + name, ec)) {
|
} else if (std::filesystem::exists(util::Platform::getDragonXDataDir() + "/" + name, ec)) {
|
||||||
Notifications::instance().warning(TR("wallets_exists"));
|
Notifications::instance().warning(TR("wallets_exists"));
|
||||||
@@ -286,10 +449,33 @@ private:
|
|||||||
struct WalletRow {
|
struct WalletRow {
|
||||||
std::string fileName;
|
std::string fileName;
|
||||||
std::string dir;
|
std::string dir;
|
||||||
|
std::string canonPath; // symlink-resolved, absolute path — the STABLE identity of the physical
|
||||||
|
// file (two paths reaching the same wallet share it), used for the
|
||||||
|
// in-place link name + to de-dup rows. Computed once at scan time.
|
||||||
bool inDatadir = false;
|
bool inDatadir = false;
|
||||||
long long sizeBytes = 0;
|
long long sizeBytes = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Light facts read off each wallet.dat WITHOUT loading it (util/wallet_file_probe.h), computed on a
|
||||||
|
// background thread so scan() never blocks the UI on a big file read. Results live in a shared,
|
||||||
|
// index-aligned batch: the probe thread fills it, render() reads it under the mutex, and a re-scan
|
||||||
|
// supersedes the old batch (cancel + swap) so a detached in-flight probe can't touch stale rows.
|
||||||
|
struct ProbeResult {
|
||||||
|
bool probed = false; // validated as a BDB wallet and scanned
|
||||||
|
bool complete = false; // scan covered the whole file → a MISSING marker is trustworthy
|
||||||
|
bool encrypted = false; // has an mkey record
|
||||||
|
bool hdSeed = false; // HD/seed wallet (else legacy)
|
||||||
|
bool hasCounts = false; // an exact btree walk produced the counts below
|
||||||
|
int keyCount = 0; // transparent + shielded spendable keys (≈ addresses, incl. change)
|
||||||
|
int txCount = 0; // wallet transaction records
|
||||||
|
long long createdEpoch = 0; // wallet birthday (earliest keymeta nCreateTime); 0 = unknown
|
||||||
|
};
|
||||||
|
struct ProbeBatch {
|
||||||
|
std::mutex mtx;
|
||||||
|
std::vector<ProbeResult> results; // index-aligned with s_rows at the moment of scan()
|
||||||
|
std::atomic<bool> cancel{false};
|
||||||
|
};
|
||||||
|
|
||||||
// Force a "wallet-...dat" filename (plain, no path) so a new/imported wallet shows in the
|
// Force a "wallet-...dat" filename (plain, no path) so a new/imported wallet shows in the
|
||||||
// datadir scan and can't collide with the daemon's internal .dat files. Returns "" if the
|
// datadir scan and can't collide with the daemon's internal .dat files. Returns "" if the
|
||||||
// input has no usable characters.
|
// input has no usable characters.
|
||||||
@@ -308,51 +494,225 @@ private:
|
|||||||
return clean + ".dat";
|
return clean + ".dat";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy an out-of-datadir wallet file into the datadir (the daemon only loads plain filenames
|
// A reserved bare-filename PREFIX for the datadir links that let out-of-datadir wallets open in place.
|
||||||
// from there) under a wallet-*.dat name, then switch to it. Never overwrites an existing file.
|
// Each external wallet gets its own STABLE link name derived from its path — so switching between two
|
||||||
static void importAndOpen(App* app, const WalletRow& r) {
|
// of them is a real -wallet=<different name> switch (not a no-op on one shared name), and the wallet
|
||||||
|
// index tracks each separately (correct per-wallet rescan + cached data). Hidden from the list.
|
||||||
|
static constexpr const char* kLinkPrefix = "wallet-ip-";
|
||||||
|
static bool isLinkName(const std::string& n) { return n.rfind(kLinkPrefix, 0) == 0; }
|
||||||
|
|
||||||
|
// Stable per-target bare link name, e.g. "wallet-ip-1a2b3c4d.dat" (FNV-1a of the absolute path — a
|
||||||
|
// deterministic, cross-platform, cross-run hash, unlike std::hash). Feed it a canonicalOf() path so the
|
||||||
|
// SAME physical wallet always maps to the same name regardless of which path reached it.
|
||||||
|
static std::string linkNameFor(const std::string& absPath) {
|
||||||
|
std::uint64_t h = 1469598103934665603ULL;
|
||||||
|
for (unsigned char c : absPath) { h ^= c; h *= 1099511628211ULL; }
|
||||||
|
char buf[40];
|
||||||
|
std::snprintf(buf, sizeof(buf), "%s%08x.dat", kLinkPrefix,
|
||||||
|
static_cast<unsigned>(h ^ (h >> 32)));
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Symlink-resolved, absolute path — the stable identity of a physical wallet file. Two different path
|
||||||
|
// strings that reach the same file (e.g. a symlinked mount /mnt/usb vs the real /media/usb) collapse to
|
||||||
|
// one value, so they share a single in-place link name and de-dup to one row. fs::canonical resolves
|
||||||
|
// symlinks (the file exists at scan time); on failure fall back to a lexical-absolute path.
|
||||||
|
static std::string canonicalOf(const std::filesystem::path& p) {
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
std::error_code ec;
|
||||||
|
fs::path c = fs::canonical(p, ec);
|
||||||
|
if (ec) c = fs::weakly_canonical(p, ec);
|
||||||
|
return c.empty() ? p.string() : c.string();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open an out-of-datadir wallet IN PLACE. The daemon only loads a bare filename from its datadir, so
|
||||||
|
// link the real file in under its per-target name and switch to that. On Linux/macOS prefer a SYMLINK
|
||||||
|
// (no privileges, spans volumes so a wallet on a USB / other partition works, and it's visibly a
|
||||||
|
// pointer — not a duplicate); on Windows prefer a HARD LINK (a symlink there needs admin / Developer
|
||||||
|
// Mode), falling back to a symlink. Either way we NEVER copy (that would fork the wallet) and NEVER
|
||||||
|
// delete a real file — only our own link.
|
||||||
|
static void openInPlace(App* app, const WalletRow& r) {
|
||||||
namespace fs = std::filesystem;
|
namespace fs = std::filesystem;
|
||||||
std::string dest = normalizeWalletName(r.fileName);
|
|
||||||
if (dest.empty()) { Notifications::instance().warning(TR("wallets_name_invalid")); return; }
|
|
||||||
std::error_code ec;
|
std::error_code ec;
|
||||||
const std::string datadir = util::Platform::getDragonXDataDir();
|
const std::string datadir = util::Platform::getDragonXDataDir();
|
||||||
fs::create_directories(datadir, ec);
|
fs::create_directories(datadir, ec);
|
||||||
const std::string destPath = datadir + "/" + dest;
|
const std::string src = r.dir + "/" + r.fileName;
|
||||||
if (fs::exists(destPath, ec)) { Notifications::instance().warning(TR("wallets_exists")); return; }
|
// The row could be stale (file moved/deleted between the scan and this click). Don't link a ghost:
|
||||||
fs::copy_file(r.dir + "/" + r.fileName, destPath, ec);
|
// on Linux/macOS create_symlink to a missing target would happily succeed (a dangling link), we'd
|
||||||
if (ec) { Notifications::instance().error(TR("wallets_import_failed")); return; }
|
// switch to it, and the daemon guard would silently fall back to wallet.dat — "opened X, got the
|
||||||
Notifications::instance().success(TR("wallets_imported"));
|
// default". Refresh the list instead so the vanished row drops out.
|
||||||
app->switchToWallet(dest);
|
if (!fs::exists(src, ec)) { scan(); return; }
|
||||||
|
// canonPath (symlinks resolved) is the wallet's stable identity: hash it so the same file reached
|
||||||
|
// via any path maps to ONE link name, and point the link straight at the resolved real file.
|
||||||
|
const std::string target = r.canonPath.empty() ? canonicalOf(src) : r.canonPath;
|
||||||
|
const std::string linkName = linkNameFor(target);
|
||||||
|
const std::string linkPath = datadir + "/" + linkName;
|
||||||
|
|
||||||
|
// Reconcile whatever already holds our reserved name. We only ever reclaim a link of OURS — never
|
||||||
|
// destroy an unknown file's unique data. is_symlink uses lstat, so it also catches a DANGLING prior
|
||||||
|
// symlink (source moved) that fs::exists — which follows the link — would miss and that
|
||||||
|
// create_symlink would then trip over ("file exists").
|
||||||
|
if (fs::is_symlink(linkPath, ec)) {
|
||||||
|
fs::remove(linkPath, ec); // our prior symlink (maybe dangling) — replace it
|
||||||
|
} else if (fs::exists(linkPath, ec)) {
|
||||||
|
if (fs::equivalent(linkPath, src, ec)) { // already a valid hard link to THIS wallet
|
||||||
|
app->switchToWallet(linkName); s_open = false; return; // → reuse as-is (same inode)
|
||||||
|
}
|
||||||
|
// A DIFFERENT file holds our reserved name. scan()/create() forbid real wallets at this prefix,
|
||||||
|
// so it's a stale orphan (a hard link whose source moved, or a copy left by a datadir that was
|
||||||
|
// migrated across filesystems) — NOT the current wallet; reusing it would load the wrong
|
||||||
|
// balance. Reclaim the name only if the file is provably redundant (another hard link still
|
||||||
|
// holds the data); otherwise refuse rather than risk destroying something unique.
|
||||||
|
if (fs::hard_link_count(linkPath, ec) > 1) {
|
||||||
|
fs::remove(linkPath, ec);
|
||||||
|
} else {
|
||||||
|
Notifications::instance().error(TR("wallets_open_failed"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::error_code lec;
|
||||||
|
#ifdef _WIN32
|
||||||
|
fs::create_hard_link(target, linkPath, lec); // 1) hard link — no privileges, same volume
|
||||||
|
if (lec) { lec.clear(); fs::create_symlink(target, linkPath, lec); } // 2) symlink — cross-volume (needs admin / Developer Mode)
|
||||||
|
#else
|
||||||
|
fs::create_symlink(target, linkPath, lec); // 1) symlink — no privileges, spans volumes
|
||||||
|
if (lec) { lec.clear(); fs::create_hard_link(target, linkPath, lec); } // 2) hard link — same-volume fallback
|
||||||
|
#endif
|
||||||
|
if (lec) { Notifications::instance().error(TR("wallets_open_failed")); return; } // no copy fallback (would fork the wallet)
|
||||||
|
|
||||||
|
app->switchToWallet(linkName); // distinct per wallet → the index tracks rescan/cache correctly
|
||||||
s_open = false;
|
s_open = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scan the datadir (wallet-prefixed *.dat only, to skip peers.dat / asmap.dat / fee_estimates.dat)
|
// Scan the datadir (wallet-prefixed *.dat only, to skip peers.dat / asmap.dat / fee_estimates.dat)
|
||||||
// and each user-added folder (any *.dat). Exception-safe: iterates with error_codes.
|
// and each user-added folder (any *.dat). User-added folders are searched RECURSIVELY into
|
||||||
|
// subdirectories; the datadir stays top-level only (never descend into blocks/ chainstate/ …).
|
||||||
|
// Exception-safe: iterates with error_codes. Recursion is bounded (depth / hit / visit caps) and
|
||||||
|
// never follows directory symlinks (std default), so it can't loop or stall the UI on a huge tree.
|
||||||
static void scan() {
|
static void scan() {
|
||||||
|
// Supersede any in-flight probe batch: its detached thread notices cancel and stops. It only ever
|
||||||
|
// touches its OWN batch (never s_rows), so rebuilding the list below is safe while it winds down.
|
||||||
|
if (s_probe) s_probe->cancel.store(true);
|
||||||
s_rows.clear();
|
s_rows.clear();
|
||||||
namespace fs = std::filesystem;
|
namespace fs = std::filesystem;
|
||||||
auto addFrom = [](const std::string& dir, bool inDatadir) {
|
// Guards for recursive external-folder scans (a user could point us at a large tree / home dir).
|
||||||
std::error_code ec;
|
constexpr int kMaxDepth = 8; // don't descend deeper than this below the chosen folder
|
||||||
if (dir.empty() || !fs::is_directory(dir, ec)) return;
|
constexpr size_t kMaxHits = 200; // cap total wallet files listed
|
||||||
fs::directory_iterator it(dir, ec), end;
|
constexpr int kMaxVisited = 40000; // cap total entries walked, so scan() can't stall the UI
|
||||||
for (; !ec && it != end; it.increment(ec)) {
|
|
||||||
|
// Node/daemon .dat files that are NOT wallets. The datadir scan already restricts to a "wallet"
|
||||||
|
// prefix, but recursive external folders accept any *.dat — and a user can easily point the picker
|
||||||
|
// at a folder that IS or CONTAINS a datadir, so filter these out everywhere or the list fills with
|
||||||
|
// blk*/rev* block files, peers.dat, etc. presented as importable "wallets".
|
||||||
|
auto isNodeArtifact = [](const std::string& n) {
|
||||||
|
static const char* kExact[] = { "peers.dat", "banlist.dat", "fee_estimates.dat",
|
||||||
|
"mempool.dat", "asmap.dat", "zindex.dat" };
|
||||||
|
for (const char* e : kExact) if (n == e) return true;
|
||||||
|
return n.rfind("blk", 0) == 0 || n.rfind("rev", 0) == 0; // blk00000.dat / rev00000.dat
|
||||||
|
};
|
||||||
|
std::unordered_set<std::string> seenCanon; // one row per physical wallet. The datadir is scanned
|
||||||
|
// first, so a datadir wallet wins over the same file
|
||||||
|
// reached via an external (possibly symlinked) path.
|
||||||
|
auto consider = [&](const fs::path& p, bool inDatadir) {
|
||||||
|
// String-only checks FIRST (no syscalls) — the vast majority of entries in a large tree are
|
||||||
|
// neither .dat nor wallets, so reject them before paying for a stat().
|
||||||
|
const std::string name = p.filename().string();
|
||||||
|
if (name.size() <= 4 || name.substr(name.size() - 4) != ".dat") return;
|
||||||
|
if (inDatadir) { if (name.rfind("wallet", 0) != 0 || isLinkName(name)) return; } // datadir: wallet-prefixed, hide in-place links
|
||||||
|
else { if (isNodeArtifact(name)) return; } // external: reject node junk
|
||||||
std::error_code fec;
|
std::error_code fec;
|
||||||
if (!it->is_regular_file(fec)) continue;
|
if (!fs::is_regular_file(p, fec)) return;
|
||||||
std::string name = it->path().filename().string();
|
|
||||||
if (name.size() <= 4 || name.substr(name.size() - 4) != ".dat") continue;
|
|
||||||
if (inDatadir && name.rfind("wallet", 0) != 0) continue;
|
|
||||||
WalletRow r;
|
WalletRow r;
|
||||||
r.fileName = name;
|
r.fileName = name;
|
||||||
r.dir = dir;
|
r.dir = p.parent_path().string(); // the file's ACTUAL parent (may be a subdir) — Open reads r.dir + "/" + r.fileName
|
||||||
|
r.canonPath = canonicalOf(p); // symlink-resolved identity (see below)
|
||||||
|
if (!seenCanon.insert(r.canonPath).second) return; // same physical wallet already listed via another path
|
||||||
r.inDatadir = inDatadir;
|
r.inDatadir = inDatadir;
|
||||||
r.sizeBytes = static_cast<long long>(fs::file_size(it->path(), fec));
|
r.sizeBytes = static_cast<long long>(fs::file_size(p, fec));
|
||||||
s_rows.push_back(std::move(r));
|
s_rows.push_back(std::move(r)); // encryption/seed flags are filled in asynchronously (see below)
|
||||||
|
};
|
||||||
|
// Directory names we never descend into during a recursive external scan — node data subtrees hold
|
||||||
|
// no user wallets and would otherwise burn the visit budget (and flood the list) with block files.
|
||||||
|
auto isNodeSubtree = [](const std::string& n) {
|
||||||
|
return n == "blocks" || n == "chainstate" || n == "database" ||
|
||||||
|
n == "indexes" || n == "index";
|
||||||
|
};
|
||||||
|
auto addFrom = [&](const std::string& dir, bool inDatadir, bool recursive) {
|
||||||
|
std::error_code ec;
|
||||||
|
if (dir.empty() || !fs::is_directory(dir, ec)) return;
|
||||||
|
if (!recursive) {
|
||||||
|
fs::directory_iterator it(dir, ec), end;
|
||||||
|
for (; !ec && it != end; it.increment(ec)) consider(it->path(), inDatadir);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// skip_permission_denied keeps a locked subdir from aborting the whole walk; the iterator
|
||||||
|
// does NOT follow directory symlinks by default, so symlink cycles can't cause infinite loops.
|
||||||
|
fs::recursive_directory_iterator it(dir, fs::directory_options::skip_permission_denied, ec), end;
|
||||||
|
int visited = 0;
|
||||||
|
for (; !ec && it != end; it.increment(ec)) {
|
||||||
|
if (++visited > kMaxVisited) break;
|
||||||
|
std::error_code dec;
|
||||||
|
if (it->is_directory(dec)) {
|
||||||
|
// Prune: don't descend past the depth cap or into node data subtrees.
|
||||||
|
if (it.depth() >= kMaxDepth || isNodeSubtree(it->path().filename().string()))
|
||||||
|
it.disable_recursion_pending();
|
||||||
|
continue; // directories aren't wallet files
|
||||||
|
}
|
||||||
|
consider(it->path(), inDatadir);
|
||||||
|
if (s_rows.size() >= kMaxHits) break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
addFrom(util::Platform::getDragonXDataDir(), /*inDatadir=*/true);
|
addFrom(util::Platform::getDragonXDataDir(), /*inDatadir=*/true, /*recursive=*/false);
|
||||||
if (s_app)
|
if (s_app)
|
||||||
for (const auto& f : s_app->walletIndex().extraFolders())
|
for (const auto& f : s_app->walletIndex().extraFolders())
|
||||||
addFrom(f, /*inDatadir=*/false);
|
addFrom(f, /*inDatadir=*/false, /*recursive=*/true);
|
||||||
|
|
||||||
|
// Probe the encryption/seed flags on a background thread so a large wallet.dat read never stalls
|
||||||
|
// the UI. The thread is DETACHED and captures only its own heap batch + a copy of the target paths
|
||||||
|
// (no static/s_rows access), so it's safe to outlive a re-scan or app shutdown. Results land in the
|
||||||
|
// shared batch that render() reads under the mutex; badges fill in over the next few frames.
|
||||||
|
auto batch = std::make_shared<ProbeBatch>();
|
||||||
|
batch->results.resize(s_rows.size());
|
||||||
|
s_probe = batch;
|
||||||
|
std::vector<std::pair<std::string, std::size_t>> targets;
|
||||||
|
targets.reserve(s_rows.size());
|
||||||
|
for (std::size_t i = 0; i < s_rows.size(); ++i)
|
||||||
|
targets.emplace_back(s_rows[i].dir + "/" + s_rows[i].fileName, i);
|
||||||
|
|
||||||
|
auto runProbe = [](std::shared_ptr<ProbeBatch> batch,
|
||||||
|
std::vector<std::pair<std::string, std::size_t>> targets) {
|
||||||
|
// Per-file 256 MB (BDB sorts long-keyed mkey/hdchain LATE, so a small cap risks a
|
||||||
|
// false-negative); a shared budget bounds total I/O, charged by bytes actually read.
|
||||||
|
std::size_t budget = 768u * 1024u * 1024u;
|
||||||
|
constexpr std::size_t kPerFile = 256u * 1024u * 1024u;
|
||||||
|
for (const auto& t : targets) {
|
||||||
|
if (batch->cancel.load()) return;
|
||||||
|
ProbeResult res;
|
||||||
|
if (budget > 0) {
|
||||||
|
// Primary: an exact btree walk (encryption/seed flags + address/tx counts). If it
|
||||||
|
// can't fully parse (unknown BDB variant, or a >cap file), fall back to the cheap
|
||||||
|
// byte-scan for badges only (no counts).
|
||||||
|
const auto bt = util::parseWalletBtree(t.first, std::min(budget, kPerFile));
|
||||||
|
if (bt.parsed && bt.complete) {
|
||||||
|
res = ProbeResult{ true, true, bt.encrypted, bt.hdSeed, true, bt.addresses(), bt.txCount, bt.createdEpoch };
|
||||||
|
budget -= std::min(budget, bt.bytesRead);
|
||||||
|
} else {
|
||||||
|
const auto pr = util::probeWalletFile(t.first, std::min(budget, kPerFile));
|
||||||
|
res = ProbeResult{ pr.isBerkeleyDB, pr.scanComplete, pr.encrypted, pr.hdSeed };
|
||||||
|
budget -= std::min(budget, std::max(bt.bytesRead, pr.bytesRead));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::lock_guard<std::mutex> lk(batch->mtx);
|
||||||
|
if (t.second < batch->results.size()) batch->results[t.second] = res;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (targets.empty()) {
|
||||||
|
// nothing to probe
|
||||||
|
} else if (s_app && s_app->isScreenshotSweeping()) {
|
||||||
|
runProbe(batch, targets); // offline sweep: probe synchronously so the captured frame is populated
|
||||||
|
} else {
|
||||||
|
std::thread(runProbe, batch, targets).detach();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static inline bool s_open = false;
|
static inline bool s_open = false;
|
||||||
@@ -360,6 +720,10 @@ private:
|
|||||||
static inline bool s_needScan = false;
|
static inline bool s_needScan = false;
|
||||||
static inline char s_newName[128] = "";
|
static inline char s_newName[128] = "";
|
||||||
static inline std::vector<WalletRow> s_rows;
|
static inline std::vector<WalletRow> s_rows;
|
||||||
|
static inline std::shared_ptr<ProbeBatch> s_probe; // current async probe batch (index-aligned w/ s_rows)
|
||||||
|
static inline std::vector<std::size_t> s_order; // display order (indices into s_rows) for the active sort
|
||||||
|
static inline int s_sortMode = 0; // 0 created · 1 addresses · 2 txs · 3 size
|
||||||
|
static inline bool s_sortDesc = true; // newest / most / largest first
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace ui
|
} // namespace ui
|
||||||
|
|||||||
@@ -258,25 +258,41 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["wallets_col_name"] = "Wallet";
|
strings_["wallets_col_name"] = "Wallet";
|
||||||
strings_["wallets_col_size"] = "Size";
|
strings_["wallets_col_size"] = "Size";
|
||||||
strings_["wallets_col_addresses"] = "Addresses";
|
strings_["wallets_col_addresses"] = "Addresses";
|
||||||
|
strings_["wallets_col_txs"] = "txs";
|
||||||
|
strings_["wallets_col_keys"] = "keys";
|
||||||
|
strings_["wallets_created"] = "created";
|
||||||
|
strings_["wallets_sort_by"] = "Sort:";
|
||||||
|
strings_["wallets_sort_created"] = "Created";
|
||||||
|
strings_["wallets_sort_addresses"] = "Addresses";
|
||||||
|
strings_["wallets_sort_txs"] = "Txs";
|
||||||
|
strings_["wallets_sort_size"] = "Size";
|
||||||
|
strings_["wallets_sort_asc"] = "Ascending (oldest / fewest / smallest first)";
|
||||||
|
strings_["wallets_sort_desc"] = "Descending (newest / most / largest first)";
|
||||||
strings_["wallets_col_balance"] = "Balance";
|
strings_["wallets_col_balance"] = "Balance";
|
||||||
strings_["wallets_col_opened"] = "Last opened";
|
strings_["wallets_col_opened"] = "Last opened";
|
||||||
strings_["wallets_current"] = "current";
|
strings_["wallets_current"] = "current";
|
||||||
strings_["wallets_active"] = "Active";
|
strings_["wallets_active"] = "Active";
|
||||||
|
strings_["wallets_badge_encrypted"] = "Encrypted (passphrase-protected)";
|
||||||
|
strings_["wallets_badge_seed"] = "Seed phrase wallet (HD)";
|
||||||
|
strings_["wallets_badge_legacy"] = "Legacy wallet (no seed phrase)";
|
||||||
|
strings_["wallets_badge_unknown"] = "Wallet type not fully determined (large file — open to confirm)";
|
||||||
|
strings_["wallets_badge_seed_short"] = "Seed phrase";
|
||||||
|
strings_["wallets_badge_encrypted_short"] = "Encrypted";
|
||||||
|
strings_["wallets_badge_legacy_short"] = "Legacy";
|
||||||
|
strings_["wallets_badge_unknown_short"] = "Unknown";
|
||||||
strings_["wallets_open"] = "Open";
|
strings_["wallets_open"] = "Open";
|
||||||
strings_["wallets_never"] = "Never";
|
strings_["wallets_open_folder"] = "Open folder location";
|
||||||
strings_["wallets_import_hint"] = "Import to open";
|
strings_["wallets_open_inplace_tt"] = "Open this wallet where it is — linked into the data directory (no copy)";
|
||||||
strings_["wallets_import_tt"] = "This wallet is outside the data directory. Opening it will copy it in first (coming soon).";
|
strings_["wallets_open_failed"] = "Couldn't open this wallet in place. It's likely on a different drive than your data directory — move it onto the same drive (on Windows, enabling Developer Mode also lets it link across drives).";
|
||||||
strings_["wallets_external_tt"] = "Outside your data directory \xE2\x80\x94 Import to copy it in.";
|
strings_["wallets_never"] = "Never opened";
|
||||||
|
strings_["wallets_external_tt"] = "Outside your data directory \xE2\x80\x94 Open links it in place (no copy).";
|
||||||
strings_["wallets_scan_folder"] = "Scan another folder for wallets\xE2\x80\xA6";
|
strings_["wallets_scan_folder"] = "Scan another folder for wallets\xE2\x80\xA6";
|
||||||
strings_["wallets_folder_invalid"] = "That folder doesn't exist.";
|
strings_["wallets_folder_invalid"] = "That folder doesn't exist.";
|
||||||
strings_["wallets_import"] = "Import";
|
|
||||||
strings_["wallets_new_label"] = "Create a new wallet:";
|
strings_["wallets_new_label"] = "Create a new wallet:";
|
||||||
strings_["wallets_new_hint"] = "Name (e.g. savings)";
|
strings_["wallets_new_hint"] = "Name (e.g. savings)";
|
||||||
strings_["wallets_create"] = "Create wallet";
|
strings_["wallets_create"] = "Create wallet";
|
||||||
strings_["wallets_name_invalid"] = "Please enter a valid wallet name.";
|
strings_["wallets_name_invalid"] = "Please enter a valid wallet name.";
|
||||||
strings_["wallets_exists"] = "A wallet with that name already exists.";
|
strings_["wallets_exists"] = "A wallet with that name already exists.";
|
||||||
strings_["wallets_imported"] = "Wallet imported — switching…";
|
|
||||||
strings_["wallets_import_failed"] = "Could not import that wallet file.";
|
|
||||||
strings_["wallets_creating"] = "Creating wallet — the node will restart…";
|
strings_["wallets_creating"] = "Creating wallet — the node will restart…";
|
||||||
strings_["wallets_reveal"] = "Reveal folder";
|
strings_["wallets_reveal"] = "Reveal folder";
|
||||||
// In-app folder picker (Scan another folder)
|
// In-app folder picker (Scan another folder)
|
||||||
@@ -666,6 +682,14 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["debug_logging"] = "DEBUG OPTIONS";
|
strings_["debug_logging"] = "DEBUG OPTIONS";
|
||||||
strings_["settings_debug_select"] = "Select categories to enable daemon debug logging (-debug= flags).";
|
strings_["settings_debug_select"] = "Select categories to enable daemon debug logging (-debug= flags).";
|
||||||
strings_["settings_debug_restart_note"] = "Changes take effect after restarting the daemon.";
|
strings_["settings_debug_restart_note"] = "Changes take effect after restarting the daemon.";
|
||||||
|
// Debug-options gate (confirmation + optional re-auth)
|
||||||
|
strings_["debug_gate_title"] = "Show debug options?";
|
||||||
|
strings_["debug_gate_warning"] = "These are advanced diagnostic and node-tuning options. They can expose sensitive information and change how your node runs. Only continue if you know what you're doing.";
|
||||||
|
strings_["debug_gate_pin_prompt"] = "Enter your unlock PIN to continue:";
|
||||||
|
strings_["debug_gate_pass_prompt"] = "Enter your wallet passphrase to continue:";
|
||||||
|
strings_["debug_gate_confirm"] = "Show debug options";
|
||||||
|
strings_["debug_gate_incorrect"] = "Incorrect PIN or passphrase.";
|
||||||
|
strings_["debug_gate_verifying"] = "Verifying\xE2\x80\xA6";
|
||||||
|
|
||||||
// Settings window (legacy dialog) descriptions
|
// Settings window (legacy dialog) descriptions
|
||||||
strings_["settings_language_note"] = "Note: Some text requires restart to update";
|
strings_["settings_language_note"] = "Note: Some text requires restart to update";
|
||||||
|
|||||||
313
src/util/wallet_file_probe.h
Normal file
313
src/util/wallet_file_probe.h
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
// DragonX Wallet - ImGui Edition
|
||||||
|
// Copyright 2024-2026 The Hush Developers
|
||||||
|
// Released under the GPLv3
|
||||||
|
//
|
||||||
|
// wallet_file_probe.h — read light metadata off a wallet.dat WITHOUT loading it into the daemon.
|
||||||
|
//
|
||||||
|
// DragonX wallet.dat is a Berkeley DB (btree) key/value store. Only the private-key material is
|
||||||
|
// encrypted (ckey/czkey/csapzkey/chdseed); the record *keys* and non-secret metadata live in
|
||||||
|
// plaintext, so a handful of boolean facts can be recovered cheaply by (a) validating the BDB btree
|
||||||
|
// magic and (b) scanning the raw bytes for the length-prefixed record names the daemon writes
|
||||||
|
// (a std::string is serialized as CompactSize(len)+bytes, so e.g. an "mkey" record's key begins with
|
||||||
|
// the 5 bytes 0x04 'm' 'k' 'e' 'y'). This is a heuristic PRESENCE test — reliable for booleans, not
|
||||||
|
// for exact counts — with no libdb dependency and no daemon. It never reads or exposes key material.
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstring>
|
||||||
|
#include <fstream>
|
||||||
|
#include <string>
|
||||||
|
#include <utility>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace dragonx {
|
||||||
|
namespace util {
|
||||||
|
|
||||||
|
struct WalletFileProbe {
|
||||||
|
bool isBerkeleyDB = false; ///< file has a valid BDB btree metapage magic (looks like a real wallet.dat)
|
||||||
|
bool encrypted = false; ///< has an "mkey" master-key record → passphrase-encrypted
|
||||||
|
bool hdSeed = false; ///< has hdseed/chdseed/hdchain → HD/seed wallet (else legacy)
|
||||||
|
bool hasShielded = false; ///< has zkey/czkey/sapzkey/csapzkey → holds shielded addresses
|
||||||
|
// A found marker is always definitive; a MISSING marker is only trustworthy when the scan actually
|
||||||
|
// covered the whole file. `scanComplete` is true when we reached EOF, or early-exited having found
|
||||||
|
// everything — false only when the byte cap cut the scan short. Callers should treat negative flags
|
||||||
|
// (e.g. "legacy" = !hdSeed, or "not encrypted" = !encrypted) as reliable only when scanComplete is true.
|
||||||
|
bool scanComplete = false;
|
||||||
|
std::size_t bytesRead = 0; ///< bytes actually consumed (early-exit reads far less than the file) — for budgeting
|
||||||
|
};
|
||||||
|
|
||||||
|
// Probe a wallet.dat by header-validating it as a BDB btree then byte-scanning (bounded, streaming,
|
||||||
|
// early-exit) for record markers. `maxBytes` caps how far we read so a pathologically large file can't
|
||||||
|
// stall the caller. Returns an all-false probe (isBerkeleyDB=false) for anything that isn't a readable
|
||||||
|
// BDB btree file. Safe to call on a wallet currently open by the daemon (read-only, pattern scan only).
|
||||||
|
inline WalletFileProbe probeWalletFile(const std::string& path,
|
||||||
|
std::size_t maxBytes = 96u * 1024u * 1024u) {
|
||||||
|
WalletFileProbe out;
|
||||||
|
std::ifstream f(path, std::ios::binary);
|
||||||
|
if (!f) return out;
|
||||||
|
|
||||||
|
// --- 1) Validate the Berkeley DB btree metapage magic (offset 12, either endianness). ---
|
||||||
|
unsigned char hdr[16];
|
||||||
|
f.read(reinterpret_cast<char*>(hdr), sizeof(hdr));
|
||||||
|
if (f.gcount() < static_cast<std::streamsize>(sizeof(hdr))) return out;
|
||||||
|
auto rd32 = [](const unsigned char* p, bool le) -> uint32_t {
|
||||||
|
return le ? (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24)
|
||||||
|
: (uint32_t)p[3] | ((uint32_t)p[2] << 8) | ((uint32_t)p[1] << 16) | ((uint32_t)p[0] << 24);
|
||||||
|
};
|
||||||
|
constexpr uint32_t kBtreeMagic = 0x00053162u; // DB_BTREEMAGIC
|
||||||
|
if (rd32(hdr + 12, true) != kBtreeMagic && rd32(hdr + 12, false) != kBtreeMagic) return out;
|
||||||
|
out.isBerkeleyDB = true;
|
||||||
|
|
||||||
|
// --- 2) Streaming byte-scan for length-prefixed record markers. ---
|
||||||
|
// group: 0 = encrypted, 1 = hd/seed, 2 = shielded.
|
||||||
|
const std::vector<std::pair<std::string, int>> patterns = {
|
||||||
|
{std::string("\x04", 1) + "mkey", 0}, // master key → wallet is encrypted
|
||||||
|
{std::string("\x06", 1) + "hdseed", 1}, // plaintext HD seed
|
||||||
|
{std::string("\x07", 1) + "chdseed", 1}, // encrypted HD seed
|
||||||
|
{std::string("\x07", 1) + "hdchain", 1}, // HD chain counter (present in both HD variants)
|
||||||
|
{std::string("\x04", 1) + "zkey", 2}, // sprout shielded key
|
||||||
|
{std::string("\x05", 1) + "czkey", 2}, // encrypted sprout key
|
||||||
|
{std::string("\x07", 1) + "sapzkey", 2}, // sapling shielded key
|
||||||
|
{std::string("\x08", 1) + "csapzkey", 2}, // encrypted sapling key
|
||||||
|
};
|
||||||
|
std::size_t maxPat = 0;
|
||||||
|
for (const auto& p : patterns) maxPat = std::max(maxPat, p.first.size());
|
||||||
|
const std::size_t overlap = maxPat > 0 ? maxPat - 1 : 0; // bytes carried between chunks for split matches
|
||||||
|
|
||||||
|
f.clear();
|
||||||
|
f.seekg(0, std::ios::beg);
|
||||||
|
constexpr std::size_t kChunk = 1u << 20; // 1 MiB
|
||||||
|
std::vector<char> buf(kChunk);
|
||||||
|
std::string carry;
|
||||||
|
std::size_t readTotal = 0;
|
||||||
|
bool enc = false, hd = false, sh = false;
|
||||||
|
bool eof = false, allFound = false;
|
||||||
|
while (readTotal < maxBytes) {
|
||||||
|
const std::size_t want = std::min(kChunk, maxBytes - readTotal);
|
||||||
|
f.read(buf.data(), static_cast<std::streamsize>(want));
|
||||||
|
const std::streamsize got = f.gcount();
|
||||||
|
if (got <= 0) { eof = true; break; }
|
||||||
|
readTotal += static_cast<std::size_t>(got);
|
||||||
|
|
||||||
|
std::string window;
|
||||||
|
window.reserve(carry.size() + static_cast<std::size_t>(got));
|
||||||
|
window.assign(carry);
|
||||||
|
window.append(buf.data(), static_cast<std::size_t>(got));
|
||||||
|
|
||||||
|
for (const auto& p : patterns) {
|
||||||
|
bool& flag = (p.second == 0) ? enc : (p.second == 1) ? hd : sh;
|
||||||
|
if (!flag && window.find(p.first) != std::string::npos) flag = true;
|
||||||
|
}
|
||||||
|
if (enc && hd && sh) { allFound = true; break; } // nothing more to learn
|
||||||
|
|
||||||
|
if (window.size() > overlap) carry.assign(window.data() + window.size() - overlap, overlap);
|
||||||
|
else carry.assign(window);
|
||||||
|
if (static_cast<std::size_t>(got) < want) { eof = true; break; } // reached EOF
|
||||||
|
}
|
||||||
|
out.encrypted = enc;
|
||||||
|
out.hdSeed = hd;
|
||||||
|
out.hasShielded = sh;
|
||||||
|
out.bytesRead = readTotal;
|
||||||
|
// false only if the byte cap cut a larger file short. If we stopped because readTotal hit maxBytes on
|
||||||
|
// an exact chunk boundary, ifstream never set eofbit — peek() tells us whether the file actually ended
|
||||||
|
// there (EOF → complete) or there's more beyond the cap (a real byte → truncated).
|
||||||
|
out.scanComplete = allFound || eof;
|
||||||
|
if (!out.scanComplete && f && f.peek() == std::ifstream::traits_type::eof()) out.scanComplete = true;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Tier 2: exact record counts by actually walking the Berkeley DB btree (still no daemon, no libdb).
|
||||||
|
//
|
||||||
|
// wallet.dat is a BDB btree: a metapage (page 0) points at a root page; internal pages point at child
|
||||||
|
// pages; leaf pages hold (key,data) item pairs. We traverse from the root, visiting only reachable
|
||||||
|
// leaves (so freed/stale pages aren't counted), and tally records by the length-prefixed type name each
|
||||||
|
// key begins with. Every offset is bounds-checked against the page/file; a visited-set + page/key caps
|
||||||
|
// make it safe on a corrupt or adversarial file. On any structural surprise it returns parsed=false and
|
||||||
|
// the caller falls back to the byte-scan probe above.
|
||||||
|
struct WalletBtreeStats {
|
||||||
|
bool parsed = false; ///< the btree walked cleanly
|
||||||
|
bool complete = false; ///< the whole file was read (not cap-truncated) → counts are exact
|
||||||
|
int transparentKeys = 0; ///< key + wkey + ckey (spendable keys, incl. change; keypool "pool" recs excluded)
|
||||||
|
int shieldedKeys = 0; ///< zkey + czkey + sapzkey + csapzkey (≈ shielded addresses)
|
||||||
|
int addressBook = 0; ///< name records (labeled/received addresses)
|
||||||
|
int txCount = 0; ///< tx records (wallet transactions)
|
||||||
|
long long createdEpoch = 0; ///< earliest keymeta nCreateTime (wallet birthday); 0 = unknown
|
||||||
|
bool encrypted = false; ///< saw an mkey record
|
||||||
|
bool hdSeed = false; ///< saw hdseed/chdseed/hdchain
|
||||||
|
std::size_t bytesRead = 0; ///< bytes actually read (for budgeting)
|
||||||
|
int addresses() const { return transparentKeys + shieldedKeys; }
|
||||||
|
};
|
||||||
|
|
||||||
|
inline WalletBtreeStats parseWalletBtree(const std::string& path,
|
||||||
|
std::size_t maxBytes = 256u * 1024u * 1024u) {
|
||||||
|
WalletBtreeStats st;
|
||||||
|
std::ifstream f(path, std::ios::binary);
|
||||||
|
if (!f) return st;
|
||||||
|
std::string buf;
|
||||||
|
{
|
||||||
|
f.seekg(0, std::ios::end);
|
||||||
|
std::streamoff sz = f.tellg();
|
||||||
|
if (sz < 512) return st;
|
||||||
|
const std::size_t want = std::min<std::size_t>(static_cast<std::size_t>(sz), maxBytes);
|
||||||
|
f.seekg(0, std::ios::beg);
|
||||||
|
buf.resize(want);
|
||||||
|
f.read(&buf[0], static_cast<std::streamsize>(want));
|
||||||
|
buf.resize(static_cast<std::size_t>(std::max<std::streamsize>(0, f.gcount())));
|
||||||
|
if (buf.size() < 512) return st;
|
||||||
|
st.bytesRead = buf.size();
|
||||||
|
st.complete = (buf.size() == static_cast<std::size_t>(sz)); // read the whole file, not cap-truncated
|
||||||
|
}
|
||||||
|
const unsigned char* B = reinterpret_cast<const unsigned char*>(buf.data());
|
||||||
|
const std::size_t N = buf.size();
|
||||||
|
|
||||||
|
// Byte order comes from the metapage magic (BDB stores fields in the creating machine's order).
|
||||||
|
auto rd32at = [&](std::size_t o, bool le) -> uint32_t {
|
||||||
|
return le ? (uint32_t)B[o] | ((uint32_t)B[o+1]<<8) | ((uint32_t)B[o+2]<<16) | ((uint32_t)B[o+3]<<24)
|
||||||
|
: (uint32_t)B[o+3] | ((uint32_t)B[o+2]<<8) | ((uint32_t)B[o+1]<<16) | ((uint32_t)B[o]<<24);
|
||||||
|
};
|
||||||
|
constexpr uint32_t kBtreeMagic = 0x00053162u;
|
||||||
|
bool le;
|
||||||
|
if (rd32at(12, true) == kBtreeMagic) le = true;
|
||||||
|
else if (rd32at(12, false) == kBtreeMagic) le = false;
|
||||||
|
else return st; // not a BDB btree
|
||||||
|
|
||||||
|
auto r32 = [&](std::size_t o) { return o + 4 <= N ? rd32at(o, le) : 0u; };
|
||||||
|
auto r16 = [&](std::size_t o) -> uint32_t {
|
||||||
|
if (o + 2 > N) return 0;
|
||||||
|
return le ? (uint32_t)B[o] | ((uint32_t)B[o+1]<<8) : (uint32_t)B[o+1] | ((uint32_t)B[o]<<8);
|
||||||
|
};
|
||||||
|
|
||||||
|
const uint32_t pagesize = r32(20);
|
||||||
|
if (pagesize < 512 || pagesize > 65536 || (pagesize & (pagesize - 1)) != 0) return st; // must be a power of two
|
||||||
|
const uint32_t npages = static_cast<uint32_t>(N / pagesize);
|
||||||
|
const uint32_t root = r32(88);
|
||||||
|
if (npages == 0 || root == 0 || root >= npages) return st;
|
||||||
|
// Page-level checksums (DB_CHKSUM) or encryption (DB_ENCRYPT) shift the per-page offset index array
|
||||||
|
// (26 → 32 / 64), which our fixed-offset-26 reads don't account for — that would silently under-count
|
||||||
|
// rather than fail. DragonX wallets use neither, so bail to the byte-scan fallback if either is set
|
||||||
|
// (metapage encrypt_alg@24, metaflags@26 & DBMETA_CHKSUM 0x01).
|
||||||
|
if (B[24] != 0 || (B[26] & 0x01)) return st;
|
||||||
|
|
||||||
|
// BDB page layout: hdr[26] = {..., entries@20:u16, level@24:u8, type@25:u8}, then a u16 offset array.
|
||||||
|
// Items on a leaf are BKEYDATA {len@0:u16, type@2:u8, data@3}; on an internal page BINTERNAL {pgno@4}.
|
||||||
|
constexpr uint8_t P_IBTREE = 3, P_LBTREE = 5, P_BTREEMETA = 9, B_KEYDATA = 1;
|
||||||
|
constexpr std::size_t kMaxPagesVisited = 600000; // bounds a corrupt/huge file
|
||||||
|
constexpr int kMaxKeys = 4000000;
|
||||||
|
|
||||||
|
std::vector<bool> visited(npages, false);
|
||||||
|
std::size_t pagesVisited = 0;
|
||||||
|
int keys = 0;
|
||||||
|
bool aborted = false;
|
||||||
|
auto rdpgno = [&](const unsigned char* p) -> uint32_t {
|
||||||
|
return le ? (uint32_t)p[0] | ((uint32_t)p[1]<<8) | ((uint32_t)p[2]<<16) | ((uint32_t)p[3]<<24)
|
||||||
|
: (uint32_t)p[3] | ((uint32_t)p[2]<<8) | ((uint32_t)p[1]<<16) | ((uint32_t)p[0]<<24);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Walk the btree rooted at `rootPg`, invoking fn(keyPtr,keyLen, dataPtr,dataLen,dataType) per leaf pair.
|
||||||
|
auto traverse = [&](uint32_t rootPg, auto&& fn) {
|
||||||
|
std::fill(visited.begin(), visited.end(), false);
|
||||||
|
std::vector<uint32_t> stack;
|
||||||
|
// Mark pages visited at PUSH time (here + at each internal child below) so every page is enqueued
|
||||||
|
// at most once. The stack then stays O(npages) — a crafted file with many internal pages all
|
||||||
|
// referencing a shared child set can't balloon it to ~npages*entries pushes (a ~0.5 GB DoS).
|
||||||
|
if (rootPg < npages && !visited[rootPg]) { visited[rootPg] = true; stack.push_back(rootPg); }
|
||||||
|
while (!stack.empty()) {
|
||||||
|
const uint32_t pg = stack.back(); stack.pop_back();
|
||||||
|
if (pg >= npages) continue;
|
||||||
|
if (++pagesVisited > kMaxPagesVisited) { aborted = true; return; }
|
||||||
|
const std::size_t base = static_cast<std::size_t>(pg) * pagesize;
|
||||||
|
if (base + 26 > N) continue;
|
||||||
|
const uint8_t type = B[base + 25];
|
||||||
|
const uint32_t entries = r16(base + 20);
|
||||||
|
if (26 + static_cast<std::size_t>(entries) * 2 > pagesize) continue; // index array must fit
|
||||||
|
if (type == P_IBTREE) {
|
||||||
|
for (uint32_t i = 0; i < entries; ++i) {
|
||||||
|
const uint32_t off = r16(base + 26 + i * 2);
|
||||||
|
if (off + 8 > pagesize) continue;
|
||||||
|
const uint32_t child = r32(base + off + 4);
|
||||||
|
if (child > 0 && child < npages && !visited[child]) { visited[child] = true; stack.push_back(child); }
|
||||||
|
}
|
||||||
|
} else if (type == P_LBTREE) {
|
||||||
|
for (uint32_t i = 0; i + 1 < entries; i += 2) { // items alternate (key, data)
|
||||||
|
if (++keys > kMaxKeys) { aborted = true; return; }
|
||||||
|
const uint32_t ko = r16(base + 26 + i * 2);
|
||||||
|
const uint32_t dO = r16(base + 26 + (i + 1) * 2);
|
||||||
|
if (ko + 3 > pagesize || dO + 3 > pagesize) continue;
|
||||||
|
if (B[base + ko + 2] != B_KEYDATA) continue; // overflow/dup key — never a type key
|
||||||
|
const uint32_t kl = r16(base + ko);
|
||||||
|
if (kl < 1 || ko + 3 + kl > pagesize) continue;
|
||||||
|
const uint8_t dtype = B[base + dO + 2];
|
||||||
|
const uint32_t dl = r16(base + dO);
|
||||||
|
const unsigned char* dp = (dO + 3 + dl <= pagesize) ? B + base + dO + 3 : nullptr;
|
||||||
|
fn(B + base + ko + 3, kl, dp, dl, dtype);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// metapage / overflow / free pages: ignored.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// wallet.dat stores its records in a NAMED sub-database ("main"): the file's root btree is a master map
|
||||||
|
// of subdb-name -> subdb meta/root pgno. Follow each mapping to the real record btree. (A plain,
|
||||||
|
// single-database BDB file has no such mapping, so we fall back to walking the file root directly.)
|
||||||
|
std::vector<uint32_t> subRoots;
|
||||||
|
traverse(root, [&](const unsigned char*, uint32_t, const unsigned char* dp, uint32_t dl, uint8_t dtype) {
|
||||||
|
if (dtype != B_KEYDATA || dl != 4 || !dp) return;
|
||||||
|
// The master-db value (the subdb's meta/root pgno) is stored BIG-ENDIAN regardless of the file's
|
||||||
|
// native order. Try big-endian first, then native, and accept whichever lands on a real subdb page.
|
||||||
|
const uint32_t cand[2] = {
|
||||||
|
(uint32_t)dp[3] | ((uint32_t)dp[2]<<8) | ((uint32_t)dp[1]<<16) | ((uint32_t)dp[0]<<24), // big-endian
|
||||||
|
rdpgno(dp), // native
|
||||||
|
};
|
||||||
|
for (const uint32_t pgno : cand) {
|
||||||
|
if (pgno == 0 || pgno >= npages) continue;
|
||||||
|
const uint8_t pt = B[static_cast<std::size_t>(pgno) * pagesize + 25];
|
||||||
|
if (pt == P_BTREEMETA) { // subdb metapage → its root is at +88
|
||||||
|
const uint32_t sr = r32(static_cast<std::size_t>(pgno) * pagesize + 88);
|
||||||
|
if (sr > 0 && sr < npages) { subRoots.push_back(sr); break; }
|
||||||
|
} else if (pt == P_LBTREE || pt == P_IBTREE) { // mapping points straight at the root
|
||||||
|
subRoots.push_back(pgno); break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (aborted) return st;
|
||||||
|
if (subRoots.empty()) subRoots.push_back(root); // single-database file
|
||||||
|
|
||||||
|
auto countRecord = [&](const unsigned char* kp, uint32_t kl,
|
||||||
|
const unsigned char* dp, uint32_t dl, uint8_t dtype) {
|
||||||
|
const uint32_t nlen = kp[0]; // CompactSize length of the type string
|
||||||
|
if (nlen < 2 || nlen > 20 || 1u + nlen > kl) return;
|
||||||
|
const char* nm = reinterpret_cast<const char*>(kp + 1);
|
||||||
|
auto is = [&](const char* s) { return std::strlen(s) == nlen && std::memcmp(nm, s, nlen) == 0; };
|
||||||
|
if (is("key") || is("wkey") || is("ckey")) st.transparentKeys++;
|
||||||
|
else if (is("zkey") || is("czkey") || is("sapzkey") || is("csapzkey")) st.shieldedKeys++;
|
||||||
|
else if (is("name")) st.addressBook++;
|
||||||
|
else if (is("tx")) st.txCount++;
|
||||||
|
else if (is("mkey")) st.encrypted = true;
|
||||||
|
else if (is("hdseed") || is("chdseed") || is("hdchain")) st.hdSeed = true;
|
||||||
|
else if (is("keymeta")) {
|
||||||
|
// CKeyMetadata data = nVersion(int32) + nCreateTime(int64) + …, all little-endian (Bitcoin
|
||||||
|
// serialization). The earliest non-zero nCreateTime is the wallet birthday (daemon's
|
||||||
|
// nTimeFirstKey). B_KEYDATA(1) only; overflow values aren't inline.
|
||||||
|
if (dtype == B_KEYDATA && dp && dl >= 12) {
|
||||||
|
long long t = 0;
|
||||||
|
for (int b = 0; b < 8; ++b) t |= static_cast<long long>(dp[4 + b]) << (8 * b);
|
||||||
|
if (t > 0 && (st.createdEpoch == 0 || t < st.createdEpoch)) st.createdEpoch = t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const uint32_t sr : subRoots) {
|
||||||
|
traverse(sr, [&](const unsigned char* kp, uint32_t kl, const unsigned char* dp, uint32_t dl, uint8_t dt) {
|
||||||
|
countRecord(kp, kl, dp, dl, dt);
|
||||||
|
});
|
||||||
|
if (aborted) return st;
|
||||||
|
}
|
||||||
|
st.parsed = true;
|
||||||
|
return st;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace util
|
||||||
|
} // namespace dragonx
|
||||||
@@ -36,6 +36,7 @@
|
|||||||
#include "util/pool_registry.h"
|
#include "util/pool_registry.h"
|
||||||
#include "util/daemon_updater.h"
|
#include "util/daemon_updater.h"
|
||||||
#include "util/lite_server_probe.h"
|
#include "util/lite_server_probe.h"
|
||||||
|
#include "util/wallet_file_probe.h"
|
||||||
#include "wallet/lite_connection_service.h"
|
#include "wallet/lite_connection_service.h"
|
||||||
#include "wallet/lite_diagnostics.h"
|
#include "wallet/lite_diagnostics.h"
|
||||||
#include "wallet/lite_owned_string.h"
|
#include "wallet/lite_owned_string.h"
|
||||||
@@ -737,6 +738,97 @@ void testPaymentUri()
|
|||||||
EXPECT_EQ(invalid.error, std::string("Invalid negative amount"));
|
EXPECT_EQ(invalid.error, std::string("Invalid negative amount"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void testWalletFileProbe()
|
||||||
|
{
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
using dragonx::util::probeWalletFile;
|
||||||
|
const fs::path dir = fs::path("/tmp/dragonx") / "probe";
|
||||||
|
std::error_code ec; fs::create_directories(dir, ec);
|
||||||
|
auto writef = [](const fs::path& p, const std::string& d) {
|
||||||
|
std::ofstream o(p, std::ios::binary); o.write(d.data(), (std::streamsize)d.size());
|
||||||
|
};
|
||||||
|
// A valid Berkeley DB btree metapage has the magic 0x00053162 at byte offset 12 (little-endian here).
|
||||||
|
auto bdb = []() { std::string h(16, '\0'); h[12] = 0x62; h[13] = 0x31; h[14] = 0x05; h[15] = 0x00; return h; };
|
||||||
|
// Wallet records are keyed by a CompactSize-length-prefixed name, e.g. "mkey" -> {0x04,'m','k','e','y'}.
|
||||||
|
auto rec = [](const std::string& name) { return std::string(1, (char)name.size()) + name; };
|
||||||
|
|
||||||
|
// 1) Non-BDB file → not recognized, no flags.
|
||||||
|
writef(dir / "junk.dat", std::string(4096, 'x'));
|
||||||
|
{ auto p = probeWalletFile((dir / "junk.dat").string());
|
||||||
|
EXPECT_FALSE(p.isBerkeleyDB); EXPECT_FALSE(p.encrypted); EXPECT_FALSE(p.hdSeed); }
|
||||||
|
|
||||||
|
// 2) BDB with no markers → legacy plaintext; a complete scan makes the negatives trustworthy.
|
||||||
|
writef(dir / "legacy.dat", bdb() + std::string(2000, '\0'));
|
||||||
|
{ auto p = probeWalletFile((dir / "legacy.dat").string());
|
||||||
|
EXPECT_TRUE(p.isBerkeleyDB); EXPECT_FALSE(p.encrypted); EXPECT_FALSE(p.hdSeed);
|
||||||
|
EXPECT_FALSE(p.hasShielded); EXPECT_TRUE(p.scanComplete); }
|
||||||
|
|
||||||
|
// 3) Encrypted (mkey) + HD (hdchain) + shielded (csapzkey).
|
||||||
|
writef(dir / "enc.dat", bdb() + std::string(500, 'a') + rec("mkey") + std::string(300, 'b') +
|
||||||
|
rec("hdchain") + std::string(300, 'c') + rec("csapzkey") + std::string(500, 'd'));
|
||||||
|
{ auto p = probeWalletFile((dir / "enc.dat").string());
|
||||||
|
EXPECT_TRUE(p.encrypted); EXPECT_TRUE(p.hdSeed); EXPECT_TRUE(p.hasShielded); }
|
||||||
|
|
||||||
|
// 4) False-positive guard: the bare word "mkey" WITHOUT the 0x04 length prefix must not count.
|
||||||
|
writef(dir / "bare.dat", bdb() + std::string(100, '\0') + "mkey" + std::string(100, '\0'));
|
||||||
|
{ auto p = probeWalletFile((dir / "bare.dat").string()); EXPECT_FALSE(p.encrypted); }
|
||||||
|
|
||||||
|
// 5) Byte cap: a marker beyond maxBytes is missed AND the scan is flagged incomplete (so a caller
|
||||||
|
// won't wrongly assert "legacy"); the same file scanned fully finds it and reports complete.
|
||||||
|
{ std::string d = bdb(); d.resize(2u * 1024 * 1024, 'q'); d += rec("mkey"); writef(dir / "deep.dat", d);
|
||||||
|
auto capped = probeWalletFile((dir / "deep.dat").string(), 1024u * 1024u);
|
||||||
|
EXPECT_TRUE(capped.isBerkeleyDB); EXPECT_FALSE(capped.encrypted); EXPECT_FALSE(capped.scanComplete);
|
||||||
|
EXPECT_TRUE(capped.bytesRead <= 1024u * 1024u); // budget accounting reflects a truncated read
|
||||||
|
auto full = probeWalletFile((dir / "deep.dat").string());
|
||||||
|
EXPECT_TRUE(full.encrypted); EXPECT_TRUE(full.scanComplete); }
|
||||||
|
|
||||||
|
// 6) Exact-boundary case: a file whose length is an exact 1 MiB multiple (and == maxBytes) must still
|
||||||
|
// report scanComplete=true (the peek() guard), so a fully-scanned wallet isn't mislabeled truncated.
|
||||||
|
{ std::string d = bdb(); d.resize(1024u * 1024u, 'e'); // exactly one 1 MiB chunk, no markers
|
||||||
|
writef(dir / "exact.dat", d);
|
||||||
|
auto p = probeWalletFile((dir / "exact.dat").string(), 1024u * 1024u);
|
||||||
|
EXPECT_TRUE(p.isBerkeleyDB); EXPECT_TRUE(p.scanComplete); EXPECT_FALSE(p.encrypted); EXPECT_FALSE(p.hdSeed); }
|
||||||
|
|
||||||
|
// 7) Btree record counting (tier 2): hand-build a minimal single-database BDB btree — a 512-byte
|
||||||
|
// metapage (magic/pagesize/root) + one leaf page holding "tx", "key", and a "keymeta" record — and
|
||||||
|
// confirm parseWalletBtree walks it, tallies the counts, and reads the keymeta nCreateTime (wallet
|
||||||
|
// birthday). Also that a non-BDB file yields parsed=false.
|
||||||
|
{
|
||||||
|
std::string w(1024, '\0');
|
||||||
|
auto put16 = [&](std::size_t o, unsigned v) { w[o] = (char)(v & 0xff); w[o+1] = (char)((v >> 8) & 0xff); };
|
||||||
|
auto put32 = [&](std::size_t o, unsigned v) { for (int k=0;k<4;k++) w[o+k] = (char)((v >> (8*k)) & 0xff); };
|
||||||
|
put32(12, 0x00053162u); put32(20, 512); w[25] = (char)9; put32(88, 1); // page 0: metapage, root = page 1
|
||||||
|
const std::size_t P = 512; // page 1: a btree leaf
|
||||||
|
put16(P + 20, 6); w[P + 24] = (char)1; w[P + 25] = (char)5; // entries=6 (3 pairs), level 1, P_LBTREE
|
||||||
|
// index array → (key,data) item offsets, laid out high→low without overlap:
|
||||||
|
put16(P + 26, 500); put16(P + 28, 496); // tx
|
||||||
|
put16(P + 30, 488); put16(P + 32, 484); // key
|
||||||
|
put16(P + 34, 472); put16(P + 36, 456); // keymeta
|
||||||
|
auto keyItem = [&](std::size_t off, const std::string& type) { // BKEYDATA holding CompactSize(len)+type
|
||||||
|
std::string kd; kd += (char)type.size(); kd += type;
|
||||||
|
put16(P + off, (unsigned)kd.size()); w[P + off + 2] = (char)1;
|
||||||
|
for (std::size_t k = 0; k < kd.size(); ++k) w[P + off + 3 + k] = kd[k];
|
||||||
|
};
|
||||||
|
auto dataItem = [&](std::size_t off) { put16(P + off, 1); w[P + off + 2] = (char)1; w[P + off + 3] = 0; };
|
||||||
|
keyItem(500, "tx"); dataItem(496);
|
||||||
|
keyItem(488, "key"); dataItem(484);
|
||||||
|
keyItem(472, "keymeta");
|
||||||
|
// keymeta data = CKeyMetadata: nVersion(int32=10) + nCreateTime(int64) — little-endian.
|
||||||
|
const long long kCreated = 1700000000LL;
|
||||||
|
put16(P + 456, 12); w[P + 456 + 2] = (char)1; // BKEYDATA len=12, type=1, data@459
|
||||||
|
put32(456 + 3 + P, 10); // nVersion
|
||||||
|
for (int k = 0; k < 8; ++k) w[P + 456 + 3 + 4 + k] = (char)((kCreated >> (8 * k)) & 0xff);
|
||||||
|
writef(dir / "btree.dat", w);
|
||||||
|
auto s = dragonx::util::parseWalletBtree((dir / "btree.dat").string());
|
||||||
|
EXPECT_TRUE(s.parsed); EXPECT_TRUE(s.complete);
|
||||||
|
EXPECT_EQ(s.txCount, 1); EXPECT_EQ(s.transparentKeys, 1); EXPECT_EQ(s.addresses(), 1);
|
||||||
|
EXPECT_EQ(s.createdEpoch, kCreated);
|
||||||
|
EXPECT_FALSE(dragonx::util::parseWalletBtree((dir / "junk.dat").string()).parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::remove_all(dir, ec);
|
||||||
|
}
|
||||||
|
|
||||||
void testAmountFormatting()
|
void testAmountFormatting()
|
||||||
{
|
{
|
||||||
EXPECT_EQ(dragonx::util::formatAmountFixed(1.0), std::string("1.00000000"));
|
EXPECT_EQ(dragonx::util::formatAmountFixed(1.0), std::string("1.00000000"));
|
||||||
@@ -6263,6 +6355,7 @@ int main()
|
|||||||
testHushChatOutgoing();
|
testHushChatOutgoing();
|
||||||
testHushChatTransport();
|
testHushChatTransport();
|
||||||
testHushChatShuffledReceive();
|
testHushChatShuffledReceive();
|
||||||
|
testWalletFileProbe();
|
||||||
testAddressChecksumValidation();
|
testAddressChecksumValidation();
|
||||||
testLiteServerProbeLive();
|
testLiteServerProbeLive();
|
||||||
testXmrigLiveInstall();
|
testXmrigLiveInstall();
|
||||||
|
|||||||
Reference in New Issue
Block a user