diff --git a/gui/__main__.py b/gui/__main__.py index 68fbf8b..0a8ab4c 100755 --- a/gui/__main__.py +++ b/gui/__main__.py @@ -6,12 +6,15 @@ from core.Constants import Constants from core.models.DatabaseOperation import DatabaseOperation, DBErrorType from core.models.manage.migrations import migrate_sql from core.models.manage.clear_sql_model import clear_sql_model +from core.controllers.SyncController import new_sync +from essentials.observers.ConnectionObserver import ConnectionObserver +from core.observers.ClientObserver import ClientObserver # generic import os import sys from pathlib import Path - +from functools import partial # ============================================================================ # UTIL FUNCTIONS AND RECOVERY UI @@ -65,6 +68,43 @@ def recovery_dialog(custom_error, db_version_you_have): logger.error(f"[DB MANAGEMENT] User opted to close the app WITHOUT wiping the database, even though they need to.") sys.exit() + +def emergency_sync(): + from gui.v2.ui.popups.generic_choice import show_generic_options + from gui.v2.ui.popups.terminal_threading import show_terminal + + option = show_generic_options( + title="Sync the Database", + option_one="Clearweb (fastest)", + option_two="Tor", + option_three="No, Exit." + ) + + if option == 3: + sys.exit() + + client_observer = ClientObserver() + connection_observer = ConnectionObserver() + + if option == 1 or option == 2: + # # setup the function to do: + do_this_function = partial(new_sync, client_observer, connection_observer) + + sync_worked = show_terminal( + do_this_function=do_this_function, + connection_observer=connection_observer, + client_observer=client_observer + ) + + if sync_worked: + print("it worked") + +# ============================================================================ +# BEGIN PROGRAM +# ============================================================================ + + + # ============================================================================ # ASSETS FOLDER # ============================================================================ @@ -100,8 +140,28 @@ except: # ============================================================================ migration_happened = False +# ============================================================================ +# ZERO DATABASE = SYNC NOW +# ============================================================================ +if not main_db_exists: + made_tables = create_ALL_tables() + if made_tables: + # force sync: + emergency_sync() + + # we don't have to worry about migrations, they just got a new DB + create_ONLY_db_version_table() + + from gui.main_ui import start_ui + start_ui(force_sync=False) + sys.exit() + + +# ============================================================================ +# LEGACY VERSION. MIGRATE +# ============================================================================ # are they upgrading from a legacy version? that would mean the table existed, but not the version table, -if not version_table_exists and main_db_exists: +if not version_table_exists: logger.info(f"[DB MANAGEMENT] We are dealing with a legacy database, we need to transition the user.") migration = migrate_sql() @@ -122,6 +182,7 @@ if not version_table_exists and main_db_exists: if not version_table_exists: create_ONLY_db_version_table() + # ============================================================================ # COMPARE DB VERISONS # ============================================================================ @@ -131,66 +192,9 @@ is_compatable = compatability_dict.get("result", False) logger.info(f"[DB MANAGEMENT] The result of the check is {is_compatable} and the reason is {reason}") # ============================================================================ -# IF THE VERSIONS MATCH +# IF the versions do NOT match # ============================================================================ -if is_compatable: - try: - made_tables = create_ALL_tables() - # SCREEN FAILED TABLES - if not made_tables: - custom_error = "Critical Failure with starting the models of the database." - logger.error(f"[DB MANAGEMENT] {custom_error}") - close_session() - from gui.v2.ui.popups.Database_version import show_recovery_dialog - choice = show_recovery_dialog({"message": custom_error, "db_version": 0, "status": "cant_make"}) - logger.info(f"[DB MANAGEMENT] From the database error options, the user picked {choice}") - sys.exit() - - except: - logger.info(f"[DB MANAGEMENT] create ALL tables failed. Running migrations...") - migration = migrate_sql() - logger.info(f"[DB MANAGEMENT] Results of migration is {migration.valid}") - - if not migration.valid: - db_version_you_have = 0 - failed_migration(db_version_you_have) - - # ASSUME TABLES CREATED - logger.info("[DB MANAGEMENT] Tables successfully made or initialized if pre-existing") - - # UPDATE DATA - did_it_insert = insert_new_version(session, Constants.DB_VERSION_THIS_APP_WANTS) - if not did_it_insert: - logger.error(f"[DB MANAGEMENT] Critical Failure with updating/inserting the new DB version into the database.") - - # Load GUI either way: - from gui.main_ui import start_ui - - # If the user lacks a database, we want to force them to sync the new data, to avoid NoneType errors when enabling existing profiles, - if reason == "no_database": - logger.info(f"[DB MANAGEMENT] User had no database to begin with, so now we're entering GUI with sync on..") - force_sync = True - else: - logger.info(f"[DB MANAGEMENT] Launcher is now passing it off to launch the main GUI window WITHOUT force sync..") - if migration_happened: - force_sync = True - clear_sync_cache() - else: - force_sync = False - - # if we deleted their DB, we want to force sync, - if lock_file.exists(): - force_sync = True - lock_file.unlink() # unlock for next time - - # Start GUI either way: - logger.info(f"[DB MANAGEMENT] Starting GUI..") - start_ui(force_sync) - -# ============================================================================ -# but IF the versions do NOT match -# ============================================================================ -else: +if not is_compatable: logger.error(f"[DB MANAGEMENT] The App is expecting a different DB version than the real database. The reason is {reason}") db_version_you_have = compatability_dict.get("old_db_version", "Error getting the Version") @@ -202,4 +206,57 @@ else: # THEN DISPLAY CHOICES AND RECOVERY UI recovery_dialog(custom_error, db_version_you_have) + # goodbye. exits. + +# ============================================================================ +# THE VERSIONS MATCH IF THEY ARE STILL HERE +# ============================================================================ +try: + made_tables = create_ALL_tables() + # SCREEN FAILED TABLES + if not made_tables: + custom_error = "Critical Failure with starting the models of the database." + logger.error(f"[DB MANAGEMENT] {custom_error}") + close_session() + from gui.v2.ui.popups.Database_version import show_recovery_dialog + choice = show_recovery_dialog({"message": custom_error, "db_version": 0, "status": "cant_make"}) + logger.info(f"[DB MANAGEMENT] From the database error options, the user picked {choice}") + sys.exit() + +except: + logger.info(f"[DB MANAGEMENT] create ALL tables failed. Running migrations...") + migration = migrate_sql() + logger.info(f"[DB MANAGEMENT] Results of migration is {migration.valid}") + + if not migration.valid: + db_version_you_have = 0 + failed_migration(db_version_you_have) + +# ASSUME TABLES CREATED +logger.info("[DB MANAGEMENT] Tables successfully made or initialized if pre-existing") + +# UPDATE DATA +did_it_insert = insert_new_version(session, Constants.DB_VERSION_THIS_APP_WANTS) +if not did_it_insert: + logger.error(f"[DB MANAGEMENT] Critical Failure with updating/inserting the new DB version into the database.") + +# Load GUI either way: +from gui.main_ui import start_ui + +logger.info(f"[DB MANAGEMENT] Launcher is now passing it off to launch the main GUI window WITHOUT force sync..") +if migration_happened: + force_sync = True + clear_sync_cache() +else: + force_sync = False + +# if we deleted their DB, we want to force sync, +if lock_file.exists(): + force_sync = True + lock_file.unlink() # unlock for next time + +# Start GUI either way: +logger.info(f"[DB MANAGEMENT] Starting GUI..") +start_ui(force_sync) + diff --git a/gui/assets/yaml_mappings/application_versions.yaml b/gui/assets/yaml_mappings/application_versions.yaml new file mode 100644 index 0000000..9bbeb6b --- /dev/null +++ b/gui/assets/yaml_mappings/application_versions.yaml @@ -0,0 +1,24 @@ +fields: + - name: id + path: ['id'] + required: true + + - name: application_code + path: ['application', 'code'] + required: true + + - name: version_number + path: ['version_number'] + required: true + + - name: format_revision + path: ['format_revision'] + + - name: download_path + path: ['download_path'] + + - name: released_at + path: ['released_at'] + + - name: file_hash + path: ['file_hash'] diff --git a/gui/v2/ui/popups/generic_choice.py b/gui/v2/ui/popups/generic_choice.py new file mode 100644 index 0000000..b944526 --- /dev/null +++ b/gui/v2/ui/popups/generic_choice.py @@ -0,0 +1,163 @@ +import sys +from PyQt6.QtWidgets import QApplication, QDialog, QWidget, QVBoxLayout, QLabel, QPushButton +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QFont, QColor +from PyQt6.QtWidgets import QGraphicsDropShadowEffect + +class GenericOptionMenu(QDialog): + def __init__(self, title, option_one, option_two, option_three): + super().__init__() + self.selected_option = None + self.init_ui(title, option_one, option_two, option_three) + + def init_ui(self, title, option_one, option_two, option_three): + self.setWindowTitle(title) + self.setGeometry(100, 100, 500, 600) + self.setModal(True) + + # Main container + layout = QVBoxLayout(self) + layout.setSpacing(25) + layout.setContentsMargins(40, 50, 40, 50) + + # Apply dark cyberpunk background + self.setStyleSheet(""" + QDialog { + background-color: #0a0e27; + color: #00ff88; + } + """) + + # Title Label + title_label = QLabel(title) + title_label.setFont(QFont("Courier New", 16, QFont.Weight.Bold)) + title_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + title_label.setStyleSheet(""" + QLabel { + color: #00ff88; + background-color: transparent; + padding: 20px; + border: 2px solid #00ff88; + border-radius: 5px; + letter-spacing: 2px; + } + """) + + # Add glow effect to title + title_shadow = QGraphicsDropShadowEffect() + title_shadow.setBlurRadius(15) + title_shadow.setColor(QColor(0, 255, 136, 200)) + title_shadow.setOffset(0, 0) + title_label.setGraphicsEffect(title_shadow) + + layout.addWidget(title_label) + layout.addSpacing(30) + + # Button 1 + btn1 = self.create_button(option_one, 1) + layout.addWidget(btn1) + + # Button 2 + btn2 = self.create_button(option_two, 2) + layout.addWidget(btn2) + + # Button 3 + btn3 = self.create_button(option_three, 3) + layout.addWidget(btn3) + + layout.addStretch() + + # Status label + status_label = QLabel("▓░ READY FOR INPUT ░▓") + status_label.setFont(QFont("Courier New", 10)) + status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + status_label.setStyleSheet(""" + QLabel { + color: #ff0088; + background-color: transparent; + padding: 10px; + letter-spacing: 1px; + } + """) + layout.addWidget(status_label) + + def create_button(self, text, button_num): + button = QPushButton(text) + button.setFont(QFont("Courier New", 12, QFont.Weight.Bold)) + button.setMinimumHeight(60) + button.setCursor(Qt.CursorShape.PointingHandCursor) + + # Color variations for each button + if button_num == 1: + neon_color = "#00ff88" # Cyan green + rgb_color = QColor(0, 255, 136) + elif button_num == 2: + neon_color = "#00d4ff" # Cyan blue + rgb_color = QColor(0, 212, 255) + else: + neon_color = "#ff0088" # Magenta + rgb_color = QColor(255, 0, 136) + + button.setStyleSheet(f""" + QPushButton {{ + background-color: #1a1f3a; + color: {neon_color}; + border: 2px solid {neon_color}; + border-radius: 8px; + padding: 10px 20px; + font-weight: bold; + letter-spacing: 2px; + }} + QPushButton:hover {{ + background-color: {neon_color}; + color: #0a0e27; + border: 2px solid {neon_color}; + }} + QPushButton:pressed {{ + background-color: #0a0e27; + border: 3px solid {neon_color}; + }} + """) + + # Add drop shadow glow effect + shadow = QGraphicsDropShadowEffect() + shadow.setBlurRadius(20) + shadow.setColor(rgb_color) + shadow.setOffset(0, 0) + button.setGraphicsEffect(shadow) + + button.clicked.connect(lambda: self.on_button_clicked(button_num)) + return button + + def on_button_clicked(self, button_num): + self.selected_option = button_num + self.accept() + +def show_generic_options( + title: str, + option_one: str, + option_two: str, + option_three: str +) -> int: + + app = QApplication(sys.argv) + + # Create the menu with custom title and options + menu = GenericOptionMenu( + title=title, + option_one=option_one, + option_two=option_two, + option_three=option_three + ) + + # Show the menu and get the result + result = menu.exec() + + if result == QDialog.DialogCode.Accepted: + print(f"User selected option: {menu.selected_option}") + return menu.selected_option + else: + print("User cancelled") + return None + + sys.exit(0) diff --git a/gui/v2/ui/popups/terminal_threading.py b/gui/v2/ui/popups/terminal_threading.py new file mode 100644 index 0000000..9d56e53 --- /dev/null +++ b/gui/v2/ui/popups/terminal_threading.py @@ -0,0 +1,281 @@ +from essentials.observers.ConnectionObserver import ConnectionObserver +from core.observers.ClientObserver import ClientObserver +from core.services.networking.api_requests.ApiResponseModel import ApiResponse, ErrorType +from core.controllers.SyncController import new_sync +from core.models.Result import Result, ResultError + +from PyQt6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QTextEdit, + QLabel, QPushButton, QApplication) +from PyQt6.QtGui import QFont, QColor, QTextCursor +from PyQt6.QtCore import Qt, QTimer, QThread, pyqtSignal, pyqtSlot +from PyQt6.QtWidgets import QGraphicsDropShadowEffect +import sys +import traceback +from typing import Callable, Any +from PyQt6.QtGui import QTextCharFormat + + +from gui.v2.infrastructure.ThreadSafetyTool import ThreadSafetyTool + +import threading +from threading import Thread + +# mainthread = ThreadSafetyTool() + + +class NotificationTerminalUI(QDialog): + def __init__(self, connection_observer: ConnectionObserver, client_observer: ClientObserver): + super().__init__() + self.mainthread = ThreadSafetyTool() + + self.connection_observer = connection_observer + self.client_observer = client_observer + self.synchronized = False + self.worker_thread = None + self.task_success = False + + self.connection_observer.subscribe("connecting", + lambda msg: self.add_notification("CONNECT", msg, "#00ff88")) + + self.connection_observer.subscribe("tor_bootstrapping", + lambda msg: self.add_notification("BOOTSTRAP", msg, "#00d4ff")) + + self.connection_observer.subscribe("tor_bootstrap_progressing", + lambda msg: self.add_notification("PROGRESS", msg, "#ff0088")) + + self.connection_observer.subscribe("tor_bootstrapped", + lambda msg: self.add_notification("SUCCESS", msg, "#00ff88")) + + self.connection_observer.subscribe("custom_message", + lambda msg: self.add_notification("INFO", msg, "#ffff00")) + + # Subscribe to client_observer events + self.client_observer.subscribe("synchronizing", + lambda event: self.add_notification("SYNC", + event.subject if event.subject else "Sync in progress...", "#00d4ff")) + + self.client_observer.subscribe("synchronized", + lambda event: self.on_synchronized()) + + self.client_observer.subscribe("updating", + lambda event: self.add_notification("UPDATE", "Updating client...", "#ff0088")) + + self.client_observer.subscribe("update_progressing", + lambda event: self.add_notification("PROGRESS", + f'Current progress: {event.meta.get("progress", 0):.2f}%', "#ffff00")) + + self.client_observer.subscribe("updated", + lambda event: self.add_notification("COMPLETE", + "Restart client to apply update.", "#00ff88")) + + self.client_observer.subscribe("custom_message", + lambda event: self.add_notification("ERROR", + event.subject if event.subject else "Error, check logs", "#ff0088")) + + self.init_ui() + + def init_ui(self): + self.setWindowTitle("SYNC PROCESS") + self.setGeometry(100, 100, 700, 600) + self.setModal(True) + + layout = QVBoxLayout(self) + layout.setSpacing(15) + layout.setContentsMargins(30, 30, 30, 30) + + self.setStyleSheet(""" + QDialog { + background-color: #0a0e27; + color: #00ff88; + } + """) + + # Title Label + title_label = QLabel("▓░ NOTIFICATION TERMINAL ░▓") + title_label.setFont(QFont("Courier New", 14, QFont.Weight.Bold)) + title_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + title_label.setStyleSheet(""" + QLabel { + color: #00ff88; + background-color: transparent; + padding: 15px; + border: 2px solid #00ff88; + border-radius: 5px; + letter-spacing: 2px; + } + """) + title_shadow = QGraphicsDropShadowEffect() + title_shadow.setBlurRadius(15) + title_shadow.setColor(QColor(0, 255, 136, 200)) + title_shadow.setOffset(0, 0) + title_label.setGraphicsEffect(title_shadow) + layout.addWidget(title_label) + + # Scrollable text display + self.output_text = QTextEdit() + self.output_text.setReadOnly(True) + self.output_text.setFont(QFont("Courier New", 10)) + self.output_text.setMinimumHeight(400) + self.output_text.setStyleSheet(""" + QTextEdit { + background-color: #1a1f3a; + color: #00ff88; + border: 2px solid #00ff88; + border-radius: 5px; + padding: 15px; + font-size: 16pt; + font-family: 'Courier New', monospace; + selection-background-color: #ff0088; + } + QScrollBar:vertical { + background-color: #0a0e27; + width: 12px; + border-radius: 6px; + } + QScrollBar::handle:vertical { + background-color: #00ff88; + border-radius: 6px; + min-height: 20px; + } + QScrollBar::handle:vertical:hover { + background-color: #00d4ff; + } + """) + layout.addWidget(self.output_text) + + # Status label + self.status_label = QLabel("◄ WAITING FOR TASK START ►") + self.status_label.setFont(QFont("Courier New", 9)) + self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.status_label.setStyleSheet(""" + QLabel { + color: #ff0088; + background-color: transparent; + padding: 8px; + letter-spacing: 1px; + } + """) + layout.addWidget(self.status_label) + + # Button layout + button_layout = QHBoxLayout() + clear_btn = QPushButton("CLEAR") + clear_btn.setFont(QFont("Courier New", 10, QFont.Weight.Bold)) + clear_btn.setMinimumHeight(40) + clear_btn.setCursor(Qt.CursorShape.PointingHandCursor) + clear_btn.setStyleSheet(""" + QPushButton { + background-color: #1a1f3a; + color: #00d4ff; + border: 2px solid #00d4ff; + border-radius: 5px; + padding: 8px 20px; + font-weight: bold; + } + QPushButton:hover { + background-color: #00d4ff; + color: #0a0e27; + } + QPushButton:pressed { + background-color: #0a0e27; + border: 3px solid #00d4ff; + } + """) + clear_btn.clicked.connect(self.clear_output) + shadow = QGraphicsDropShadowEffect() + shadow.setBlurRadius(15) + shadow.setColor(QColor(0, 212, 255)) + shadow.setOffset(0, 0) + clear_btn.setGraphicsEffect(shadow) + button_layout.addWidget(clear_btn) + button_layout.addStretch() + layout.addLayout(button_layout) + + def add_notification(self, prefix: str, message: str, color: str = "#00ff88"): + """Queue to main thread safely.""" + self.mainthread(lambda: self._add_notification_impl(prefix, message, color))() + + def _add_notification_impl(self, prefix: str, message: str, color: str): + """Actual implementation on main thread.""" + cursor = self.output_text.textCursor() + cursor.movePosition(cursor.MoveOperation.End) + fmt = QTextCharFormat() + fmt.setForeground(QColor(color)) + cursor.insertText(f"[{prefix}] {message}\n", fmt) + self.output_text.setTextCursor(cursor) + self.output_text.ensureCursorVisible() + + def set_status(self, text: str): + """This runs on the main thread, safely.""" + self.status_label.setText(text) + + def on_synchronized(self): + """Handle synchronized notification and exit.""" + self.add_notification("SYNC", "Sync complete ✓", "#00ff88") + self.set_status("◄ SYNCHRONIZED - CLOSING ►") + self.synchronized = True + self.task_success = True + QTimer.singleShot(500, self.accept) + + def run_task(self, task_func: Callable): + """ + Purpose: + Insert any function for a pure python background thread. + Which then pushes to the main UI via ThreadSafety + """ + if self.worker_thread is not None and self.worker_thread.is_alive(): + self.add_notification("SYSTEM", "Task already running!", "#ff0088") + return + + self.set_status("◄ TASK RUNNING ►") + self.add_notification("SYSTEM", "Background task started", "#00d4ff") + + # Start in pure Python thread (daemon=True so it doesn't block exit) + self.worker_thread = Thread(target=self._run_task_wrapper, args=(task_func,), daemon=True) + self.worker_thread.start() + + def _run_task_wrapper(self, task_func: Callable): + """Wrapper that runs task and handles errors.""" + try: + task_func() + self.task_success = True + except Exception as e: + self.add_notification("ERROR", f"Task failed: {str(e)}", "#ff0088") + self.set_status("◄ TASK FAILED ►") + self.task_success = False + + def clear_output(self): + """Clear the terminal output.""" + self.output_text.clear() + self.add_notification("SYSTEM", "Terminal cleared", "#00d4ff") + + def get_result(self) -> bool: + """Return True if synchronized, False otherwise.""" + return self.synchronized and self.task_success + + def closeEvent(self, event): + """Ensure thread is properly cleaned up on close.""" + if self.worker_thread is not None and self.worker_thread.is_alive(): + self.worker_thread.join(timeout=1) + event.accept() + + +def show_terminal( + do_this_function: Callable[[Any, Any], None], + connection_observer: ConnectionObserver, + client_observer: ClientObserver +) -> bool: + + app = QApplication.instance() or QApplication(sys.argv) + + # Create the UI + dialog = NotificationTerminalUI(connection_observer=connection_observer, client_observer=client_observer) + + # Start the background task + dialog.run_task(do_this_function) + + # Show dialog and wait + dialog.exec() + + # Return success + return dialog.get_result() diff --git a/gui/v2/workers/worker_thread.py b/gui/v2/workers/worker_thread.py index b0c0c58..ea78ef3 100755 --- a/gui/v2/workers/worker_thread.py +++ b/gui/v2/workers/worker_thread.py @@ -5,7 +5,9 @@ from PyQt6.QtCore import QThread, pyqtSignal from core.controllers.ApplicationVersionController import ApplicationVersionController from core.controllers.ClientController import ClientController -from core.controllers.ConfigurationController import ConfigurationController +from core.controllers.ConfigurationController import ConfigurationController, ConnectionChoice + +from core.controllers.SyncController import new_sync from core.controllers.InvoiceController import InvoiceController from core.controllers.LocationController import LocationController from core.controllers.ProfileController import ProfileController @@ -98,8 +100,7 @@ class WorkerThread(QThread): def check_for_update(self): self.text_output.emit("Checking for updates...") - ClientController.sync(client_observer=client_observer, - connection_observer=connection_observer) + new_sync(client_observer=client_observer, connection_observer=connection_observer) update_available = ClientController.can_be_updated() if update_available: self.text_output.emit("An update is available. Downloading...")