From e3263739417c5f0fc481fc779d0ad11047de961a Mon Sep 17 00:00:00 2001 From: sinus Date: Mon, 9 Dec 2024 01:12:27 +0100 Subject: [PATCH] first app skel: yaml config translations / language & direction change & reload load/save wallets from encrypted files load/save wallets/load last wallet on sartup --- application.py | 1691 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1691 insertions(+) create mode 100755 application.py diff --git a/application.py b/application.py new file mode 100755 index 0000000..00844e4 --- /dev/null +++ b/application.py @@ -0,0 +1,1691 @@ +#!.venv/bin/python +"""Khadhroony SRLPv4 main script""" + +import argparse +import base64 +import gettext +import json +import locale +import os +import platform +import portalocker +import random +import re +import rc_doc +import rc_icons +import rc_images +import sys +import yaml + +from cryptography.fernet import Fernet, InvalidToken +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.backends import default_backend +from datetime import datetime, timezone +from loguru import logger +from PySide6 import __version__ as pyside6version +from PySide6.QtCore import ( + QAbstractTableModel, + QEasingCurve, + QEvent, + QMargins, + QModelIndex, + QObject, + QPropertyAnimation, + Qt, + QTimer, + Signal +) +from PySide6.QtGui import ( + QAction, + QColor, + QCursor, + QIcon, + QKeyEvent, + QLinearGradient, + QPainter, + QPixmap, + QFont +) +from PySide6.QtPdf import QPdfDocument +from PySide6.QtPdfWidgets import QPdfView +from PySide6.QtWidgets import ( + QApplication, + QDialog, + QDialogButtonBox, + QFileDialog, + QFormLayout, + QGraphicsOpacityEffect, + QGridLayout, + QLabel, + QLineEdit, + QMainWindow, + QMenu, + QMessageBox, + QSplashScreen, + QPushButton, + QSystemTrayIcon, + QTableView, + QTabWidget, + QToolBar, + QVBoxLayout, + QWidget, + QAbstractItemView, + QHeaderView +) +from solders.keypair import Keypair +from typing import Any, IO + +APP_NAME: str = "KhadhroonySRLPv4" +"""Application Name""" +APP_ABOUT_NAME: str = "Khadhroony Sol RLPv4 Trading App" +"""Application Full Name""" +APP_FULL_NAME: str = "Khadhroony Solana Raydium Liquidity Pool v4 Trading App" +"""Application Full Name""" +APP_DESC: str = "Khadhroony Solana Raydium Liquidity Pool v4 Trading Application" +"""Application Version""" +APP_VERSION_INFO = ('0', '0', '2') +APP_VERSION = '.'.join(APP_VERSION_INFO) +"""Application Name + Version""" +APP_NAME_VERSION = "Khadhroony Solana Raydium Liquidity Pool v4 Trading Application @ " + APP_VERSION +"""Application Langs""" +APP_LANGS = ["en", "fr", "ar"] +APP_RTL_LANGS = [ + "ae", # avestique + "aeb", # arabe tunisien + "aec", # arabe saïdi + "ar", # arabe + "arb", # arabe standard moderne + "arc", # araméen + "arq", # arabe algérien + "ary", # arabe marocain + "arz", # arabe égyptien + "ayl", # arabe libyen + "ckb", # sorani + "dv", # divéhi (maldivien) + "fa", # persan + "glk", # guilaki + "he", # hébreu + "khw", # khowar + "mzn", # mazandarani + "ota", # turc ottoman + "otk", # vieux turc + "pnb", # pendjabi occidental + "ps", # pachto + "syr", # syriaque + "tmr", # judéo-araméen babylonien + "ug", # ouïghour + "ur" # ourdou +] +"""Application Salt for wallet encrypting""" +APP_SALT = APP_NAME + " @ " + APP_VERSION_INFO[0] + "." + APP_VERSION_INFO[1] + +YAML_CONFIG_FILE = "KhadhroonySRLPv4.yaml" + +DEFAULT_YAML_CONFIG = { + "lastFile": "", + "lastFiles": [], + "defaultLang": "en", + "lastWidth": 1024, + "lastHeight": 768, + "lastMaximized": True +} + + +WALLET_FILE_EXT = "kew" +WALLET_FILE_DESC = "Khadhroony Encrypted Wallet" +WALLETS_FOLDER = "./wallets" + +LOCALES_DIR = os.path.join(os.path.dirname(__file__), "locale") +_ = gettext.gettext +is_rtl = False + + +def load_yaml_app_config(): + """Load the configuration file or create a default one.""" + if not os.path.exists(YAML_CONFIG_FILE): + logger.debug(f"{YAML_CONFIG_FILE} not found, creating new one") + with open(YAML_CONFIG_FILE, "w") as f: + yaml.dump(DEFAULT_YAML_CONFIG, f) + return DEFAULT_YAML_CONFIG + with open(YAML_CONFIG_FILE, "r") as f: + data = yaml.safe_load(f) + logger.debug(f"{YAML_CONFIG_FILE} safe loaded") + # Validation et complétion des données + for key, val in DEFAULT_YAML_CONFIG.items(): + if key not in data: + logger.debug(f"{key} not found") + data[key] = val + elif not isinstance(data[key], type(val)): + logger.debug(f"{key} wrong type : wanted {type(val)}, found {type(data[key])}") + data[key] = val + return data + + +def save_yaml_app_config(cfg): + """Save the configuration to the file.""" + with open(YAML_CONFIG_FILE, "w") as f: + yaml.dump(cfg, f) + + +def create_wallets_folder(): + """Vérifie si le répertoire wallets existe et le créé dans le cas contraire.""" + if not os.path.exists(WALLETS_FOLDER): + os.makedirs(WALLETS_FOLDER) + + +def derive_key(password: str, salt: bytes) -> bytes: + """ + Derive a symmetric key from the given password and salt. + :param password: The password to derive the key from. + :param salt: A random salt for the key derivation. + :return: The derived key. + """ + kdf = PBKDF2HMAC( + algorithm = hashes.SHA256(), + length = 32, + salt = salt, + iterations = 100_000, + backend = default_backend(), + ) + return base64.urlsafe_b64encode(kdf.derive(password.encode())) + + +def encrypt_string(password: str, salt: bytes, plaintext: str) -> str: + """ + Encrypts a string using a password. + :param password: The password to derive the encryption key. + :param salt: A salt for the key derivation. + :param plaintext: The string to encrypt. + :return: A tuple containing the salt and the encrypted string. + """ + key = derive_key(password, salt) # Derive the encryption key + fernet = Fernet(key) + encrypted_text = fernet.encrypt(plaintext.encode()) + return encrypted_text.decode() + + +def center_on_screen(widget: QWidget): + """Centre la fenêtre sur le moniteur actif (celui où le curseur est présent).""" + screen = QApplication.screenAt(QCursor().pos()) # Obtenir le moniteur actif + if not screen: + screen = QApplication.primaryScreen() # Par défaut, utiliser l'écran principal + + screen_geometry = screen.availableGeometry() # Dimensions de l'écran + widget_geometry = widget.frameGeometry() + # Calcul des coordonnées pour centrer la fenêtre + x = screen_geometry.x() + (screen_geometry.width() - widget_geometry.width()) // 2 + y = screen_geometry.y() + (screen_geometry.height() - widget_geometry.height()) // 2 + widget.move(x, y) + + +def decrypt_string(password: str, salt: bytes, encrypted_text: bytes | str) -> str: + """ + Decrypts an encrypted string using the given password and salt. + :param password: The password to derive the decryption key. + :param salt: The salt used during encryption. + :param encrypted_text: The encrypted string to decrypt. + :return: The decrypted string. + """ + enc_text = encrypted_text + if isinstance(encrypted_text, str): + enc_text = encrypted_text.encode() + key = derive_key(password, salt) # Derive the decryption key + fernet = Fernet(key) + return fernet.decrypt(enc_text).decode() + + +def get_system_language() -> str: + """Get the system language.""" + global is_rtl + # sys_locale = locale.getdefaultlocale()[0] + sys_locale = locale.getlocale()[0] + default_lang = 'en' + if sys_locale: + lang_split = sys_locale.split("_")[0] # Extract language code + if lang_split in APP_LANGS: + if lang_split in APP_RTL_LANGS: + is_rtl = True + else: + is_rtl = False + return lang_split + is_rtl = False + return default_lang + + +def get_lang_or_fallback(lang: str) -> str: + global is_rtl + if lang in APP_LANGS: + if lang in APP_RTL_LANGS: + is_rtl = True + else: + is_rtl = False + return lang + return get_system_language() + + +def set_app_lang_code(lang_code): + """Set the application language.""" + global _ + global is_rtl + try: + lang = gettext.translation("app", localedir = LOCALES_DIR, languages = [lang_code]) + lang.install() + _ = lang.gettext + if lang_code in APP_RTL_LANGS: + is_rtl = True + else: + is_rtl = False + except FileNotFoundError: + # Fallback to default language (English) + gettext.install("app", localedir = LOCALES_DIR, names = ["en"]) + _ = gettext.gettext + is_rtl = False + + +def get_qt_dir() -> Qt.LayoutDirection: + global is_rtl + return Qt.LayoutDirection.RightToLeft if is_rtl else Qt.LayoutDirection.LeftToRight + + +def get_qt_align_center() -> Qt.AlignmentFlag: + return Qt.AlignmentFlag.AlignCenter + + +def get_qt_align_vchl(rtl:bool = False) -> Qt.AlignmentFlag: + if rtl: + return Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignRight + else: + return Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft + + +def get_qt_align_vchr(rtl:bool = False) -> Qt.AlignmentFlag: + if rtl: + return Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft + else: + return Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignRight + + +def get_qt_align_hcenter() -> Qt.AlignmentFlag: + return Qt.AlignmentFlag.AlignHCenter + + +def get_qt_align_hl(rtl:bool = False) -> Qt.AlignmentFlag: + if rtl: + return Qt.AlignmentFlag.AlignRight + else: + return Qt.AlignmentFlag.AlignLeft + + +def get_qt_align_hr(rtl:bool = False) -> Qt.AlignmentFlag: + if rtl: + return Qt.AlignmentFlag.AlignLeft + else: + return Qt.AlignmentFlag.AlignRight + +class LockedFile: + """ + Une classe pour gérer les fichiers avec verrouillage exclusif. + Utilise portalocker pour éviter les accès simultanés. + """ + + def __init__(self, file_path, mode = "r"): + """ + Initialise un Verrou. + + :param file_path: Chemin vers le fichier à verrouiller. + :param mode: Mode d'ouverture du fichier ('r', 'w', 'a', etc.). + """ + self._file = None + self._filePath = file_path + self._mode = mode + + def __enter__(self) -> IO[Any]: + """ + Ouvre et verrouille le fichier. + """ + self._file = open(file = self._filePath, mode = self._mode) + # Application d'un verrou exclusif + portalocker.lock(self._file, portalocker.LOCK_EX) + return self._file + + def __exit__(self, exc_type, exc_val, exc_tb): + """ + Libère le verrou et ferme le fichier. + """ + try: + portalocker.unlock(self._file) + finally: + self._file.close() + + +class SplashScreen(QSplashScreen): + """SplashScreen class.""" + + def __init__(self, fade_in_duration = 1500, display_duration = 2000, fade_out_duration = 1500): + super().__init__(QPixmap(":imgSplash")) + # logger.info(f"SplashScreen {Consts.APP_FULL_NAME} ({Consts.APP_VERSION})") + self.setWindowFlag(Qt.WindowType.FramelessWindowHint) # Supprimer les boutons de fermeture + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + # Appliquer l'effet d'opacité pour gérer les transitions + self._opacityEffect = QGraphicsOpacityEffect(self) + self.setGraphicsEffect(self._opacityEffect) + # Animation Fade-in + self._fadeIn = QPropertyAnimation(self._opacityEffect, b"opacity") + self._fadeIn.setDuration(fade_in_duration) + self._fadeIn.setStartValue(0) + self._fadeIn.setEndValue(1) + self._fadeIn.setEasingCurve(QEasingCurve.Type.InOutQuad) + # Animation Fade-out + self.fadeOut = QPropertyAnimation(self._opacityEffect, b"opacity") + self.fadeOut.setDuration(fade_out_duration) + self.fadeOut.setStartValue(1) + self.fadeOut.setEndValue(0) + self.fadeOut.setEasingCurve(QEasingCurve.Type.InOutQuad) + # Planification de l'animation fade-out après la durée + QTimer.singleShot(display_duration, self.start_fade_out) + # Lancer l'animation fade-in + self._fadeIn.start() + + def start_fade_out(self): + """Lancer l'animation fade-out et fermer le splash screen à la fin.""" + self.fadeOut.start() + self.fadeOut.finished.connect(self.close) + + def showEvent(self, event) -> None: + super().showEvent(event) + center_on_screen(self) + + +class AboutWindow(QWidget): + + def __init__(self, bg_txt:str, parent = None): + super().__init__(parent) + self.setWindowTitle(_("About %(app)s") % {'app': APP_NAME_VERSION}) + self.setWindowIcon(QIcon(":imgIcon")) + self.setFixedSize(900, 600) + self.setWindowModality(Qt.WindowModality.ApplicationModal) + self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint | Qt.WindowType.WindowStaysOnTopHint) + self._bg_Qpix = QPixmap(":imgAbout") + # Texte centré placé avant le bouton de fermeture pour ne pas empêcher le click de celui-là + self._about_Qlbl = QLabel(self) + self._about_Qlbl.setText(bg_txt) + self._about_Qlbl.setAlignment(Qt.AlignmentFlag.AlignCenter) # Centrer le texte + self._about_Qlbl.setStyleSheet("font-size: 20px; color: white;") # Style du texte + self._about_Qlbl.setWordWrap(True) # Activer le retour à la ligne + self._about_Qlbl.setGeometry(0, 0, self.width(), self.height()) # Occuper toute la fenêtre + # Bouton de fermeture de la fenetre + self._exit_Qpbtn = QPushButton(self) + self._exit_Qpbtn.setIcon(QIcon(":icoQuit")) + self._exit_Qpbtn.setFixedSize(24, 24) + self._exit_Qpbtn.setGeometry(self.width() - 44, 20, 24, 24) # Position et taille + self._exit_Qpbtn.clicked.connect(self.close) + self._exit_Qpbtn.move(self.width() - self._exit_Qpbtn.width() - 20, 20) + + # self.outsideClickFilter = OutsideCloseClickFilter(self) + # QApplication.instance().installEventFilter(self.outsideClickFilter) + + def closeEvent(self, event) -> None: + # QApplication.instance().removeEventFilter(self.outsideClickFilter) + super().closeEvent(event) + + def paintEvent(self, event): + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform) + # Redimensionner l'image pour qu'elle corresponde à la taille de la fenêtre + scaled_image = self._bg_Qpix.scaled( + self.size(), + Qt.AspectRatioMode.IgnoreAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + painter.drawPixmap(0, 0, scaled_image) + painter.setOpacity(0.7) + # Ajouter un effet de dégradé + gradient = QLinearGradient(0, 0, 0, self.height()) + gradient.setColorAt(0, QColor(0, 0, 0, 0)) # Transparent en haut + gradient.setColorAt(1, QColor(0, 0, 0, 128)) # Sombre en bas + painter.fillRect(self.rect(), gradient) + + def event(self, event): + """Surveiller les événements de la fenêtre.""" + if event.type() == QEvent.Type.WindowDeactivate: + self.close() + return super().event(event) + + def keyPressEvent(self, event: QKeyEvent): + """Gérer les raccourcis clavier pour fermer la fenêtre.""" + if event.key() in {Qt.Key.Key_Escape, Qt.Key.Key_Space, Qt.Key.Key_Enter, Qt.Key.Key_Return}: + print("Fenêtre fermée avec une touche simple.") + self.close() + elif event.key() == Qt.Key.Key_F4 and event.modifiers() & Qt.KeyboardModifier.ControlModifier: + print("Fenêtre fermée avec Ctrl+F4.") + self.close() + else: + # Si une autre touche est pressée, laisser le comportement par défaut + super().keyPressEvent(event) + + +class HelpWindow(QWidget): + + def showEvent(self, event) -> None: + super().showEvent(event) + center_on_screen(self) + + def __init__(self, pdf_doc:str, parent = None): + super().__init__(parent) + self.setWindowTitle(_("Help - %(app)s") % {'app': APP_NAME_VERSION}) + # self.setWindowIcon(QIcon(":/res/images/favicon.ico")) + self.setWindowIcon(QIcon(":imgIcon")) + self.setFixedSize(900, 600) + self.setWindowModality(Qt.WindowModality.ApplicationModal) + self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.MSWindowsFixedSizeDialogHint) + # Créer le document PDF et le visualiseur PDF + self._pdfDoc = QPdfDocument(self) + self._pdfView = QPdfView() + self._pdfView.setDocument(self._pdfDoc) + # Configurer le widget central pour afficher le PDF + self._main_Qvlo = QVBoxLayout() + self.setLayout(self._main_Qvlo) + self._main_Qvlo.setSpacing(0) + self._main_Qvlo.setContentsMargins(QMargins(0, 0, 0, 0)) + self._main_Qvlo.addWidget(self._pdfView) + # self._pdfDoc.load(":/res/help.pdf") + self._pdfDoc.load(pdf_doc) + if self._pdfDoc.status() != QPdfDocument.Status.Ready: + self.close() + else: + # Afficher la première page du PDF + self._pdfView.setPageMode(QPdfView.PageMode.MultiPage) + self._pdfView.setZoomMode(QPdfView.ZoomMode.FitToWidth) + self._pdfView.setPageSpacing(0) + self._pdfView.setDocumentMargins(QMargins(0, 0, 0, 0)) + + +class LogEmitter(QObject): + """ + Objet émettant un signal pour envoyer les logs vers l'interface graphique. + """ + newLogSignal = Signal(dict) # Le log est transmis sous forme de dictionnaire + + +class LogHandler: + """ + Gestionnaire personnalisé pour diriger les logs de loguru vers un QTableView. + """ + + def __init__(self, emitter: LogEmitter): + self._logEmitter = emitter + + def __call__(self, message: str): + """ + Appelé par loguru pour chaque message de log. + """ + parsed_log = self.parse_log_message(message) + if parsed_log: + self._logEmitter.newLogSignal.emit(parsed_log) + + @staticmethod + def parse_log_message(message: str): + """ + Parse un message de log loguru au format attendu (timestamp, LEVEL, message). + """ + parts = message.split(" -|- ", 2) # On attend le format "{time} -|- {level} -|- {message}" + if len(parts) == 3: + return { + "timestamp": parts[0], # Timestamp + "level": parts[1], # Niveau du log + "message": parts[2] # Message + } + return None + + +class LogsTableModel(QAbstractTableModel): + """ + Modèle pour gérer l'affichage des logs dans un QTableView. + """ + + def __init__(self, display_lines: int = 100): + super().__init__() + self._dataList = [] # Stocke les logs + self._maxLines = display_lines # Nombre maximum de lignes + self._headers = [_("Timestamp"), _("Level"), _("Message")] + self._sortColumn = 0 + self._sortOrder = Qt.SortOrder.AscendingOrder + self.sort(self._sortColumn, self._sortOrder) + + def rowCount(self, parent = QModelIndex()): + return len(self._dataList) + + def columnCount(self, parent = QModelIndex()): + return len(self._headers) + + def set_headers(self, new_headers: list): + self._headers = new_headers + self.headerDataChanged.emit(Qt.Orientation.Horizontal, 0, len(self._headers) - 1) + self.layoutChanged.emit() + + def headerData(self, section:int, orientation:Qt.Orientation, role:Qt.ItemDataRole = Qt.ItemDataRole.DisplayRole) -> Any: + """ + Définit les en-têtes des colonnes. + """ + if role == Qt.ItemDataRole.DisplayRole and orientation == Qt.Orientation.Horizontal: + return self._headers[section] + + def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any: + if not index.isValid(): + return None + elif role == Qt.ItemDataRole.DisplayRole: + log = self._dataList[index.row()] + if index.column() == 0: # Timestamp + return log["timestamp"] + elif index.column() == 1: # Level + return log["level"] + elif index.column() == 2: # Message + return log["message"] + elif role == Qt.ItemDataRole.BackgroundRole and index.column() == 1: # Level + log = self._dataList[index.row()] + if log["level"] == 'DEBUG': + return QColor(Qt.GlobalColor.cyan) + elif log["level"] == 'INFO': + return QColor(Qt.GlobalColor.white) + elif log["level"] == 'SUCCESS': + return QColor(Qt.GlobalColor.green) + elif log["level"] == 'WARNING': + return QColor(Qt.GlobalColor.yellow) + elif log["level"] == 'ERROR': + return QColor(Qt.GlobalColor.red) + elif log["level"] == 'CRITICAL': + return QColor(Qt.GlobalColor.darkRed) + elif role == Qt.ItemDataRole.ForegroundRole and index.column() == 1: # Level + log = self._dataList[index.row()] + if log["level"] == 'DEBUG': + return QColor(Qt.GlobalColor.darkGray) + elif log["level"] == 'INFO': + return QColor(Qt.GlobalColor.black) + elif log["level"] == 'SUCCESS': + return QColor(Qt.GlobalColor.darkGray) + elif log["level"] == 'WARNING': + return QColor(Qt.GlobalColor.lightGray) + elif log["level"] == 'ERROR': + return QColor(Qt.GlobalColor.darkGray) + elif log["level"] == 'CRITICAL': + return QColor(Qt.GlobalColor.lightGray) + elif role == Qt.ItemDataRole.FontRole: + if index.column() == 1: # Level + # log = self._dataList[index.row()] + font = QFont("Helvetica") + font.setBold(True) + return font + return None + elif role == Qt.ItemDataRole.TextAlignmentRole: + global is_rtl + if index.column() == 0: + return get_qt_align_vchr() + elif index.column() == 1: + return get_qt_align_center() + elif index.column() == 2: + return get_qt_align_vchl() + return None + + def sort(self, column: int, order: Qt.SortOrder = Qt.SortOrder.DescendingOrder): + self._sortColumn = column + self._sortOrder = order + # Sorting logic + if column == 0: # Sorting by "timestamp" + self._dataList.sort(key = lambda log: log["timestamp"], reverse = (order == Qt.SortOrder.AscendingOrder)) + elif column == 1: # Sorting by "level" + self._dataList.sort(key = lambda log: log["level"], reverse = (order == Qt.SortOrder.AscendingOrder)) + elif column == 2: # Sorting by "message" + self._dataList.sort(key = lambda log: log["message"], reverse = (order == Qt.SortOrder.AscendingOrder)) + # Notify view that data has changed after sorting + self.layoutChanged.emit() + + def add_data(self, log: dict): + """ + Ajoute un log au modèle tout en respectant la limite maximale des lignes. + """ + self._dataList.append(log) + while len(self._dataList) > self._maxLines: + self._dataList.pop(0) # Supprime les anciennes lignes + self.sort(self._sortColumn, self._sortOrder) + self.layoutChanged.emit() + + def set_data(self, data: list): + self._dataList = data + self.sort(self._sortColumn, self._sortOrder) + self.layoutChanged.emit() + + +class LogViewerWidget(QWidget): + """ + Widget principal contenant un QTableView pour afficher les logs. + """ + + def __init__(self, max_display_lines: int = 100, parent = None): + super().__init__(parent) + # Layout principal + self._main_Qvlo = QVBoxLayout(self) + self.setLayout(self._main_Qvlo) + self._main_Qvlo.setContentsMargins(0, 0, 0, 0) + # TableView + self._logs_Qtvmb = QTableView(self) + self._main_Qvlo.addWidget(self._logs_Qtvmb) + # Model + self._logs_lQtm = LogsTableModel(max_display_lines) + self._logs_Qtvmb.setModel(self._logs_lQtm) + # selection de ligne entiere + self._logs_Qtvmb.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + # désactiver tentative d'edition + self._logs_Qtvmb.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) # Lecture seule + # alterner les couleurs + self._logs_Qtvmb.setAlternatingRowColors(True) + # remplir le background + self._logs_Qtvmb.setAutoFillBackground(True) + # activer le trie + self._logs_Qtvmb.setSortingEnabled(True) + # Configuration des tailles des colonnes + self._logs_Qtvmb.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) # Première colonne + self._logs_Qtvmb.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) # Deuxième colonne + self._logs_Qtvmb.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch) # Troisième colonne + self._logs_Qtvmb.setTextElideMode(Qt.TextElideMode.ElideNone) + self._logs_Qtvmb.setWordWrap(False) + # Émetteur de signaux pour les logs + self._logEmitter = LogEmitter() + self._logEmitter.newLogSignal.connect(self.add_log) # Connexion du signal + # Ajouter un gestionnaire personnalisé pour loguru + log_handler = LogHandler(self._logEmitter) + # logger.remove() # Supprime les sorties par défaut + logger.add(log_handler, format = "{time:YYYY-MM-DD HH:mm:ss.SSS} -|- {level} -|- {message}", level = "INFO") + # update display + self.update_display() + + def add_log(self, log: dict): + """ + Ajoute un log au modèle et effectue le défilement automatique. + """ + self._logs_lQtm.add_data(log) + + def update_display(self): + self._logs_lQtm.set_headers([_("Timestamp"), _("Level"), _("Message")]) + self.setLayoutDirection(get_qt_dir()) + + +class Wallet: + """Class Wallet can raise ValueError """ + WrongWalletName = 1 + """'walletName' wrong type (must not be empty string)""" + WrongWalletId = 2 + """'walletId' wrong type (must not be empty string)""" + WrongWalletPass = 3 + """'walletPass' wrong type (must not be empty string)""" + WrongWalletType = 4 + """'walletType' must be equal string 'solana'""" + JSONDecodeError = 5 + """Cannot decode Json""" + + def __init__(self, + wallet_name: str, + wallet_pub_key: str, + wallet_priv_key: str, + dt_crea: datetime = None, + dt_update: datetime = None, + main_sol_balance: float = 0, + main_tokens: list = None, + dev_sol_balance: float = 0, + dev_tokens: list = None, + wallet_type: str = 'solana'): + # Vérification des paramètres obligatoires + if not isinstance(wallet_name, str) or not wallet_name.strip(): + raise ValueError(Wallet.WrongWalletName) + if not isinstance(wallet_pub_key, str) or not wallet_pub_key.strip(): + raise ValueError(Wallet.WrongWalletId) + if not isinstance(wallet_priv_key, str) or not wallet_priv_key.strip(): + raise ValueError(Wallet.WrongWalletPass) + if wallet_type != "solana": + raise ValueError(Wallet.WrongWalletType) + # Attribuer les valeurs obligatoires + self.walletType = wallet_type + self.walletName = wallet_name + self.walletPubKey = wallet_pub_key + self.walletPrivKey = wallet_priv_key + # Gérer les valeurs par défaut des dates + self.dtCrea = dt_crea if isinstance(dt_crea, datetime) else datetime.now(timezone.utc) + self.dtUpdate = dt_update if isinstance(dt_update, datetime) else datetime.now(timezone.utc) + # Gérer les balances et les mettre à zéro si elles sont négatives + self.mainSolBalance = max(main_sol_balance, 0) + self.devSolBalance = max(dev_sol_balance, 0) + # Initialiser les listes de tokens si elles ne sont pas fournies + self.mainTokens = main_tokens if main_tokens is not None else [] + self.devTokens = dev_tokens if dev_tokens is not None else [] + + def to_json(self) -> str: + """Convertir l'instance en JSON""" + return json.dumps({ + "walletType": self.walletType, + "walletName": self.walletName, + "walletPubKey": self.walletPubKey, + "walletPrivKey": self.walletPrivKey, + "dtCrea": self.dtCrea.isoformat(), + "dtUpdate": self.dtUpdate.isoformat(), + "mainSolBalance": self.mainSolBalance, + "mainTokens": self.mainTokens, + "devSolBalance": self.devSolBalance, + "devTokens": self.devTokens + }) + + @staticmethod + def parse_datetime(dt_str): + """Parse une chaîne de date en datetime, retourne l'heure actuelle en UTC en cas d'erreur.""" + if isinstance(dt_str, str): + try: + return datetime.fromisoformat(dt_str) + except ValueError: + logger.warning("Format de date invalide, remplacement par l'heure actuelle.") + return datetime.now(timezone.utc) + + @staticmethod + def from_json(json_string: str): + """Créer une instance Wallet à partir d'une chaîne JSON""" + try: + data = json.loads(json_string) + # Vérification des paramètres obligatoires + if 'walletName' not in data or not isinstance(data["walletName"], str) or not data["walletName"].strip(): + raise ValueError(Wallet.WrongWalletName) + if 'walletPubKey' not in data or not isinstance(data["walletPubKey"], str) or not data["walletPubKey"].strip(): + raise ValueError(Wallet.WrongWalletId) + if 'walletPrivKey' not in data or not isinstance(data["walletPrivKey"], str) or not data["walletPrivKey"].strip(): + raise ValueError(Wallet.WrongWalletPass) + if 'walletType' not in data or data["walletType"] != "solana": + raise ValueError(Wallet.WrongWalletType) + wallet_name = data["walletName"] + wallet_pub_key = data["walletPubKey"] + wallet_priv_key = data["walletPrivKey"] + wallet_type = data["walletType"] + # Récupérer et valider les dates + dt_crea = Wallet.parse_datetime(data.get("dtCrea")) + dt_update = Wallet.parse_datetime(data.get("dtUpdate")) + # Récupérer et valider les balances, les réinitialiser à zéro si elles sont négatives + main_sol_balance = max(data.get("mainSolBalance", 0), 0) + dev_sol_balance = max(data.get("devSolBalance", 0), 0) + # Récupérer les listes de tokens, initialiser à liste vide si absentes + main_tokens = data.get("mainTokens", []) + dev_tokens = data.get("devTokens", []) + # Créer et retourner l'instance Wallet + return Wallet(wallet_name, wallet_pub_key, wallet_priv_key, dt_crea, dt_update, main_sol_balance, main_tokens, dev_sol_balance, dev_tokens, wallet_type) + except json.JSONDecodeError: + raise ValueError(Wallet.JSONDecodeError) + + +class WalletTokensTableModel(QAbstractTableModel): + + def __init__(self, parent = None): + super().__init__(parent) + self._headers = [_("Token Id"), _("Buy Price"), _("Buy Time"), _("Action")] + self._dataList = [] + + def set_headers(self, new_headers: list): + self._headers = new_headers + self.headerDataChanged.emit(Qt.Orientation.Horizontal, 0, 3) + self.layoutChanged.emit() + + def rowCount(self, parent = QModelIndex()): + return len(self._dataList) + + def columnCount(self, parent = QModelIndex()): + return len(self._headers) + + def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any: + if not index.isValid(): + return None + elif role == Qt.ItemDataRole.DisplayRole: + token = self._dataList[index.row()] + if index.column() == 0: # Timestamp + return token["token_id"] + elif index.column() == 1: # Level + return token["buy_price"] + elif index.column() == 2: # Message + return token["buy_time"] + elif index.column() == 3: # Action + return "" + elif role == Qt.ItemDataRole.TextAlignmentRole: + global is_rtl + if index.column() == 0: + return get_qt_align_vchr() + elif index.column() == 1: + return get_qt_align_center() + elif index.column() == 2: + return get_qt_align_vchl(not is_rtl) + elif role == Qt.ItemDataRole.DecorationRole and index.column() == 0: + return QIcon(":icoWallet") + + return None + + def headerData(self, section, orientation, role = Qt.ItemDataRole.DisplayRole): + if role == Qt.ItemDataRole.DisplayRole and orientation == Qt.Orientation.Horizontal: + return self._headers[section] + return None + + def set_data(self, data:list): + self._dataList = data + self.layoutChanged.emit() + + +class WalletWidget(QWidget): + + def __init__(self, parent = None): + super().__init__(parent) + self.setWindowIcon(QIcon(":icoWallet")) + self.wallet = None + self.fileKey = None + self.filePath = None + self._main_Qglo = QGridLayout(self) + self.setLayout(self._main_Qglo) + # Line 1: Wallet Name + self._walletName_Qlbl = QLabel() + self.walletName_Qled = QLineEdit() + self._saveWallet_Qpbtn = QPushButton() + self._init_line1() + # Line 2: Wallet PubKey + self._walletPubKey_Qlbl = QLabel() + self.walletPubKey_Qled = QLineEdit() + self._copyToClipboard_Qpbtn = QPushButton() + self._init_line2() + # Line 3: Balances + self._realBalance_Qlbl = QLabel() + self.realBalance_Qled = QLineEdit() + self._demoBalance_Qlbl = QLabel() + self.demoBalance_Qled = QLineEdit() + self._init_line3() + # Line 4: Tokens Label + self._tokens_Qlbl = QLabel() + self._init_line4() + # # Line 5: TableView + self._tokens_qtbvmb = QTableView(self) + self._tokens_wtQtm = WalletTokensTableModel(self) + self._init_tokens_qtbvmb() + self.update_display() + self.populate_table() + + def set_wallet(self, wallet: Wallet, file_pass: str, file_path:str): + self.save_wallet() + self.fileKey = file_pass + self.filePath = file_path + self.wallet = wallet + self.update_wallet_display() + + def update_wallet_display(self): + if self.wallet is not None: + self.walletPubKey_Qled.setText(self.wallet.walletPubKey) + self.walletName_Qled.setText(self.wallet.walletName) + self.realBalance_Qled.setText(f"{self.wallet.mainSolBalance:.9f}") + self.demoBalance_Qled.setText(f"{self.wallet.devSolBalance:.9f}") + else: + self.walletPubKey_Qled.setText("") + self.walletName_Qled.setText("") + self.realBalance_Qled.setText(f"{0:.9f}") + self.demoBalance_Qled.setText(f"{0:.9f}") + + def save_wallet(self): + if self.wallet is not None and self.fileKey is not None and self.filePath is not None: + encrypted_data = encrypt_string(self.fileKey, APP_SALT.encode(), self.wallet.to_json()) + with LockedFile(self.filePath, "w+b") as file: + file.seek(0) + file.truncate() + file.write(encrypted_data.encode()) + + def _init_line1(self): + self.walletName_Qled.setAlignment(get_qt_align_center()) + self._saveWallet_Qpbtn.setIcon(QIcon(":icoSave")) + self._saveWallet_Qpbtn.setMaximumWidth(50) + self._saveWallet_Qpbtn.clicked.connect(self.save_wallet) + self._main_Qglo.addWidget(self._walletName_Qlbl, 0, 0, 1, 2) + self._main_Qglo.addWidget(self.walletName_Qled, 0, 2, 1, 9) + self._main_Qglo.addWidget(self._saveWallet_Qpbtn, 0, 11, 1, 1) + + def _init_line2(self): + self.walletPubKey_Qled.setAlignment(get_qt_align_center()) + self.walletPubKey_Qled.setText("XXXXXXXXXXXX") + self.walletPubKey_Qled.setReadOnly(True) + self._copyToClipboard_Qpbtn.setIcon(QIcon(":icoCopy")) + self._copyToClipboard_Qpbtn.setMaximumWidth(50) + self._copyToClipboard_Qpbtn.clicked.connect(self.copy_wallet_priv_key_to_clipboard) + self._main_Qglo.addWidget(self._walletPubKey_Qlbl, 1, 0, 1, 2) + self._main_Qglo.addWidget(self.walletPubKey_Qled, 1, 2, 1, 9) + self._main_Qglo.addWidget(self._copyToClipboard_Qpbtn, 1, 11, 1, 1) + + def _init_line3(self): + self.realBalance_Qled.setAlignment(get_qt_align_vchr()) + self.realBalance_Qled.setText("10.000000000") + # self.realBalance_Qled.setMaximumWidth(300) + self.realBalance_Qled.setReadOnly(True) + self.demoBalance_Qled.setAlignment(get_qt_align_vchr()) + self.demoBalance_Qled.setText("10.000000000") + # self.demoBalance_Qled.setMaximumWidth(300) + self.demoBalance_Qled.setReadOnly(True) + self._main_Qglo.addWidget(self._realBalance_Qlbl, 2, 0, 1, 2) + self._main_Qglo.addWidget(self.realBalance_Qled, 2, 2, 1, 3) + self._main_Qglo.addWidget(self._demoBalance_Qlbl, 2, 7, 1, 2) + self._main_Qglo.addWidget(self.demoBalance_Qled, 2, 9, 1, 3) + + def _init_line4(self): + self._tokens_Qlbl.setAlignment(get_qt_align_center()) + self._main_Qglo.addWidget(self._tokens_Qlbl, 3, 0, 1, 12) + + def _init_tokens_qtbvmb(self): + self._tokens_qtbvmb.setModel(self._tokens_wtQtm) + # selection de ligne entiere + self._tokens_qtbvmb.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + # desactiver tentative d'edition + self._tokens_qtbvmb.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) # Lecture seule + # alterner les couleurs + self._tokens_qtbvmb.setAlternatingRowColors(True) + # remplir le background + self._tokens_qtbvmb.setAutoFillBackground(True) + # activer le trie + self._tokens_qtbvmb.setSortingEnabled(True) + + # Configuration des tailles des colonnes + self._tokens_qtbvmb.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) # Première colonne + self._tokens_qtbvmb.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) # Deuxième colonne + self._tokens_qtbvmb.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Interactive) # Troisième colonne + self._tokens_qtbvmb.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.Stretch) # Quatrième colonne + self._tokens_qtbvmb.setTextElideMode(Qt.TextElideMode.ElideNone) + self._tokens_qtbvmb.setWordWrap(False) + self._main_Qglo.addWidget(self._tokens_qtbvmb, 4, 0, 1, 12) + + def update_display(self): + global is_rtl + self.setLayoutDirection(get_qt_dir()) + self._walletName_Qlbl.setText(_("Wallet Name:")) + self._walletName_Qlbl.setAlignment(get_qt_align_vchr(is_rtl)) + self._walletPubKey_Qlbl.setText(_("Wallet PubKey:")) + self._walletPubKey_Qlbl.setAlignment(get_qt_align_vchr(is_rtl)) + self._realBalance_Qlbl.setText(_("Balance Real:")) + self._realBalance_Qlbl.setAlignment(get_qt_align_vchr(is_rtl)) + self._demoBalance_Qlbl.setText(_("Balance Demo:")) + self._demoBalance_Qlbl.setAlignment(get_qt_align_vchr(is_rtl)) + self._tokens_Qlbl.setText(_("My Tokens:")) + self._tokens_wtQtm.set_headers([_("Token Id"), _("Buy Price"), _("Buy Time"), _("Action")]) + + def populate_table(self): + data = [] + for x in range(10): + token_id = ''.join(random.choices('ABCDEFGHIJKLMNOPQRSTUVWXYZ', k = 3)) + buy_price = f"{random.uniform(0.000000001, 1.000000000):.9f}" + buy_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] + action = _("Sell") # Placeholder for button-like behavior + + token = { + "token_id": token_id, # token_id + "buy_price": buy_price, # Niveau du log + "buy_time": buy_time, # Message + "action": action # Message + } + + data.append(token) + + self._tokens_wtQtm.set_data(data) + + def copy_wallet_priv_key_to_clipboard(self): + """Copier le texte de QLineEdit dans le presse-papiers.""" + clipboard = QApplication.clipboard() + clipboard.setText(self.walletPubKey_Qled.text()) + logger.info(_("walletId copied to clipboard")) + + +class SimpleMsgBox(QMessageBox): + def showEvent(self, event) -> None: + super().showEvent(event) + center_on_screen(self) + + def __init__(self, window_title: str, window_text: str, ico: QMessageBox.Icon, parent = None): + super().__init__(parent) + self.setLayoutDirection(get_qt_dir()) + self.setOption(QMessageBox.Option.DontUseNativeDialog, True) + self.setWindowIcon(QIcon(":imgIcon")) + self.setWindowTitle(window_title) + self.setText(window_text) + self.setIcon(ico) + + +class NewWalletNameAndPassDialog(QDialog): + + def showEvent(self, event) -> None: + super().showEvent(event) + center_on_screen(self) + + def __init__(self, parent = None): + super().__init__(parent) + self.setLayoutDirection(get_qt_dir()) + self.setWindowIcon(QIcon(":imgIcon")) + self.setWindowTitle(_("Wallet Name and EncryptPass")) + main_qflo = QFormLayout() + self.setLayout(main_qflo) + self._walletName_Qle = QLineEdit(self) + self._walletEncyptPass_Qle = QLineEdit(self) + self._walletEncyptPass_Qle.setEchoMode(QLineEdit.EchoMode.Password) + main_qflo.addRow(_("Wallet Name:"), self._walletName_Qle) + main_qflo.addRow(_("Wallet EncryptPass:"), self._walletEncyptPass_Qle) + + validate_qpbtn = QPushButton(_("Validate")) + cancel_qpbtn = QPushButton(_("Cancel")) + + button_qdbbox = QDialogButtonBox(self) + button_qdbbox.addButton(validate_qpbtn, QDialogButtonBox.ButtonRole.AcceptRole) + button_qdbbox.addButton(cancel_qpbtn, QDialogButtonBox.ButtonRole.RejectRole) + + # Connecter les signaux des boutons + validate_qpbtn.clicked.connect(self.validate_and_accept) + cancel_qpbtn.clicked.connect(self.reject) + + main_qflo.addWidget(button_qdbbox) + + self.walletName = None + self.walletEncyptPass = None + + def validate_and_accept(self): + # Validation du champ 1 + if len(self._walletName_Qle.text().strip()) < 2: + msg_qmbox = SimpleMsgBox(_("Error"), _("The Wallet Name must have a minimum of 2 characters."), QMessageBox.Icon.Warning, self) + msg_qmbox.addButton(_("Ok"), QMessageBox.ButtonRole.ActionRole) + msg_qmbox.exec() + return + # Validation du champ 2 (mot de passe) + if not re.match(r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$", self._walletEncyptPass_Qle.text()): + msg_qmbox = SimpleMsgBox(_("Error"), _("The Wallet EncryptPass must have a minimum of 1 lower case character, 1 upper case character, 1 numeric character, 1 special character (@$!%*?&), and must have a minimum of 8 characters length."), QMessageBox.Icon.Warning, self) + msg_qmbox.addButton(_("Ok"), QMessageBox.ButtonRole.ActionRole) + msg_qmbox.exec() + return + self.walletName = self._walletName_Qle.text().strip() + self.walletEncyptPass = self._walletEncyptPass_Qle.text() + # Si les deux champs sont valides, on accepte la saisie + self.accept() + + +class NewWalletFileDialog(QFileDialog): + + def showEvent(self, event) -> None: + super().showEvent(event) + center_on_screen(self) + + def __init__(self, parent = None): + super().__init__(parent) + self.setOption(QFileDialog.Option.DontUseNativeDialog, True) # Empêche d'écraser un fichier existant + self.setWindowTitle(_("Save Content to New Wallet File")) + self.setWindowIcon(QIcon(":imgIcon")) + self.setAcceptMode(QFileDialog.AcceptMode.AcceptSave) # Mode "Enregistrer sous" + self.setFileMode(QFileDialog.FileMode.AnyFile) # Permet de saisir un nouveau fichier + self.setOption(QFileDialog.Option.DontConfirmOverwrite, True) + self.setLabelText(QFileDialog.DialogLabel.LookIn, _("Wallets location:")) + self.setLabelText(QFileDialog.DialogLabel.FileName, _("File name:")) + self.setLabelText(QFileDialog.DialogLabel.FileType, "File type:") + self.setLabelText(QFileDialog.DialogLabel.Accept, _("Validate")) + self.setLabelText(QFileDialog.DialogLabel.Reject, _("Cancel")) + self.setDirectory(WALLETS_FOLDER) + self.setDefaultSuffix(WALLET_FILE_EXT) + self.setNameFilter(_("%(desc)s (*.%(ext)s)") % {"desc": WALLET_FILE_DESC, "ext": WALLET_FILE_EXT}) + # self.setMinimumSize(dialog.width(), dialog.height()) + + +class LoadWalletPassDialog(QDialog): + + def showEvent(self, event) -> None: + super().showEvent(event) + center_on_screen(self) + + def __init__(self, file_name: str, parent = None): + super().__init__(parent) + self.setLayoutDirection(get_qt_dir()) + self.setWindowIcon(QIcon(":imgIcon")) + self.setWindowTitle(_("Wallet EncryptPass for file '%(fileName)s'") % {"fileName": file_name}) + main_qflo = QFormLayout() + self.setLayout(main_qflo) + self._walletEncyptPass_Qle = QLineEdit(self) + self._walletEncyptPass_Qle.setEchoMode(QLineEdit.EchoMode.Password) + main_qflo.addRow(_("Wallet EncryptPass:"), self._walletEncyptPass_Qle) + + validate_qpbtn = QPushButton(_("Validate")) + cancel_qpbtn = QPushButton(_("Cancel")) + button_qdbbox = QDialogButtonBox(self) + button_qdbbox.addButton(validate_qpbtn, QDialogButtonBox.ButtonRole.AcceptRole) + button_qdbbox.addButton(cancel_qpbtn, QDialogButtonBox.ButtonRole.RejectRole) + # Connecter les signaux des boutons + validate_qpbtn.clicked.connect(self.validate_and_accept) + cancel_qpbtn.clicked.connect(self.reject) + main_qflo.addWidget(button_qdbbox) + self.walletEncyptPass = None + + def validate_and_accept(self): + # Validation du champ 1 + if len(self._walletEncyptPass_Qle.text().strip()) < 1: + msg_qmbox = SimpleMsgBox(_("Error"), _("You must enter a valid Password."), QMessageBox.Icon.Warning, self) + msg_qmbox.addButton(_("Ok"), QMessageBox.ButtonRole.ActionRole) + msg_qmbox.exec() + return + self.walletEncyptPass = self._walletEncyptPass_Qle.text() + # Si les deux champs sont valides, on accepte la saisie + self.accept() + + +class LoadWalletFileDialog(QFileDialog): + + def showEvent(self, event) -> None: + super().showEvent(event) + center_on_screen(self) + + def __init__(self, parent = None): + super().__init__(parent) + self.setOption(QFileDialog.Option.DontUseNativeDialog, True) # Empêche d'écraser un fichier existant + self.setWindowTitle(_("Load Wallet File")) + self.setWindowIcon(QIcon(":imgIcon")) + self.setAcceptMode(QFileDialog.AcceptMode.AcceptOpen) # Mode "Ouvrir sous" + self.setFileMode(QFileDialog.FileMode.ExistingFile) # Permet de saisir un fichier existant + self.setDirectory(WALLETS_FOLDER) + self.setDefaultSuffix(WALLET_FILE_EXT) + self.setLabelText(QFileDialog.DialogLabel.LookIn, _("Wallets location:")) + self.setLabelText(QFileDialog.DialogLabel.FileName, _("File name:")) + self.setLabelText(QFileDialog.DialogLabel.FileType, "File type:") + self.setLabelText(QFileDialog.DialogLabel.Accept, _("Validate")) + self.setLabelText(QFileDialog.DialogLabel.Reject, _("Cancel")) + self.setNameFilter(_("%(desc)s (*.%(ext)s)") % {"desc": WALLET_FILE_DESC, "ext": WALLET_FILE_EXT}) + # self.setMinimumSize(dialog.width(), dialog.height()) + + +class MainWindow(QMainWindow): + + def closeEvent(self, event: QEvent): + """Intercepter la fermeture pour masquer la fenêtre au lieu de quitter.""" + self.hide() + event.ignore() + + def showEvent(self, event) -> None: + self.showApp_Qact.setDisabled(True) + self.hideApp_Qact.setDisabled(False) + super().showEvent(event) + if self.mustCenter: + self.mustCenter = False + center_on_screen(self) + + def hideEvent(self, event) -> None: + self.showApp_Qact.setDisabled(False) + self.hideApp_Qact.setDisabled(True) + self.mustCenter = True + super().hideEvent(event) + + def __init__(self, cfg:dict = DEFAULT_YAML_CONFIG): + super().__init__() + self.config = cfg + self.currentLang = get_lang_or_fallback(self.config['defaultLang']) + self.setWindowIcon(QIcon(":imgIcon")) + self.setWindowTitle(APP_NAME_VERSION) + self.setMinimumSize(1024, 768) + self.resize(max(self.config.get('lastWidth'), 1024), max(self.config.get('lastHeight'), 768)) + self.mustCenter = True + # Main Menubar + self.main_Qmnub = self.menuBar() + # File Menu + self.file_Qmnu = QMenu() + self.newWallet_Qact = QAction(self) + self.newWallet_Qact.setIcon(QIcon(":icoWalletNew")) + self.openWallet_Qact = QAction(self) + self.openWallet_Qact.setIcon(QIcon(":icoWalletOpen")) + self.filesHistory_Qmnu = QMenu() + self.filesHistory_Qmnu.setIcon(QIcon(":icoLastFiles")) + self.noFileHistory_Qact = QAction(self) + self.noFileHistory_Qact.setIcon(QIcon(":icoNoHistory")) + self.noFileHistory_Qact.setDisabled(True) + self.hideApp_Qact = QAction(self) + self.hideApp_Qact.setIcon(QIcon(":icoTrayHide")) + self.closeApp_Qact = QAction(self) + self.closeApp_Qact.setIcon(QIcon(":icoQuit")) + self.forceCloseApp_Qact = QAction(self) + self.forceCloseApp_Qact.setIcon(QIcon(":icoForceQuit")) + # finalize initialisation of File Menu + self._init_file_qmnu() + # Langs Menu + self.langs_Qmnu = QMenu() + self.changeLangEn_Qact = QAction(QIcon(":icoLang"), "English", self) + self.changeLangFr_Qact = QAction(QIcon(":icoLang"), "Français", self) + self.changeLangAr_Qact = QAction(QIcon(":icoLang"), "العربية", self) + self.changeLangDe_Qact = QAction(QIcon(":icoLang"), "Deutsch", self) + self.changeLangIt_Qact = QAction(QIcon(":icoLang"), "Italiano", self) + self.changeLangEs_Qact = QAction(QIcon(":icoLang"), "Espagnol", self) + # finalize initialisation of Langs Menu + self._init_langs_qmnu() + # Help Menu + self.help_Qmnu = QMenu() + self.help_Qact = QAction(self) + self.help_Qact.setIcon(QIcon(":icoHelp")) + self.about_Qact = QAction(self) + self.about_Qact.setIcon(QIcon(":icoAbout")) + self._init_help_qmnu() + # set texts and titles for Main Menubar + # Main Toolbar + self.main_Qtb = QToolBar() + self._init_main_toolbar() + # Initialisation de l'icône du System Tray + self.tray_Qsti = QSystemTrayIcon(QIcon(":imgIcon"), self) + # self.trayIcon.setToolTip(f"{APP_FULL_NAME} ({APP_VERSION})") # don't work on linux ??? + # Menu du trayIcon + self.tray_Qmnu = QMenu() + self.showApp_Qact = QAction(self) + self._init_tray_qmnu() + # Central widget: QTabWidget + self.main_Qtabw = QTabWidget(self) + self.setCentralWidget(self.main_Qtabw) + self.token_qw = QWidget() + self.main_Qtabw.addTab(self.token_qw, QIcon(":icoTokens"), _("Tokens:")) + self.token_qw.setLayout(QVBoxLayout()) + self.token_qw.layout().addWidget(QTableView()) + self.wallet_Qw = WalletWidget(self) + self.main_Qtabw.addTab(self.wallet_Qw, QIcon(":icoWallet"), _("Wallet: %(walletName)s") % {"walletName": ""}) + self.logViewer_Qw = LogViewerWidget(1000, self) + self.main_Qtabw.addTab(self.logViewer_Qw, QIcon(":icoLogs"), _("Logs:")) + self.update_display() + logger.success(_("Init of %(appName)s") % {"appName": APP_ABOUT_NAME}) + + def first_load(self): + # logger.debug(type(config)) + configLastMaximized = self.config.get('lastMaximized', True) + if configLastMaximized: + self.showMaximized() + else: + self.show() + lastFile = self.config["lastFile"] + if lastFile is None or lastFile == "": + self._init_new_or_load_wallet() + elif not os.path.exists(lastFile): + msg_qmbox = SimpleMsgBox(_("Error"), _("The File '%(fileName)s' does not exists. Please choose a new file.") % {"fileName": lastFile}, QMessageBox.Icon.Warning, self) + msg_qmbox.addButton(_("Ok"), QMessageBox.ButtonRole.ActionRole) + msg_qmbox.exec() + self._init_new_or_load_wallet() + else: + self._ask_load_wallet_pass(lastFile, True) + + def _quit_app_func(self): + msg_qmbox = SimpleMsgBox(_("Do you really want to Quit?"), _("If you accept, all the files will be saved\nand the application will be closed."), QMessageBox.Icon.Question, self) + yes_qpbtn = msg_qmbox.addButton(_("Yes"), QMessageBox.ButtonRole.ActionRole) + msg_qmbox.addButton(_("No"), QMessageBox.ButtonRole.ActionRole) # no_qpbtn = msg_qmbox.addButton(_("No"), QMessageBox.ButtonRole.ActionRole) + msg_qmbox.exec() + if msg_qmbox.clickedButton() == yes_qpbtn: + self.config['lastMaximized'] = self.isMaximized() + # if self.isMaximized(): + # self.showNormal() + # TODO: save size of non maximized window + self.config['lastHeight'] = self.height() + self.config['lastWidth'] = self.width() + # saving self.currentLang in config memory + self.config['defaultLang'] = self.currentLang + # save memory config to file + save_yaml_app_config(self.config) + + self.wallet_Qw.save_wallet() + # close instance + QApplication.instance().quit() + + def _force_quit_app_func(self): + msg_qmbox = SimpleMsgBox(_("Do you really want to Quit?"), _("If you accept, nothing will be saved\nand the application will be closed."), QMessageBox.Icon.Critical, self) + yes_qpbtn = msg_qmbox.addButton(_("Yes"), QMessageBox.ButtonRole.ActionRole) + msg_qmbox.addButton(_("No"), QMessageBox.ButtonRole.ActionRole) # no_qpbtn = msg_qmbox.addButton(_("No"), QMessageBox.ButtonRole.ActionRole) + msg_qmbox.exec() + if msg_qmbox.clickedButton() == yes_qpbtn: + QApplication.instance().quit() + + def _change_lang(self, lang_code, lang_name): + + self.currentLang = get_lang_or_fallback(lang_code) + set_app_lang_code(self.currentLang) + logger.info(_("Switching to language %(lang)s.") % {"lang": lang_name}) + self.update_display() + + + + def _init_tray_qmnu(self): + self.tray_Qsti.setContextMenu(self.tray_Qmnu) + self.tray_Qmnu.addAction(self.hideApp_Qact) + self.showApp_Qact.setIcon(QIcon(":icoTrayShow")) + self.showApp_Qact.setDisabled(True) + self.showApp_Qact.triggered.connect(self.show) + self.tray_Qmnu.addAction(self.showApp_Qact) + self.tray_Qmnu.addAction(self.closeApp_Qact) + self.tray_Qmnu.addAction(self.forceCloseApp_Qact) + + self.tray_Qsti.show() + + def _show_about_window(self): + """Affiche une boîte de dialogue 'À propos'.""" + bg_txt = f"

