diff --git a/gui/__main__.py b/gui/__main__.py old mode 100755 new mode 100644 index 2b71abc..8aa34e1 --- a/gui/__main__.py +++ b/gui/__main__.py @@ -1,909 +1,94 @@ +from core.errors.logger import logger +from core.models.manage.session_management import init_session, get_session, close_session, create_ALL_tables, create_ONLY_db_version_table, get_path +from core.models.manage.version_check import check_database_compatibility, insert_new_version, get_custom_message +from core.Constants import Constants +# generic import os import sys -_WORKSPACE_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -if _WORKSPACE_ROOT not in sys.path: - sys.path.insert(0, _WORKSPACE_ROOT) - -import logging -import traceback - -from PyQt6.QtWidgets import ( - QApplication, QMainWindow, QStackedWidget, QLabel, QPushButton, - QDialog, QVBoxLayout, QHBoxLayout -) -from PyQt6.QtGui import QPixmap, QFont, QFontDatabase -from PyQt6 import QtGui -from PyQt6.QtCore import Qt, QTimer, QEvent - -from core.Constants import Constants -from core.controllers.ConfigurationController import ConfigurationController -from core.Errors import UnknownConnectionTypeError -from core.errors.logger import logger as core_logger - -core_logger.propagate = False - -from gui.v2.infrastructure import orm -from gui.v2.infrastructure.navigator import Navigator -from gui.v2.infrastructure.setup_observers import setup_observers -from gui.v2.infrastructure.connection_manager import ConnectionManager -from gui.v2.infrastructure.page_registry import ( - PRELOAD_ORDER, - PRELOAD_SKIP, - ASYNC_PREPARE, - REGULAR_FLOW_ONLY, - PAGE_REGISTRY, -) -from gui.v2.workers.worker_thread import WorkerThread -from gui.v2.workers.page_data_worker import PageDataWorker -from gui.v2.ui.builders.bottom_section import create_bottom_section -from gui.v2.ui.builders.top_section import create_top_section -from gui.v2.ui.builders.system_tray import init_system_tray -from gui.v2.actions.config import ( - setup_gui_directory, - load_gui_config, - save_gui_config, - default_gui_config, -) -from gui.v2.actions.logging_setup import ( - is_logging_enabled, - setup_gui_logging, - teardown_gui_logging, -) -from gui.v2.actions.flags import ( - has_shown_systemwide_prompt, - mark_systemwide_prompt_shown, - has_shown_fast_mode_prompt, - mark_fast_mode_prompt_shown, - set_fast_mode_enabled, - is_fast_registration_enabled, - check_first_launch, -) -from gui.v2.actions.profile_data import ( - read_profile_data, - write_profile_data, - clear_profile_data, -) -from gui.v2.actions.ticket_failure import ( - save_ticket_verification_failure, - get_ticket_verification_failure, - clear_ticket_verification_failure, -) -from gui.v2.actions.should_be_synchronized import should_be_synchronized - - -class CustomWindow(QMainWindow): - def __init__(self): - super().__init__() - sys.excepthook = self._handle_exception - self.setWindowFlags(Qt.WindowType.Window) - - gui_dir = os.path.dirname(os.path.abspath(__file__)) - font_path = os.path.join(gui_dir, 'resources', 'fonts') - - # Get's SQLAlchemy going, - orm.start() - - retro_gaming_path = os.path.join(font_path, 'retro-gaming.ttf') - font_id = QFontDatabase.addApplicationFont(retro_gaming_path) - font_family = QFontDatabase.applicationFontFamilies(font_id)[0] - app_font = QFont(font_family) - app_font.setPointSize(10) - QApplication.setFont(app_font) - - self.open_sans_family = None - open_sans_path = os.path.join(font_path, 'open-sans.ttf') - if os.path.exists(open_sans_path): - open_sans_id = QFontDatabase.addApplicationFont(open_sans_path) - if open_sans_id != -1: - self.open_sans_family = QFontDatabase.applicationFontFamilies(open_sans_id)[0] - - self.setAttribute(Qt.WidgetAttribute.WA_NoSystemBackground) - self.setWindowTitle('HydraVeil') - - screen = QApplication.primaryScreen() - ratio = screen.devicePixelRatio() - self.host_screen_width = int(screen.size().width() * ratio) - self.host_screen_height = int(screen.size().height() * ratio) - - self._data = {"Profile_1": {}} - - self.gui_config_home = os.path.join(Constants.HV_CONFIG_HOME, 'gui') - self.gui_config_file = os.path.join(self.gui_config_home, 'config.json') - self.gui_cache_home = os.path.join(Constants.HV_CACHE_HOME, 'gui') - self.gui_log_file = os.path.join(self.gui_cache_home, 'gui.log') - self._gui_log_handlers = [] - - setup_gui_directory( - self.gui_cache_home, self.gui_config_home, self.gui_config_file) - - self.btn_path = os.getenv('BTN_PATH', os.path.join( - gui_dir, 'resources', 'images')) - self.css_path = os.getenv('CSS_PATH', os.path.join( - gui_dir, 'resources', 'styles')) - - self.is_downloading = False - self.current_profile_id = None - self.connection_manager = ConnectionManager() - - current_connection = None - self.is_tor_mode = current_connection == 'tor' - - self.is_animating = False - self.animation_step = 0 - self.page_history = [] - self.log_path = None - - self.setFixedSize(800, 570) - - top = create_top_section(self, self.css_path) - self.__dict__.update(top) - - self.marquee_text = "" - self.marquee_position = 0 - self.marquee_enabled = False - self.scroll_speed = 1 - - self.page_stack = QStackedWidget(self) - self.page_stack.setGeometry(0, 0, 800, 570) - - bottom = create_bottom_section( - self, - self.btn_path, - Constants.HV_CLIENT_VERSION_NUMBER, - should_be_synchronized(), - ) - self.__dict__.update(bottom) - self.status_label = bottom['status_label'] - self.tor_text = bottom['tor_text'] - self.toggle_button = bottom['toggle_button'] - self.tor_icon = bottom['tor_icon'] - self.sync_button = bottom['sync_button'] - self.page_stack.raise_() - - self.observers = setup_observers(self.update_status) - - self.update_image() - self.set_scroll_speed(7) - - self.toggle_button.clicked.connect(self.toggle_mode) - self.sync_button.clicked.connect(self.sync) - QApplication.instance().installEventFilter(self) - - self.create_toggle(self.is_tor_mode) - self.animation_timer = QTimer() - self.animation_timer.timeout.connect(self.animate_toggle) - self.check_logging() - - self.navigator = Navigator(self.page_stack, self) - - self._preload_queue = [] - self._preload_total = 0 - self._preload_done = 0 - self._preload_failed = 0 - self._preload_skipped_runtime = 0 - self._preload_dispatched = 0 - self._preload_inflight = 0 - self._preload_workers = {} - self._preload_summary_emitted = False - self._preload_t_start = 0.0 - self._preload_timer = QTimer(self) - self._preload_timer.setSingleShot(True) - self._preload_timer.timeout.connect(self._preload_next) - - self.page_stack.currentChanged.connect(self.page_changed) - - self.show() - self.init_ui() - - self.tray = None - self.tray = init_system_tray(self, self.btn_path) - - current_connection = self.get_current_connection() - self.is_tor_mode = current_connection == 'tor' - self.set_toggle_state(self.is_tor_mode) - - core_logger.info( - f"HydraVeil application opened (client version {Constants.HV_CLIENT_VERSION_NUMBER}, " - f"connection={current_connection}, tor_mode={self.is_tor_mode})" - ) - - def init_ui(self): - if self.check_first_launch(): - self.navigator.navigate("welcome") - else: - self.navigator.navigate("menu") - self._start_background_preload() - - def _start_background_preload(self): - import time - self._preload_queue = [n for n in PRELOAD_ORDER if n not in PRELOAD_SKIP] - fast_mode = is_fast_registration_enabled(self.gui_config_file) - skipped_regular = [] - if fast_mode: - skipped_regular = [n for n in self._preload_queue if n in REGULAR_FLOW_ONLY] - self._preload_queue = [n for n in self._preload_queue if n not in REGULAR_FLOW_ONLY] - self._preload_total = len(self._preload_queue) - self._preload_done = 0 - self._preload_failed = 0 - self._preload_skipped_runtime = 0 - self._preload_dispatched = 0 - self._preload_inflight = 0 - self._preload_summary_emitted = False - self._preload_t_start = time.perf_counter() - print(f"[preload] ============================================================", flush=True) - print(f"[preload] starting background preload: {self._preload_total} pages queued", flush=True) - print(f"[preload] order : {', '.join(self._preload_queue)}", flush=True) - print(f"[preload] skipped : {', '.join(sorted(PRELOAD_SKIP))} (unsafe init)", flush=True) - if fast_mode: - print(f"[preload] fastmode: ON -> skipping regular-flow pages: " - f"{', '.join(skipped_regular) if skipped_regular else '(none)'}", flush=True) - print(f"[preload] gap : 250 ms between loads, 700 ms initial delay", flush=True) - print(f"[preload] ============================================================", flush=True) - self._preload_timer.start(700) - - def _preload_next(self): - while self._preload_queue and self._preload_queue[0] in self.navigator._instances: - skipped = self._preload_queue.pop(0) - self._preload_skipped_runtime += 1 - print(f"[preload] {skipped:30s} SKIP (user navigated here first)", flush=True) - if not self._preload_queue: - self._maybe_emit_preload_summary() - return - self._preload_dispatched += 1 - idx = self._preload_dispatched - name = self._preload_queue.pop(0) - print(f"[preload] ---- {idx}/{self._preload_total} ----", flush=True) - if name in ASYNC_PREPARE: - self._dispatch_async_preload(name) - else: - ok = self.navigator.preload(name) - if ok: - self._preload_done += 1 - else: - self._preload_failed += 1 - if self._preload_queue: - self._preload_timer.start(250) - else: - self._maybe_emit_preload_summary() - - def _dispatch_async_preload(self, name): - module_path, class_name = PAGE_REGISTRY[name] - print(f"[preload] {name:30s} ASYNC {module_path}.{class_name}", flush=True) - worker = PageDataWorker(name, module_path, class_name, self) - worker.data_ready.connect(self._on_preload_data_ready) - worker.failed.connect(self._on_preload_failed) - worker.finished.connect(lambda n=name: self._cleanup_preload_worker(n)) - self._preload_workers[name] = worker - self._preload_inflight += 1 - worker.start() - - def _on_preload_data_ready(self, name, payload): - self._preload_inflight -= 1 - if name in self.navigator._instances: - print(f"[preload] {name:30s} SKIP (already cached, async result dropped)", flush=True) - else: - try: - page = self.navigator._instantiate(name, prepared=payload) - self.navigator.register_instance(name, page) - self._preload_done += 1 - print(f"[preload] {name:30s} OK (async, cached={len(self.navigator._instances)}, " - f"stack_size={self.page_stack.count()})", flush=True) - except Exception as e: - self._preload_failed += 1 - print(f"[preload] {name:30s} FAIL (async build) {type(e).__name__}: {e}", flush=True) - self._maybe_emit_preload_summary() - - def _on_preload_failed(self, name, error): - self._preload_inflight -= 1 - print(f"[preload] {name:30s} FAIL (async prepare) {error}", flush=True) - if name not in self.navigator._instances: - ok = self.navigator.preload(name) - if ok: - self._preload_done += 1 - else: - self._preload_failed += 1 - self._maybe_emit_preload_summary() - - def _cleanup_preload_worker(self, name): - worker = self._preload_workers.pop(name, None) - if worker is not None: - worker.deleteLater() - - def _maybe_emit_preload_summary(self): - if self._preload_summary_emitted: - return - if not self._preload_queue and self._preload_inflight == 0: - self._preload_summary_emitted = True - self._emit_preload_summary() - - def _emit_preload_summary(self): - import time - total_s = time.perf_counter() - self._preload_t_start - print(f"[preload] ============================================================", flush=True) - print(f"[preload] complete: {self._preload_done} loaded, " - f"{self._preload_failed} failed, " - f"{self._preload_skipped_runtime} skipped (user beat us to it) " - f"in {total_s:.2f}s", flush=True) - print(f"[preload] cached pages now: {sorted(self.navigator._instances.keys())}", flush=True) - print(f"[preload] ============================================================", flush=True) - - def perform_update_check(self): - from core.controllers.ClientController import ClientController - update_available = ClientController.can_be_updated() - - if update_available: - menu_page = self.navigator.get_cached("menu") - if menu_page is not None: - menu_page.on_update_check_finished() - - def update_values(self, available_locations, available_browsers, status, is_tor, locations, all_browsers): - if not status: - self.update_status('Sync failed. Please try again later.') - return - - from PyQt6.QtWidgets import QPushButton - from gui.v2.actions.sync import generate_grid_positions - - self.connection_manager.set_synced(True) - self.connection_manager.store_locations(locations) - self.connection_manager.store_browsers(available_browsers) - - location_positions = generate_grid_positions(len(available_locations)) - browser_positions = generate_grid_positions(len(available_browsers)) - available_locations_list = [ - (QPushButton, loc, location_positions[i]) - for i, loc in enumerate(available_locations) - ] - available_browsers_list = [ - (QPushButton, brw, browser_positions[i]) - for i, brw in enumerate(available_browsers) - ] - - browser_page = self.navigator.get_cached("browser") - if browser_page is not None: - browser_page.create_interface_elements(available_browsers_list) - - location_page = self.navigator.get_cached("location") - if location_page is not None: - location_page.create_interface_elements(available_locations_list) - - hidetor_page = self.navigator.get_cached("hidetor") - if hidetor_page is not None: - hidetor_page.create_interface_elements(available_locations_list) - - protocol_page = self.navigator.get_cached("protocol") - if protocol_page is not None: - protocol_page.enable_protocol_buttons() - - menu_page = self.navigator.get_cached("menu") - if menu_page is not None: - if hasattr(menu_page, 'refresh_profiles_data'): - menu_page.refresh_profiles_data() - if hasattr(menu_page, 'buttons'): - for button in menu_page.buttons: - parent = button.parent() - if parent: - verification_icons = parent.findChildren(QPushButton) - for icon in verification_icons: - if icon.geometry().width() == 20 and icon.geometry().height() == 20: - icon.setEnabled(True) - - def sync(self): - core_logger.info("User clicked sync button") - if ConfigurationController.get_connection() == 'tor': - self.worker_thread = WorkerThread('SYNC_TOR') - else: - self.worker_thread = WorkerThread('SYNC') - self.sync_button.setIcon(QtGui.QIcon( - os.path.join(self.btn_path, 'icon_sync.png'))) - self.worker_thread.finished.connect( - lambda: self.perform_update_check()) - self.worker_thread.sync_output.connect(self.update_values) - self.worker_thread.start() - - def _handle_exception(self, identifier, message, trace): - if issubclass(identifier, UnknownConnectionTypeError): - self.setup_popup() - os.execv(sys.executable, [sys.executable] + sys.argv) - else: - config = self._load_gui_config() - if config and config["logging"]["gui_logging_enabled"] == True: - logging.error( - f"Uncaught exception:\n" - f"Type: {identifier.__name__}\n" - f"Value: {str(message)}\n" - f"Traceback:\n{''.join(traceback.format_tb(trace))}" - ) - sys.__excepthook__(identifier, message, trace) - - def get_current_connection(self): - return ConfigurationController.get_connection() - - def setup_popup(self): - connection_dialog = QDialog(self) - connection_dialog.setWindowTitle("Connection Type") - connection_dialog.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.WindowStaysOnTopHint | - Qt.WindowType.CustomizeWindowHint | Qt.WindowType.WindowTitleHint) - connection_dialog.setModal(True) - connection_dialog.setFixedSize(400, 200) - - layout = QVBoxLayout(connection_dialog) - - label = QLabel( - "Please set your preference for connecting to Simplified Privacy's billing server.") - label.setWordWrap(True) - label.setAlignment(Qt.AlignmentFlag.AlignCenter) - label.setStyleSheet( - "font-size: 12px; margin-bottom: 20px; color: black;") - - button_layout = QHBoxLayout() - - regular_btn = QPushButton("Regular") - tor_btn = QPushButton("Tor") - - button_style = """ - QPushButton { - background-color: #2b2b2b; - color: white; - border: none; - padding: 10px 20px; - border-radius: 5px; - font-size: 12px; - } - QPushButton:hover { - background-color: #3b3b3b; - } - QPushButton:pressed { - background-color: #1b1b1b; - } - """ - regular_btn.setStyleSheet(button_style) - tor_btn.setStyleSheet(button_style) - - button_layout.addWidget(regular_btn) - button_layout.addWidget(tor_btn) - - layout.addWidget(label) - layout.addLayout(button_layout) - - def set_regular_connection(): - ConfigurationController.set_connection('system') - self.is_tor_mode = False - self.set_toggle_state(False) - core_logger.info("User selected 'Regular' connection type from popup") - connection_dialog.accept() - - def set_tor_connection(): - ConfigurationController.set_connection('tor') - self.is_tor_mode = True - self.set_toggle_state(True) - core_logger.info("User selected 'Tor' connection type from popup") - connection_dialog.accept() - - regular_btn.clicked.connect(set_regular_connection) - tor_btn.clicked.connect(set_tor_connection) - - connection_dialog.exec() - - return ConfigurationController.get_connection() - - def _setup_gui_directory(self): - setup_gui_directory( - self.gui_cache_home, self.gui_config_home, self.gui_config_file) - - def _load_gui_config(self): - return load_gui_config(self.gui_config_file) - - def _save_gui_config(self, config): - save_gui_config(self.gui_config_file, config) - - def _default_gui_config(self): - return default_gui_config() - - def save_ticket_verification_failure(self, result): - return save_ticket_verification_failure(self.gui_config_file, result) - - def get_ticket_verification_failure(self): - return get_ticket_verification_failure(self.gui_config_file) - - def clear_ticket_verification_failure(self): - clear_ticket_verification_failure(self.gui_config_file) - - def check_logging(self): - config = self._load_gui_config() - if is_logging_enabled(config): - self._setup_gui_logging() - - def has_shown_systemwide_prompt(self): - return has_shown_systemwide_prompt(self.gui_config_file) - - def mark_systemwide_prompt_shown(self): - mark_systemwide_prompt_shown(self.gui_config_file) - - def stop_gui_logging(self): - teardown_gui_logging(self.gui_log_file, self._gui_log_handlers) - self._gui_log_handlers = [] - - config = self._load_gui_config() - if config: - config["logging"]["gui_logging_enabled"] = False - self._save_gui_config(config) - - def _setup_gui_logging(self): - self._gui_log_handlers = setup_gui_logging( - self.gui_cache_home, self.gui_log_file, self._gui_log_handlers) - - def set_tor_icon(self): - if self.is_tor_mode: - icon_path = os.path.join(self.btn_path, "toricon_mini.png") - else: - icon_path = os.path.join(self.btn_path, "toricon_mini_false.png") - pixmap = QPixmap(icon_path) - self.tor_icon.setPixmap(pixmap) - self.tor_icon.setScaledContents(True) - - def create_toggle(self, is_tor_mode): - handle_x = 30 if is_tor_mode else 3 - colors = self._get_toggle_colors(is_tor_mode) - - if not hasattr(self, 'animation_timer'): - self.animation_timer = QTimer() - self.animation_timer.timeout.connect(self.animate_toggle) - - self.toggle_bg = QLabel(self.toggle_button) - self.toggle_bg.setGeometry(0, 0, 50, 22) - - self.toggle_handle = QLabel(self.toggle_button) - self.toggle_handle.setGeometry(handle_x, 3, 16, 16) - self.toggle_handle.setStyleSheet(""" - QLabel { - background-color: white; - border-radius: 8px; - } - """) - - self.on_label = QLabel("ON", self.toggle_button) - self.on_label.setGeometry(8, 4, 15, 14) - - self.off_label = QLabel("OFF", self.toggle_button) - self.off_label.setGeometry(25, 4, 40, 14) - - for widget in (self.toggle_bg, self.toggle_handle, self.on_label, self.off_label): - widget.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents) - - self._update_toggle_colors(colors) - if is_tor_mode: - self.set_tor_icon() - - self.raise_bottom_controls() - - def _get_toggle_colors(self, is_tor_mode): - return { - 'bg': "#00a8ff" if is_tor_mode else "#2c3e50", - 'tor': "white" if is_tor_mode else "transparent", - 'sys': "transparent" if is_tor_mode else "white" - } - - def _update_toggle_colors(self, colors): - self.toggle_bg.setStyleSheet(f""" - QLabel {{ - background-color: {colors['bg']}; - border-radius: 11px; - }} - """) - - self.on_label.setStyleSheet(f""" - QLabel {{ - color: {colors['tor']}; - font-size: 9px; - font-weight: bold; - }} - """) - - self.off_label.setStyleSheet(f""" - QLabel {{ - color: {colors['sys']}; - font-size: 9px; - font-weight: bold; - }} - """) - - def set_toggle_state(self, is_tor_mode): - self.is_tor_mode = is_tor_mode - self.toggle_button.setChecked(is_tor_mode) - handle_x = 30 if is_tor_mode else 3 - self.toggle_handle.setGeometry(handle_x, 3, 16, 16) - self._update_toggle_colors(self._get_toggle_colors(is_tor_mode)) - self.set_tor_icon() - - def toggle_mode(self, *_args): - if self.is_animating and not self.animation_timer.isActive(): - self.is_animating = False - - if not self.is_animating: - target_is_tor = not self.is_tor_mode - new_connection = 'tor' if target_is_tor else 'system' - core_logger.info(f"Tor toggle clicked; switching connection mode to '{new_connection}'") - self.update_status(f"Tor toggle clicked: switching to {new_connection}") - - try: - ConfigurationController.set_connection(new_connection) - except Exception as error: - core_logger.exception( - f"Tor toggle failed while switching connection mode to '{new_connection}'") - self.update_status(f"Tor toggle failed: {error}") - self.set_toggle_state(self.is_tor_mode) - return - - self.set_toggle_state(target_is_tor) - self.is_animating = True - self.animation_step = 0 - self.animation_timer.start(16) - return - - core_logger.info("Tor toggle clicked while animation was still running") - self.update_status("Tor toggle clicked: still switching") - - def raise_bottom_controls(self): - for widget in (self.status_label, self.tor_text, self.toggle_button, self.tor_icon, self.sync_button): - widget.raise_() - - def eventFilter(self, source, event): - if event.type() == QEvent.Type.MouseButtonPress and event.button() == Qt.MouseButton.LeftButton: - if self._is_toggle_event(event): - self.toggle_mode() - return True - return super().eventFilter(source, event) - - def _is_toggle_event(self, event): - if not hasattr(self, 'toggle_button') or not self.toggle_button.isVisible(): - return False - if hasattr(event, 'globalPosition'): - global_pos = event.globalPosition().toPoint() - else: - global_pos = event.globalPos() - for widget in (self.tor_text, self.toggle_button, self.tor_icon): - if widget.isVisible() and widget.rect().contains(widget.mapFromGlobal(global_pos)): - return True - return False - - def animate_toggle(self): - TOTAL_STEPS = 5 - if self.animation_step <= TOTAL_STEPS: - progress = self.animation_step / TOTAL_STEPS - - if self.is_tor_mode: - new_x = 20 + (10 * progress) - start_color = "#2c3e50" - end_color = "#00a8ff" - else: - new_x = 31 - (28 * progress) - start_color = "#00a8ff" - end_color = "#2c3e50" - - self.toggle_handle.setGeometry(int(new_x), 3, 16, 16) - - bg_color = self.interpolate_color(start_color, end_color, progress) - self.toggle_bg.setStyleSheet(f""" - QLabel {{ - background-color: {bg_color}; - border-radius: 11px; - }} - """) - - self.off_label.setStyleSheet(f""" - QLabel {{ - color: rgba(255, 255, 255, {1 - progress if self.is_tor_mode else progress}); - font-size: 9px; - font-weight: bold; - }} - """) - self.on_label.setStyleSheet(f""" - QLabel {{ - color: rgba(255, 255, 255, {progress if self.is_tor_mode else 1 - progress}); - font-size: 9px; - font-weight: bold; - }} - """) - - self.animation_step += 1 - else: - self.animation_timer.stop() - self.is_animating = False - self.set_tor_icon() - - def interpolate_color(self, start_color, end_color, progress): - def hex_to_rgb(hex_color): - hex_color = hex_color.lstrip('#') - return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) - - start_rgb = hex_to_rgb(start_color) - end_rgb = hex_to_rgb(end_color) - - current_rgb = tuple( - int(start_rgb[i] + (end_rgb[i] - start_rgb[i]) * progress) - for i in range(3) - ) - - return f"rgb{current_rgb}" - - def closeEvent(self, event=None): - core_logger.info("HydraVeil application closing") - - # Close SQLAlchemy, - orm.stop() - - connected_profiles = self.connection_manager.get_connected_profiles() - if connected_profiles: - self.update_status('Profiles are still connected (disconnect flow lands in Chunk 9).') - if event is not None: - event.accept() - else: - if event is not None: - event.accept() - - def update_image(self, appearance_value="original"): - image_path = os.path.join(self.btn_path, f"{appearance_value}.png") - pixmap = QPixmap(image_path) - self.label.setPixmap(pixmap) - self.label.setScaledContents(True) - - def read_data(self): - return read_profile_data(self._data) - - def write_data(self, data): - write_profile_data(self._data, data) - - def clear_data(self): - self._data = clear_profile_data() - - def _set_status_font_size(self, font_size): - self.status_label.setStyleSheet( - f"color: rgb(0, 255, 255); font-size: {font_size}px;") - - def update_status(self, text, clear=False): - if text is None: - self._set_status_font_size(16) - self.status_label.setText('Status:') - self.disable_marquee() - return - - if clear: - self._set_status_font_size(16) - self.status_label.setText('') - return - - full_text = 'Status: ' + text - metrics = self.status_label.fontMetrics() - available_width = self.status_label.width() - 10 - - if metrics.horizontalAdvance(full_text) > available_width: - self.enable_marquee(text) - else: - self.status_label.setText(full_text) - self.disable_marquee() - - def check_first_launch(self): - return check_first_launch(self.gui_config_file) - - def has_shown_fast_mode_prompt(self): - return has_shown_fast_mode_prompt(self.gui_config_file) - - def mark_fast_mode_prompt_shown(self): - mark_fast_mode_prompt_shown(self.gui_config_file) - - def set_fast_mode_enabled(self, enabled): - set_fast_mode_enabled(self.gui_config_file, enabled) - - def navigate_after_profile_created(self): - if not self.has_shown_fast_mode_prompt(): - self.navigator.navigate("fast_mode_prompt") - else: - menu_page = self.navigator.get_cached("menu") - if menu_page is not None and hasattr(menu_page, 'refresh_menu_buttons'): - menu_page.refresh_menu_buttons() - self.navigator.navigate("menu") - - def enable_marquee(self, text): - self.marquee_text = text + " " - self.marquee_position = 0 - self.marquee_enabled = True - self.marquee_timer.start(500) - - def disable_marquee(self): - self.marquee_enabled = False - self.marquee_timer.stop() - - def update_marquee(self): - if not self.marquee_enabled: - return - - metrics = self.status_label.fontMetrics() - text_width = metrics.horizontalAdvance(self.marquee_text) - - self.marquee_position += self.scroll_speed - if self.marquee_position >= text_width: - self.marquee_position = 0 - - looped_text = self.marquee_text * 2 - - chars_that_fit = metrics.horizontalAdvance( - looped_text[:self.text_width]) - - visible_text = looped_text[self.marquee_position: - self.marquee_position + chars_that_fit] - - while metrics.horizontalAdvance(visible_text) < self.text_width: - visible_text += self.marquee_text - - while metrics.horizontalAdvance(visible_text) > self.text_width: - visible_text = visible_text[:-1] - - display_text = 'Status: ' + ' ' * \ - (self.text_start_x - metrics.horizontalAdvance('Status: ')) - display_text += visible_text - - self.status_label.setText(display_text) - - def set_scroll_speed(self, speed): - self.scroll_speed = speed - - def page_changed(self, index): - if should_be_synchronized(): - sync_icon = QtGui.QIcon(os.path.join( - self.btn_path, 'icon_sync_urgent.png')) - else: - sync_icon = QtGui.QIcon(os.path.join( - self.btn_path, 'icon_sync.png')) - - self.sync_button.setIcon(sync_icon) - - current_page = self.page_stack.widget(index) - if self.page_history: - previous_page = self.page_history[-1] - else: - previous_page = None - - current_name = getattr(current_page, 'name', None) - previous_name = getattr(previous_page, 'name', None) - - if current_name == 'Menu': - self.update_status(None) - if previous_name in ('Resume', 'Editor', 'Settings', 'FastRegistration', 'FastModePrompt'): - if hasattr(current_page, 'eliminacion'): - current_page.eliminacion() - elif current_name == 'Resume': - self.update_status('Choose a name for your profile') - if hasattr(current_page, 'eliminacion'): - current_page.eliminacion() - elif current_name == 'Id': - pass - elif current_name == 'Location': - self.update_status(None, clear=True) - elif current_name == 'InstallPackage': - self.update_status('Please check package availability.') - else: - self.update_status(None) - - if current_name == 'Menu': - self.sync_button.setVisible(True) - self.tor_text.setVisible(True) - self.toggle_button.setGeometry(670, 541, 50, 22) - self.tor_icon.setGeometry(730, 537, 25, 25) - else: - self.sync_button.setVisible(False) - self.tor_text.setVisible(False) - self.toggle_button.setGeometry(540, 541, 50, 22) - self.tor_icon.setGeometry(600, 537, 25, 25) - - self.raise_bottom_controls() - - if current_page != previous_page: - self.page_history.append(current_page) - - -if __name__ == "__main__": - app = QApplication(sys.argv) - window = CustomWindow() - sys.exit(app.exec()) +# ============================================================================ +# INITIALIZE DATABASE +# ============================================================================ + +# (engine, Session, _session all initialized from session_management) +init_session() +session = get_session() +create_ONLY_db_version_table() + +# ============================================================================ +# COMPARE DB VERISONS +# ============================================================================ + +compatability_dict = check_database_compatibility(session) +reason = compatability_dict.get("reason", "error") +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 is_compatable: + made_tables = create_ALL_tables() + + # SCREEN FAILED TABLES + if not made_tables: + logger.error(f"[DB MANAGEMENT] Critical Failure with starting the models of the database.") + close_session() + sys.exit() + + # ASSUME TABLES CREATED + logger.info("[DB MANAGEMENT] Tables successfully made or initialized if pre-existing") + + # original reason was he had no database, now tag it that he's got the new version, + if reason == "no_database": + logger.info(f"[DB MANAGEMENT] User had no database to begin with, so after we created the database, we are about to insert the new version in the DB metadata table for next time.") + + # 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.") + + logger.info(f"[DB MANAGEMENT] Launcher is now passing it off to launch the main GUI window...") + from main_ui import start_ui + force_sync = False + start_ui(force_sync) + +# ============================================================================ +# but IF the versions do NOT match +# ============================================================================ +else: + 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") + + # WHAT IS THE REASON? + custom_error = get_custom_message(reason, compatability_dict) + + # shut down db connection + close_session() + +# ============================================================================ +# THEN DISPLAY CHOICES AND RECOVERY UI +# ============================================================================ + from v2.ui.popups.Database_version import show_recovery_dialog + choice = show_recovery_dialog({"message": custom_error, "db_version": db_version_you_have, "status": "version_mismatch"}) + logger.info(f"[DB MANAGEMENT] From the database error options, the user picked {choice}") + + system_path = get_path() + database_path = system_path / "storage.db" + + if choice == 1: + if os.path.exists(database_path): + os.remove(database_path) + logger.info(f"[DB MANAGEMENT] Deleted the DB file at {database_path}") + logger.info(f"[DB MANAGEMENT] Closing the app gracefully, for them to reboot..") + sys.exit() + + elif choice == 2: + logger.info(f"[DB MANAGEMENT] User opted to move the DB file.") + from v2.ui.popups.pick_folder_to_move import launch_file_picker + launch_file_picker(database_path) + sys.exit() + + else: + logger.error(f"[DB MANAGEMENT] User opted to close the app WITHOUT wiping the database, even though they need to.") + sys.exit() diff --git a/gui/__pycache__/main_ui.cpython-312.pyc b/gui/__pycache__/main_ui.cpython-312.pyc new file mode 100644 index 0000000..273b5de Binary files /dev/null and b/gui/__pycache__/main_ui.cpython-312.pyc differ diff --git a/gui/main_ui.py b/gui/main_ui.py new file mode 100755 index 0000000..9853dde --- /dev/null +++ b/gui/main_ui.py @@ -0,0 +1,912 @@ +import os +import sys + +_WORKSPACE_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if _WORKSPACE_ROOT not in sys.path: + sys.path.insert(0, _WORKSPACE_ROOT) + +import logging +import traceback + +from PyQt6.QtWidgets import ( + QApplication, QMainWindow, QStackedWidget, QLabel, QPushButton, + QDialog, QVBoxLayout, QHBoxLayout +) +from PyQt6.QtGui import QPixmap, QFont, QFontDatabase +from PyQt6 import QtGui +from PyQt6.QtCore import Qt, QTimer, QEvent + +from core.Constants import Constants +from core.controllers.ConfigurationController import ConfigurationController +from core.Errors import UnknownConnectionTypeError +from core.errors.logger import logger as core_logger + +core_logger.propagate = False + +from gui.v2.infrastructure import orm +from gui.v2.infrastructure.navigator import Navigator +from gui.v2.infrastructure.setup_observers import setup_observers +from gui.v2.infrastructure.connection_manager import ConnectionManager +from gui.v2.infrastructure.page_registry import ( + PRELOAD_ORDER, + PRELOAD_SKIP, + ASYNC_PREPARE, + REGULAR_FLOW_ONLY, + PAGE_REGISTRY, +) +from gui.v2.workers.worker_thread import WorkerThread +from gui.v2.workers.page_data_worker import PageDataWorker +from gui.v2.ui.builders.bottom_section import create_bottom_section +from gui.v2.ui.builders.top_section import create_top_section +from gui.v2.ui.builders.system_tray import init_system_tray +from gui.v2.actions.config import ( + setup_gui_directory, + load_gui_config, + save_gui_config, + default_gui_config, +) +from gui.v2.actions.logging_setup import ( + is_logging_enabled, + setup_gui_logging, + teardown_gui_logging, +) +from gui.v2.actions.flags import ( + has_shown_systemwide_prompt, + mark_systemwide_prompt_shown, + has_shown_fast_mode_prompt, + mark_fast_mode_prompt_shown, + set_fast_mode_enabled, + is_fast_registration_enabled, + check_first_launch, +) +from gui.v2.actions.profile_data import ( + read_profile_data, + write_profile_data, + clear_profile_data, +) +from gui.v2.actions.ticket_failure import ( + save_ticket_verification_failure, + get_ticket_verification_failure, + clear_ticket_verification_failure, +) +from gui.v2.actions.should_be_synchronized import should_be_synchronized + + +class CustomWindow(QMainWindow): + def __init__(self, force_sync): + super().__init__() + self.force_sync = force_sync # passed in from loader + + sys.excepthook = self._handle_exception + self.setWindowFlags(Qt.WindowType.Window) + + gui_dir = os.path.dirname(os.path.abspath(__file__)) + font_path = os.path.join(gui_dir, 'resources', 'fonts') + + retro_gaming_path = os.path.join(font_path, 'retro-gaming.ttf') + font_id = QFontDatabase.addApplicationFont(retro_gaming_path) + font_family = QFontDatabase.applicationFontFamilies(font_id)[0] + app_font = QFont(font_family) + app_font.setPointSize(10) + QApplication.setFont(app_font) + + self.open_sans_family = None + open_sans_path = os.path.join(font_path, 'open-sans.ttf') + if os.path.exists(open_sans_path): + open_sans_id = QFontDatabase.addApplicationFont(open_sans_path) + if open_sans_id != -1: + self.open_sans_family = QFontDatabase.applicationFontFamilies(open_sans_id)[0] + + self.setAttribute(Qt.WidgetAttribute.WA_NoSystemBackground) + self.setWindowTitle('HydraVeil') + + screen = QApplication.primaryScreen() + ratio = screen.devicePixelRatio() + self.host_screen_width = int(screen.size().width() * ratio) + self.host_screen_height = int(screen.size().height() * ratio) + + self._data = {"Profile_1": {}} + + self.gui_config_home = os.path.join(Constants.HV_CONFIG_HOME, 'gui') + self.gui_config_file = os.path.join(self.gui_config_home, 'config.json') + self.gui_cache_home = os.path.join(Constants.HV_CACHE_HOME, 'gui') + self.gui_log_file = os.path.join(self.gui_cache_home, 'gui.log') + self._gui_log_handlers = [] + + setup_gui_directory( + self.gui_cache_home, self.gui_config_home, self.gui_config_file) + + self.btn_path = os.getenv('BTN_PATH', os.path.join( + gui_dir, 'resources', 'images')) + self.css_path = os.getenv('CSS_PATH', os.path.join( + gui_dir, 'resources', 'styles')) + + self.is_downloading = False + self.current_profile_id = None + self.connection_manager = ConnectionManager() + + current_connection = None + self.is_tor_mode = current_connection == 'tor' + + self.is_animating = False + self.animation_step = 0 + self.page_history = [] + self.log_path = None + + self.setFixedSize(800, 570) + + top = create_top_section(self, self.css_path) + self.__dict__.update(top) + + self.marquee_text = "" + self.marquee_position = 0 + self.marquee_enabled = False + self.scroll_speed = 1 + + self.page_stack = QStackedWidget(self) + self.page_stack.setGeometry(0, 0, 800, 570) + + bottom = create_bottom_section( + self, + self.btn_path, + Constants.HV_CLIENT_VERSION_NUMBER, + should_be_synchronized(), + ) + self.__dict__.update(bottom) + self.status_label = bottom['status_label'] + self.tor_text = bottom['tor_text'] + self.toggle_button = bottom['toggle_button'] + self.tor_icon = bottom['tor_icon'] + self.sync_button = bottom['sync_button'] + self.page_stack.raise_() + + self.observers = setup_observers(self.update_status) + + self.update_image() + self.set_scroll_speed(7) + + self.toggle_button.clicked.connect(self.toggle_mode) + self.sync_button.clicked.connect(self.sync) + QApplication.instance().installEventFilter(self) + + self.create_toggle(self.is_tor_mode) + self.animation_timer = QTimer() + self.animation_timer.timeout.connect(self.animate_toggle) + self.check_logging() + + self.navigator = Navigator(self.page_stack, self) + + self._preload_queue = [] + self._preload_total = 0 + self._preload_done = 0 + self._preload_failed = 0 + self._preload_skipped_runtime = 0 + self._preload_dispatched = 0 + self._preload_inflight = 0 + self._preload_workers = {} + self._preload_summary_emitted = False + self._preload_t_start = 0.0 + self._preload_timer = QTimer(self) + self._preload_timer.setSingleShot(True) + self._preload_timer.timeout.connect(self._preload_next) + + self.page_stack.currentChanged.connect(self.page_changed) + + self.show() + self.init_ui() + + self.tray = None + self.tray = init_system_tray(self, self.btn_path) + + current_connection = self.get_current_connection() + self.is_tor_mode = current_connection == 'tor' + self.set_toggle_state(self.is_tor_mode) + + core_logger.info( + f"HydraVeil application opened (client version {Constants.HV_CLIENT_VERSION_NUMBER}, " + f"connection={current_connection}, tor_mode={self.is_tor_mode})" + ) + + if self.force_sync: + self.sync() + + + def init_ui(self): + if self.check_first_launch(): + self.navigator.navigate("welcome") + else: + self.navigator.navigate("menu") + self._start_background_preload() + + def _start_background_preload(self): + import time + self._preload_queue = [n for n in PRELOAD_ORDER if n not in PRELOAD_SKIP] + fast_mode = is_fast_registration_enabled(self.gui_config_file) + skipped_regular = [] + if fast_mode: + skipped_regular = [n for n in self._preload_queue if n in REGULAR_FLOW_ONLY] + self._preload_queue = [n for n in self._preload_queue if n not in REGULAR_FLOW_ONLY] + self._preload_total = len(self._preload_queue) + self._preload_done = 0 + self._preload_failed = 0 + self._preload_skipped_runtime = 0 + self._preload_dispatched = 0 + self._preload_inflight = 0 + self._preload_summary_emitted = False + self._preload_t_start = time.perf_counter() + print(f"[preload] ============================================================", flush=True) + print(f"[preload] starting background preload: {self._preload_total} pages queued", flush=True) + print(f"[preload] order : {', '.join(self._preload_queue)}", flush=True) + print(f"[preload] skipped : {', '.join(sorted(PRELOAD_SKIP))} (unsafe init)", flush=True) + if fast_mode: + print(f"[preload] fastmode: ON -> skipping regular-flow pages: " + f"{', '.join(skipped_regular) if skipped_regular else '(none)'}", flush=True) + print(f"[preload] gap : 250 ms between loads, 700 ms initial delay", flush=True) + print(f"[preload] ============================================================", flush=True) + self._preload_timer.start(700) + + def _preload_next(self): + while self._preload_queue and self._preload_queue[0] in self.navigator._instances: + skipped = self._preload_queue.pop(0) + self._preload_skipped_runtime += 1 + print(f"[preload] {skipped:30s} SKIP (user navigated here first)", flush=True) + if not self._preload_queue: + self._maybe_emit_preload_summary() + return + self._preload_dispatched += 1 + idx = self._preload_dispatched + name = self._preload_queue.pop(0) + print(f"[preload] ---- {idx}/{self._preload_total} ----", flush=True) + if name in ASYNC_PREPARE: + self._dispatch_async_preload(name) + else: + ok = self.navigator.preload(name) + if ok: + self._preload_done += 1 + else: + self._preload_failed += 1 + if self._preload_queue: + self._preload_timer.start(250) + else: + self._maybe_emit_preload_summary() + + def _dispatch_async_preload(self, name): + module_path, class_name = PAGE_REGISTRY[name] + print(f"[preload] {name:30s} ASYNC {module_path}.{class_name}", flush=True) + worker = PageDataWorker(name, module_path, class_name, self) + worker.data_ready.connect(self._on_preload_data_ready) + worker.failed.connect(self._on_preload_failed) + worker.finished.connect(lambda n=name: self._cleanup_preload_worker(n)) + self._preload_workers[name] = worker + self._preload_inflight += 1 + worker.start() + + def _on_preload_data_ready(self, name, payload): + self._preload_inflight -= 1 + if name in self.navigator._instances: + print(f"[preload] {name:30s} SKIP (already cached, async result dropped)", flush=True) + else: + try: + page = self.navigator._instantiate(name, prepared=payload) + self.navigator.register_instance(name, page) + self._preload_done += 1 + print(f"[preload] {name:30s} OK (async, cached={len(self.navigator._instances)}, " + f"stack_size={self.page_stack.count()})", flush=True) + except Exception as e: + self._preload_failed += 1 + print(f"[preload] {name:30s} FAIL (async build) {type(e).__name__}: {e}", flush=True) + self._maybe_emit_preload_summary() + + def _on_preload_failed(self, name, error): + self._preload_inflight -= 1 + print(f"[preload] {name:30s} FAIL (async prepare) {error}", flush=True) + if name not in self.navigator._instances: + ok = self.navigator.preload(name) + if ok: + self._preload_done += 1 + else: + self._preload_failed += 1 + self._maybe_emit_preload_summary() + + def _cleanup_preload_worker(self, name): + worker = self._preload_workers.pop(name, None) + if worker is not None: + worker.deleteLater() + + def _maybe_emit_preload_summary(self): + if self._preload_summary_emitted: + return + if not self._preload_queue and self._preload_inflight == 0: + self._preload_summary_emitted = True + self._emit_preload_summary() + + def _emit_preload_summary(self): + import time + total_s = time.perf_counter() - self._preload_t_start + print(f"[preload] ============================================================", flush=True) + print(f"[preload] complete: {self._preload_done} loaded, " + f"{self._preload_failed} failed, " + f"{self._preload_skipped_runtime} skipped (user beat us to it) " + f"in {total_s:.2f}s", flush=True) + print(f"[preload] cached pages now: {sorted(self.navigator._instances.keys())}", flush=True) + print(f"[preload] ============================================================", flush=True) + + def perform_update_check(self): + from core.controllers.ClientController import ClientController + update_available = ClientController.can_be_updated() + + if update_available: + menu_page = self.navigator.get_cached("menu") + if menu_page is not None: + menu_page.on_update_check_finished() + + def update_values(self, available_locations, available_browsers, status, is_tor, locations, all_browsers): + if not status: + self.update_status('Sync failed. Please try again later.') + return + + from PyQt6.QtWidgets import QPushButton + from gui.v2.actions.sync import generate_grid_positions + + self.connection_manager.set_synced(True) + self.connection_manager.store_locations(locations) + self.connection_manager.store_browsers(available_browsers) + + location_positions = generate_grid_positions(len(available_locations)) + browser_positions = generate_grid_positions(len(available_browsers)) + available_locations_list = [ + (QPushButton, loc, location_positions[i]) + for i, loc in enumerate(available_locations) + ] + available_browsers_list = [ + (QPushButton, brw, browser_positions[i]) + for i, brw in enumerate(available_browsers) + ] + + browser_page = self.navigator.get_cached("browser") + if browser_page is not None: + browser_page.create_interface_elements(available_browsers_list) + + location_page = self.navigator.get_cached("location") + if location_page is not None: + location_page.create_interface_elements(available_locations_list) + + hidetor_page = self.navigator.get_cached("hidetor") + if hidetor_page is not None: + hidetor_page.create_interface_elements(available_locations_list) + + protocol_page = self.navigator.get_cached("protocol") + if protocol_page is not None: + protocol_page.enable_protocol_buttons() + + menu_page = self.navigator.get_cached("menu") + if menu_page is not None: + if hasattr(menu_page, 'refresh_profiles_data'): + menu_page.refresh_profiles_data() + if hasattr(menu_page, 'buttons'): + for button in menu_page.buttons: + parent = button.parent() + if parent: + verification_icons = parent.findChildren(QPushButton) + for icon in verification_icons: + if icon.geometry().width() == 20 and icon.geometry().height() == 20: + icon.setEnabled(True) + + def sync(self): + core_logger.info("User clicked sync button") + if ConfigurationController.get_connection() == 'tor': + self.worker_thread = WorkerThread('SYNC_TOR') + else: + self.worker_thread = WorkerThread('SYNC') + self.sync_button.setIcon(QtGui.QIcon( + os.path.join(self.btn_path, 'icon_sync.png'))) + self.worker_thread.finished.connect( + lambda: self.perform_update_check()) + self.worker_thread.sync_output.connect(self.update_values) + self.worker_thread.start() + + def _handle_exception(self, identifier, message, trace): + if issubclass(identifier, UnknownConnectionTypeError): + self.setup_popup() + os.execv(sys.executable, [sys.executable] + sys.argv) + else: + config = self._load_gui_config() + if config and config["logging"]["gui_logging_enabled"] == True: + logging.error( + f"Uncaught exception:\n" + f"Type: {identifier.__name__}\n" + f"Value: {str(message)}\n" + f"Traceback:\n{''.join(traceback.format_tb(trace))}" + ) + sys.__excepthook__(identifier, message, trace) + + def get_current_connection(self): + return ConfigurationController.get_connection() + + def setup_popup(self): + connection_dialog = QDialog(self) + connection_dialog.setWindowTitle("Connection Type") + connection_dialog.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.WindowStaysOnTopHint | + Qt.WindowType.CustomizeWindowHint | Qt.WindowType.WindowTitleHint) + connection_dialog.setModal(True) + connection_dialog.setFixedSize(400, 200) + + layout = QVBoxLayout(connection_dialog) + + label = QLabel( + "Please set your preference for connecting to Simplified Privacy's billing server.") + label.setWordWrap(True) + label.setAlignment(Qt.AlignmentFlag.AlignCenter) + label.setStyleSheet( + "font-size: 12px; margin-bottom: 20px; color: black;") + + button_layout = QHBoxLayout() + + regular_btn = QPushButton("Regular") + tor_btn = QPushButton("Tor") + + button_style = """ + QPushButton { + background-color: #2b2b2b; + color: white; + border: none; + padding: 10px 20px; + border-radius: 5px; + font-size: 12px; + } + QPushButton:hover { + background-color: #3b3b3b; + } + QPushButton:pressed { + background-color: #1b1b1b; + } + """ + regular_btn.setStyleSheet(button_style) + tor_btn.setStyleSheet(button_style) + + button_layout.addWidget(regular_btn) + button_layout.addWidget(tor_btn) + + layout.addWidget(label) + layout.addLayout(button_layout) + + def set_regular_connection(): + ConfigurationController.set_connection('system') + self.is_tor_mode = False + self.set_toggle_state(False) + core_logger.info("User selected 'Regular' connection type from popup") + connection_dialog.accept() + + def set_tor_connection(): + ConfigurationController.set_connection('tor') + self.is_tor_mode = True + self.set_toggle_state(True) + core_logger.info("User selected 'Tor' connection type from popup") + connection_dialog.accept() + + regular_btn.clicked.connect(set_regular_connection) + tor_btn.clicked.connect(set_tor_connection) + + connection_dialog.exec() + + return ConfigurationController.get_connection() + + def _setup_gui_directory(self): + setup_gui_directory( + self.gui_cache_home, self.gui_config_home, self.gui_config_file) + + def _load_gui_config(self): + return load_gui_config(self.gui_config_file) + + def _save_gui_config(self, config): + save_gui_config(self.gui_config_file, config) + + def _default_gui_config(self): + return default_gui_config() + + def save_ticket_verification_failure(self, result): + return save_ticket_verification_failure(self.gui_config_file, result) + + def get_ticket_verification_failure(self): + return get_ticket_verification_failure(self.gui_config_file) + + def clear_ticket_verification_failure(self): + clear_ticket_verification_failure(self.gui_config_file) + + def check_logging(self): + config = self._load_gui_config() + if is_logging_enabled(config): + self._setup_gui_logging() + + def has_shown_systemwide_prompt(self): + return has_shown_systemwide_prompt(self.gui_config_file) + + def mark_systemwide_prompt_shown(self): + mark_systemwide_prompt_shown(self.gui_config_file) + + def stop_gui_logging(self): + teardown_gui_logging(self.gui_log_file, self._gui_log_handlers) + self._gui_log_handlers = [] + + config = self._load_gui_config() + if config: + config["logging"]["gui_logging_enabled"] = False + self._save_gui_config(config) + + def _setup_gui_logging(self): + self._gui_log_handlers = setup_gui_logging( + self.gui_cache_home, self.gui_log_file, self._gui_log_handlers) + + def set_tor_icon(self): + if self.is_tor_mode: + icon_path = os.path.join(self.btn_path, "toricon_mini.png") + else: + icon_path = os.path.join(self.btn_path, "toricon_mini_false.png") + pixmap = QPixmap(icon_path) + self.tor_icon.setPixmap(pixmap) + self.tor_icon.setScaledContents(True) + + def create_toggle(self, is_tor_mode): + handle_x = 30 if is_tor_mode else 3 + colors = self._get_toggle_colors(is_tor_mode) + + if not hasattr(self, 'animation_timer'): + self.animation_timer = QTimer() + self.animation_timer.timeout.connect(self.animate_toggle) + + self.toggle_bg = QLabel(self.toggle_button) + self.toggle_bg.setGeometry(0, 0, 50, 22) + + self.toggle_handle = QLabel(self.toggle_button) + self.toggle_handle.setGeometry(handle_x, 3, 16, 16) + self.toggle_handle.setStyleSheet(""" + QLabel { + background-color: white; + border-radius: 8px; + } + """) + + self.on_label = QLabel("ON", self.toggle_button) + self.on_label.setGeometry(8, 4, 15, 14) + + self.off_label = QLabel("OFF", self.toggle_button) + self.off_label.setGeometry(25, 4, 40, 14) + + for widget in (self.toggle_bg, self.toggle_handle, self.on_label, self.off_label): + widget.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents) + + self._update_toggle_colors(colors) + if is_tor_mode: + self.set_tor_icon() + + self.raise_bottom_controls() + + def _get_toggle_colors(self, is_tor_mode): + return { + 'bg': "#00a8ff" if is_tor_mode else "#2c3e50", + 'tor': "white" if is_tor_mode else "transparent", + 'sys': "transparent" if is_tor_mode else "white" + } + + def _update_toggle_colors(self, colors): + self.toggle_bg.setStyleSheet(f""" + QLabel {{ + background-color: {colors['bg']}; + border-radius: 11px; + }} + """) + + self.on_label.setStyleSheet(f""" + QLabel {{ + color: {colors['tor']}; + font-size: 9px; + font-weight: bold; + }} + """) + + self.off_label.setStyleSheet(f""" + QLabel {{ + color: {colors['sys']}; + font-size: 9px; + font-weight: bold; + }} + """) + + def set_toggle_state(self, is_tor_mode): + self.is_tor_mode = is_tor_mode + self.toggle_button.setChecked(is_tor_mode) + handle_x = 30 if is_tor_mode else 3 + self.toggle_handle.setGeometry(handle_x, 3, 16, 16) + self._update_toggle_colors(self._get_toggle_colors(is_tor_mode)) + self.set_tor_icon() + + def toggle_mode(self, *_args): + if self.is_animating and not self.animation_timer.isActive(): + self.is_animating = False + + if not self.is_animating: + target_is_tor = not self.is_tor_mode + new_connection = 'tor' if target_is_tor else 'system' + core_logger.info(f"Tor toggle clicked; switching connection mode to '{new_connection}'") + self.update_status(f"Tor toggle clicked: switching to {new_connection}") + + try: + ConfigurationController.set_connection(new_connection) + except Exception as error: + core_logger.exception( + f"Tor toggle failed while switching connection mode to '{new_connection}'") + self.update_status(f"Tor toggle failed: {error}") + self.set_toggle_state(self.is_tor_mode) + return + + self.set_toggle_state(target_is_tor) + self.is_animating = True + self.animation_step = 0 + self.animation_timer.start(16) + return + + core_logger.info("Tor toggle clicked while animation was still running") + self.update_status("Tor toggle clicked: still switching") + + def raise_bottom_controls(self): + for widget in (self.status_label, self.tor_text, self.toggle_button, self.tor_icon, self.sync_button): + widget.raise_() + + def eventFilter(self, source, event): + if event.type() == QEvent.Type.MouseButtonPress and event.button() == Qt.MouseButton.LeftButton: + if self._is_toggle_event(event): + self.toggle_mode() + return True + return super().eventFilter(source, event) + + def _is_toggle_event(self, event): + if not hasattr(self, 'toggle_button') or not self.toggle_button.isVisible(): + return False + if hasattr(event, 'globalPosition'): + global_pos = event.globalPosition().toPoint() + else: + global_pos = event.globalPos() + for widget in (self.tor_text, self.toggle_button, self.tor_icon): + if widget.isVisible() and widget.rect().contains(widget.mapFromGlobal(global_pos)): + return True + return False + + def animate_toggle(self): + TOTAL_STEPS = 5 + if self.animation_step <= TOTAL_STEPS: + progress = self.animation_step / TOTAL_STEPS + + if self.is_tor_mode: + new_x = 20 + (10 * progress) + start_color = "#2c3e50" + end_color = "#00a8ff" + else: + new_x = 31 - (28 * progress) + start_color = "#00a8ff" + end_color = "#2c3e50" + + self.toggle_handle.setGeometry(int(new_x), 3, 16, 16) + + bg_color = self.interpolate_color(start_color, end_color, progress) + self.toggle_bg.setStyleSheet(f""" + QLabel {{ + background-color: {bg_color}; + border-radius: 11px; + }} + """) + + self.off_label.setStyleSheet(f""" + QLabel {{ + color: rgba(255, 255, 255, {1 - progress if self.is_tor_mode else progress}); + font-size: 9px; + font-weight: bold; + }} + """) + self.on_label.setStyleSheet(f""" + QLabel {{ + color: rgba(255, 255, 255, {progress if self.is_tor_mode else 1 - progress}); + font-size: 9px; + font-weight: bold; + }} + """) + + self.animation_step += 1 + else: + self.animation_timer.stop() + self.is_animating = False + self.set_tor_icon() + + def interpolate_color(self, start_color, end_color, progress): + def hex_to_rgb(hex_color): + hex_color = hex_color.lstrip('#') + return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) + + start_rgb = hex_to_rgb(start_color) + end_rgb = hex_to_rgb(end_color) + + current_rgb = tuple( + int(start_rgb[i] + (end_rgb[i] - start_rgb[i]) * progress) + for i in range(3) + ) + + return f"rgb{current_rgb}" + + def closeEvent(self, event=None): + core_logger.info("HydraVeil application closing") + + # Close SQLAlchemy, + orm.stop() + + connected_profiles = self.connection_manager.get_connected_profiles() + if connected_profiles: + self.update_status('Profiles are still connected (disconnect flow lands in Chunk 9).') + if event is not None: + event.accept() + else: + if event is not None: + event.accept() + + def update_image(self, appearance_value="original"): + image_path = os.path.join(self.btn_path, f"{appearance_value}.png") + pixmap = QPixmap(image_path) + self.label.setPixmap(pixmap) + self.label.setScaledContents(True) + + def read_data(self): + return read_profile_data(self._data) + + def write_data(self, data): + write_profile_data(self._data, data) + + def clear_data(self): + self._data = clear_profile_data() + + def _set_status_font_size(self, font_size): + self.status_label.setStyleSheet( + f"color: rgb(0, 255, 255); font-size: {font_size}px;") + + def update_status(self, text, clear=False): + if text is None: + self._set_status_font_size(16) + self.status_label.setText('Status:') + self.disable_marquee() + return + + if clear: + self._set_status_font_size(16) + self.status_label.setText('') + return + + full_text = 'Status: ' + text + metrics = self.status_label.fontMetrics() + available_width = self.status_label.width() - 10 + + if metrics.horizontalAdvance(full_text) > available_width: + self.enable_marquee(text) + else: + self.status_label.setText(full_text) + self.disable_marquee() + + def check_first_launch(self): + return check_first_launch(self.gui_config_file) + + def has_shown_fast_mode_prompt(self): + return has_shown_fast_mode_prompt(self.gui_config_file) + + def mark_fast_mode_prompt_shown(self): + mark_fast_mode_prompt_shown(self.gui_config_file) + + def set_fast_mode_enabled(self, enabled): + set_fast_mode_enabled(self.gui_config_file, enabled) + + def navigate_after_profile_created(self): + if not self.has_shown_fast_mode_prompt(): + self.navigator.navigate("fast_mode_prompt") + else: + menu_page = self.navigator.get_cached("menu") + if menu_page is not None and hasattr(menu_page, 'refresh_menu_buttons'): + menu_page.refresh_menu_buttons() + self.navigator.navigate("menu") + + def enable_marquee(self, text): + self.marquee_text = text + " " + self.marquee_position = 0 + self.marquee_enabled = True + self.marquee_timer.start(500) + + def disable_marquee(self): + self.marquee_enabled = False + self.marquee_timer.stop() + + def update_marquee(self): + if not self.marquee_enabled: + return + + metrics = self.status_label.fontMetrics() + text_width = metrics.horizontalAdvance(self.marquee_text) + + self.marquee_position += self.scroll_speed + if self.marquee_position >= text_width: + self.marquee_position = 0 + + looped_text = self.marquee_text * 2 + + chars_that_fit = metrics.horizontalAdvance( + looped_text[:self.text_width]) + + visible_text = looped_text[self.marquee_position: + self.marquee_position + chars_that_fit] + + while metrics.horizontalAdvance(visible_text) < self.text_width: + visible_text += self.marquee_text + + while metrics.horizontalAdvance(visible_text) > self.text_width: + visible_text = visible_text[:-1] + + display_text = 'Status: ' + ' ' * \ + (self.text_start_x - metrics.horizontalAdvance('Status: ')) + display_text += visible_text + + self.status_label.setText(display_text) + + def set_scroll_speed(self, speed): + self.scroll_speed = speed + + def page_changed(self, index): + if should_be_synchronized(): + sync_icon = QtGui.QIcon(os.path.join( + self.btn_path, 'icon_sync_urgent.png')) + else: + sync_icon = QtGui.QIcon(os.path.join( + self.btn_path, 'icon_sync.png')) + + self.sync_button.setIcon(sync_icon) + + current_page = self.page_stack.widget(index) + if self.page_history: + previous_page = self.page_history[-1] + else: + previous_page = None + + current_name = getattr(current_page, 'name', None) + previous_name = getattr(previous_page, 'name', None) + + if current_name == 'Menu': + self.update_status(None) + if previous_name in ('Resume', 'Editor', 'Settings', 'FastRegistration', 'FastModePrompt'): + if hasattr(current_page, 'eliminacion'): + current_page.eliminacion() + elif current_name == 'Resume': + self.update_status('Choose a name for your profile') + if hasattr(current_page, 'eliminacion'): + current_page.eliminacion() + elif current_name == 'Id': + pass + elif current_name == 'Location': + self.update_status(None, clear=True) + elif current_name == 'InstallPackage': + self.update_status('Please check package availability.') + else: + self.update_status(None) + + if current_name == 'Menu': + self.sync_button.setVisible(True) + self.tor_text.setVisible(True) + self.toggle_button.setGeometry(670, 541, 50, 22) + self.tor_icon.setGeometry(730, 537, 25, 25) + else: + self.sync_button.setVisible(False) + self.tor_text.setVisible(False) + self.toggle_button.setGeometry(540, 541, 50, 22) + self.tor_icon.setGeometry(600, 537, 25, 25) + + self.raise_bottom_controls() + + if current_page != previous_page: + self.page_history.append(current_page) + + +def start_ui(force_sync): + app = QApplication(sys.argv) + window = CustomWindow(force_sync) + sys.exit(app.exec()) diff --git a/gui/v2/ui/popups/Database_version.py b/gui/v2/ui/popups/Database_version.py new file mode 100644 index 0000000..bde74b0 --- /dev/null +++ b/gui/v2/ui/popups/Database_version.py @@ -0,0 +1,279 @@ +from core.Constants import Constants + +from PyQt6.QtWidgets import QApplication, QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QFrame, QScrollArea +from PyQt6.QtCore import Qt, QSize +from PyQt6.QtGui import QIcon, QFont, QColor, QPalette, QPixmap, QPainter +import sys + +APP_VERSION = Constants.DB_VERSION_THIS_APP_WANTS + +class DatabaseConflictDialog(QDialog): + WIPE = 1 + MOVE_DB = 2 + EXIT = 3 + + def __init__(self, check: dict, parent=None): + super().__init__(parent) + self.setWindowTitle("Database Recovery") + self.setModal(True) + self.user_choice = self.EXIT + self.setMinimumWidth(550) + self.setMinimumHeight(400) + self.setMaximumWidth(750) + self.setMaximumHeight(600) + + # Modern dark theme + self.setStyleSheet(""" + DatabaseConflictDialog { + background: qlineargradient(x1:0, y1:0, x2:1, y2:1, + stop:0 #0f172a, stop:1 #1e293b); + } + """) + + main_layout = QVBoxLayout() + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.setSpacing(0) + + # Header with gradient and icon + header = QFrame() + header.setStyleSheet(""" + QFrame { + background: qlineargradient(x1:0, y1:0, x2:1, y2:0, + stop:0 #3b82f6, stop:1 #1e40af); + border: none; + } + """) + header.setFixedHeight(100) + header_layout = QHBoxLayout() + header_layout.setContentsMargins(30, 20, 30, 20) + + # Warning icon (using unicode) + icon_label = QLabel("⚠️") + icon_label.setFont(QFont("Segoe UI", 48)) + header_layout.addWidget(icon_label, 0, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) + + # Title + title_layout = QVBoxLayout() + title_layout.setSpacing(5) + title = QLabel("Database Compatibility") + title.setFont(QFont("Segoe UI", 18, QFont.Weight.Bold)) + title.setStyleSheet("color: white;") + title_layout.addWidget(title) + + subtitle = QLabel("Your App version and Database don't match") + subtitle.setFont(QFont("Segoe UI", 11)) + subtitle.setStyleSheet("color: rgba(255, 255, 255, 0.8);") + title_layout.addWidget(subtitle) + + header_layout.addLayout(title_layout, 1) + header.setLayout(header_layout) + main_layout.addWidget(header) + + # Content area with scroll support + content = QFrame() + content.setStyleSheet(""" + QFrame { + background: #1e293b; + border: none; + } + """) + content_layout = QVBoxLayout() + content_layout.setContentsMargins(0, 0, 0, 0) + content_layout.setSpacing(0) + + # Scrollable area for dynamic content + scroll_area = QScrollArea() + scroll_area.setWidgetResizable(True) + scroll_area.setStyleSheet(""" + QScrollArea { + background: #1e293b; + border: none; + } + QScrollBar:vertical { + background: #1e293b; + width: 12px; + } + QScrollBar::handle:vertical { + background: #475569; + border-radius: 6px; + min-height: 20px; + } + QScrollBar::handle:vertical:hover { + background: #64748b; + } + QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { + border: none; + } + """) + + # Inner scrollable widget + scroll_widget = QFrame() + scroll_widget.setStyleSheet("background: #1e293b; border: none;") + scroll_layout = QVBoxLayout() + scroll_layout.setContentsMargins(40, 30, 40, 30) + scroll_layout.setSpacing(15) + + # Main message + message = QLabel(check["message"]) + message.setFont(QFont("Segoe UI", 12)) + message.setStyleSheet("color: #e2e8f0; line-height: 1.6;") + message.setWordWrap(True) + scroll_layout.addWidget(message) + + # Version info + if check.get("db_version"): + info_frame = QFrame() + info_frame.setStyleSheet(""" + QFrame { + background: rgba(59, 130, 246, 0.1); + border: 1px solid rgba(59, 130, 246, 0.3); + border-radius: 6px; + } + """) + info_layout = QVBoxLayout() + info_layout.setContentsMargins(15, 12, 15, 12) + info_layout.setSpacing(5) + + app_ver = QLabel(f"App is looking for database version: {APP_VERSION}") + app_ver.setFont(QFont("Segoe UI", 10)) + app_ver.setStyleSheet("color: #94a3b8;") + info_layout.addWidget(app_ver) + + db_ver = QLabel(f"Your Actual Database version: {check['db_version']}") + db_ver.setFont(QFont("Segoe UI", 10)) + db_ver.setStyleSheet("color: #94a3b8;") + info_layout.addWidget(db_ver) + + info_frame.setLayout(info_layout) + scroll_layout.addWidget(info_frame) + + scroll_layout.addStretch() + scroll_widget.setLayout(scroll_layout) + scroll_area.setWidget(scroll_widget) + content_layout.addWidget(scroll_area, 1) + + content.setLayout(content_layout) + main_layout.addWidget(content, 1) + + # Button area + button_area = QFrame() + button_area.setStyleSheet(""" + QFrame { + background: #0f172a; + border-top: 1px solid #334155; + } + """) + button_layout = QVBoxLayout() + button_layout.setContentsMargins(40, 20, 40, 20) + button_layout.setSpacing(12) + + if check["status"] == "version_mismatch": + btn_backup = self._create_button( + "Create a Fresh Database. Then restart (recommended)", + "primary", + "✅" + ) + btn_backup.clicked.connect(self.on_wipe_create) + button_layout.addWidget(btn_backup) + + btn_different = self._create_button( + "Move the Stale Database for Debug Purposes", + "secondary", + "📁" + ) + btn_different.clicked.connect(self.on_move_db) + button_layout.addWidget(btn_different) + + btn_exit = self._create_button( + "Exit App without action.", + "danger", + "❌" + ) + btn_exit.clicked.connect(self.on_exit) + button_layout.addWidget(btn_exit) + + button_area.setLayout(button_layout) + main_layout.addWidget(button_area) + + self.setLayout(main_layout) + + def _create_button(self, text, style_type, icon): + """Create a styled button""" + btn = QPushButton(f" {icon} {text}") + btn.setFont(QFont("Segoe UI", 11, QFont.Weight.Medium)) + btn.setFixedHeight(45) + btn.setCursor(Qt.CursorShape.PointingHandCursor) + + if style_type == "primary": + btn.setStyleSheet(""" + QPushButton { + background: qlineargradient(x1:0, y1:0, x2:1, y2:0, + stop:0 #3b82f6, stop:1 #2563eb); + color: white; + border: none; + border-radius: 6px; + font-weight: 600; + } + QPushButton:hover { + background: qlineargradient(x1:0, y1:0, x2:1, y2:0, + stop:0 #2563eb, stop:1 #1d4ed8); + } + QPushButton:pressed { + background: #1d4ed8; + } + """) + elif style_type == "secondary": + btn.setStyleSheet(""" + QPushButton { + background: #334155; + color: #e2e8f0; + border: 1px solid #475569; + border-radius: 6px; + font-weight: 600; + } + QPushButton:hover { + background: #475569; + border: 1px solid #64748b; + } + QPushButton:pressed { + background: #1e293b; + } + """) + elif style_type == "danger": + btn.setStyleSheet(""" + QPushButton { + background: #64748b; + color: #e2e8f0; + border: 1px solid #475569; + border-radius: 6px; + font-weight: 600; + } + QPushButton:hover { + background: #ef4444; + border: 1px solid #dc2626; + } + QPushButton:pressed { + background: #dc2626; + } + """) + + return btn + + def on_wipe_create(self): + self.user_choice = self.WIPE + self.accept() + + def on_move_db(self): + self.user_choice = self.MOVE_DB + self.accept() + + def on_exit(self): + self.user_choice = self.EXIT + self.reject() + +def show_recovery_dialog(check: dict): + """Show recovery dialog as standalone app and return user choice""" + app = QApplication.instance() or QApplication(sys.argv) + dialog = DatabaseConflictDialog(check) + dialog.exec() + return dialog.user_choice diff --git a/gui/v2/ui/popups/pick_folder_to_move.py b/gui/v2/ui/popups/pick_folder_to_move.py new file mode 100644 index 0000000..daff390 --- /dev/null +++ b/gui/v2/ui/popups/pick_folder_to_move.py @@ -0,0 +1,50 @@ +import shutil +import sys +from pathlib import Path +from PyQt6.QtWidgets import QApplication, QFileDialog + +def move_database_file(original_filepath: str) -> bool: + """ + Prompts the user to select a destination for a .db file and moves it there. + + Args: + original_filepath: The current path to the .db file + + Returns: + True if the move was successful, False otherwise + """ + # Create a QApplication if one doesn't already exist + app = QApplication.instance() + if app is None: + app = QApplication(sys.argv) + + # Validate that the original file exists + if not Path(original_filepath).exists(): + print(f"Error: File not found at {original_filepath}") + return False + + # Open the file save dialog + destination, _ = QFileDialog.getSaveFileName( + None, + "Select destination for database file", + str(Path(original_filepath).parent), # Start in the original file's directory + "Database Files (*.db);;All Files (*)" + ) + + # If the user cancelled the dialog + if not destination: + print("Operation cancelled by user") + return False + + try: + # Move the file + shutil.move(original_filepath, destination) + print(f"File successfully moved to: {destination}") + return True + except Exception as e: + print(f"Error moving file: {e}") + return False + + +def launch_file_picker(original_path): + move_database_file(original_path)