|
| 1 | +# Electrum - lightweight Bitcoin client |
| 2 | +# Copyright (C) 2011 Thomas Voegtlin |
| 3 | +# |
| 4 | +# Permission is hereby granted, free of charge, to any person |
| 5 | +# obtaining a copy of this software and associated documentation files |
| 6 | +# (the "Software"), to deal in the Software without restriction, |
| 7 | +# including without limitation the rights to use, copy, modify, merge, |
| 8 | +# publish, distribute, sublicense, and/or sell copies of the Software, |
| 9 | +# and to permit persons to whom the Software is furnished to do so, |
| 10 | +# subject to the following conditions: |
| 11 | +# |
| 12 | +# The above copyright notice and this permission notice shall be |
| 13 | +# included in all copies or substantial portions of the Software. |
| 14 | +# |
| 15 | +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, |
| 16 | +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF |
| 17 | +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND |
| 18 | +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS |
| 19 | +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN |
| 20 | +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN |
| 21 | +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
| 22 | +# SOFTWARE. |
| 23 | +import os |
| 24 | +import json |
| 25 | +import shutil |
| 26 | +import datetime |
| 27 | +import stat |
| 28 | + |
| 29 | +from PyQt5.QtWidgets import QMessageBox, QDialog, QVBoxLayout, QLabel, QPushButton, QApplication |
| 30 | +from PyQt5.QtCore import Qt |
| 31 | + |
| 32 | +from .util import old_user_dir, user_dir, make_dir |
| 33 | +from .logging import get_logger |
| 34 | + |
| 35 | +_logger = get_logger(__name__) |
| 36 | + |
| 37 | + |
| 38 | +def is_factwallet_data(directory: str) -> bool: |
| 39 | + """ |
| 40 | + Determine if a directory contains FactWallet data by checking for FACT genesis block hash. |
| 41 | +
|
| 42 | + Args: |
| 43 | + directory: Path to the suspected FactWallet data directory |
| 44 | +
|
| 45 | + Returns: |
| 46 | + bool: True if the directory contains FactWallet data |
| 47 | + """ |
| 48 | + if not os.path.exists(directory): |
| 49 | + return False |
| 50 | + |
| 51 | + # Check config file for FACT genesis block hash |
| 52 | + config_path = os.path.join(directory, "config") |
| 53 | + if os.path.exists(config_path): |
| 54 | + try: |
| 55 | + with open(config_path, 'r', encoding='utf-8') as f: |
| 56 | + config = json.load(f) |
| 57 | + # Check for FACT genesis block hash |
| 58 | + blockchain_preferred_block = config.get('blockchain_preferred_block', {}) |
| 59 | + block_hash = blockchain_preferred_block.get('hash') |
| 60 | + |
| 61 | + # FACT genesis block hashes |
| 62 | + FACT_MAINNET_GENESIS = "79cb40f8075b0e3dc2bc468c5ce2a7acbe0afd36c6c3d3a134ea692edac7de49" |
| 63 | + FACT_TESTNET_GENESIS = "550bbf0a444d9f92189f067dd225f5b8a5d92587ebc2e8398d143236072580af" |
| 64 | + |
| 65 | + if block_hash in [FACT_MAINNET_GENESIS, FACT_TESTNET_GENESIS]: |
| 66 | + return True |
| 67 | + |
| 68 | + except (json.JSONDecodeError, OSError): |
| 69 | + pass |
| 70 | + |
| 71 | + return False |
| 72 | + |
| 73 | + |
| 74 | +def perform_migration(electrum_dir: str, backup_dir: str, factwallet_dir: str) -> bool: |
| 75 | + """ |
| 76 | + Perform the migration: backup old dir and copy contents to new dir. |
| 77 | +
|
| 78 | + Args: |
| 79 | + electrum_dir: Source Electrum directory path |
| 80 | + backup_dir: Backup directory path |
| 81 | + factwallet_dir: Destination FactWallet directory path |
| 82 | +
|
| 83 | + Returns: |
| 84 | + bool: True if successful, False otherwise |
| 85 | + """ |
| 86 | + try: |
| 87 | + # 1. Move the old wallet directory to backup location |
| 88 | + _logger.info(f"Moving {electrum_dir} to {backup_dir}") |
| 89 | + shutil.move(electrum_dir, backup_dir) |
| 90 | + |
| 91 | + # 2. Create the new FactWallet directory |
| 92 | + make_dir(factwallet_dir) |
| 93 | + |
| 94 | + # 3. Copy all files from backup to new FactWallet directory (excluding socket files) |
| 95 | + _logger.info(f"Copying from {backup_dir} to {factwallet_dir}") |
| 96 | + def ignore_sockets(dir, files): |
| 97 | + socket_files = [] |
| 98 | + for f in files: |
| 99 | + file_path = os.path.join(dir, f) |
| 100 | + try: |
| 101 | + if stat.S_ISSOCK(os.stat(file_path).st_mode): |
| 102 | + socket_files.append(f) |
| 103 | + except Exception: |
| 104 | + # Let files we can't identify pass |
| 105 | + pass |
| 106 | + return socket_files |
| 107 | + shutil.copytree(backup_dir, factwallet_dir, dirs_exist_ok=True, ignore=ignore_sockets) |
| 108 | + |
| 109 | + # 4. Create a marker file to indicate successful migration |
| 110 | + marker_path = os.path.join(factwallet_dir, '.migrated_from_electrum') |
| 111 | + with open(marker_path, 'w', encoding='utf-8') as f: |
| 112 | + f.write(f"Migrated from `{electrum_dir}` (backup: `{backup_dir}`) to `{factwallet_dir}` on {datetime.datetime.now().isoformat()}") |
| 113 | + |
| 114 | + return True |
| 115 | + except Exception as e: |
| 116 | + _logger.error(f"Migration failed: {e}") |
| 117 | + return False |
| 118 | + |
| 119 | + |
| 120 | +class MigrationDialog(QDialog): |
| 121 | + """Dialog that asks the user if they want to migrate data from Electrum's directory to FactWallet's.""" |
| 122 | + |
| 123 | + def __init__(self, electrum_dir: str, factwallet_dir: str, parent=None): |
| 124 | + super().__init__(parent) |
| 125 | + self.electrum_dir = electrum_dir |
| 126 | + self.factwallet_dir = factwallet_dir |
| 127 | + when = datetime.datetime.now().strftime("%Y%m%d%H%M%S") |
| 128 | + self.backup_dir = f"{electrum_dir}-backupByFactWallet-{when}" |
| 129 | + |
| 130 | + self.result = False |
| 131 | + self.error = False |
| 132 | + |
| 133 | + self.setWindowTitle("FactWallet Migration") |
| 134 | + self.setMinimumWidth(600) |
| 135 | + |
| 136 | + layout = QVBoxLayout() |
| 137 | + |
| 138 | + # Message |
| 139 | + message = QLabel( |
| 140 | + f"Your FACT0RN wallet is currently stored in:<br>" |
| 141 | + f"<code>{electrum_dir}</code><br><br>" |
| 142 | + f"Would you like to migrate it to:<br>" |
| 143 | + f"<code>{factwallet_dir}</code><br><br>" |
| 144 | + f"A backup will be created before migration<br><br>" |
| 145 | + f"If you choose No, a new, empty wallet will be created, and your old wallet will be left unchanged." |
| 146 | + ) |
| 147 | + message.setWordWrap(True) |
| 148 | + message.setTextFormat(Qt.RichText) |
| 149 | + layout.addWidget(message) |
| 150 | + |
| 151 | + # Buttons |
| 152 | + button_layout = QVBoxLayout() |
| 153 | + |
| 154 | + self.migrate_button = QPushButton("Yes, migrate my wallet") |
| 155 | + self.migrate_button.clicked.connect(self.do_migrate) |
| 156 | + button_layout.addWidget(self.migrate_button) |
| 157 | + |
| 158 | + self.skip_button = QPushButton("No, create a new wallet") |
| 159 | + self.skip_button.clicked.connect(self.reject) |
| 160 | + button_layout.addWidget(self.skip_button) |
| 161 | + |
| 162 | + layout.addLayout(button_layout) |
| 163 | + self.setLayout(layout) |
| 164 | + |
| 165 | + def do_migrate(self): |
| 166 | + # Perform the migration |
| 167 | + if perform_migration(self.electrum_dir, self.backup_dir, self.factwallet_dir): |
| 168 | + self.result = True |
| 169 | + # QMessageBox.information is a modal dialog - it will block execution until the user |
| 170 | + # acknowledges the message box by clicking OK. This ensures the migration dialog |
| 171 | + # doesn't complete its exec_() call until after the user sees and acknowledges the result. |
| 172 | + QMessageBox.information( |
| 173 | + self, |
| 174 | + "Migration Complete", |
| 175 | + f"Your wallet has been successfully migrated.\n\n" |
| 176 | + f"A backup containing your original wallet is available at:\n" |
| 177 | + f"{self.backup_dir}\n" |
| 178 | + ) |
| 179 | + # This will only be called after the user acknowledges the info dialog |
| 180 | + self.accept() |
| 181 | + else: |
| 182 | + self.error = True |
| 183 | + QMessageBox.critical( |
| 184 | + self, |
| 185 | + "Migration Failed", |
| 186 | + f"Failed to migrate wallet from {self.electrum_dir} to {self.factwallet_dir}.\n\n" |
| 187 | + f"A backup containing your original wallet is available at:\n" |
| 188 | + f"{self.backup_dir}\n\n" |
| 189 | + f"Please try manually copying your wallet or create a new, empty wallet." |
| 190 | + ) |
| 191 | + # This will only be called after the user acknowledges the critical dialog |
| 192 | + self.reject() |
| 193 | + |
| 194 | + |
| 195 | +def run_migration(electrum_dir: str, factwallet_dir: str) -> bool: |
| 196 | + """ |
| 197 | + Run the migration process after user confirmation. |
| 198 | +
|
| 199 | + Args: |
| 200 | + electrum_dir: Source Electrum directory path |
| 201 | + factwallet_dir: Destination FactWallet directory path |
| 202 | +
|
| 203 | + Returns: |
| 204 | + bool: True if migration was either successful or skipped by user |
| 205 | + """ |
| 206 | + # Initialize QApplication for the migration dialog |
| 207 | + app = QApplication.instance() |
| 208 | + if app is None: |
| 209 | + app = QApplication([]) |
| 210 | + created_app = True |
| 211 | + else: |
| 212 | + created_app = False |
| 213 | + |
| 214 | + try: |
| 215 | + # Show migration dialog |
| 216 | + dialog = MigrationDialog(electrum_dir, factwallet_dir) |
| 217 | + result = dialog.exec_() == QDialog.Accepted and dialog.result |
| 218 | + |
| 219 | + if dialog.error: |
| 220 | + raise Exception("Error during wallet migration") |
| 221 | + |
| 222 | + if result: |
| 223 | + _logger.info(f"Successfully migrated data from {electrum_dir} to {factwallet_dir}") |
| 224 | + else: |
| 225 | + _logger.info("User skipped migration") |
| 226 | + |
| 227 | + return True |
| 228 | + except Exception as e: |
| 229 | + _logger.error(f"Error during migration: {e}") |
| 230 | + raise e |
| 231 | + finally: |
| 232 | + # Clean up the QApplication if we created it |
| 233 | + if created_app and app is not None: |
| 234 | + app.quit() |
| 235 | + |
| 236 | + |
| 237 | +def check_for_migration(config_options: dict = None) -> None: |
| 238 | + """ |
| 239 | + Check if migration is needed and handle it if so. |
| 240 | +
|
| 241 | + Args: |
| 242 | + config_options: Config options dictionary that gets passed to SimpleConfig |
| 243 | + """ |
| 244 | + is_portable = config_options.get('portable', False) |
| 245 | + |
| 246 | + # Skip migration in these cases: |
| 247 | + # 1. On Android (each app has its own data directory) |
| 248 | + if 'ANDROID_DATA' in os.environ: |
| 249 | + _logger.info("Skipping migration check on Android") |
| 250 | + return |
| 251 | + |
| 252 | + # 2. If user specified a custom data directory outside of portable mode |
| 253 | + if config_options and config_options.get('electrum_path') and not is_portable: |
| 254 | + _logger.info(f"Custom data directory specified: {config_options.get('electrum_path')}") |
| 255 | + return |
| 256 | + |
| 257 | + # Locate data directories |
| 258 | + if is_portable: |
| 259 | + factwallet_dir = config_options['electrum_path'] |
| 260 | + electrum_dir = os.path.join(os.path.dirname(config_options['electrum_path']), 'electrum_data') |
| 261 | + else: |
| 262 | + factwallet_dir = user_dir() |
| 263 | + electrum_dir = old_user_dir() |
| 264 | + |
| 265 | + # Skip migration if FactWallet directory already exists |
| 266 | + if os.path.exists(factwallet_dir): |
| 267 | + _logger.info(f"FactWallet directory already exists: {factwallet_dir}") |
| 268 | + return |
| 269 | + |
| 270 | + # Check if Electrum directory exists and contains FactWallet data |
| 271 | + if not electrum_dir or not os.path.exists(electrum_dir): |
| 272 | + _logger.info(f"No Electrum directory found at: {electrum_dir}") |
| 273 | + return |
| 274 | + |
| 275 | + if is_factwallet_data(electrum_dir): |
| 276 | + _logger.info(f"Found FactWallet data in Electrum directory: {electrum_dir}") |
| 277 | + run_migration(electrum_dir, factwallet_dir) |
0 commit comments