{APP_FULL_NAME}: {APP_VERSION}
PYTHON: {platform.python_version()}
Qt: {pyside6version}
OS: {platform.system()}

" + about_window = AboutWindow(bg_txt, self) + about_window.show() + + def _show_help_window(self): + """Affiche une boîte de dialogue 'Aide'.""" + help_window = HelpWindow(":pdfHelp", self) + help_window.show() + + def _init_main_toolbar(self): + # Création de la barre d'outils + self.addToolBar(self.main_Qtb) + self.main_Qtb.setMovable(False) + favicon_toolbar_qlbl = QLabel() + favicon_toolbar_qlbl.setPixmap(QPixmap(":imgFavicon").scaled(24, 24, Qt.AspectRatioMode.KeepAspectRatio)) + self.main_Qtb.addWidget(favicon_toolbar_qlbl) + self.main_Qtb.addSeparator() + self.main_Qtb.addAction(self.openWallet_Qact) + self.main_Qtb.addAction(self.newWallet_Qact) + self.main_Qtb.addSeparator() + # self.toolbarPcp.addAction(self.actionOpenWsMainNetConnexion) + + def _init_new_or_load_wallet(self): + msg_qmbox = SimpleMsgBox(_("Action Required"), _("To use the application, you have to load a wallet or to create a new one."), QMessageBox.Icon.Critical, self) + load_qpbtn = msg_qmbox.addButton(_("Load Wallet"), QMessageBox.ButtonRole.ActionRole) + create_qpbtn = msg_qmbox.addButton(_("New Wallet"), QMessageBox.ButtonRole.ActionRole) + msg_qmbox.addButton(_("Quit"), QMessageBox.ButtonRole.RejectRole) # quit_qpbtn = msg_qmbox.addButton(_("No"), QMessageBox.ButtonRole.ActionRole) + msg_qmbox.exec() + if msg_qmbox.clickedButton() == load_qpbtn: + create_wallets_folder() + self._ask_load_wallet_filepath(True) + elif msg_qmbox.clickedButton() == create_qpbtn: + create_wallets_folder() + self._ask_new_wallet_name_and_pass(True) + else: + self.close() + QApplication.instance().quit() + return + + def _create_new_wallet(self): + create_wallets_folder() + self._ask_new_wallet_name_and_pass() + + def _ask_new_wallet_name_and_pass(self, return_to_prev: bool = False): + wallet_dialog = NewWalletNameAndPassDialog(self) + if wallet_dialog.exec(): + new_wallet_name = wallet_dialog.walletName + new_wallet_pass = wallet_dialog.walletEncyptPass + self._ask_new_wallet_filepath(new_wallet_name, new_wallet_pass, return_to_prev) + elif return_to_prev: + self._init_new_or_load_wallet() + + def _ask_new_wallet_filepath(self, new_wallet_name: str, new_wallet_pass: str, return_to_prev: bool = False): + saved_file = False + while True: + saved_file = False + # Ouvre le QFileDialog pour choisir un emplacement et un nom de fichier + dialog = NewWalletFileDialog(self) + if dialog.exec(): + file_path = dialog.selectedFiles()[0] + # Ajoute l'extension .kew si elle est manquante + if not file_path.endswith("." + WALLET_FILE_EXT): + file_path += "." + WALLET_FILE_EXT + file_name = os.path.basename(file_path) + # Vérifie si le fichier existe déjà + if os.path.exists(file_path): + msg_qmbox = SimpleMsgBox(_("Error"), _("The File '%(fileName)s' already exists. Please choose a new file.") % {"fileName": file_name}, QMessageBox.Icon.Warning, self) + msg_qmbox.addButton(_("Ok"), QMessageBox.ButtonRole.ActionRole) + msg_qmbox.exec() + continue # Relance le dialogue pour forcer un nouveau fichier + # Enregistre le contenu dans le nouveau fichier + try: + keypair = Keypair() + wallet = Wallet(wallet_name = new_wallet_name, wallet_pub_key = str(keypair.pubkey()), wallet_priv_key = str(keypair.secret())) + encrypted_data = encrypt_string(new_wallet_pass, APP_SALT.encode(), wallet.to_json()) + with LockedFile(file_path, "w+b") as file: + file.write(encrypted_data.encode()) + file_path = os.path.realpath(file_path) + if file_path not in self.config["lastFiles"]: + self.config["lastFiles"].append(file_path) + while len(self.config["lastFiles"]) > 5: + self.config["lastFiles"].pop(0) + self._refresh_files_history_qmnu() + self.config["lastFile"] = file_path + self.wallet_Qw.set_wallet(wallet, new_wallet_pass, file_path) + self.main_Qtabw.setTabText(1, _("Wallet: %(walletName)s") % {"walletName": wallet.walletName}) + saved_file = True + break # Quitte la boucle après avoir sauvegardé + except Exception as e: + logger.error(e) + msg_qmbox = SimpleMsgBox(_("Error"), _("An error occurred while saving the file '%(fileName)s'.") % {"fileName": file_name}, QMessageBox.Icon.Warning, self) + msg_qmbox.addButton(_("Ok"), QMessageBox.ButtonRole.ActionRole) + msg_qmbox.exec() + break # Quitte la boucle en cas d'erreur + else: + break # L'utilisateur a annulé. + if saved_file: + return + if return_to_prev: + self._ask_new_wallet_name_and_pass(return_to_prev) + + def _load_wallet(self): + self._ask_load_wallet_filepath() + + def _ask_load_wallet_filepath(self, return_to_prev: bool = False): + loaded_file = False + file_path = None + while True: + dialog = LoadWalletFileDialog(self) + if dialog.exec(): + file_path = dialog.selectedFiles()[0] + file_name = os.path.basename(file_path) + if not os.path.exists(file_path): + msg_qmbox = SimpleMsgBox(_("Error"), _("The File '%(fileName)s' does not exists. Please choose another file.") % {"fileName": file_name}, QMessageBox.Icon.Warning, self) + msg_qmbox.addButton(_("Ok"), QMessageBox.ButtonRole.ActionRole) + msg_qmbox.exec() + elif self.config["lastFile"] is None or file_path != self.config["lastFile"] or return_to_prev: + loaded_file = True + else: + msg_qmbox = SimpleMsgBox(_("Error"), _("The File '%(fileName)s' is already loaded. Please choose another file.") % {"fileName": file_name}, QMessageBox.Icon.Warning, self) + msg_qmbox.addButton(_("Ok"), QMessageBox.ButtonRole.ActionRole) + msg_qmbox.exec() + continue # Relance le dialogue pour forcer un nouveau fichier + if loaded_file: + break + # Relance le dialogue pour forcer un nouveau fichier + continue + else: + break # L'utilisateur a annulé. + if loaded_file: + self._ask_load_wallet_pass(file_path, return_to_prev) + return + if return_to_prev: + self._init_new_or_load_wallet() + + def _ask_load_wallet_pass(self, file_path: str, return_to_prev: bool = False): + loaded_file = False + wallet = None + wallet_pass = None + while not loaded_file: + file_name = os.path.basename(file_path) + wallet_dialog = LoadWalletPassDialog(file_name, self) + if wallet_dialog.exec(): + wallet_pass = wallet_dialog.walletEncyptPass + try: + with LockedFile(file_path, "rb") as file: + enc_data = file.read() + wallet = Wallet.from_json(decrypt_string(wallet_pass, APP_SALT.encode(), enc_data.decode())) + loaded_file = True + break + except portalocker.exceptions.LockException as e: + logger.error(_("LockException for Wallet file %(fileName)s %(exp)s") % {"fileName": file_path, "exp": e}) + msg_qmbox = SimpleMsgBox(_("Error"), _("The File '%(fileName)s' seems to be locked. Please choose another file.") % {"fileName": file_name}, QMessageBox.Icon.Critical, self) + msg_qmbox.addButton(_("Ok"), QMessageBox.ButtonRole.ActionRole) + msg_qmbox.exec() + loaded_file = False + break + except InvalidToken as e: + logger.error(_("Wrong password for Wallet file %(fileName)s %(exp)s") % {"fileName": file_path, "exp": e}) + msg_qmbox = SimpleMsgBox(_("Error"), _("The password you provided for file '%(fileName)s' is incorrect. Please try again.") % {"fileName": file_name}, QMessageBox.Icon.Critical, self) + msg_qmbox.addButton(_("Ok"), QMessageBox.ButtonRole.ActionRole) + msg_qmbox.exec() + loaded_file = False + continue + except json.JSONDecodeError as e: + logger.error(_("Cannot decode json in Wallet file %(fileName)s %(exp)s") % {"fileName": file_path, "exp": e}) + msg_qmbox = SimpleMsgBox(_("Error"), _("The file '%(fileName)s' seems to be corrupted. Please choose another file.") % {"fileName": file_name}, QMessageBox.Icon.Critical, self) + msg_qmbox.addButton(_("Ok"), QMessageBox.ButtonRole.ActionRole) + msg_qmbox.exec() + loaded_file = False + break + except ValueError as e: + logger.error(_("Wrong values in %(fileName)s %(exp)s") % {"fileName": file_path, "exp": e}) + msg_qmbox = SimpleMsgBox(_("Error"), _("The file '%(fileName)s' seems to have wrong values. Please choose another file.") % {"fileName": file_name}, QMessageBox.Icon.Critical, self) + msg_qmbox.addButton(_("Ok"), QMessageBox.ButtonRole.ActionRole) + msg_qmbox.exec() + loaded_file = False + break + else: + break + if loaded_file: + file_path = os.path.realpath(file_path) + if file_path not in self.config["lastFiles"]: + self.config["lastFiles"].append(file_path) + while len(self.config["lastFiles"]) > 5: + self.config["lastFiles"].pop(0) + self._refresh_files_history_qmnu() + self.config["lastFile"] = file_path + self.wallet_Qw.set_wallet(wallet, wallet_pass, file_path) + self.main_Qtabw.setTabText(1, _("Wallet: %(walletName)s") % {"walletName": wallet.walletName}) + return + if return_to_prev: + self._ask_load_wallet_filepath(return_to_prev) + + def _init_file_qmnu(self): + """Initialize File Menu.""" + # add File Menu to Main MenuBar + self.main_Qmnub.addMenu(self.file_Qmnu) + # add action "New Wallet" to File Menu + self.file_Qmnu.addAction(self.newWallet_Qact) + # set action target + self.newWallet_Qact.triggered.connect(self._create_new_wallet) + # add action "Open Wallet" to File Menu + self.file_Qmnu.addAction(self.openWallet_Qact) + # set action target + self.openWallet_Qact.triggered.connect(self._load_wallet) + self.file_Qmnu.addSeparator() + # add files + self.file_Qmnu.addMenu(self.filesHistory_Qmnu) + self._refresh_files_history_qmnu() + self.file_Qmnu.addSeparator() + # add action "Hide application" to File Menu + self.file_Qmnu.addAction(self.hideApp_Qact) + # set action target + self.hideApp_Qact.triggered.connect(self.hide) + # add action "Close application" to File Menu + self.file_Qmnu.addAction(self.closeApp_Qact) + # set action target + self.closeApp_Qact.triggered.connect(self._quit_app_func) + # add action "Close without saving" to File Menu + self.file_Qmnu.addAction(self.forceCloseApp_Qact) + # set action target + self.forceCloseApp_Qact.triggered.connect(self._force_quit_app_func) + + def _refresh_files_history_qmnu(self): + self.filesHistory_Qmnu.clear() + for file_path in self.config.get('lastFiles', []): + if not os.path.exists(file_path): + self.config['lastFiles'].remove(file_path) + else: + file_name = os.path.basename(file_path) + file_open_qact = QAction(QIcon(":icoWallet"), file_name, self) + self.filesHistory_Qmnu.addAction(file_open_qact) + # TODO: Add trigger connect + else: + self.filesHistory_Qmnu.addAction(self.noFileHistory_Qact) + + def _init_langs_qmnu(self): + """Initialize Langs Menu.""" + self.main_Qmnub.addMenu(self.langs_Qmnu) + # + self.langs_Qmnu.addAction(self.changeLangEn_Qact) + self.changeLangEn_Qact.triggered.connect(lambda: self._change_lang("en", self.changeLangEn_Qact.text())) + # + self.langs_Qmnu.addAction(self.changeLangFr_Qact) + self.changeLangFr_Qact.triggered.connect(lambda: self._change_lang("fr", self.changeLangFr_Qact.text())) + # + self.langs_Qmnu.addAction(self.changeLangAr_Qact) + self.changeLangAr_Qact.triggered.connect(lambda: self._change_lang("ar", self.changeLangAr_Qact.text())) + # self.changeLangAr_Qact.setDisabled(True) + # + self.langs_Qmnu.addAction(self.changeLangDe_Qact) + self.changeLangDe_Qact.triggered.connect(lambda: self._change_lang("de", self.changeLangDe_Qact.text())) + self.changeLangDe_Qact.setDisabled(True) + # + self.langs_Qmnu.addAction(self.changeLangIt_Qact) + self.changeLangIt_Qact.triggered.connect(lambda: self._change_lang("it", self.changeLangIt_Qact.text())) + self.changeLangIt_Qact.setDisabled(True) + # + self.langs_Qmnu.addAction(self.changeLangEs_Qact) + self.changeLangEs_Qact.triggered.connect(lambda: self._change_lang("es", self.changeLangEs_Qact.text())) + self.changeLangEs_Qact.setDisabled(True) + + def _init_help_qmnu(self): + """Initialize Help menu.""" + # add Help Menu to Main MenuBar + self.main_Qmnub.addMenu(self.help_Qmnu) + # add action "Help Content" to Help Menu + self.help_Qmnu.addAction(self.help_Qact) + # set action target + self.help_Qact.triggered.connect(self._show_help_window) + # add action "About Khadhroony SRLPv4" to Help Menu + self.help_Qmnu.addAction(self.about_Qact) + # set action target + self.about_Qact.triggered.connect(self._show_about_window) + + def update_display(self): + """refresh translations of texts in main menubar when language change.""" + self.file_Qmnu.setTitle(_("File")) + self.newWallet_Qact.setText(_("New Wallet")) + self.openWallet_Qact.setText(_("Open Wallet")) + self.filesHistory_Qmnu.setTitle(_("Last Files")) + self.noFileHistory_Qact.setText(_("No file found")) + self.hideApp_Qact.setText(_("Hide application")) + self.closeApp_Qact.setText(_("Close application")) + self.forceCloseApp_Qact.setText(_("Close without saving")) + self.langs_Qmnu.setTitle(_("Languages")) + self.help_Qmnu.setTitle(_("Help")) + self.help_Qact.setText(_("Help Content")) + self.about_Qact.setText(_("About %(app)s") % {"app":APP_NAME}) + self.showApp_Qact.setText(_("Show application")) + self.main_Qtabw.setTabText(0, _("Tokens:")) + if self.wallet_Qw.wallet is None: + self.main_Qtabw.setTabText(1, _("Wallet: %(walletName)s") % {"walletName": ""}) + else: + self.main_Qtabw.setTabText(1, _("Wallet: %(walletName)s") % {"walletName": self.wallet_Qw.wallet.walletName}) + self.main_Qtabw.setTabText(2, _("Log Display:")) + self.wallet_Qw.update_display() + self.logViewer_Qw.update_display() + self.setLayoutDirection(get_qt_dir()) + + +if __name__ == "__main__": + logger.remove() # Supprime les sorties par défaut + logger.add(sys.stderr, level = 'TRACE', format = '{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {line: <4} - {message}', filter = None, colorize = None, serialize = False, backtrace = True, diagnose = True, enqueue = False, context = None, catch = True) + logger.add("logs/KhadhroonyRaydium4.{time}.log", format = "{time:YYYY-MM-DD HH:mm:ss.SSS} - {level} - {line: <4}: {message}", level = "DEBUG") + app_lang = get_lang_or_fallback(get_system_language()) + set_app_lang_code(app_lang) + gettext.bindtextdomain('argparse', 'locale') + gettext.textdomain('argparse') + parser = argparse.ArgumentParser( + description = "===~-<=<~~> No Khadhrawy <$<~>$> No Hamroony <~~>=>-~===\n\n" + APP_DESC + "\n" + _("Launch without options to show main window"), + formatter_class = argparse.RawTextHelpFormatter, + epilog = "===~-<=<~~> No Khadhrawy <$<~>$> No Hamroony <~~>=>-~===" + ) + # parser.add_argument("-v", "--version", action="store_true", help="show version and exit.") + parser.add_argument('-v', '--version', action = 'version', version = "{prog} (v. {version})".format(prog = APP_NAME, version = APP_VERSION), help = _("show program version and exit")) + args = parser.parse_args() + # creation du répertoire de wallets + create_wallets_folder() + # chargement configuration de base / historique / langue / display etc ... + config = load_yaml_app_config() + configLocale = config["defaultLang"] + app_lang = get_lang_or_fallback(configLocale) + set_app_lang_code(app_lang) + config['defaultLang'] = app_lang + app = QApplication(sys.argv) + # chargement du SplashScreen + splash_screen = SplashScreen() + splash_screen.show() + # Planifier l'affichage de la fenêtre principale après le splash screen + main_window = MainWindow(config) + splash_screen.fadeOut.finished.connect(main_window.first_load) + + sys.exit(app.exec())