From 9f714b9f7b65f3aa8cf4fd9700c1592446db0f2d Mon Sep 17 00:00:00 2001 From: JOhn Date: Sat, 13 Jun 2026 16:19:11 -0400 Subject: [PATCH] update: added v2 gui --- gui/v2/__init__.py | 0 gui/v2/__main__.py | 709 +++++ gui/v2/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 153 bytes gui/v2/__pycache__/__main__.cpython-312.pyc | Bin 0 -> 38572 bytes gui/v2/actions/__init__.py | 0 .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 161 bytes .../__pycache__/config.cpython-312.pyc | Bin 0 -> 1959 bytes .../actions/__pycache__/flags.cpython-312.pyc | Bin 0 -> 2352 bytes .../key_interpretation.cpython-312.pyc | Bin 0 -> 1449 bytes .../__pycache__/locations.cpython-312.pyc | Bin 0 -> 1699 bytes .../__pycache__/logging_setup.cpython-312.pyc | Bin 0 -> 3181 bytes .../__pycache__/profile_data.cpython-312.pyc | Bin 0 -> 623 bytes .../__pycache__/profile_order.cpython-312.pyc | Bin 0 -> 2774 bytes .../should_be_synchronized.cpython-312.pyc | Bin 0 -> 821 bytes .../actions/__pycache__/sync.cpython-312.pyc | Bin 0 -> 731 bytes .../ticket_failure.cpython-312.pyc | Bin 0 -> 2760 bytes gui/v2/actions/config.py | 38 + gui/v2/actions/flags.py | 63 + gui/v2/actions/key_interpretation.py | 30 + gui/v2/actions/locations.py | 34 + gui/v2/actions/logging_setup.py | 66 + gui/v2/actions/profile_data.py | 10 + gui/v2/actions/profile_order.py | 61 + gui/v2/actions/should_be_synchronized.py | 9 + gui/v2/actions/sync.py | 19 + gui/v2/actions/ticket_failure.py | 57 + gui/v2/infrastructure/__init__.py | 0 .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 150 bytes .../connection_manager.cpython-312.pyc | Bin 0 -> 4964 bytes .../__pycache__/navigator.cpython-312.pyc | Bin 0 -> 2128 bytes .../__pycache__/page_registry.cpython-312.pyc | Bin 0 -> 2564 bytes .../setup_observers.cpython-312.pyc | Bin 0 -> 8599 bytes gui/v2/infrastructure/connection_manager.py | 76 + gui/v2/infrastructure/navigator.py | 33 + gui/v2/infrastructure/page_registry.py | 32 + gui/v2/infrastructure/setup_observers.py | 74 + gui/v2/ui/__init__.py | 0 .../ui/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 138 bytes gui/v2/ui/builders/__init__.py | 0 .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 165 bytes .../bottom_section.cpython-312.pyc | Bin 0 -> 2470 bytes .../__pycache__/system_tray.cpython-312.pyc | Bin 0 -> 1685 bytes .../__pycache__/top_section.cpython-312.pyc | Bin 0 -> 1480 bytes gui/v2/ui/builders/bottom_section.py | 54 + gui/v2/ui/builders/system_tray.py | 34 + gui/v2/ui/builders/top_section.py | 30 + gui/v2/ui/pages/Page.py | 86 + gui/v2/ui/pages/__init__.py | 0 .../ui/pages/__pycache__/Page.cpython-312.pyc | Bin 0 -> 5299 bytes .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 144 bytes .../__pycache__/_dummy_page.cpython-312.pyc | Bin 0 -> 1416 bytes .../__pycache__/browser_page.cpython-312.pyc | Bin 0 -> 14952 bytes .../__pycache__/editor_page.cpython-312.pyc | Bin 0 -> 46121 bytes .../fast_registration_page.cpython-312.pyc | Bin 0 -> 46286 bytes .../__pycache__/hidetor_page.cpython-312.pyc | Bin 0 -> 7960 bytes .../__pycache__/location_page.cpython-312.pyc | Bin 0 -> 15328 bytes ...location_verification_page.cpython-312.pyc | Bin 0 -> 14127 bytes .../__pycache__/menu_page.cpython-312.pyc | Bin 0 -> 73254 bytes .../__pycache__/protocol_page.cpython-312.pyc | Bin 0 -> 6640 bytes .../__pycache__/screen_page.cpython-312.pyc | Bin 0 -> 19017 bytes .../__pycache__/settings_page.cpython-312.pyc | Bin 0 -> 123309 bytes gui/v2/ui/pages/_blank_page.py | 17 + gui/v2/ui/pages/browser_page.py | 241 ++ gui/v2/ui/pages/connection_page.py | 48 + gui/v2/ui/pages/currency_selection_page.py | 65 + gui/v2/ui/pages/duration_selection_page.py | 92 + gui/v2/ui/pages/editor_page.py | 817 ++++++ gui/v2/ui/pages/fast_mode_prompt_page.py | 105 + gui/v2/ui/pages/fast_registration_page.py | 787 ++++++ gui/v2/ui/pages/hidetor_page.py | 105 + gui/v2/ui/pages/id_page.py | 190 ++ gui/v2/ui/pages/install_system_package.py | 488 ++++ gui/v2/ui/pages/location_page.py | 254 ++ gui/v2/ui/pages/location_verification_page.py | 284 ++ gui/v2/ui/pages/menu_page.py | 1247 +++++++++ gui/v2/ui/pages/payment_confirmed_page.py | 149 ++ gui/v2/ui/pages/payment_details_page.py | 419 +++ gui/v2/ui/pages/plan_picker_page.py | 101 + gui/v2/ui/pages/policy_suggestion_page.py | 136 + gui/v2/ui/pages/protocol_page.py | 87 + gui/v2/ui/pages/residential_page.py | 101 + gui/v2/ui/pages/resume_page.py | 463 ++++ gui/v2/ui/pages/screen_page.py | 347 +++ gui/v2/ui/pages/settings_page.py | 2325 +++++++++++++++++ gui/v2/ui/pages/sync_screen.py | 177 ++ gui/v2/ui/pages/systemwide_prompt_page.py | 115 + gui/v2/ui/pages/ticket_crypto_picker_page.py | 112 + .../ui/pages/ticket_or_billing_choice_page.py | 117 + gui/v2/ui/pages/ticket_prep_page.py | 117 + gui/v2/ui/pages/tor_page.py | 84 + gui/v2/ui/pages/welcome_page.py | 124 + gui/v2/ui/pages/wireguard_page.py | 136 + gui/v2/ui/popups/__init__.py | 0 .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 163 bytes .../confirmation_popup.cpython-312.pyc | Bin 0 -> 5739 bytes ...ndpoint_verification_popup.cpython-312.pyc | Bin 0 -> 7572 bytes .../ticket_data_loss_popup.cpython-312.pyc | Bin 0 -> 14407 bytes gui/v2/ui/popups/confirmation_popup.py | 91 + .../ui/popups/endpoint_verification_popup.py | 122 + gui/v2/ui/popups/qrcode_dialog.py | 105 + gui/v2/ui/popups/ticket_data_loss_popup.py | 232 ++ gui/v2/ui/styles/__init__.py | 0 .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 163 bytes .../styles/__pycache__/styles.cpython-312.pyc | Bin 0 -> 3618 bytes gui/v2/ui/styles/styles.py | 147 ++ gui/v2/ui/widgets/__init__.py | 0 .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 164 bytes .../clickable_label.cpython-312.pyc | Bin 0 -> 5003 bytes .../terminal_widget.cpython-312.pyc | Bin 0 -> 3347 bytes gui/v2/ui/widgets/clickable_label.py | 68 + gui/v2/ui/widgets/confetti.py | 50 + gui/v2/ui/widgets/terminal_widget.py | 52 + gui/v2/workers/__init__.py | 0 .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 161 bytes .../ticketing_worker_thread.cpython-312.pyc | Bin 0 -> 4812 bytes .../__pycache__/worker.cpython-312.pyc | Bin 0 -> 12045 bytes .../__pycache__/worker_thread.cpython-312.pyc | Bin 0 -> 19434 bytes gui/v2/workers/ticketing_worker_thread.py | 83 + gui/v2/workers/worker.py | 232 ++ gui/v2/workers/worker_thread.py | 295 +++ 120 files changed, 12842 insertions(+) create mode 100755 gui/v2/__init__.py create mode 100755 gui/v2/__main__.py create mode 100644 gui/v2/__pycache__/__init__.cpython-312.pyc create mode 100644 gui/v2/__pycache__/__main__.cpython-312.pyc create mode 100755 gui/v2/actions/__init__.py create mode 100644 gui/v2/actions/__pycache__/__init__.cpython-312.pyc create mode 100644 gui/v2/actions/__pycache__/config.cpython-312.pyc create mode 100644 gui/v2/actions/__pycache__/flags.cpython-312.pyc create mode 100644 gui/v2/actions/__pycache__/key_interpretation.cpython-312.pyc create mode 100644 gui/v2/actions/__pycache__/locations.cpython-312.pyc create mode 100644 gui/v2/actions/__pycache__/logging_setup.cpython-312.pyc create mode 100644 gui/v2/actions/__pycache__/profile_data.cpython-312.pyc create mode 100644 gui/v2/actions/__pycache__/profile_order.cpython-312.pyc create mode 100644 gui/v2/actions/__pycache__/should_be_synchronized.cpython-312.pyc create mode 100644 gui/v2/actions/__pycache__/sync.cpython-312.pyc create mode 100644 gui/v2/actions/__pycache__/ticket_failure.cpython-312.pyc create mode 100755 gui/v2/actions/config.py create mode 100755 gui/v2/actions/flags.py create mode 100755 gui/v2/actions/key_interpretation.py create mode 100755 gui/v2/actions/locations.py create mode 100755 gui/v2/actions/logging_setup.py create mode 100755 gui/v2/actions/profile_data.py create mode 100755 gui/v2/actions/profile_order.py create mode 100755 gui/v2/actions/should_be_synchronized.py create mode 100755 gui/v2/actions/sync.py create mode 100755 gui/v2/actions/ticket_failure.py create mode 100755 gui/v2/infrastructure/__init__.py create mode 100755 gui/v2/infrastructure/__pycache__/__init__.cpython-312.pyc create mode 100644 gui/v2/infrastructure/__pycache__/connection_manager.cpython-312.pyc create mode 100644 gui/v2/infrastructure/__pycache__/navigator.cpython-312.pyc create mode 100644 gui/v2/infrastructure/__pycache__/page_registry.cpython-312.pyc create mode 100644 gui/v2/infrastructure/__pycache__/setup_observers.cpython-312.pyc create mode 100755 gui/v2/infrastructure/connection_manager.py create mode 100755 gui/v2/infrastructure/navigator.py create mode 100755 gui/v2/infrastructure/page_registry.py create mode 100755 gui/v2/infrastructure/setup_observers.py create mode 100755 gui/v2/ui/__init__.py create mode 100755 gui/v2/ui/__pycache__/__init__.cpython-312.pyc create mode 100755 gui/v2/ui/builders/__init__.py create mode 100644 gui/v2/ui/builders/__pycache__/__init__.cpython-312.pyc create mode 100644 gui/v2/ui/builders/__pycache__/bottom_section.cpython-312.pyc create mode 100644 gui/v2/ui/builders/__pycache__/system_tray.cpython-312.pyc create mode 100644 gui/v2/ui/builders/__pycache__/top_section.cpython-312.pyc create mode 100755 gui/v2/ui/builders/bottom_section.py create mode 100755 gui/v2/ui/builders/system_tray.py create mode 100755 gui/v2/ui/builders/top_section.py create mode 100755 gui/v2/ui/pages/Page.py create mode 100755 gui/v2/ui/pages/__init__.py create mode 100644 gui/v2/ui/pages/__pycache__/Page.cpython-312.pyc create mode 100755 gui/v2/ui/pages/__pycache__/__init__.cpython-312.pyc create mode 100755 gui/v2/ui/pages/__pycache__/_dummy_page.cpython-312.pyc create mode 100644 gui/v2/ui/pages/__pycache__/browser_page.cpython-312.pyc create mode 100644 gui/v2/ui/pages/__pycache__/editor_page.cpython-312.pyc create mode 100644 gui/v2/ui/pages/__pycache__/fast_registration_page.cpython-312.pyc create mode 100644 gui/v2/ui/pages/__pycache__/hidetor_page.cpython-312.pyc create mode 100644 gui/v2/ui/pages/__pycache__/location_page.cpython-312.pyc create mode 100644 gui/v2/ui/pages/__pycache__/location_verification_page.cpython-312.pyc create mode 100644 gui/v2/ui/pages/__pycache__/menu_page.cpython-312.pyc create mode 100644 gui/v2/ui/pages/__pycache__/protocol_page.cpython-312.pyc create mode 100644 gui/v2/ui/pages/__pycache__/screen_page.cpython-312.pyc create mode 100644 gui/v2/ui/pages/__pycache__/settings_page.cpython-312.pyc create mode 100755 gui/v2/ui/pages/_blank_page.py create mode 100755 gui/v2/ui/pages/browser_page.py create mode 100755 gui/v2/ui/pages/connection_page.py create mode 100755 gui/v2/ui/pages/currency_selection_page.py create mode 100755 gui/v2/ui/pages/duration_selection_page.py create mode 100755 gui/v2/ui/pages/editor_page.py create mode 100755 gui/v2/ui/pages/fast_mode_prompt_page.py create mode 100755 gui/v2/ui/pages/fast_registration_page.py create mode 100755 gui/v2/ui/pages/hidetor_page.py create mode 100755 gui/v2/ui/pages/id_page.py create mode 100755 gui/v2/ui/pages/install_system_package.py create mode 100755 gui/v2/ui/pages/location_page.py create mode 100755 gui/v2/ui/pages/location_verification_page.py create mode 100755 gui/v2/ui/pages/menu_page.py create mode 100755 gui/v2/ui/pages/payment_confirmed_page.py create mode 100755 gui/v2/ui/pages/payment_details_page.py create mode 100755 gui/v2/ui/pages/plan_picker_page.py create mode 100755 gui/v2/ui/pages/policy_suggestion_page.py create mode 100755 gui/v2/ui/pages/protocol_page.py create mode 100755 gui/v2/ui/pages/residential_page.py create mode 100755 gui/v2/ui/pages/resume_page.py create mode 100755 gui/v2/ui/pages/screen_page.py create mode 100755 gui/v2/ui/pages/settings_page.py create mode 100755 gui/v2/ui/pages/sync_screen.py create mode 100755 gui/v2/ui/pages/systemwide_prompt_page.py create mode 100755 gui/v2/ui/pages/ticket_crypto_picker_page.py create mode 100755 gui/v2/ui/pages/ticket_or_billing_choice_page.py create mode 100755 gui/v2/ui/pages/ticket_prep_page.py create mode 100755 gui/v2/ui/pages/tor_page.py create mode 100755 gui/v2/ui/pages/welcome_page.py create mode 100755 gui/v2/ui/pages/wireguard_page.py create mode 100755 gui/v2/ui/popups/__init__.py create mode 100644 gui/v2/ui/popups/__pycache__/__init__.cpython-312.pyc create mode 100644 gui/v2/ui/popups/__pycache__/confirmation_popup.cpython-312.pyc create mode 100644 gui/v2/ui/popups/__pycache__/endpoint_verification_popup.cpython-312.pyc create mode 100644 gui/v2/ui/popups/__pycache__/ticket_data_loss_popup.cpython-312.pyc create mode 100755 gui/v2/ui/popups/confirmation_popup.py create mode 100755 gui/v2/ui/popups/endpoint_verification_popup.py create mode 100755 gui/v2/ui/popups/qrcode_dialog.py create mode 100755 gui/v2/ui/popups/ticket_data_loss_popup.py create mode 100755 gui/v2/ui/styles/__init__.py create mode 100644 gui/v2/ui/styles/__pycache__/__init__.cpython-312.pyc create mode 100644 gui/v2/ui/styles/__pycache__/styles.cpython-312.pyc create mode 100755 gui/v2/ui/styles/styles.py create mode 100755 gui/v2/ui/widgets/__init__.py create mode 100644 gui/v2/ui/widgets/__pycache__/__init__.cpython-312.pyc create mode 100644 gui/v2/ui/widgets/__pycache__/clickable_label.cpython-312.pyc create mode 100644 gui/v2/ui/widgets/__pycache__/terminal_widget.cpython-312.pyc create mode 100755 gui/v2/ui/widgets/clickable_label.py create mode 100755 gui/v2/ui/widgets/confetti.py create mode 100755 gui/v2/ui/widgets/terminal_widget.py create mode 100755 gui/v2/workers/__init__.py create mode 100644 gui/v2/workers/__pycache__/__init__.cpython-312.pyc create mode 100644 gui/v2/workers/__pycache__/ticketing_worker_thread.cpython-312.pyc create mode 100644 gui/v2/workers/__pycache__/worker.cpython-312.pyc create mode 100644 gui/v2/workers/__pycache__/worker_thread.cpython-312.pyc create mode 100755 gui/v2/workers/ticketing_worker_thread.py create mode 100755 gui/v2/workers/worker.py create mode 100755 gui/v2/workers/worker_thread.py diff --git a/gui/v2/__init__.py b/gui/v2/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/gui/v2/__main__.py b/gui/v2/__main__.py new file mode 100755 index 0000000..6f371c7 --- /dev/null +++ b/gui/v2/__main__.py @@ -0,0 +1,709 @@ +import logging +import os +import sys +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 + +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.navigator import Navigator +from gui.v2.infrastructure.setup_observers import setup_observers +from gui.v2.infrastructure.connection_manager import ConnectionManager +from gui.v2.workers.worker_thread import WorkerThread +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, + 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) + + current_dir = os.path.dirname(os.path.abspath(__file__)) + parent_dir = os.path.dirname(current_dir) + font_path = os.path.join(parent_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( + parent_dir, 'resources', 'images')) + self.css_path = os.getenv('CSS_PATH', os.path.join( + parent_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) + + 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.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") + + 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) + + self._update_toggle_colors(colors) + if is_tor_mode: + self.set_tor_icon() + + 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 + 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): + if not self.is_animating: + self.is_animating = True + self.animation_step = 0 + self.animation_timer.start(16) + self.is_tor_mode = not self.is_tor_mode + new_connection = 'tor' if self.is_tor_mode else 'system' + ConfigurationController.set_connection(new_connection) + core_logger.info(f"User toggled connection mode to '{new_connection}'") + + 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") + 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) + + if current_page != previous_page: + self.page_history.append(current_page) + + +if __name__ == "__main__": + app = QApplication(sys.argv) + window = CustomWindow() + sys.exit(app.exec()) diff --git a/gui/v2/__pycache__/__init__.cpython-312.pyc b/gui/v2/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..af7ebd69e67b0ad7bcf545fa5afec0bb9888eff7 GIT binary patch literal 153 zcmX@j%ge<81WpPnSs?l`h(HIQS%4zb87dhx8U0o=6fpsLpFwJVS?Fiv=cekHq^9e; zlYX3W>lsWCF+)?X6ERomuBjNaG8;Qe0*kJW=VX!UP0w84x8Nkl+v73 XyCPPgL5x6L3}Sp_W@Kb6Vg|ARc(Wr> literal 0 HcmV?d00001 diff --git a/gui/v2/__pycache__/__main__.cpython-312.pyc b/gui/v2/__pycache__/__main__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1e89a983488b142651a1a7f53a0f1b80e587478a GIT binary patch literal 38572 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 literal 0 HcmV?d00001 diff --git a/gui/v2/actions/__pycache__/flags.cpython-312.pyc b/gui/v2/actions/__pycache__/flags.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..dab09e7ed42727546aacee1e8c7ea02c4f9402e8 GIT binary patch literal 2352 zcmc&#%}*Og6rb50ybFut@*RQ&PC}rDVn?K3N(fO%iqs1^G({>GYw@h@4Qm_CE<{!h z^`QrPDz`YNas=@YAobEiZ@sW`t87qGd%ySI zFTr3Jg7MXz@n=6v2>rnq^&$95bq(K(9xm)w6^;q#r6F!P?Rh!x?N_$w6yGV zooa6PI?HF*t%RA$KhGTqjDNuu)0%Z4xS{655-Hu#+(7M>CjVR{^3VNvCZE;fmX?k` z&L=mrTFx@#=K4&gK$$wTsp-ZHc!=}4IU84#mY&a<@sy#a&DeUu9m%L>?YoKQg%Y*< zun>HLV`JV1Q9>uda5+&4&Xg8^S9+XKzoYbgfA_n)yT;x^MVWl1Oxw!z{?gHB6=mU= zEL7nPY7eskJD0;;>arRTA95!!wlD?gH!ojIFaEsxsd{S(r+`($tVH}U~{4;;0J>OW*; zXG%4#L^e+~4<^J=Dw*aN2Zzl4@kR}GCuNi+RivYO8uUJ^z{)(y^G$v0y`Sed*KL?V$| zRt}h&m1qK9Hyj0<32yv3h_|HUU^!(=p*v-EeIzYZBqzIqYz#U zRBIt51u@#O8`;hnnZlhNw50 z(UNOt4A@QZ!yAZcg19t3Z>~jtn0h(o$b)5}D&mpw>+smiM|;ZtmzD6HzeGIrVd=@= z0VEGO%JANxtxP(%CU+Kh=k4wrPHYxJx^2s&WyqoF6M3i{+8_K?zU_qi-h}$S!mI)j8RR9&}9UpVCWX_pmHIDD)4g!8!HRH={2@$-vH=_vZcHd-Iz&`+F{z z(O?8Sn@|2RHSKSy%m(OcT$VwcXjmf})`{+zIPsJDeIhg}X1v4*4_qOo2$*NUf$3#H&rkIN=m}gLn0=iYcx8|T?fM|uH*u*4 zMfa0(9xU~jzSb(`{obbsO~MH3S{zv(B8>SAb*X3~(Im+6S<4d0ca+9R+YOP!{FYMv zHer^azE_Qt9m}Qo^EhMst+vG|_q{l&V#TQz;oNGFxNNm)-S$0?*b-EyoCl=$!IkuA zv3s?Unbm6`;j7G_Jld_sS?Z~*^+OW&KK{-R81flyP|tEvJ9z9;8^I2u98pgmZR$Qd z6NGw9z*`-*+RK0A9eRG(Lk}eb)!5`#i}db8bt+2e2dHUbO5Z=8Pl zV(Wb6-f%A}uAO9_XHM^r@OkmhaQ9WJ7-h?U+^n8ut5NBzD7SXHeU{sbN(bZ8?Qv@5 z_}jD8t!Vw=C?BOu$3LE>--*`ukMiU72hZ}aQp?fC7e|E)Q@iy;;#px_e0x-SS=<~w zeOY{exGUqDK9 TcuQX$ZC+>~UhKSe3Rw$M2AbH54Pv zi(SCLLf$J1Y0rS8YhJuUe_!^V=2+&iKB2ev8G`QDEDCY(X##*U9Y;eg5+8CK`W*F4cr*YtIvv#0h%0fv(ok)e|(^Knr!WaQNW6 znG>{BhG#xh8#|c&a;k{}&(|tzwNI)aHPJ)^P1VuVf%+1KTM`(*Qoi11)d5ACk15G0LWu!vah^e_5b1zlvZ0VQ5rq-jn{S>lgja=a+qvH zm+R5xMs)o+y52-VPVK?XMkrDbMH-={dT8ks-E6u0S#!sea|5_*_0Y9bw9uA8@SU=o zwW_Yx?jIx$<0t6)sl3>8-2a+6lv8>Bt&UF0OL?*7Wu;mOpoyQw%dgL;0J{7KfNa+? zY6)!p`+VfTt~cf^BjB5R-j#Xq{erS`3%r>3t}F>JZa}VYdE=7sg9w>cU_ya5Z{Gqk h#*#Yh%6GO5J)gmM$u#rXl=2~E{UAJdO~VNr`!AEPXTJad literal 0 HcmV?d00001 diff --git a/gui/v2/actions/__pycache__/logging_setup.cpython-312.pyc b/gui/v2/actions/__pycache__/logging_setup.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ae774f2e0e0513867df83cabae192a6009af9a2a GIT binary patch literal 3181 zcmbtWO>7fK6rSB3+v}es8#^X{P#h8`Y>4faP*5WjR0;`*BveX!AZ=wC@5ZsQz0vF@ zA!vh)ganbOLQez)*uCn8+B`q!NqCD9V&9lg^4i4u7w{#^fH*L57)UG*dzc z39?$%BsLBnm*T6r&OI(!p#3-y;nYF~bv^iHA#ru$+!uPRf$V8t}5BBn>Vm zYb4yLlAesmRAS2a=4@z6Ny#Bij)l%B(V3K-*3^(X9hjPzP$V!b$CCkUGeo}GqoGJt ziz{h0L|v$|HZvWZo;Tcawc0OoVh23_)ILlypkS-ZTrV^3O&r?n}kNB;) z1-f4DH8EBCsstaYaiR!X;(r6^Y!MTOm3k`>WtR2?J^ znXJhq{3dHM<(r5O{fBmssbn%`ZOMA&FaXF~aqscCx=9r|(4lULvE!fiXC{g(p%RKPF!{O1PzqXYhNY{@1?GJeCoWa$thnv9oOQK zWc(TiJ+Hwks==osS7a%UR75bqun#FH711;q8P1`2Qa%?+OG!N7F^-LpXbgCMZ0Mrl z!sD`N-MEJ2NQw$`*qdQhHA$J#3_Hfuks|yX{M4Q`)o?^4iAp=83X+S^i2c+dIi<{+ zZtZ8UM&)UOs0N&lXj6u@m@L(>VXG?Mh5_U0Nk!z1dV=fGNOVdL6Sy{9C7cgW;$0X* zbOs>|++i~U`=q&17ZIT=M!j0xi#c{8MbT(@HmXm6&sS=Qlv1Oyi1gu-TT*KfLG^n4 zsbAqrk^x)Ldh5cC3wP^(XnGEV+d^|*=*S8k1-`!^bnaTXMr-E$t^+i+=RMt7Pxp$x z;W?HWDL^3)TeGnBLC?d!`+YgsQ=B=Fg$I`7Ie2*6yFc&k&3b#+`kxG}4s3XjXT}Q9 zormpN*#2Pn;mG}w92AQ)2ea_tiaiH=x81FIcW2h!x#9L_E)-xx9=2p*%Y){J`|t11 z!R|u)fy{**Y$>j^WntU$N$jaS8pJkUcsg&}c395dxaDfjyZl*~eTK`sBpwi6^I4PyKju;=oa6w zJtRPZR&_P1TDIrn_44A~Ah)4}HEYYN*%82X> z`3UJCv(`PN5FsMakGcstLP#$rDhZ1`&%L+Vq{$H^DRb$!p65qc<|q+W$zVkx6 zfTykE&1`v^m!0{RzHCe1@1DSITY>lF`Hn2#vC@;{dyCJa;PNbuE{=YCamNOlg1hyA zcifu0F?Z|wjq7)lIa}uz?^v=g+VA!~=MV01fbV*}+gyFM|JfC)r{1WYINV#_(_QRO z`-Ri}?9(p$=?^VWLkyw)_S2^@wA*h?$N3^6ZV P9Zc`5bBw_FiZ=cQV+d}l literal 0 HcmV?d00001 diff --git a/gui/v2/actions/__pycache__/profile_data.cpython-312.pyc b/gui/v2/actions/__pycache__/profile_data.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..39595b22b33b6597f6d460ac9c3622abd8a3704e GIT binary patch literal 623 zcmZ8ezfZzY5Pq+%{16CAL}M%ogPM?tjk+2qW@5r%;zC~9CzKT0ycSFo1_uX6H~1Hb z|Angy3`Wz{f55=zW|M%`FAiI;@cqJ{h0!RO?Lsgh26-g6 z#GruGkN_oLE-Ct=3!5cy8GM!o7?3`6??Y50BdHoBuZ4YQTD*QXA|)Cq2J@JUr;9%o zt!cNp=JAGBvGs17cRW{fopRGR1S|KrX_XsYQ$z2aYfSe{yW?t3#D_$ujvs6b&W!qM zRwRHPN4Ob)2lAQ9KBp!rb*!i}NDv(bFARl;;!hy>6W?0BVAR{EFHx2fp9nk!H;Q!~ zkitunh(RKVJIuFiX1F+{SQK$g=I({*@pX}?Z3EoR1As^+@pLdz@*j#iQPknhyK*Fw zP|?Acw%jy6AG{4UiAig7DFVsnfajvYy2Y7TADo3Mb}Y7&-W%q|;da7-)sh@8P@q78 m&HqUgmu=hVTKr0EK{Z_T4g}varF1G$T9_s%J)Qv+LdIVbl76=U literal 0 HcmV?d00001 diff --git a/gui/v2/actions/__pycache__/profile_order.cpython-312.pyc b/gui/v2/actions/__pycache__/profile_order.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..45ec89fe3086dc447e56c46e7e7e361a538b4ffd GIT binary patch literal 2774 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 literal 0 HcmV?d00001 diff --git a/gui/v2/actions/config.py b/gui/v2/actions/config.py new file mode 100755 index 0000000..602a14e --- /dev/null +++ b/gui/v2/actions/config.py @@ -0,0 +1,38 @@ +import json +import logging +import os + + +def setup_gui_directory(gui_cache_home, gui_config_home, gui_config_file): + os.makedirs(gui_cache_home, exist_ok=True) + os.makedirs(gui_config_home, exist_ok=True) + + if not os.path.exists(gui_config_file): + default_config = { + "logging": { + "gui_logging_enabled": False, + "log_level": "INFO" + } + } + with open(gui_config_file, 'w') as f: + json.dump(default_config, f, indent=4) + + +def load_gui_config(gui_config_file): + try: + with open(gui_config_file, 'r') as f: + return json.load(f) + except FileNotFoundError: + return None + except json.JSONDecodeError: + logging.error("Invalid GUI config file format") + return None + + +def save_gui_config(gui_config_file, config): + with open(gui_config_file, 'w') as f: + json.dump(config, f, indent=4) + + +def default_gui_config(): + return {"logging": {"gui_logging_enabled": False, "log_level": "INFO"}} diff --git a/gui/v2/actions/flags.py b/gui/v2/actions/flags.py new file mode 100755 index 0000000..a52add0 --- /dev/null +++ b/gui/v2/actions/flags.py @@ -0,0 +1,63 @@ +from gui.v2.actions.config import load_gui_config, save_gui_config, default_gui_config + + +def has_shown_systemwide_prompt(gui_config_file): + config = load_gui_config(gui_config_file) + if not config: + return False + return config.get("systemwide", {}).get("prompt_shown", False) + + +def mark_systemwide_prompt_shown(gui_config_file): + config = load_gui_config(gui_config_file) + if config is None: + config = default_gui_config() + if "systemwide" not in config: + config["systemwide"] = {} + config["systemwide"]["prompt_shown"] = True + save_gui_config(gui_config_file, config) + + +def has_shown_fast_mode_prompt(gui_config_file): + config = load_gui_config(gui_config_file) + if not config: + return False + return config.get("fast_mode", {}).get("prompt_shown", False) + + +def mark_fast_mode_prompt_shown(gui_config_file): + config = load_gui_config(gui_config_file) + if config is None: + config = default_gui_config() + if "fast_mode" not in config: + config["fast_mode"] = {} + config["fast_mode"]["prompt_shown"] = True + save_gui_config(gui_config_file, config) + + +def set_fast_mode_enabled(gui_config_file, enabled): + config = load_gui_config(gui_config_file) + if config is None: + config = default_gui_config() + if "registrations" not in config: + config["registrations"] = {} + config["registrations"]["fast_registration_enabled"] = bool(enabled) + save_gui_config(gui_config_file, config) + + +def check_first_launch(gui_config_file): + config = load_gui_config(gui_config_file) + if config is None: + config = default_gui_config() + + is_first_launch = not config.get( + "first_launch", {}).get("completed", False) + + if is_first_launch: + if "first_launch" not in config: + config["first_launch"] = {} + config["first_launch"]["completed"] = True + save_gui_config(gui_config_file, config) + return True + else: + return False diff --git a/gui/v2/actions/key_interpretation.py b/gui/v2/actions/key_interpretation.py new file mode 100755 index 0000000..7202537 --- /dev/null +++ b/gui/v2/actions/key_interpretation.py @@ -0,0 +1,30 @@ +def interpret_key_results(result: dict) -> str: + if not isinstance(result, dict): + return "There was an error with the format of the reply from the operation." + + valid = result.get('valid', False) + comparison = result.get('comparison', 'error') + error_msg = result.get('message', None) + + if error_msg: + if error_msg == "api_connection_issue": + return "There was a connection error with connecting to the API." + elif error_msg == "invalid_key": + return "Your original public key is in an invalid format to begin with." + else: + return "Unknown Error." + + if comparison == "same": + first_sentence = "The key you had locally matched what the server had also." + elif comparison == "different": + first_sentence = "Your local key was different than the server's key." + else: + first_sentence = "There was an error with the comparison." + + if valid: + second_sentence = "The signature now matches with the new key." + else: + second_sentence = "But the signature still does not match, even with the new key. If you prepare tickets anyway, they might not verify when used." + + final_msg = first_sentence + " " + second_sentence + return final_msg diff --git a/gui/v2/actions/locations.py b/gui/v2/actions/locations.py new file mode 100755 index 0000000..3e7ba6e --- /dev/null +++ b/gui/v2/actions/locations.py @@ -0,0 +1,34 @@ +def location_candidates(profile, preferred=None): + candidates = [] + seen = set() + + def add(val): + if val is None: + return + s = str(val).strip().lower().replace(' ', '_') + if s and s not in seen: + seen.add(s) + candidates.append(s) + + if preferred: + add(preferred) + + sources = [] + try: + if profile and profile.connection and profile.connection.location: + sources.append(profile.connection.location) + except Exception: + pass + try: + if profile and profile.location: + sources.append(profile.location) + except Exception: + pass + + for source in sources: + add(getattr(source, 'country_name', None)) + add(getattr(source, 'name', None)) + add(getattr(source, 'code', None)) + add(getattr(source, 'country_code', None)) + + return candidates diff --git a/gui/v2/actions/logging_setup.py b/gui/v2/actions/logging_setup.py new file mode 100755 index 0000000..a781abb --- /dev/null +++ b/gui/v2/actions/logging_setup.py @@ -0,0 +1,66 @@ +import logging +import os +import sys + +from core.errors.logger import logger as core_logger + + +def is_logging_enabled(config): + if config is None: + return False + return bool(config.get("logging", {}).get("gui_logging_enabled")) + + +def setup_gui_logging(gui_cache_home, gui_log_file, current_handlers): + os.makedirs(gui_cache_home, exist_ok=True) + + formatter = logging.Formatter( + '%(asctime)s - %(levelname)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' + ) + + file_handler = logging.FileHandler(gui_log_file) + file_handler.setLevel(logging.INFO) + file_handler.setFormatter(formatter) + + stream_handler = logging.StreamHandler(sys.stdout) + stream_handler.setLevel(logging.INFO) + stream_handler.setFormatter(formatter) + + root_logger = logging.getLogger() + root_logger.setLevel(logging.INFO) + root_logger.handlers = [] + root_logger.addHandler(file_handler) + root_logger.addHandler(stream_handler) + + for handler in current_handlers: + try: + core_logger.removeHandler(handler) + except Exception: + pass + core_logger.addHandler(file_handler) + core_logger.addHandler(stream_handler) + + if not os.path.exists(gui_log_file) or os.path.getsize(gui_log_file) == 0: + logging.info("GUI logging system initialized") + + return [file_handler, stream_handler] + + +def teardown_gui_logging(gui_log_file, current_handlers): + for handler in current_handlers: + try: + core_logger.removeHandler(handler) + except Exception: + pass + + root_logger = logging.getLogger() + for handler in list(root_logger.handlers): + root_logger.removeHandler(handler) + try: + handler.close() + except Exception: + pass + + if os.path.exists(gui_log_file): + os.remove(gui_log_file) diff --git a/gui/v2/actions/profile_data.py b/gui/v2/actions/profile_data.py new file mode 100755 index 0000000..021d3a0 --- /dev/null +++ b/gui/v2/actions/profile_data.py @@ -0,0 +1,10 @@ +def read_profile_data(data): + return data["Profile_1"] + + +def write_profile_data(data, payload): + data["Profile_1"].update(payload) + + +def clear_profile_data(): + return {"Profile_1": {}} diff --git a/gui/v2/actions/profile_order.py b/gui/v2/actions/profile_order.py new file mode 100755 index 0000000..602fde7 --- /dev/null +++ b/gui/v2/actions/profile_order.py @@ -0,0 +1,61 @@ +from gui.v2.actions.config import default_gui_config, load_gui_config, save_gui_config + + +def _clean_ids(profile_ids): + ids = [] + for profile_id in profile_ids or []: + try: + profile_id = int(profile_id) + except (TypeError, ValueError): + continue + if profile_id not in ids: + ids.append(profile_id) + return ids + + +def _load_order(gui_config_file): + if not gui_config_file: + return [] + config = load_gui_config(gui_config_file) + if not config: + return [] + return _clean_ids(config.get("profiles", {}).get("visual_order", [])) + + +def _save_order(gui_config_file, order): + if not gui_config_file: + return + config = load_gui_config(gui_config_file) + if config is None: + config = default_gui_config() + if "profiles" not in config: + config["profiles"] = {} + config["profiles"]["visual_order"] = _clean_ids(order) + save_gui_config(gui_config_file, config) + + +def normalize_profile_order(gui_config_file, existing_ids): + existing_order = _clean_ids(existing_ids) + existing = set(existing_order) + saved_order = [profile_id for profile_id in _load_order( + gui_config_file) if profile_id in existing] + ordered = saved_order + [ + profile_id for profile_id in existing_order if profile_id not in saved_order] + _save_order(gui_config_file, ordered) + return ordered + + +def append_profile_to_visual_order(gui_config_file, profile_id, existing_ids): + try: + profile_id = int(profile_id) + except (TypeError, ValueError): + return + existing_order = [ + existing_id for existing_id in _clean_ids(existing_ids) if existing_id != profile_id] + existing = set(existing_order) + saved_order = [ + existing_id for existing_id in _load_order(gui_config_file) if existing_id in existing] + ordered = saved_order + [ + existing_id for existing_id in existing_order if existing_id not in saved_order] + ordered.append(profile_id) + _save_order(gui_config_file, ordered) diff --git a/gui/v2/actions/should_be_synchronized.py b/gui/v2/actions/should_be_synchronized.py new file mode 100755 index 0000000..dbffd62 --- /dev/null +++ b/gui/v2/actions/should_be_synchronized.py @@ -0,0 +1,9 @@ +from datetime import datetime, timezone, timedelta + +from core.controllers.ConfigurationController import ConfigurationController + + +def should_be_synchronized(): + current_datetime = datetime.today().astimezone(timezone.utc) + last_synced_at = ConfigurationController.get_last_synced_at() + return last_synced_at is None or (current_datetime - last_synced_at) >= timedelta(days=30) diff --git a/gui/v2/actions/sync.py b/gui/v2/actions/sync.py new file mode 100755 index 0000000..806bea3 --- /dev/null +++ b/gui/v2/actions/sync.py @@ -0,0 +1,19 @@ +def generate_grid_positions(num_items): + positions = [] + start_x = 395 + start_y = 90 + button_width = 185 + button_height = 75 + h_spacing = 10 + v_spacing = 105 + + for i in range(num_items): + col = i % 2 + row = i // 2 + + x = start_x + (col * (button_width + h_spacing)) + y = start_y + (row * v_spacing) + + positions.append((x, y, button_width, button_height)) + + return positions diff --git a/gui/v2/actions/ticket_failure.py b/gui/v2/actions/ticket_failure.py new file mode 100755 index 0000000..20c1cea --- /dev/null +++ b/gui/v2/actions/ticket_failure.py @@ -0,0 +1,57 @@ +import logging +from datetime import datetime, timezone + +from gui.v2.actions.config import load_gui_config, save_gui_config, default_gui_config + + +def save_ticket_verification_failure(gui_config_file, result): + try: + failed_validations = result.get('failed_validations', []) if isinstance(result, dict) else [] + if not failed_validations: + return False + + config = load_gui_config(gui_config_file) + if config is None: + config = default_gui_config() + if "tickets" not in config: + config["tickets"] = {} + + config["tickets"]["failed_verification"] = { + "message": result.get('message', 'verification_failed'), + "how_many_failed": result.get('how_many_failed', len(failed_validations)), + "failed_validations": list(failed_validations), + "updated_at": datetime.now(timezone.utc).isoformat(), + } + save_gui_config(gui_config_file, config) + return True + except Exception as e: + logging.error(f"Error saving ticket verification failure: {e}") + return False + + +def get_ticket_verification_failure(gui_config_file): + try: + config = load_gui_config(gui_config_file) + if not config: + return None + failure = config.get("tickets", {}).get("failed_verification") + if not isinstance(failure, dict): + return None + failed_validations = failure.get("failed_validations") + if not failed_validations: + return None + return failure + except Exception as e: + logging.error(f"Error loading ticket verification failure: {e}") + return None + + +def clear_ticket_verification_failure(gui_config_file): + try: + config = load_gui_config(gui_config_file) + if not config or "tickets" not in config: + return + config["tickets"].pop("failed_verification", None) + save_gui_config(gui_config_file, config) + except Exception as e: + logging.error(f"Error clearing ticket verification failure: {e}") diff --git a/gui/v2/infrastructure/__init__.py b/gui/v2/infrastructure/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/gui/v2/infrastructure/__pycache__/__init__.cpython-312.pyc b/gui/v2/infrastructure/__pycache__/__init__.cpython-312.pyc new file mode 100755 index 0000000000000000000000000000000000000000..3c1f4487166b023d76f386be32930d649bdb99d8 GIT binary patch literal 150 zcmX@j%ge<81bm7rSs?l`h(HIQS%4zb87dhx8U0o=6fpsLpFwJVnL1m=_@$OBq~+(N zq!z`bmu8k3fyfvjm6?}TlvrF+RGM5;T9g_SAD@|*SrQ+wS5Wzj!zMRBr8Fniu80+A T3L_8~gBTx~85tRin1L(+7#brv literal 0 HcmV?d00001 diff --git a/gui/v2/infrastructure/__pycache__/connection_manager.cpython-312.pyc b/gui/v2/infrastructure/__pycache__/connection_manager.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0c96f83df3a369c3046bca7184dc3c3a73b11c3f GIT binary patch literal 4964 zcmcgwT}&I<6~1@;!x#($m?Q*3Fh2o?1Z)Uo*(ixXn{GD?OLoO-wM#dd8qb87H8#C> z9Kdq4c}OKzN|kI?DUr6TU9~TuJoJ@5RojQEQeW&qWM`C0kt$W{8-j_xew@wbcaLUkADu7J>Q;cI@UZ5_{c1JSHkpX`C$5SxSjO zen?c|K2cpy1&eD|Sar*uzhOtFM#H--c`Fvz-bf~NmW;pa&7?}&5dJ`JCRr%c@(DcWITn==SH0pPevn1EAbfDjap@nC6~Fz%2}TC~=DaB0;L6(yF4>57tWam2)+J%{VXuK?U5FS~kvJ-X)J7K4SB_BHnd zsUV-lZcm}751(DT0`1W$lAW(k2l3fjXammYV#^*bZL}0B(816!1MMOZk0BTq;CZ+$ zl)`C$CIh)*XWb0hpwqIzj={9yn%DFVfwdrAZ7)Sk<@nWLkhS7?3-Ii(-hpLGpL2TQ zXl@ClaTIxv6r@02>d#62d1){w4X&p*N4BK#tT@ivU_M%I0@9|@oj`!mLS?fv^b*Xk z64nk6nVROwF4HoGZ<+%f1qDlnLGXSL!;a=|1gRVW3>$uH&7Bv!a$?ulZ$A@5mOT|0 z@%QjTd9_$5=YlzG8TzzXIqnJ>fnC;1wIBnf&t6hClo1w6b-{WQk(~iwiTiz8uv>irazy$pTKf&fOP1cCFP5Jyulu)8jBfVk zFHSwXI92f1=lwl7e^1^&kn<0$&*ulnbA#jC{s}W=<(wpEMcKKks5<`kzJF@&``C_N z!SofAK^`P3Ca3Aa16g@2qN++X#r5Q3S%pmZI$cwd5z~<4ia^MU{=C?k6Fc*woD=2s z{^#Q8|J*>#7Q+Rqu@2M(L*GtI%d*2e%8`~GW;~7Z$}9`!#9&_R&WYV$&psE!{~yb$ z)EJLOSOx2vnYNg`qdB+CbiZ@yj^Z_a=CH9ZC-$wkJQqitHllyc@I>!G5rtz#rM=@0 znJE=eTp{P;G?ne-QQP(D9;fP+__}dv0sy!HoP6?rzHKzuHky@2vv$N4iyg37`kFeQ zgHCy&1l=o?N;{2g3k1WBM+9ctaS}vWrvhtFJC4Prq`$oh@N}Qkkjk7`(Tb+Llmd^g z=9|vtn$B%W16gt4JKT@TSngMVvRX0gHz-5b6cgU?ay_D__<_u*#x2M?Q>9NiHQk@> z2SMBi0FG{fQ^n6DOQ&-8B5k@C>&sDxiJ~(ZRyB6miN4YaFWu_YrZhPK?{Yr?u6p3% zr=NYA6;C^TcS8={8k`qrnm z0%MyW{_e(aZ~Sp_>++8ZtzAFAzT+Wn;T@4QHUHyfeakPWv(njJLVe9IK)MT_-||lQ z;7^Aq>d2-9@Qvs~^i#w-X(H(Qrkw&FI2MAd^2NkTdzHZN@gGcFSbBvd%*LKFd zSNxZy;5tZjl!AJUp7N_$y3?uIVIB-(UI$2~lRC0uhf_aHO|vvS0m3X*6-t7A z*=8e0m4b&fD$K);>IU2~?ouc$6-vSvP+DN7LNWM)eChgJc+l>~aziu13|!D>KpKVL zUAj!zI4CE(8D9L%STmE9UB%JjJ>(kDMx7mcN+PnT@q<^AgQ<%9*S}3wWO@?>^A7>Q zlQq)AE1zB2u4#QWc|AXQ^V#IhJr_OwWBOw9=H|+yF)KNq3A6OxRfj+zA+#6?PtB6k!YjuWpv(-bU&jg!d3;5Iz9dAZA#Y zyEnu7W&$oCe;?pJ*%d@jaJPwiCg`r0daePwJ^j0W>bZdZA%sx`vpa}X2SWRZUImHLxJ^X)_b@MTmn?()rDr+4##7mZ! fuCR|`41fJj7f;4n zySX5xRZ%x2`BOV27j+|_6&JOfl3%o&MJ)${oUH1g$mwckmIPpP5(J`CuhD4q>3hk! zd{#{wYC1WZmy207XXr`2Fg#aMh%~&QYMJ45QA^Uka3!hbW{IR5q$nFjq9$|hr4ogb z9T35xW{Bc)q;c7VyVgW~UI4O&svUi0?hB#XHCW~j10B_l&NBCrMA%{%yWx!P#54bMI!BOv(hrX|Fx z;d7PnfE6Ct;SR&Y)$sYF{x|nVt^R8k-&=nFLB~;hccp#EY9HG9_@Mn-`OZ<~;)DL} z!Gp+f`S#$?M8XD%7-_nHl`~4WsASOtsL@iRQqD(QM&eE z)Z#By1N^42AyfhbRsg*IB6*exq|>lf}IHuflRhfq*HlY{h$oE*FglmC~4_v$at zI4@5q!gm?xIS8!>I!$>uxXtj2qAbaCsTXjGB=V6}yU z|Dld3*u>w$pYeSot7&EEJyjd}5MeQzfz?H{#Ll2a9A_t&{nLQ2=)iK@KonpdbzvPX zsYW8sI_1|66$=VromOt6sN2D@C0Q*PT0Tdj^opl~LRw*AxzmfriwV<3P2L8whMq8N zuoZw%;kzxqyTT7y{Ls$Ap0fAh{*B4~;i*Ub^glqhhulJxI zf(iCVoM0#8O|+cTbPz@ONb2bab6ylbD@vJ~gwPU4x+%Ft2|ej9a^Ap67D9vX5I;T!-lV6c1P$4M+)Nj&@GB%JS#sA&y8p^{TU tYEEL*6NPU*OGj!+{0T(j}Z{Z>|a;M!T$gN literal 0 HcmV?d00001 diff --git a/gui/v2/infrastructure/__pycache__/page_registry.cpython-312.pyc b/gui/v2/infrastructure/__pycache__/page_registry.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..79a24b3a41d3b0d630f8aa84fdc0dee528d2983f GIT binary patch literal 2564 zcmaJ@&2QsW5O>nLN#i)*ZIgcQZg-aeO_oX=5bSEVAC#i~h?=4bkT9;_YijD)*0a+p z-1rB$?12kc4*V@#e5`yzLfj2|?obe!rRV%)FQWp{h9vKCl07{_sy$ zlK!H~=u;FAZ+?b{UnEmP5=y;Jp){HTIgMnHGbjTxi)KOQkODG~RFDN!1X)65kQGz~ zSwnLm=g|VlI%fXob@;p%$$)qatK4_49B*y;hT;Psn+6z z8!zV-cCO)!p0yl97vw}|Mm|o>KNnC&obe2TG0mBnGxu;(uFGJY0`EOwb52mWM^K;{ z0ArNQ32`nxJhA`6#YxIG2l7N1nY@9CMBF11?J*dm%$v67>z1W?L(j*302t@6obY-N z;w_zw3~L8GLYD>TaDF&@X%gHU=mbHi)p)3DCof1n2Jk)wvziodXdBx9^!dge=DMKp zUWZu^`@U)SJV=?C>Bz@3X1h1gg?M!);-HxWR?nnfxcU&zo3Fu|otmVN1*6pAc33uU zp2X#yzJ!CK!4d4cKF?O;4WxEBieRR_n8bG-%QS|XH|W9TLRVPuJIvm_HVgj4`0Jqo zVVkdc*y0C>2bkLxj_%0*t_a3}5Ny*LMo1B1M>ygUHsUpm`UjVcZdx9vS48)Axlv?K z+#)XApqgtM=UChcg$`Ic%pf?;cs`$cVO(VB`pzx#{1J2QFwd-lr#}G=9^e((=FT1F6R{Ep@KpDF4NjHm`C@`BNbPg5 zAC8#?|9-p{#_v0^y{l0A3$TuDof@W16qZL*y591zoLF8!#rH9+Bv&k~B$p;EjuMD9 z2$iwfhBeXeTSE(#u_=Ugp(NTHmJ`hiOY!%A*-V9M=h^Mq?Vb z++mpg44OW$@Ds8P!Bg<(RY68msZ=UTua(nR(@LOz5>3J5pVMkkd=km<7|lqs668OQ zGE~V*axo}9i)N{kljH)0DOAZzawVvK8L3n$NOCSvwxS|cN|Ia-D*I8HDisl+N|l-< zt3lz@XpSoLLRp|nU4(8>Wl?yRsIn}{E5Yjb(F#>oCAk!occV3`tg{N9Mt7)km*sgJ u-J{Beq&&K69A7oQrQgNx=y&0(tLnkkvO(p#P3`sLe;)(-CzU*f=DY=n@0be! literal 0 HcmV?d00001 diff --git a/gui/v2/infrastructure/__pycache__/setup_observers.cpython-312.pyc b/gui/v2/infrastructure/__pycache__/setup_observers.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1710a87c9649978b34d5cb49ff65586966be3d65 GIT binary patch literal 8599 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 literal 0 HcmV?d00001 diff --git a/gui/v2/infrastructure/connection_manager.py b/gui/v2/infrastructure/connection_manager.py new file mode 100755 index 0000000..b5aa5f8 --- /dev/null +++ b/gui/v2/infrastructure/connection_manager.py @@ -0,0 +1,76 @@ +from core.controllers.ProfileController import ProfileController + + +class ConnectionManager: + def __init__(self): + self._connected_profiles = set() + self._is_synced = False + self._is_profile_being_enabled = {} + self.profile_button_objects = {} + self.available_resolutions = ['800x600', '1024x760', '1152x1080', '1280x1024', + '1920x1080', '2560x1440', '2560x1600', '1920x1440', '1792x1344', '2048x1152'] + self._location_list = [] + self._browser_list = [] + + def get_profile_button_objects(self, profile_id): + return self.profile_button_objects.get(profile_id, None) + + def set_profile_button_objects(self, profile_id, profile_button_objects): + self.profile_button_objects[profile_id] = profile_button_objects + + def get_available_resolutions(self, profile_id): + profile = ProfileController.get(profile_id) + if profile and hasattr(profile, 'resolution') and profile.resolution: + self.available_resolutions.append(profile.resolution) + return self.available_resolutions + + def add_custom_resolution(self, resolution): + self.available_resolutions.append(resolution) + + def add_connected_profile(self, profile_id): + self._connected_profiles.add(profile_id) + + def remove_connected_profile(self, profile_id): + self._connected_profiles.discard(profile_id) + + def is_profile_connected(self, profile_id): + return profile_id in self._connected_profiles + + def get_connected_profiles(self): + return list(self._connected_profiles) + + def set_synced(self, status): + self._is_synced = status + + def is_synced(self): + return self._is_synced + + def get_location_info(self, location_key): + if not location_key: + return None + try: + country_code, location_code = location_key.split('_') + for location in self._location_list: + if location.country_code == country_code and location.code == location_code: + return location + + return None + except (ValueError, AttributeError): + return None + + def store_locations(self, locations): + self._location_list = locations + + def store_browsers(self, browsers): + self._browser_list = browsers + + def get_browser_list(self): + return self._browser_list + + def get_location_list(self): + if self.is_synced(): + location_names = [ + f'{location.country_code}_{location.code}' for location in self._location_list] + return location_names + else: + return [] diff --git a/gui/v2/infrastructure/navigator.py b/gui/v2/infrastructure/navigator.py new file mode 100755 index 0000000..9f7cbaf --- /dev/null +++ b/gui/v2/infrastructure/navigator.py @@ -0,0 +1,33 @@ +import importlib + +from gui.v2.infrastructure.page_registry import PAGE_REGISTRY + + +class Navigator: + def __init__(self, page_stack, custom_window): + self.page_stack = page_stack + self.custom_window = custom_window + self._instances = {} + + def navigate(self, name): + page = self._instances.get(name) + if page is None: + if name not in PAGE_REGISTRY: + self._warn_not_migrated(name) + return + module_path, class_name = PAGE_REGISTRY[name] + module = importlib.import_module(module_path) + cls = getattr(module, class_name) + page = cls(self.page_stack, self.custom_window) + self.page_stack.addWidget(page) + self._instances[name] = page + self.page_stack.setCurrentIndex(self.page_stack.indexOf(page)) + + def get_cached(self, name): + return self._instances.get(name) + + def _warn_not_migrated(self, name): + try: + self.custom_window.update_status(f"Page '{name}' not migrated yet.") + except Exception: + pass diff --git a/gui/v2/infrastructure/page_registry.py b/gui/v2/infrastructure/page_registry.py new file mode 100755 index 0000000..774ca6e --- /dev/null +++ b/gui/v2/infrastructure/page_registry.py @@ -0,0 +1,32 @@ +PAGE_REGISTRY = { + "blank": ("gui.v2.ui.pages._blank_page", "BlankPage"), + "welcome": ("gui.v2.ui.pages.welcome_page", "WelcomePage"), + "menu": ("gui.v2.ui.pages.menu_page", "MenuPage"), + "protocol": ("gui.v2.ui.pages.protocol_page", "ProtocolPage"), + "hidetor": ("gui.v2.ui.pages.hidetor_page", "HidetorPage"), + "residential": ("gui.v2.ui.pages.residential_page", "ResidentialPage"), + "tor": ("gui.v2.ui.pages.tor_page", "TorPage"), + "connection": ("gui.v2.ui.pages.connection_page", "ConnectionPage"), + "location": ("gui.v2.ui.pages.location_page", "LocationPage"), + "screen": ("gui.v2.ui.pages.screen_page", "ScreenPage"), + "resume": ("gui.v2.ui.pages.resume_page", "ResumePage"), + "browser": ("gui.v2.ui.pages.browser_page", "BrowserPage"), + "editor": ("gui.v2.ui.pages.editor_page", "EditorPage"), + "install_system_package": ("gui.v2.ui.pages.install_system_package", "InstallSystemPackage"), + "wireguard": ("gui.v2.ui.pages.wireguard_page", "WireGuardPage"), + "sync_screen": ("gui.v2.ui.pages.sync_screen", "SyncScreen"), + "settings": ("gui.v2.ui.pages.settings_page", "Settings"), + "id": ("gui.v2.ui.pages.id_page", "IdPage"), + "payment_confirmed": ("gui.v2.ui.pages.payment_confirmed_page", "PaymentConfirmed"), + "systemwide_prompt": ("gui.v2.ui.pages.systemwide_prompt_page", "SystemwidePromptPage"), + "policy_suggestion": ("gui.v2.ui.pages.policy_suggestion_page", "PolicySuggestionPage"), + "duration_selection": ("gui.v2.ui.pages.duration_selection_page", "DurationSelectionPage"), + "currency_selection": ("gui.v2.ui.pages.currency_selection_page", "CurrencySelectionPage"), + "payment_details": ("gui.v2.ui.pages.payment_details_page", "PaymentDetailsPage"), + "plan_picker": ("gui.v2.ui.pages.plan_picker_page", "PlanPickerPage"), + "ticket_crypto_picker": ("gui.v2.ui.pages.ticket_crypto_picker_page", "TicketCryptoPickerPage"), + "ticket_prep": ("gui.v2.ui.pages.ticket_prep_page", "TicketPrepPage"), + "ticket_or_billing_choice": ("gui.v2.ui.pages.ticket_or_billing_choice_page", "TicketOrBillingChoicePage"), + "fast_registration": ("gui.v2.ui.pages.fast_registration_page", "FastRegistrationPage"), + "fast_mode_prompt": ("gui.v2.ui.pages.fast_mode_prompt_page", "FastModePromptPage"), +} diff --git a/gui/v2/infrastructure/setup_observers.py b/gui/v2/infrastructure/setup_observers.py new file mode 100755 index 0000000..51b3ef2 --- /dev/null +++ b/gui/v2/infrastructure/setup_observers.py @@ -0,0 +1,74 @@ +from core.observers.ApplicationVersionObserver import ApplicationVersionObserver +from core.observers.ClientObserver import ClientObserver +from core.observers.ConnectionObserver import ConnectionObserver +from core.observers.InvoiceObserver import InvoiceObserver +from core.observers.ProfileObserver import ProfileObserver +from core.observers.TicketObserver import TicketObserver +from core.controllers.ApplicationController import ApplicationController + + +application_version_observer = ApplicationVersionObserver() +client_observer = ClientObserver() +connection_observer = ConnectionObserver() +invoice_observer = InvoiceObserver() +profile_observer = ProfileObserver() +ticket_observer = TicketObserver() + + +def setup_observers(update_status): + profile_observer.subscribe( + 'created', lambda event: update_status('Profile Created')) + profile_observer.subscribe( + 'destroyed', lambda event: update_status('Profile destroyed')) + + client_observer.subscribe( + 'synchronizing', lambda event: update_status('Sync in progress...')) + client_observer.subscribe( + 'synchronized', lambda event: update_status('Sync complete')) + + client_observer.subscribe( + 'updating', lambda event: update_status('Updating client...')) + client_observer.subscribe('update_progressing', lambda event: update_status( + f'Current progress: {event.meta.get('progress'):.2f}%')) + client_observer.subscribe('updated', lambda event: update_status( + 'Restart client to apply update.')) + + application_version_observer.subscribe('downloading', lambda event: update_status( + f'Downloading {ApplicationController.get(event.subject.application_code).name}')) + application_version_observer.subscribe('download_progressing', lambda event: update_status( + f'Downloading {ApplicationController.get(event.subject.application_code).name} {event.meta.get('progress'):.2f}%')) + application_version_observer.subscribe('downloaded', lambda event: update_status( + f'Downloaded {ApplicationController.get(event.subject.application_code).name}')) + + connection_observer.subscribe('connecting', lambda event: update_status( + f'[{event.subject.get("attempt_count")}/{event.subject.get("maximum_number_of_attempts")}] Performing connection attempt...')) + + connection_observer.subscribe('tor_bootstrapping', lambda event: update_status( + 'Establishing Tor connection...')) + + connection_observer.subscribe('tor_bootstrap_progressing', lambda event: update_status( + f'Bootstrapping Tor {event.meta.get('progress'):.2f}%')) + + connection_observer.subscribe( + 'tor_bootstrapped', lambda event: update_status('Tor connection established.')) + + ticket_observer.subscribe('connecting', lambda event: update_status('Connecting to ticket server...')) + ticket_observer.subscribe('sync_done', lambda event: update_status('Ticket prices synced.')) + ticket_observer.subscribe('waiting', lambda event: update_status('Waiting for payment...')) + ticket_observer.subscribe('paid', lambda event: update_status('Payment received.')) + ticket_observer.subscribe('ticket_ready', lambda event: update_status('Ticket ready.')) + ticket_observer.subscribe('used', lambda event: update_status('Ticket used.')) + ticket_observer.subscribe('connection_error', lambda event: update_status('Ticket server connection error.')) + ticket_observer.subscribe('failed_output', lambda event: update_status(f'{event.subject if event.subject else ""}')) + ticket_observer.subscribe('failed_input', lambda event: update_status(f'{event.subject if event.subject else ""}')) + ticket_observer.subscribe('unknown_error', lambda event: update_status('Unknown ticket error.')) + ticket_observer.subscribe('error', lambda event: update_status(f'Ticket error: {event.subject if event.subject else ""}')) + + return { + 'application_version_observer': application_version_observer, + 'client_observer': client_observer, + 'connection_observer': connection_observer, + 'invoice_observer': invoice_observer, + 'profile_observer': profile_observer, + 'ticket_observer': ticket_observer, + } diff --git a/gui/v2/ui/__init__.py b/gui/v2/ui/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/gui/v2/ui/__pycache__/__init__.cpython-312.pyc b/gui/v2/ui/__pycache__/__init__.cpython-312.pyc new file mode 100755 index 0000000000000000000000000000000000000000..69f7ced1a51a3079d53177f5c4c4659567592e81 GIT binary patch literal 138 zcmX@j%ge<81j>pkSs?l`h(HIQS%4zb87dhx8U0o=6fpsLpFwJVX*yfQ_@$OBq~+(N zq!z`bmu8k3fyfvj1tjC+GxIV_;^XxSDt~d<y560&y{j@sXL4k+Fyw G$N~T?(H5P=Rpvj9b=GgLBYGWxA#C}INgK7-W!a?#Jo&rQ`YNln*x z$xklLP0cGQ)-Nv5&8SQ%O4Kb&&CJnFFU`~k;W8tAAemH}nUj)QRIDE#pP83g5+AQu iPQKYK>M=wSofwqa1s*x(+QrV}T`ew{}AwlY)Bba&b&3nK1 z=FOY8zxjL~1_nBFDi`A!=1;;{!_gu3SAclPh>XZ8%m!H<3Y&LqIP%U7Crfyzg7Vx3 zM`@(+dDn&uXzoj9LnyOhevbg%3Oh`G?PfA1Ddxc1?UJ62moQcfVbB!@}FEO}L7SIf|FYG4!^M$XO?%8prx*@P^|D=0}IEXXit7K}I_FwZ$1+ z-;4;NTlDl~gv<4dWT4B#s1-+7#(9J9i|++|pE%f+aXvR@+1sl))P>*gMqP%hFaEH2 zOdJtMY<%1=I>8Y7T8~0>gFVp+xd}TeixT~IbcR!##{p{xPgzthXi@f>J7LonWw7Fy zbt@L?mb(!CpP1usW5StFT3+o{oUpt?O$oVo?H!9U+&vnzmfT*!ZqDW`>RE^AvPK4H z@P^9}#L36pIuj1u+9Sm!^H7$u7hdR|AJ|GbttzUv64ElM_s%YaV#~{P3SS6qss+5L z%N1!Qv~<4s;6i0$9Vc-~UkR-$vQ)sKdy=NhY9Uk^iOY&27c!yKp}3~r*NNOu+)Tr? z_dRiw^n_p>wwuuBlp%tS^K>~`xNs!wcDw|RdpGV&awdycLMc@d54X0cg8UP476WH{ z<1pQoO%g$X2uaDvPB^kTpqC5jM2a@LieH8qmH1fh|hN^w`SQu?!G zN|C7Q$Elo@#u~cXBKIHm zn0zs*K?tUh!i5Ay7);Y5NOX$89F+C$RBFTEP$OMa8zB?v3^XOCnc_s9s#mmPIYOcu$g*#Y{<#lK;I}6y{V(RzxVaXi9}IS3aS` zX`vt^#j-h;)}$ncYw6k1=D{QRtmsdn(QPx&M+4Q<-(B3k*+#=H6s)6Q^-gWHmfndq zQH08->uCD9;Ashybz!n4%+!UMpYOD0m+Q03t=V`TenR}f$#R$3mjvRSI(nxqoFkHD zDwwRJ$+qWYEwX!ZX;+AmjrR|DW_Wyee17LjW9Z!WwKnp$5YelSR@1d}JB}tgLuFHS zH1(R{gTevBbMAv-W-L_W8~(ZNkJ{*13z5(c4R+#96s0oazlxja)T^PfZ#TdB;!X5e zO>3h0S0mHa&Bn;=_Kh|gYN6wGbiBF>=T7a+G|?iJQSf{dE&Smcd+rZBo_R9&Xzt0v zqlMao-KDFIscQ}Y^{?HpdUt+3(U`i_@W;P$za+RM{_6QAnxTjjbu>|RTO9RyqZM4J z2NxQ_2*m%IX9BZrf3O`mP3DQ2zXy0P59jCjeYi0u?AE4XF^6uK*YNqs8vaxkGZNP6 z3p;!P1J>5nl6+Y4UQ4p7Y7%{Gv;>|q+8Myo!URzGAg_ugMY^b+1(tjOb<#jyJ6V?f VopJxgL|-t|FPP)|e1zqx-M`7^7`gxe literal 0 HcmV?d00001 diff --git a/gui/v2/ui/builders/__pycache__/system_tray.cpython-312.pyc b/gui/v2/ui/builders/__pycache__/system_tray.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f03ade5975e749b6ba6696589dbe00936e1d48d0 GIT binary patch literal 1685 zcmd5+-)q}O9KVxJvJ}a)+s4hWW({4+FvW2wTROTftSl^RW7>t73=G3Kk!GKfEhEWk z9b|A}uoenU=|c=-5cZUjGV*VXzAVHcMoD3??jdhw9t`@_JJ}bdP#E-S2fFX)KHpz= zU%p>G3y1pvS^p-+i@)&z{7DaNaXrwPB48b8K!XOzP{|pv#AP^0F>mlCltDg5hENhS zBEiD9leycagJ{4rR-tNJoaNL^&|kqd#&uY_F( z4x}Vq#gkTD3T*YH4Qs#=dWU1S&b@`>HK=iUu8v$Z1HRP(s0)tBJc|-Tfh;NkX2^va z?+QNx4f(t9ld#phU0wW-Xkiv3kgmAZd!-E@-gLQQB+xPBiq5HHEV#${SY8Mo<`KvP zP5c?wLvHAQ`ZoUE-xhR&3s}ACk}DO@u(||N3o#1_h(#>WFywM?SPU>ekeYPD0u+Y= z4MQ$}!a`aAarxHKS)x1|B-^S~SV>^g?+FjyT}2a>az2iZ#;AIdZlBYwK{ikRs-{`F zMI9UIc-W%7@5Qhk49)eG95!-GhHfFM<4oH_m7G)XP|?I?Pt@;X+p)b+PSd``#EQ;= z*JtZa+BA(cu6QAW>A?BqBDOJ!<%J3P4K8cu9e);OPpsrDz3h1J(MxBSa<-oJv&aq^ z+$<$M<&N*rJ@om!P3D)}hYC;jJ$)y&XW3IzQ=3lq7+bGqlLfP+CmlVXoH3WHC6d}s z+Lc6s79+7js*%W7agu&3my={)s$xUaEn?*VPO>!SM5X3MaTz<=6QuP4Nkj+P9wTGb z0Ef!(zB0L^OtzHE?Z}xI(b$tvgbL- z+Qd&|o6dH6H-2?b{q&^_PL2Gng8qw*)Is#zyGI|`SI_UL=Qrf1A8+?>JG+;z?Wv#9 z=%fFrKpkmL?J5@tXs|ibLKoXg zq%qeag#vNOiW1>PzOKzXQxo%~Gqk+!*xhba7&}|V-5yjK^Mz@38_qBKFTffnVXO-@ qmUOLTYE?s@w&LVSe{eP(j+Z=y@BqkvflJT9=yULXM;wMx-|sa_>~qfm literal 0 HcmV?d00001 diff --git a/gui/v2/ui/builders/__pycache__/top_section.cpython-312.pyc b/gui/v2/ui/builders/__pycache__/top_section.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..31cf23e2b226684417a98a392f3dbb63862c44d8 GIT binary patch literal 1480 zcmah}L2naB6rSCkT|0@L)Io+gq{aztBnx7Tsse;iF0={ov1u z5~GdeLob{_LKQ}>gbzJX<)`#yVae7MPNk|iH7Ggt)HfS%jZ#&8_Rjm>oA=(lnfZ2q zNhYTe$e(K;*taxKGgr06aki5ye3jsZ~dD)r#tB6-|-0=3rN^=z#HesA9C0 zy#7Wem-4DGHgB0*)Df}GuPm}nd0qk6mzGOeEm}Z^#^8Yl&|w_BPY_q6Q%ZpL$9PW- zR9nWJ5TX!ue_TsJ5Tl<60F!9&!w`p$=fXI-<_RHsw3>qWR5Tf=k#|oEwEwKH5MxrG zhX*Sw*ommlMB8%{9HBrVv1lKAI6!tPnwjYTsd{P>8KD42y|WBY|J!SyiF^~iKZXs3 z#G`CDk4zNt&YkFiZZAguiMHRL;0VEvvJsx>N&P?#v_L0QWSY#7#E(W9<&z~V2RCkg z4cE)$6Jq&q6%#IVP#~DI* zLQW=Klih7mTIG_DBEjh{uln3%yt*rpm4T zU~f>*rJS`z5{%EbY+BmQ|F`y^o48ZnZ3jQ05o8 z*R1-`isdy5&9<1YLA%h&O?({~d-7dYNA8-g#W@lty@dh?o0#xp%(j5AcQ1 z&h~M(d$+gTD?dL!z=hD4>*HMS+yGyCotS^L{vxr``E-P*hB)2F=|^Ta)xFU>HNYRf zo=ZL4d9d>^co1}N_UspPmpV5`czTEz`gq~xQuf)#(~W1Pr={mt2TLCxX=pLqt^bf6 zW-j(K7YCWb=-TxoP0`nn43s|Gz5KY;xjn+EAdRv?q*WP1F%>yeg2*V!tV@&oA0vIm-?D8HhqKhT9&=-i=kMlr&qzX7WhOB?_I literal 0 HcmV?d00001 diff --git a/gui/v2/ui/builders/bottom_section.py b/gui/v2/ui/builders/bottom_section.py new file mode 100755 index 0000000..378cbb8 --- /dev/null +++ b/gui/v2/ui/builders/bottom_section.py @@ -0,0 +1,54 @@ +import os + +from PyQt6.QtWidgets import QLabel, QPushButton +from PyQt6.QtGui import QIcon +from PyQt6.QtCore import Qt + + +def create_bottom_section(parent, btn_path, client_version, is_sync_urgent): + status_label = QLabel(parent) + status_label.setGeometry(15, 518, 780, 20) + status_label.setAlignment( + Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) + status_label.setStyleSheet( + "color: rgb(0, 255, 255); font-size: 16px;") + status_label.setText( + f"Status: Client version {client_version}") + + tor_text = QLabel("Billing & Browsers", parent) + tor_text.setGeometry(532, 541, 150, 20) + tor_text.setStyleSheet(""" + QLabel { + color: cyan; + font-size: 11px; + font-weight: bold; + } + """) + + toggle_button = QPushButton(parent) + toggle_button.setGeometry(670, 541, 50, 22) + toggle_button.setCheckable(True) + + tor_icon = QLabel(parent) + tor_icon.setGeometry(730, 537, 25, 25) + + sync_button = QPushButton(parent) + sync_button.setGeometry(771, 541, 22, 22) + sync_button.setObjectName('sync_button') + + if is_sync_urgent: + sync_icon = QIcon(os.path.join(btn_path, 'icon_sync_urgent.png')) + else: + sync_icon = QIcon(os.path.join(btn_path, 'icon_sync.png')) + + sync_button.setToolTip('Sync') + sync_button.setIcon(sync_icon) + sync_button.setIconSize(sync_button.size()) + + return { + 'status_label': status_label, + 'tor_text': tor_text, + 'toggle_button': toggle_button, + 'tor_icon': tor_icon, + 'sync_button': sync_button, + } diff --git a/gui/v2/ui/builders/system_tray.py b/gui/v2/ui/builders/system_tray.py new file mode 100755 index 0000000..104f791 --- /dev/null +++ b/gui/v2/ui/builders/system_tray.py @@ -0,0 +1,34 @@ +import os + +from PyQt6.QtWidgets import QSystemTrayIcon +from PyQt6.QtGui import QIcon +from PyQt6.QtCore import QSize + + +def init_system_tray(parent, icon_base_path): + if not QSystemTrayIcon.isSystemTrayAvailable(): + return None + + tray_icon = QIcon() + window_icon = QIcon() + + tray_sizes = [22, 24] + for size in tray_sizes: + icon_path = os.path.join( + icon_base_path, f"hv-icon-{size}x{size}.png") + if os.path.exists(icon_path): + tray_icon.addFile(icon_path, QSize(size, size)) + + window_sizes = [32, 48, 64, 128] + for size in window_sizes: + icon_path = os.path.join( + icon_base_path, f"hv-icon-{size}x{size}.png") + if os.path.exists(icon_path): + window_icon.addFile(icon_path, QSize(size, size)) + + tray = QSystemTrayIcon(parent) + tray.setToolTip('HydraVeil') + tray.setIcon(tray_icon) + tray.setVisible(True) + parent.setWindowIcon(window_icon) + return tray diff --git a/gui/v2/ui/builders/top_section.py b/gui/v2/ui/builders/top_section.py new file mode 100755 index 0000000..0aa54a0 --- /dev/null +++ b/gui/v2/ui/builders/top_section.py @@ -0,0 +1,30 @@ +import os + +from PyQt6.QtWidgets import QLabel, QWidget +from PyQt6.QtCore import QTimer + + +def create_top_section(parent, css_path): + central_widget = QWidget(parent) + central_widget.setMaximumSize(800, 600) + central_widget.setGeometry(0, 0, 850, 600) + central_widget.setObjectName("centralwidget") + + label = QLabel(central_widget) + label.setGeometry(0, 0, 800, 570) + + css_file = os.path.join(css_path, 'look.css') + parent.setStyleSheet(open(css_file).read() + if os.path.exists(css_file) else '') + + marquee_timer = QTimer(parent) + marquee_timer.timeout.connect(parent.update_marquee) + + return { + 'central_widget': central_widget, + 'label': label, + 'marquee_timer': marquee_timer, + 'text_start_x': 15, + 'text_end_x': 420, + 'text_width': 405, + } diff --git a/gui/v2/ui/pages/Page.py b/gui/v2/ui/pages/Page.py new file mode 100755 index 0000000..f9cd37c --- /dev/null +++ b/gui/v2/ui/pages/Page.py @@ -0,0 +1,86 @@ +import os + +from PyQt6.QtWidgets import QWidget, QLabel, QPushButton +from PyQt6.QtGui import QIcon +from PyQt6 import QtCore + + +class Page(QWidget): + + def __init__(self, name, page_stack, custom_window, parent=None): + super().__init__(parent) + self.custom_window = custom_window + self.btn_path = custom_window.btn_path + self.name = name + self.page_stack = page_stack + self.init_ui() + self.selected_profiles = [] + self.selected_wireguard = [] + self.selected_residential = [] + self.buttons = [] + + def add_selected_profile(self, profile): + self.selected_profiles.clear() + self.selected_wireguard.clear() + self.selected_residential.clear() + self.selected_profiles.append(profile) + + def init_ui(self): + self.title = QLabel(self) + self.title.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + self.title.setObjectName("titles") + + self.display = QLabel(self) + self.display.setObjectName("display") + self.display.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + buttons_info = [ + (QPushButton, "button_back", "back", (660, 534, 48, 35)), + (QPushButton, "button_next", "next", (720, 534, 48, 35)), + (QPushButton, "button_reverse", "back", (660, 534, 48, 35)), + (QPushButton, "button_go", "next", (720, 534, 48, 35)), + (QPushButton, "button_apply", "apply", (720, 534, 48, 35)), + ] + for button_type, object_name, icon_name, geometry in buttons_info: + button = button_type(self) + button.setObjectName(object_name) + button.setGeometry(*geometry) + button.setIcon( + QIcon(os.path.join(self.btn_path, f"{icon_name}.png"))) + button.setIconSize(QtCore.QSize(48, 35)) + button.setVisible(False) + if object_name == "button_back": + self.button_back = button + self.button_back.clicked.connect(self.gestionar_back) + if object_name == "button_next": + self.button_next = button + self.button_next.clicked.connect(self.gestionar_next) + if object_name == "button_reverse": + self.button_reverse = button + self.button_reverse.clicked.connect(self.limpiar) + + if object_name == "button_go": + self.button_go = button + self.button_go.clicked.connect(self.limpiar) + + if object_name == "button_apply": + self.button_apply = button + + def gestionar_back(self): + current_index = self.page_stack.currentIndex() + if current_index == 18: + return + if current_index > 0: + self.page_stack.setCurrentIndex(current_index - 1) + + def gestionar_next(self): + current_index = self.page_stack.currentIndex() + next_index = (current_index + 1) % self.page_stack.count() + self.page_stack.setCurrentIndex(next_index) + self.limpiar() + + def limpiar(self): + self.display.clear() + for boton in self.buttons: + boton.setChecked(False) + + self.button_next.setVisible(False) diff --git a/gui/v2/ui/pages/__init__.py b/gui/v2/ui/pages/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/gui/v2/ui/pages/__pycache__/Page.cpython-312.pyc b/gui/v2/ui/pages/__pycache__/Page.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ab557430c65e078d94bc5db2c3c60dd5b2b6c545 GIT binary patch literal 5299 zcmcf_Yi}FJagRKbNAf716G^6JOQt17GEGa0W5JGr_^F4R#8E5+eX$M*ig%`urbu@0 z=tKeqf`01El6Ryi z1!;_;3vhRKb{@-l?9SewL!khH_KOoE+3O-9f5(a4JPqLS3;-)cCNh^J2^?KHZr+t} z&ASusc|O5&h;!#W^WKD)!F+%x*1uo`$fRmY++w#r+ zSd}x1Zh2?VrRJ2J6`Va^(k`7T>3Sg_<1EkYi)jXUXZ48!Rnm1S^}ZpJJU#*QU%)06 zlHg>LaLK+3xyZ@fRe!=QyRMQ1FT0`l$UO93+)~0Pd!ZL9n z*#$F4dzoZI_DXZS-2gd~Awcc3SJ+J0H1ltoF*hS-f!yL|VnNHJm5K_rgk)09t9mkN zh0-NWFU%+3Q1f!(4NI8Q^T}dLzhv?G)VyK^ieQ|irl-=EEgxeksn!ln$th`Fk&{JQ zxTxk7&3ei~y`fSiQ%X_U>U3~a(NtN<>uM@z`R169wV2o9f#4#QV0&~2b{)`J-eQUZ zspU^5)47zUC6gN3Gm7o4e;*&aRG3%BbR{!3SxA@Wfl?dOiX)d6WSSbeqNur%Oi3NX z-<9XapvV1dW9Z`1;sQn6E{535wg8$)`x$iKyhT0}Ld!lwh^%&3g=nq!K!vXgJ+;U) zW@OZej8^z-p;~7I27j$*5QcC~5U-2N;#Gp5jI2z{n7Xb6KO8pkD|AQ6u zr|hlDZkd-o7x@;9SN6%mCX9azO<)U6P!^FZ<5}X$+$6cdz5Y8wEGvD^wS^%@o zL%9;$F=KgkRmbAOC#za9ms+rb_1Ymhhvf*5eT-LcLaaXM#tz-{0Hf0(%qwr|P{}|K zgd0FZhhUjv+))f6tD_;2DpwS$DVD!6&lD`N0m4O} z=hXCNMFyad&x2B{1L`APE#y;_nU%(ntObe2qjAQYQ|F5++~NmeMlrKuzWVLmFqH;H z<;8+ye0`y)Siu7OZ%I~7`c=?c2ZfCN*SvK*YiKPj@}RNnpW?Z_D6-qWQF&r~YWu;B zP^xw9UY&gR0#soExV5hbmIJ1MxBG|QkNh9_&FE1hden?g7}1HUFzHZ44I#P~sR{?a z+}XP-zgq@&5f`C|GlhOb=wCZl6-FEk76%^=eH8m3X7(R5`j46YQ%3((Rha%V6j>cv z@7_2xu^~==);8JbIj}yxF+RN^p2em3+Lev5(;MO$oQ$pyZycW55T_poy^%mg{HlYT zc!@K_W0jXzcdw~;B7g0Qu65q)8mdg!#HUO#YKYOb-BodCZh z^sFI0d#BSpG-(`~tV&Z)K)=y9dN({;8#;&z2AF~YLmDupxFN+&>98RkzBBp9bH6`V zl}>*F+qrx<9INdczQNz@0OFO7cREaI$dHCiY1EKL*Y%Ifx5`!N1tiT^!v|}F2X646 zO8fpfhKAi*ykWMo;_Vs$Pf~c}l`nJb(Bu}N}ML5xD<*20I@cQ`2CvTlJ2nhLr{DJT6pbETvG6&WA_<-0kC$Wc;*W5jcHSYptl+~P2-N61&lTc z9eYaF?}r{k=&1^OYF)clF5J9OnW~99OmUAP?pgieeQ^Mipu7K}m-s`b5P@$9rZ8X# z18Ye-NF69swlgXc#Qn~sH`%FQjpq<@;6!Vv!!VWiT zg&JQ*(n>CuOvX5h&j1Zij8pZC6N8sxhbGnfq5I}4`I--SdLBi1&)6ez$P<5bp7VH4 zKI-5-JO3qmJn{M-ktr6SM`5tS=ND%6Bcro={Y#xjF-%ys)2L7E^|K|lUY2+mhLt@` z{Vh_L${zE=3-v#Hf?fbQxLdZfhi;DJ{zm-&AkQ1*`Oiu3=Opr#cZTEQm7`x19GQ&& E0G%=hy8r+H literal 0 HcmV?d00001 diff --git a/gui/v2/ui/pages/__pycache__/__init__.cpython-312.pyc b/gui/v2/ui/pages/__pycache__/__init__.cpython-312.pyc new file mode 100755 index 0000000000000000000000000000000000000000..1fb2085a6ffc782eaa64ce814dce37de70caf477 GIT binary patch literal 144 zcmX@j%ge<81nP<^Ss?l`h(HIQS%4zb87dhx8U0o=6fpsLpFwJV={sA+_@$OBq~+(N zq!z`bmu8k3fyfvj1tbd+(^HFM;^Q;(GE3s)^$IF~aoFVMrh($ literal 0 HcmV?d00001 diff --git a/gui/v2/ui/pages/__pycache__/_dummy_page.cpython-312.pyc b/gui/v2/ui/pages/__pycache__/_dummy_page.cpython-312.pyc new file mode 100755 index 0000000000000000000000000000000000000000..17132876f72d070ec4a17fbf7a2fac9ae276ebd6 GIT binary patch literal 1416 zcmb^wO>Y}TbY{J~9yi`J1+@*f5!=$s(k5zsXdI_P&>*-}AyyDbt4O1bcjBzOA7*wo zjw4sfArh_x5{IJp&=WV*pTZ?pjU25&An1h~C?KkynA!ElIdJ4j^X7fb`+oDirkw*E z7Oq@te=7m-n_x0fCd5gd6OVxnZ0G_Hn$VL>$;+8JC}ff=dy1(*Ac2p7ojU}!OyILz zYHO-J#ZZ42jFF4;jn5prMOZx3`1EeDchBkt5sTHvJ!^}&28^W!Yo>N7o;U#~I_JOO zDG*>n8<>(U4Zt?E;bFnd4Zt2W+Wan(`Sx!i_j5yAXhH;A6l}l@HZE z_=?xr&pqQh0TA?IA7I{#6#5e9vP1wYoXSCF@j&j&?U_tIy;(7%+p`%pz1wqBI-@d= z5U|%XnGG}P1EsIDFJ|)TZA;k;p1bmYXToc{JdKr6edSC{@Sj1Yoa@>+D28}C+i zTg*uB@FD5H9z4>0Yu9O6%n5wG8_@gV{;g)<2DGkscN|7m^zFcB3!$@5>iVtQoxO>m zOPtmYtLs~VYp?8=wyfs;77ZfbUYJE;LuiaJc6^6n9G?@xamct) ztTm&M1s?7?z8!R_Sev*Q68669w0w{FjH*JUo$?>LRx6%K*j273l!|W>Yusjo^<1*C zL->LhEZa_Z-N?ssNZf5Iwt;Hmt=h3DH;q*{{Q!z9_CGFQ+;pul#5m0JXL<(21icLvHa($Ysqm5)bgJz+~jRC-z+m#+`Y*T?0hVR>nUYKgab5%l5+ zz4Z$!j*)nGR9^Z4)lPU`z<_IpB4*`V9uc#`*bD5)6?75f&m+rCHA#)JHtAWDxGu&9 zq+(Y?@s;1B^Bhx@Au**;wEyKH_(#%|lv#s_tNz1Lv37aJ@cT}Z{hPj(_5 zaxv4d29zX=k@)`(q;5pOSLjZf@4NI0XGJDqiK9Pq5W=59Z3t>Vf%0GKEL{FN|2N=Q GV*4*RMLuu< literal 0 HcmV?d00001 diff --git a/gui/v2/ui/pages/__pycache__/browser_page.cpython-312.pyc b/gui/v2/ui/pages/__pycache__/browser_page.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9554d34b5b7f043f61c078d139bb86eb468a6339 GIT binary patch literal 14952 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 C)G;pr literal 0 HcmV?d00001 diff --git a/gui/v2/ui/pages/__pycache__/fast_registration_page.cpython-312.pyc b/gui/v2/ui/pages/__pycache__/fast_registration_page.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..af4ff329ebe44407c2a02549b48c01d76afb9230 GIT binary patch literal 46286 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 QhFgJ_dw;AU7Sj3u7aI0Vf&c&j literal 0 HcmV?d00001 diff --git a/gui/v2/ui/pages/__pycache__/hidetor_page.cpython-312.pyc b/gui/v2/ui/pages/__pycache__/hidetor_page.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0879168e8f53ae7d66decd107befd2e1cf887c28 GIT binary patch literal 7960 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 literal 0 HcmV?d00001 diff --git a/gui/v2/ui/pages/__pycache__/location_page.cpython-312.pyc b/gui/v2/ui/pages/__pycache__/location_page.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..51e03f287d55b9d8ae699c753b3ce0d0a1093dc0 GIT binary patch literal 15328 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 literal 0 HcmV?d00001 diff --git a/gui/v2/ui/pages/__pycache__/screen_page.cpython-312.pyc b/gui/v2/ui/pages/__pycache__/screen_page.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..942a1ce428628e35a206b3f37a19dc358db80627 GIT binary patch literal 19017 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 B58MC% literal 0 HcmV?d00001 diff --git a/gui/v2/ui/pages/__pycache__/settings_page.cpython-312.pyc b/gui/v2/ui/pages/__pycache__/settings_page.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6aa9a7d4a6f1999e0861fbdbcfe5367177013e47 GIT binary patch literal 123309 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 literal 0 HcmV?d00001 diff --git a/gui/v2/ui/pages/_blank_page.py b/gui/v2/ui/pages/_blank_page.py new file mode 100755 index 0000000..5dfa940 --- /dev/null +++ b/gui/v2/ui/pages/_blank_page.py @@ -0,0 +1,17 @@ +from PyQt6.QtWidgets import QWidget, QVBoxLayout, QLabel +from PyQt6.QtCore import Qt + + +class BlankPage(QWidget): + def __init__(self, page_stack, custom_window, parent=None): + super().__init__(parent) + self.page_stack = page_stack + self.custom_window = custom_window + + layout = QVBoxLayout(self) + layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + + label = QLabel("v2 — no page migrated yet") + label.setStyleSheet("color: #888888; font-size: 14px;") + label.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(label) diff --git a/gui/v2/ui/pages/browser_page.py b/gui/v2/ui/pages/browser_page.py new file mode 100755 index 0000000..4834467 --- /dev/null +++ b/gui/v2/ui/pages/browser_page.py @@ -0,0 +1,241 @@ +import os + +from PyQt6.QtWidgets import ( + QApplication, QWidget, QPushButton, QButtonGroup, QScrollArea +) +from PyQt6.QtGui import QPixmap, QIcon, QPainter, QColor, QFont +from PyQt6.QtCore import Qt +from PyQt6 import QtCore + +from gui.v2.ui.pages.Page import Page + + +class BrowserPage(Page): + def __init__(self, page_stack, main_window, parent=None): + super().__init__("Browser", page_stack, main_window, parent) + self.btn_path = main_window.btn_path + self.update_status = main_window + self.selected_browser_icon = None + self.display.setGeometry(QtCore.QRect(5, 10, 390, 520)) + self.title.setGeometry(395, 40, 380, 40) + self.title.setText("Pick a Browser") + self.button_back.setVisible(True) + + cm = main_window.connection_manager + if cm.is_synced(): + from gui.v2.actions.sync import generate_grid_positions + browsers = cm.get_browser_list() + positions = generate_grid_positions(len(browsers)) + available = [(QPushButton, brw, positions[i]) for i, brw in enumerate(browsers)] + self.create_interface_elements(available) + + def create_interface_elements(self, available_browsers): + self._setup_scroll_area() + button_widget = self._create_button_widget() + self._populate_button_widget(button_widget, available_browsers) + self.scroll_area.setWidget(button_widget) + + def _setup_scroll_area(self): + self.scroll_area = QScrollArea(self) + self.scroll_area.setGeometry(400, 90, 385, 400) + self.scroll_area.setWidgetResizable(True) + self.scroll_area.setHorizontalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self._apply_scroll_area_style() + + def _apply_scroll_area_style(self): + self.scroll_area.setStyleSheet(""" + QScrollArea { + background: transparent; + border: none; + } + QScrollBar:vertical { + border: 1px solid white; + background: white; + width: 10px; + margin: 0px 0px 0px 0px; + } + QScrollBar::handle:vertical { + background: cyan; + min-height: 0px; + } + QScrollBar::add-line:vertical { + height: 0px; + subcontrol-position: bottom; + subcontrol-origin: margin; + } + QScrollBar::sub-line:vertical { + height: 0px; + subcontrol-position: top; + subcontrol-origin: margin; + } + """) + + def _create_button_widget(self): + button_widget = QWidget() + button_widget.setStyleSheet("background: transparent;") + self.buttonGroup = QButtonGroup(self) + self.buttons = [] + return button_widget + + def _populate_button_widget(self, button_widget, available_browsers): + dimensions = self._get_button_dimensions() + total_height = self._create_browser_buttons( + button_widget, available_browsers, dimensions) + self._set_final_widget_size(button_widget, dimensions, total_height) + + def _get_button_dimensions(self): + return { + 'width': 185, + 'height': 75, + 'spacing': 20, + 'x_offset': 195 + } + + def _create_browser_buttons(self, button_widget, browser_icons, dims): + total_height = 0 + for browser_tuple in browser_icons: + _, browser_string, geometry = browser_tuple + x_coord = geometry[0] - 395 + y_coord = geometry[1] - 90 + self._create_single_button( + button_widget, browser_string, x_coord, y_coord, dims) + total_height = max(total_height, y_coord + dims['height']) + return total_height + + def _create_single_button(self, parent, icon_name, x_coord, y_coord, dims): + button = QPushButton(parent) + button.setFixedSize(dims['width'], dims['height']) + button.move(x_coord, y_coord) + button.setIconSize(button.size()) + button.setCheckable(True) + + browser_name, version = icon_name.split(':') + button.setIcon(QIcon(BrowserPage.create_browser_button_image( + f"{browser_name} {version}", self.btn_path))) + if button.icon().isNull(): + fallback_path = os.path.join( + self.btn_path, "default_browser_button.png") + button.setIcon(QIcon(BrowserPage.create_browser_button_image( + f"{browser_name} {version}", fallback_path, True))) + self._apply_button_style(button) + + self.buttons.append(button) + self.buttonGroup.addButton(button) + button.clicked.connect( + lambda _, b=icon_name: self.show_browser(b.replace(':', ' '))) + + @staticmethod + def create_browser_button_image(browser_text, btn_path, is_fallback=False): + + if ':' in browser_text: + browser_name, version = browser_text.split(':', 1) + else: + browser_name, version = browser_text.split( + )[0], ' '.join(browser_text.split()[1:]) + if is_fallback: + base_image = QPixmap(btn_path) + else: + base_image = QPixmap(os.path.join( + btn_path, f"{browser_name}_button.png")) + + if base_image.isNull(): + base_image = QPixmap(185, 75) + base_image.fill(QColor(44, 62, 80)) + + painter = QPainter(base_image) + if painter.isActive(): + BrowserPage._setup_version_font(painter, version, base_image) + if is_fallback: + BrowserPage._setup_browser_name( + painter, browser_name, base_image) + painter.end() + return base_image + + @staticmethod + def _setup_browser_name(painter, browser_name, base_image): + font_size = 13 + app_font = QApplication.font() + app_font.setPointSize(font_size) + app_font.setWeight(QFont.Weight.Bold) + painter.setFont(app_font) + painter.setPen(QColor(0, 255, 255)) + text_rect = painter.fontMetrics().boundingRect(browser_name) + while text_rect.width() > base_image.width() * 0.6 and font_size > 6: + font_size -= 1 + + x = (base_image.width() - text_rect.width()) // 2 - 30 + y = (base_image.height() + text_rect.height()) // 2 - 10 + painter.drawText(x, y, browser_name) + + @staticmethod + def _setup_version_font(painter, version, base_image): + font_size = 11 + painter.setFont(QFont('Arial', font_size)) + painter.setPen(QColor(0x88, 0x88, 0x88)) + text_rect = painter.fontMetrics().boundingRect(version) + while text_rect.width() > base_image.width() * 0.6 and font_size > 6: + font_size -= 1 + painter.setFont(QFont('Arial', font_size)) + text_rect = painter.fontMetrics().boundingRect(version) + + x = (base_image.width() - text_rect.width()) // 2 - 27 + y = (base_image.height() + text_rect.height()) // 2 + 10 + painter.drawText(x, y, version) + + def _apply_button_style(self, button): + button.setStyleSheet(""" + QPushButton { + background: transparent; + border: none; + } + QPushButton:hover { + background-color: rgba(200, 200, 200, 30); + } + QPushButton:checked { + background-color: rgba(200, 200, 200, 50); + } + """) + + def _set_final_widget_size(self, widget, dims, total_height): + widget.setFixedSize(dims['width'] * 2 + 5, + total_height + dims['spacing']) + + def show_browser(self, browser): + browser_name, version = browser.split( + )[0], ' '.join(browser.split()[1:]) + try: + base_image = self._create_browser_display_image( + browser_name, version) + self.display.setPixmap(base_image.scaled( + self.display.size(), Qt.AspectRatioMode.KeepAspectRatio)) + except Exception: + pass + self.selected_browser_icon = browser + self.button_next.setVisible(True) + self.update_swarp_json() + + def _create_browser_display_image(self, browser_name, version): + base_image = QPixmap(os.path.join( + self.btn_path, f"{browser_name}_icon.png")) + painter = QPainter(base_image) + painter.setFont(QFont('Arial', 25)) + painter.setPen(QColor(0, 255, 255)) + + text_rect = painter.fontMetrics().boundingRect(version) + x = (base_image.width() - text_rect.width()) // 2 + y = base_image.height() - 45 + + painter.drawText(x, y, version) + painter.end() + return base_image + + def update_swarp_json(self): + inserted_data = { + "browser": self.selected_browser_icon + } + + self.update_status.write_data(inserted_data) + + def gestionar_next(self): + self.custom_window.navigator.navigate("screen") diff --git a/gui/v2/ui/pages/connection_page.py b/gui/v2/ui/pages/connection_page.py new file mode 100755 index 0000000..b4d8d70 --- /dev/null +++ b/gui/v2/ui/pages/connection_page.py @@ -0,0 +1,48 @@ +import os + +from PyQt6.QtWidgets import QLabel +from PyQt6.QtGui import QPixmap +from PyQt6 import QtCore + +from gui.v2.ui.pages.Page import Page + + +class ConnectionPage(Page): + def __init__(self, page_stack, main_window=None, parent=None): + super().__init__("Connection", page_stack, main_window, parent) + + self.create_interface_elements() + + def create_interface_elements(self): + self.display.setGeometry(QtCore.QRect(5, 50, 390, 430)) + self.title.setGeometry(QtCore.QRect(465, 40, 300, 20)) + self.title.setText("Pick a TOR") + + labels_info = [ + ("server", None, (410, 115)), + ("", "rr2", (560, 115)), + ("port", None, (410, 205)), + ("", "rr2", (560, 205)), + ("username", None, (410, 295)), + ("", "rr2", (560, 295)), + ("password", None, (410, 385)), + ("", "rr2", (560, 385)) + ] + + for j, (text, image_name, position) in enumerate(labels_info): + label = QLabel(self) + label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + if image_name: + label.setGeometry(position[0], position[1], 220, 50) + else: + label.setGeometry(position[0], position[1], 140, 50) + label.setStyleSheet( + "background-color: rgba(255, 255, 255, 51); padding: 3px;") + + if image_name: + pixmap = QPixmap(os.path.join( + self.btn_path, f"{image_name}.png")) + label.setPixmap(pixmap) + label.setScaledContents(True) + else: + label.setText(text) diff --git a/gui/v2/ui/pages/currency_selection_page.py b/gui/v2/ui/pages/currency_selection_page.py new file mode 100755 index 0000000..e53f80f --- /dev/null +++ b/gui/v2/ui/pages/currency_selection_page.py @@ -0,0 +1,65 @@ +import os + +from PyQt6.QtWidgets import QButtonGroup, QLabel, QPushButton +from PyQt6.QtGui import QIcon +from PyQt6.QtCore import QSize +from PyQt6 import QtCore + +from gui.v2.ui.pages.Page import Page + + +class CurrencySelectionPage(Page): + def __init__(self, page_stack, main_window=None, parent=None): + super().__init__("Select Currency", page_stack, main_window, parent) + self.update_status = main_window + self.selected_duration = None + self.create_interface_elements() + self.button_reverse.setVisible(True) + self.button_next.setVisible(False) + + def create_interface_elements(self): + self.title = QLabel("Payment Method", self) + self.title.setGeometry(QtCore.QRect(510, 30, 250, 40)) + self.title.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + self.button_reverse.clicked.connect(self.reverse) + + button_info = [ + ("monero", (545, 75)), + ("bitcoin", (545, 290)), + ("lightnering", (545, 180)), + ("litecoin", (545, 395)) + ] + + self.buttonGroup = QButtonGroup(self) + self.buttons = [] + + for j, (icon_name, position) in enumerate(button_info): + button = QPushButton(self) + button.setGeometry(position[0], position[1], 185, 75) + button.setIconSize(QSize(190, 120)) + button.setCheckable(True) + self.buttons.append(button) + self.buttonGroup.addButton(button, j) + button.setIcon( + QIcon(os.path.join(self.btn_path, f"{icon_name}.png"))) + button.clicked.connect(self.on_currency_selected) + + def on_currency_selected(self): + self.custom_window.navigator.navigate("payment_details") + payment_details_page = self.custom_window.navigator.get_cached("payment_details") + if payment_details_page is not None: + payment_details_page.selected_duration = self.selected_duration + payment_details_page.selected_currency = self.get_selected_currency() + payment_details_page.check_invoice() + self.update_status.update_status('Loading payment details...') + + def get_selected_currency(self): + selected_button = self.buttonGroup.checkedButton() + if selected_button: + index = self.buttonGroup.id(selected_button) + currencies = ["monero", "bitcoin", "lightning", "litecoin"] + return currencies[index] + return None + + def reverse(self): + self.custom_window.navigator.navigate("duration_selection") diff --git a/gui/v2/ui/pages/duration_selection_page.py b/gui/v2/ui/pages/duration_selection_page.py new file mode 100755 index 0000000..c355c4e --- /dev/null +++ b/gui/v2/ui/pages/duration_selection_page.py @@ -0,0 +1,92 @@ +from PyQt6.QtWidgets import QComboBox, QLabel, QPushButton +from PyQt6 import QtCore + +from gui.v2.ui.pages.Page import Page + + +class DurationSelectionPage(Page): + def __init__(self, page_stack, main_window=None, parent=None): + super().__init__("Select Duration", page_stack, main_window, parent) + self.update_status = main_window + self.create_interface_elements() + self.button_reverse.setVisible(True) + + def create_interface_elements(self): + self.title = QLabel("Select Duration", self) + self.title.setGeometry(QtCore.QRect(530, 30, 210, 40)) + self.title.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + self.button_reverse.clicked.connect(self.reverse) + + self.combo_box = QComboBox(self) + self.combo_box.setGeometry(200, 200, 400, 60) + self.combo_box.addItems( + ["1 month (€1.50)", "3 months (€4.30)", "6 months (€8)", "12 months (€16)"]) + self.combo_box.setEditable(False) + self.combo_box.setMaxVisibleItems(4) + self.combo_box.setDuplicatesEnabled(True) + self.combo_box.setPlaceholderText("Select options") + self.combo_box.setStyleSheet(""" + QComboBox { + font-size: 16px; + padding: 10px; + background-color: #000000; + color: #00ffff; + border: 2px solid #00ffff; + border-radius: 8px; + } + QComboBox::drop-down { + width: 30px; + background-color: #1a1a1a; + border-left: 1px solid #00ffff; + } + QComboBox::down-arrow { + width: 12px; + height: 12px; + color: #00ffff; + } + QComboBox QAbstractItemView { + background-color: #0a0a0a; + border: 1px solid #00ffff; + border-radius: 4px; + selection-background-color: #003333; + selection-color: #00ffff; + } + QComboBox QAbstractItemView::item { + padding: 8px; + border: none; + color: #00ffff; + background-color: #0a0a0a; + } + QComboBox QAbstractItemView::item:hover { + background-color: #1a1a1a; + color: #00ffff; + } + QComboBox QAbstractItemView::item:selected { + background-color: #003333; + color: #00ffff; + } + """) + + self.next_button = QPushButton("Next", self) + self.next_button.setGeometry(350, 300, 100, 40) + self.next_button.setStyleSheet(""" + QPushButton { + font-size: 18px; + padding: 10px; + border: 2px solid #ccc; + border-radius: 10px; + background-color: cyan; + color: black; + font-weight: bold; + } + """) + self.next_button.clicked.connect(self.show_next) + + def show_next(self): + self.custom_window.navigator.navigate("currency_selection") + currency_page = self.custom_window.navigator.get_cached("currency_selection") + if currency_page is not None: + currency_page.selected_duration = self.combo_box.currentText() + + def reverse(self): + self.custom_window.navigator.navigate("id") diff --git a/gui/v2/ui/pages/editor_page.py b/gui/v2/ui/pages/editor_page.py new file mode 100755 index 0000000..ce00ca4 --- /dev/null +++ b/gui/v2/ui/pages/editor_page.py @@ -0,0 +1,817 @@ +import os + +from PyQt6.QtWidgets import ( + QLabel, QPushButton, QLineEdit, QTextEdit, QFrame, QDialog, + QVBoxLayout, QHBoxLayout, QMessageBox, QGraphicsDropShadowEffect +) +from PyQt6.QtGui import QPixmap, QIcon, QPainter, QColor, QTransform +from PyQt6.QtCore import Qt, QSize, QTimer, QPointF +from PyQt6 import QtCore, QtGui + +from core.controllers.ProfileController import ProfileController +from core.controllers.LocationController import LocationController + +from gui.v2.ui.pages.Page import Page +from gui.v2.ui.pages.browser_page import BrowserPage +from gui.v2.ui.pages.location_page import LocationPage +from gui.v2.ui.pages.screen_page import ScreenPage +from gui.v2.ui.popups.confirmation_popup import ConfirmationPopup + + +class EditorPage(Page): + def __init__(self, page_stack, main_window): + super().__init__("Editor", page_stack, main_window) + self.page_stack = page_stack + self.update_status = main_window + self.connection_manager = main_window.connection_manager + self.labels = [] + self.buttons = [] + self.title.setGeometry(570, 40, 185, 40) + self.title.setText("Edit Profile") + + self.button_apply.setVisible(True) + self.button_apply.clicked.connect(self.go_selected) + + self.button_back.setVisible(True) + try: + self.button_back.clicked.disconnect() + except TypeError: + pass + self.button_back.clicked.connect(self.go_back) + + self.name_handle = QLabel(self) + self.name_handle.setGeometry(110, 70, 400, 30) + self.name_handle.setStyleSheet('color: #cacbcb;') + self.name_handle.setText("Profile Name:") + + self.name_hint = QLabel(self) + self.name_hint.setGeometry(265, 100, 190, 20) + self.name_hint.setStyleSheet( + 'color: #888888; font-size: 10px; font-style: italic;') + self.name_hint.setText("Click here to edit profile name") + self.name_hint.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + + self.name = QLineEdit(self) + self.name.setPlaceholderText("Enter name") + self.name.setGeometry(265, 70, 190, 30) + self.name.setStyleSheet( + "color: cyan; border: 1px solid #666666; border-radius: 3px; background-color: rgba(0, 0, 0, 0.3);") + self.name.setCursor(QtCore.Qt.CursorShape.IBeamCursor) + self.name.focusInEvent = lambda event: self.name_hint.hide() + self.name.focusOutEvent = lambda event: self.name_hint.show( + ) if not self.name.text() else self.name_hint.hide() + + self.name.textChanged.connect( + lambda text: self.name_hint.hide() if text else self.name_hint.show()) + + self.temp_changes = {} + self.original_values = {} + self.res_hint_shown = False + self.display.setGeometry(QtCore.QRect(0, 60, 540, 405)) + self.display.hide() + + self.garaje = QLabel(self) + self.garaje.setGeometry(QtCore.QRect(0, 70, 540, 460)) + + self.brow_disp = QLabel(self) + self.brow_disp.setGeometry(QtCore.QRect(0, 50, 540, 460)) + self.brow_disp.setPixmap( + QPixmap(os.path.join(self.btn_path, "browser only.png"))) + self.brow_disp.hide() + self.brow_disp.lower() + + def _selected_profiles_str(self): + menu_page = self.find_menu_page() + if menu_page is not None and menu_page.selected_profiles: + return ', '.join( + [f"Profile_{profile['profile_number']}" for profile in menu_page.selected_profiles]) + profile_id = getattr(self.update_status, 'current_profile_id', None) + if profile_id is not None: + return f"Profile_{int(profile_id)}" + return '' + + def update_name_value(self): + original_name = self.data_profile.get('name', '') + new_name = self.name.text() + if original_name != new_name: + self.update_temp_value('name', new_name) + else: + pass + + def go_back(self): + selected_profiles_str = self._selected_profiles_str() + if self.has_unsaved_changes(selected_profiles_str): + self.show_unsaved_changes_popup() + else: + self.custom_window.navigator.navigate("menu") + + def find_menu_page(self): + return self.custom_window.navigator.get_cached("menu") + + def find_resume_page(self): + return self.custom_window.navigator.get_cached("resume") + + def find_protocol_page(self): + return self.custom_window.navigator.get_cached("protocol") + + def showEvent(self, event): + super().showEvent(event) + self.res_hint_shown = False + self.extraccion() + + def extraccion(self): + self.data_profile = {} + for label in self.labels: + label.deleteLater() + self.labels = [] + for button in self.buttons: + button.deleteLater() + self.buttons = [] + + selected_profiles_str = self._selected_profiles_str() + menu_page = self.find_menu_page() + if menu_page: + new_profiles = ProfileController.get_all() + self.profiles_data = menu_page.match_core_profiles( + profiles_dict=new_profiles) + self.data_profile = self.profiles_data[selected_profiles_str].copy( + ) + + profile_id = int(selected_profiles_str.split('_')[1]) + self.data_profile['id'] = profile_id + + if selected_profiles_str in self.temp_changes: + for key, value in self.temp_changes[selected_profiles_str].items(): + self.data_profile[key] = value + + self.name.textChanged.connect(self.update_name_value) + self.verificate(self.data_profile, selected_profiles_str) + + def verificate(self, data_profile, selected_profile_str): + protocol = data_profile.get("protocol", "") + + try: + if protocol == "wireguard": + self.process_and_show_labels(data_profile, { + "protocol": ['wireguard', 'residential', 'hidetor'], + "connection": ['browser-only', 'system-wide'], + "location": self.connection_manager.get_location_list(), + "browser": self.connection_manager.get_browser_list(), + "dimentions": self.connection_manager.get_available_resolutions(data_profile.get('id', '')) + }, selected_profile_str) + + elif protocol == "residential" or protocol == "hidetor": + self.process_and_show_labels(data_profile, { + "protocol": ['residential', 'wireguard', 'hidetor'], + "connection": ['tor', 'just proxy'], + "location": self.connection_manager.get_location_list(), + "browser": self.connection_manager.get_browser_list(), + "dimentions": self.connection_manager.get_available_resolutions(data_profile.get('id', '')) + }, selected_profile_str) + + elif protocol == "open": + self.process_and_show_labels(data_profile, { + "protocol": ['open'] + }, selected_profile_str) + + except Exception as e: + print(f'An error occurred in verificate: {e}') + + def process_and_show_labels(self, data_profile, parameters, selected_profile_str): + protocol = data_profile.get('protocol', "") + connection = data_profile.get('connection', "") + location = data_profile.get('location', "") + country_garaje = location + name = data_profile.get('name', "") + self.name.setText(name) + + if protocol == "hidetor": + if connection == "just proxy": + self.display.setPixmap(QPixmap(os.path.join( + self.btn_path, f"icon_{location}.png"))) + else: + self.display.setPixmap(QPixmap(os.path.join( + self.btn_path, f"hdtor_{location}.png"))) + self.display.show() + self.display.setGeometry(0, 100, 400, 394) + + self.display.setScaledContents(True) + + self.garaje.hide() + self.brow_disp.hide() + + if protocol == "wireguard": + self.display.setGeometry(0, 60, 540, 405) + self.display.show() + self.display.setPixmap(QPixmap(os.path.join( + self.btn_path, f"wireguard_{location}.png"))) + self.garaje.hide() + if connection == "browser-only": + is_supported = data_profile.get('browser_supported', False) + image_name = "browser only.png" if is_supported else "unsupported_browser_only.png" + self.brow_disp.setPixmap( + QPixmap(os.path.join(self.btn_path, image_name))) + self.brow_disp.show() + else: + self.brow_disp.hide() + + if protocol == "residential": + self.display.setGeometry(0, 60, 540, 405) + self.brow_disp.show() + self.garaje.show() + + self.display.hide() + if country_garaje: + country_garaje = 'brazil' + self.garaje.setPixmap(QPixmap(os.path.join( + self.btn_path, f"{country_garaje} garaje.png"))) + + profile_id = data_profile.get('id') + profile_obj = ProfileController.get(profile_id) + + location_info = None + if location: + try: + if '_' in location: + country_code, location_code = location.split('_', 1) + location_info = LocationController.get( + country_code, location_code) + except Exception: + location_info = None + + operator_name = "" + l_name = "" + o_name = "" + n_key = "" + + if location_info: + if hasattr(location_info, 'operator') and location_info.operator: + operator_name = location_info.operator.name + o_name = location_info.operator.name + n_key = location_info.operator.nostr_public_key + + l_name = f"{location_info.country_name}, {location_info.name}" if hasattr( + location_info, 'country_name') else "" + + if operator_name != 'Simplified Privacy' and operator_name != "" and protocol != 'hidetor': + text_color = "white" + if profile_obj and profile_obj.is_session_profile(): + text_color = "black" + + l_img = QLabel(self) + l_img.setGeometry(20, 125, 150, 150) + pixmap_loc = QPixmap(os.path.join( + self.btn_path, f"icon_{location}.png")) + l_img.setPixmap(pixmap_loc) + l_img.setScaledContents(True) + l_img.show() + self.labels.append(l_img) + + info_txt = QTextEdit(self) + info_txt.setGeometry(180, 120, 300, 250) + info_txt.setReadOnly(True) + info_txt.setFrameStyle(QFrame.Shape.NoFrame) + info_txt.setStyleSheet( + f"background: transparent; border: none; color: {text_color}; font-size: 19px;") + info_txt.setVerticalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + info_txt.setHorizontalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + info_txt.setWordWrapMode( + QtGui.QTextOption.WrapMode.WrapAtWordBoundaryOrAnywhere) + info_txt.setText( + f"Location: {l_name}\n\nOperator: {o_name}") + info_txt.show() + self.labels.append(info_txt) + + nostr_txt = QTextEdit(self) + nostr_txt.setGeometry(20, 300, 450, 170) + nostr_txt.setReadOnly(True) + nostr_txt.setFrameStyle(QFrame.Shape.NoFrame) + nostr_txt.setStyleSheet( + f"background: transparent; border: none; color: {text_color}; font-size: 21px;") + nostr_txt.setVerticalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + nostr_txt.setHorizontalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + nostr_txt.setWordWrapMode( + QtGui.QTextOption.WrapMode.WrapAnywhere) + nostr_txt.setText(f"Nostr: {n_key}") + nostr_txt.show() + self.labels.append(nostr_txt) + + for i, key in enumerate(parameters.keys()): + if key == 'browser': + browser_value = f"{data_profile.get(key, '')}" + split_value = browser_value.split(':', 1) + browser_type = split_value[0] + browser_version = split_value[1] if len( + split_value) > 1 else '' + browser_value = f"{browser_type} {browser_version}" + if browser_version == '': + browser_version = data_profile.get('browser_version', '') + browser_value = f"{browser_type} {browser_version}" + if connection == 'system-wide' or not browser_value.strip(): + base_image = QPixmap() + else: + base_image = BrowserPage.create_browser_button_image( + browser_value, self.btn_path) + if base_image.isNull(): + fallback_path = os.path.join( + self.btn_path, "default_browser_button.png") + base_image = BrowserPage.create_browser_button_image( + browser_value, fallback_path, True) + elif key == 'location': + current_value = f"{data_profile.get(key, '')}" + image_path = os.path.join( + self.btn_path, f"button_{current_value}.png") + base_image = QPixmap(image_path) + locations = self.connection_manager.get_location_info( + current_value) + provider = locations.operator.name if locations and hasattr( + locations, 'operator') else None + fallback_path = os.path.join( + self.btn_path, "default_location_button.png") + if base_image.isNull(): + if locations and hasattr(locations, 'country_name'): + location_name = locations.country_name + base_image = LocationPage.create_location_button_image( + location_name, fallback_path, provider) + else: + base_image = LocationPage.create_location_button_image( + current_value, fallback_path, provider) + else: + if locations and hasattr(locations, 'country_name'): + base_image = LocationPage.create_location_button_image( + f'{locations.country_code}_{locations.code}', fallback_path, provider, image_path) + else: + base_image = LocationPage.create_location_button_image( + current_value, fallback_path, provider, image_path) + + elif key == 'dimentions': + current_value = f"{data_profile.get(key, '')}" + if current_value != 'None': + base_image = ScreenPage.create_resolution_button_image( + self, current_value) + else: + image_path = os.path.join( + self.btn_path, f"{data_profile.get(key, '')}_button.png") + current_value = data_profile.get(key, '') + base_image = QPixmap(image_path) + + if key == 'dimentions': + label = QPushButton(self) + label.setGeometry(565, 90 + i * 80, 185, 75) + label.setFlat(True) + label.setStyleSheet("border: none; background: transparent;") + original_icon = QIcon(base_image) + label.setIconSize(QSize(185, 75)) + label.setCursor(QtCore.Qt.CursorShape.PointingHandCursor) + label.clicked.connect(self.show_custom_res_dialog) + + template_path = os.path.join( + self.btn_path, "resolution_template_button.png") + template_pixmap = QPixmap(template_path).scaled( + 187, 75) if os.path.exists(template_path) else None + + if not getattr(self, 'res_hint_shown', False) and connection != 'system-wide': + self.res_hint_shown = True + if template_pixmap: + label.setIcon(QIcon(template_pixmap)) + else: + label.setIcon(QIcon()) + + hint_label = QLabel("Click here to customize", label) + hint_label.setGeometry(15, 0, 155, 75) + hint_label.setWordWrap(True) + hint_label.setAlignment( + QtCore.Qt.AlignmentFlag.AlignCenter) + hint_label.setStyleSheet( + "color: #00ffff; font-weight: bold; font-size: 16px; background: transparent;") + hint_label.setCursor( + QtCore.Qt.CursorShape.PointingHandCursor) + hint_label.setAttribute( + QtCore.Qt.WidgetAttribute.WA_TransparentForMouseEvents) + shadow = QGraphicsDropShadowEffect(hint_label) + shadow.setBlurRadius(20) + shadow.setColor(QColor(0, 255, 255)) + shadow.setOffset(0, 0) + hint_label.setGraphicsEffect(shadow) + + def restore(): + try: + if hint_label and not hint_label.isHidden(): + hint_label.hide() + label.setIcon(original_icon) + except: + pass + QTimer.singleShot(3500, restore) + else: + label.setIcon(original_icon) + else: + label = QLabel(key, self) + label.setGeometry(565, 90 + i * 80, 185, 75) + label.setPixmap(base_image) + label.setScaledContents(True) + + label.show() + self.labels.append(label) + + try: + if key == 'browser': + value_index = [item.replace( + ':', ' ') for item in parameters[key]].index(browser_value) + else: + value_index = parameters[key].index(current_value) + except ValueError: + value_index = 0 + + prev_button = QPushButton(self) + prev_button.setGeometry(535, 90 + i * 80, 30, 75) + prev_button.clicked.connect( + lambda _, k=key, idx=value_index: self.show_previous_value(k, idx, parameters)) + prev_button.show() + icon_path = os.path.join(self.btn_path, f"UP_button.png") + icon = QPixmap(icon_path) + transform = QTransform().rotate(180) + rotated_icon = icon.transformed(transform) + + prev_button.setIcon(QIcon(rotated_icon)) + prev_button.setIconSize(prev_button.size()) + + if (key == 'location' or key == 'browser') and not self.connection_manager.is_synced(): + w = prev_button.width() + h = prev_button.height() + outline_pix = QPixmap(w, h) + outline_pix.fill(Qt.GlobalColor.transparent) + painter = QPainter(outline_pix) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + pen = QtGui.QPen(QColor(0, 255, 0)) + pen.setWidth(3) + painter.setPen(pen) + painter.setBrush(Qt.BrushStyle.NoBrush) + left_points = [ + QPointF(w*0.65, h*0.15), QPointF(w*0.25, h*0.50), QPointF(w*0.65, h*0.85)] + painter.drawPolygon(QtGui.QPolygonF(left_points)) + painter.end() + prev_button.setIcon(QIcon(outline_pix)) + prev_button.setIconSize(prev_button.size()) + + if (key == 'location' or key == 'browser') and not self.connection_manager.is_synced(): + prev_button.setStyleSheet(""" + QPushButton { + background-color: transparent; + border: none; + } + QPushButton:hover { + background-color: rgba(0, 255, 0, 0.1); + border-radius: 3px; + } + QPushButton:pressed { + background-color: rgba(0, 255, 0, 0.2); + border-radius: 3px; + } + """) + + self.buttons.append(prev_button) + + next_button = QPushButton(self) + next_button.setGeometry(750, 90 + i * 80, 30, 75) + next_button.clicked.connect( + lambda _, k=key, idx=value_index: self.show_next_value(k, idx, parameters)) + next_button.show() + self.buttons.append(next_button) + next_button.setIcon( + QIcon(os.path.join(self.btn_path, f"UP_button.png"))) + next_button.setIconSize(next_button.size()) + if (key == 'location' or key == 'browser') and not self.connection_manager.is_synced(): + w = next_button.width() + h = next_button.height() + outline_pix_r = QPixmap(w, h) + outline_pix_r.fill(Qt.GlobalColor.transparent) + painter_r = QPainter(outline_pix_r) + painter_r.setRenderHint(QPainter.RenderHint.Antialiasing) + pen_r = QtGui.QPen(QColor(0, 255, 0)) + pen_r.setWidth(3) + painter_r.setPen(pen_r) + painter_r.setBrush(Qt.BrushStyle.NoBrush) + right_points = [ + QPointF(w*0.35, h*0.15), QPointF(w*0.75, h*0.50), QPointF(w*0.35, h*0.85)] + painter_r.drawPolygon(QtGui.QPolygonF(right_points)) + painter_r.end() + next_button.setIcon(QIcon(outline_pix_r)) + next_button.setIconSize(next_button.size()) + + if (key == 'location' or key == 'browser') and not self.connection_manager.is_synced(): + next_button.setStyleSheet(""" + QPushButton { + background-color: transparent; + border: none; + } + QPushButton:hover { + background-color: rgba(0, 255, 0, 0.1); + border-radius: 3px; + } + QPushButton:pressed { + background-color: rgba(0, 255, 0, 0.2); + border-radius: 3px; + } + """) + + prev_button.setVisible(True) + next_button.setVisible(True) + if key == 'protocol' or (protocol == 'wireguard' and key == 'connection'): + prev_button.setDisabled(True) + next_button.setDisabled(True) + + if connection == 'system-wide' and (key == 'browser' or key == 'dimentions'): + prev_button.setVisible(False) + next_button.setVisible(False) + + def on_sync_complete_for_edit_profile(self, available_locations, available_browsers, status, is_tor, locations, all_browsers): + if status: + self.update_status.update_status('Sync complete.') + self.extraccion() + else: + self.update_status.update_status( + 'Sync failed. Please try again later.') + + def show_previous_value(self, key: str, index: int, parameters: dict) -> None: + if key == 'browser' or key == 'location': + if not self.connection_manager.is_synced(): + self.update_status.update_status('Syncing in progress..') + self.update_status.sync() + self.update_status.worker_thread.sync_output.connect( + self.on_sync_complete_for_edit_profile) + return + + previous_index = (index - 1) % len(parameters[key]) + previous_value = parameters[key][previous_index] + self.update_temp_value(key, previous_value) + + def show_next_value(self, key: str, index: int, parameters: dict) -> None: + if key == 'browser' or key == 'location': + if not self.connection_manager.is_synced(): + self.update_status.update_status('Syncing in progress..') + self.update_status.sync() + self.update_status.worker_thread.sync_output.connect( + self.on_sync_complete_for_edit_profile) + return + + next_index = (index + 1) % len(parameters[key]) + next_value = parameters[key][next_index] + + self.update_temp_value(key, next_value) + + def update_temp_value(self, key: str, new_value: str) -> None: + selected_profiles_str = self._selected_profiles_str() + if selected_profiles_str not in self.temp_changes: + self.temp_changes[selected_profiles_str] = {} + self.original_values[selected_profiles_str] = self.data_profile.copy( + ) + + self.temp_changes[selected_profiles_str][key] = new_value + self.extraccion() + + def show_custom_res_dialog(self): + dialog = QDialog(self) + dialog.setWindowTitle("Custom Resolution") + dialog.setFixedSize(300, 150) + dialog.setModal(True) + dialog.setStyleSheet(""" + QDialog { + background-color: #2c3e50; + border: 2px solid #00ffff; + border-radius: 10px; + } + QLabel { + color: white; + font-size: 12px; + } + QLineEdit { + background-color: #34495e; + color: white; + border: 1px solid #00ffff; + border-radius: 5px; + padding: 5px; + font-size: 12px; + } + QPushButton { + background-color: #2c3e50; + color: white; + border: 2px solid #00ffff; + border-radius: 5px; + padding: 8px; + font-size: 12px; + } + QPushButton:hover { + background-color: #34495e; + } + """) + + layout = QVBoxLayout() + input_layout = QHBoxLayout() + width_label = QLabel("Width:") + width_input = QLineEdit() + width_input.setPlaceholderText("1920") + height_label = QLabel("Height:") + height_input = QLineEdit() + height_input.setPlaceholderText("1080") + + input_layout.addWidget(width_label) + input_layout.addWidget(width_input) + input_layout.addWidget(height_label) + input_layout.addWidget(height_input) + + button_layout = QHBoxLayout() + ok_button = QPushButton("OK") + cancel_button = QPushButton("Cancel") + button_layout.addWidget(cancel_button) + button_layout.addWidget(ok_button) + + layout.addLayout(input_layout) + layout.addLayout(button_layout) + dialog.setLayout(layout) + + def validate_and_accept(): + try: + width = int(width_input.text()) + height = int(height_input.text()) + if width <= 0 or height <= 0: + return + host_w = self.custom_window.host_screen_width + host_h = self.custom_window.host_screen_height + + adjusted = False + if width > host_w: + width = host_w - 50 + adjusted = True + if height > host_h: + height = host_h - 50 + adjusted = True + + if adjusted: + QMessageBox.information( + dialog, "Notice", "Adjusted to fit host size") + + self.update_temp_value('dimentions', f"{width}x{height}") + dialog.accept() + except ValueError: + pass + + ok_button.clicked.connect(validate_and_accept) + cancel_button.clicked.connect(dialog.reject) + + width_input.returnPressed.connect(validate_and_accept) + height_input.returnPressed.connect(validate_and_accept) + + dialog.exec() + + def has_unsaved_changes(self, profile_str: str) -> bool: + return profile_str in self.temp_changes and bool(self.temp_changes[profile_str]) + + def show_apply_changes_popup(self, selected_profiles_str: str) -> bool: + if selected_profiles_str not in self.temp_changes: + return False + temp_changes = self.temp_changes[selected_profiles_str] + current_data = self.original_values[selected_profiles_str] + + current_browser = f"{current_data.get('browser', '')} {current_data.get('browser_version', '')}" + + if 'location' in temp_changes and temp_changes['location'] != current_data.get('location'): + message = "A new location would require a new subscription code.\nDo you want to proceed with erasing the old one?" + elif 'browser' in temp_changes and temp_changes['browser'] != current_browser: + message = "Changing the browser would delete all data associated with it. Proceed?" + else: + return False + + self.popup = ConfirmationPopup( + self, + message=message, + action_button_text="Continue", + cancel_button_text="Cancel" + ) + self.popup.setWindowModality(Qt.WindowModality.ApplicationModal) + self.popup.finished.connect( + lambda result: self.handle_apply_changes_response(result)) + self.popup.show() + return True + + def show_unsaved_changes_popup(self) -> None: + self.popup = ConfirmationPopup( + self, + message="You have unsaved changes. Do you want to continue?", + action_button_text="Continue", + cancel_button_text="Cancel" + ) + self.popup.setWindowModality(Qt.WindowModality.ApplicationModal) + self.popup.finished.connect( + lambda result: self.handle_unsaved_changes_response(result)) + self.popup.show() + + def _refresh_menu_and_navigate(self): + menu_page = self.find_menu_page() + if menu_page is not None and hasattr(menu_page, 'refresh_menu_buttons'): + menu_page.refresh_menu_buttons() + self.custom_window.navigator.navigate("menu") + + def handle_apply_changes_response(self, result: bool) -> None: + if result: + self.commit_changes() + self._refresh_menu_and_navigate() + + def handle_unsaved_changes_response(self, result: bool) -> None: + if result: + self.temp_changes.clear() + self._refresh_menu_and_navigate() + + def go_selected(self) -> None: + selected_profiles_str = self._selected_profiles_str() + + if selected_profiles_str in self.temp_changes and self.temp_changes[selected_profiles_str]: + needs_confirmation = self.show_apply_changes_popup( + selected_profiles_str) + if not needs_confirmation: + self.commit_changes() + self._refresh_menu_and_navigate() + + else: + self.custom_window.navigator.navigate("menu") + + def commit_changes(self) -> None: + selected_profiles_str = self._selected_profiles_str() + if selected_profiles_str in self.temp_changes: + for key, new_value in self.temp_changes[selected_profiles_str].items(): + self.update_core_profiles(key, new_value) + + self.temp_changes.pop(selected_profiles_str, None) + self.original_values.pop(selected_profiles_str, None) + + def update_core_profiles(self, key, new_value): + profile = ProfileController.get( + int(self.update_status.current_profile_id)) + self.update_res = False + if key == 'dimentions': + profile.resolution = new_value + self.update_res = True + + elif key == 'name': + profile.name = new_value + + elif key == 'connection': + if new_value == 'tor': + profile.connection.code = new_value + profile.connection.masked = True + elif new_value == 'just proxy': + profile.connection.code = 'system' + profile.connection.masked = True + else: + self.update_status.update_status( + 'System wide profiles not supported atm') + + elif key == 'browser': + browser_type, browser_version = new_value.split(':', 1) + profile.application_version.application_code = browser_type + profile.application_version.version_number = browser_version + + elif key == 'protocol': + if self.data_profile.get('connection') == 'system-wide': + self.edit_to_session() + else: + profile.connection.code = 'wireguard' if new_value == 'wireguard' else 'tor' + profile.connection.masked = False if new_value == 'wireguard' else True + + else: + location = self.connection_manager.get_location_info(new_value) + + if location: + profile.location.code = location.code + profile.location.country_code = location.country_code + profile.location.time_zone = location.time_zone + profile.subscription = None + + ProfileController.update(profile) + + def edit_to_session(self): + id = int(self.update_status.current_profile_id) + profile = self.data_profile + default_app = 'firefox:123.0' + default_resolution = '1024x760' + location_code = self.connection_manager.get_location_info( + profile.get('location')) + + connection_type = 'tor' + + profile_data = { + 'id': int(id), + 'name': profile.get('name'), + 'country_code': location_code.country_code, + 'code': location_code.code, + 'application': default_app, + 'connection_type': connection_type, + 'resolution': default_resolution, + } + resume_page = self.find_resume_page() + if resume_page: + resume_page.handle_core_action_create_profile( + 'CREATE_SESSION_PROFILE', profile_data, 'session') diff --git a/gui/v2/ui/pages/fast_mode_prompt_page.py b/gui/v2/ui/pages/fast_mode_prompt_page.py new file mode 100755 index 0000000..cdb250e --- /dev/null +++ b/gui/v2/ui/pages/fast_mode_prompt_page.py @@ -0,0 +1,105 @@ +import random + +from PyQt6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget +from PyQt6 import QtCore + +from core.controllers.ProfileController import ProfileController + +from gui.v2.ui.pages.Page import Page + + +class FastModePromptPage(Page): + def __init__(self, page_stack, main_window): + super().__init__("FastModePrompt", page_stack, main_window) + self.page_stack = page_stack + self.update_status = main_window + self.title.setGeometry(500, 40, 350, 40) + self.title.setText("Quick Setup Option") + self.button_back.setVisible(False) + self.button_apply.setVisible(False) + + container = QWidget(self) + container.setGeometry(QtCore.QRect(80, 100, 640, 360)) + v = QVBoxLayout(container) + v.setContentsMargins(0, 0, 0, 0) + v.setSpacing(20) + + large_text = QLabel( + "Would you like to switch to convenient \"fast mode\" for profile creation going forward? (recommended)") + large_text.setWordWrap(True) + large_text.setStyleSheet("color: white; font-size: 18px;") + large_text.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + v.addWidget(large_text) + + buttons_row = QWidget() + h = QHBoxLayout(buttons_row) + h.setContentsMargins(0, 0, 0, 0) + h.setSpacing(20) + + yes_btn = QPushButton("Yes Fast Mode") + yes_btn.setCursor(QtCore.Qt.CursorShape.PointingHandCursor) + yes_btn.setFixedSize(240, 56) + yes_btn.setStyleSheet( + "background-color: #27ae60; color: white; font-weight: bold; font-size: 16px; border: none; border-radius: 6px;") + yes_btn.clicked.connect(self.choose_yes) + + no_btn = QPushButton("No Keep This") + no_btn.setCursor(QtCore.Qt.CursorShape.PointingHandCursor) + no_btn.setFixedSize(160, 42) + no_btn.setStyleSheet( + "background-color: #c0392b; color: white; font-size: 14px; border: none; border-radius: 6px;") + no_btn.clicked.connect(self.choose_no) + + h.addStretch() + h.addWidget(yes_btn) + h.addWidget(no_btn) + h.addStretch() + v.addWidget(buttons_row) + + small_text = QLabel( + "You can toggle this anytime in the \"Options\" Menu, under \"Create/Edit\"") + small_text.setWordWrap(True) + small_text.setStyleSheet("color: #999999; font-size: 12px;") + small_text.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + v.addWidget(small_text) + + def finalize(self): + self.update_status.mark_fast_mode_prompt_shown() + menu_page = self.custom_window.navigator.get_cached("menu") + if menu_page is not None and hasattr(menu_page, 'refresh_menu_buttons'): + menu_page.refresh_menu_buttons() + self.custom_window.navigator.navigate("menu") + + def choose_yes(self): + self.update_status.set_fast_mode_enabled(True) + self.finalize() + + def choose_no(self): + self.update_status.set_fast_mode_enabled(False) + self.finalize() + + def initialize_default_selections(self): + if not self.selected_values['location']: + locations = self.connection_manager.get_location_list() + if locations: + random_index = random.randint(0, len(locations) - 1) + self.selected_values['location'] = locations[random_index] + + if not self.selected_values['browser']: + browsers = self.connection_manager.get_browser_list() + if browsers: + random_index = random.randint(0, len(browsers) - 1) + self.selected_values['browser'] = browsers[random_index] + + def get_next_available_profile_id(self) -> int: + profiles = ProfileController.get_all() + if not profiles: + return 1 + + existing_ids = sorted(profiles.keys()) + + for i in range(1, max(existing_ids) + 2): + if i not in existing_ids: + return i + + return 1 diff --git a/gui/v2/ui/pages/fast_registration_page.py b/gui/v2/ui/pages/fast_registration_page.py new file mode 100755 index 0000000..c06234b --- /dev/null +++ b/gui/v2/ui/pages/fast_registration_page.py @@ -0,0 +1,787 @@ +import os +import random + +from PyQt6.QtWidgets import ( + QDialog, QHBoxLayout, QLabel, QLineEdit, QMessageBox, QPushButton, QVBoxLayout +) +from PyQt6.QtGui import QIcon, QPixmap, QTransform +from PyQt6.QtCore import QSize +from PyQt6 import QtCore + +from core.controllers.ProfileController import ProfileController + +from gui.v2.actions.profile_order import append_profile_to_visual_order +from gui.v2.ui.pages.Page import Page +from gui.v2.ui.pages.browser_page import BrowserPage +from gui.v2.ui.pages.location_page import LocationPage +from gui.v2.ui.pages.location_verification_page import LocationVerificationPage +from gui.v2.workers.worker_thread import WorkerThread + + +class FastRegistrationPage(Page): + def __init__(self, page_stack, main_window): + super().__init__("FastRegistration", page_stack, main_window) + self.page_stack = page_stack + self.update_status = main_window + self.connection_manager = main_window.connection_manager + self.labels = [] + self.buttons = [] + self.title.setGeometry(550, 40, 250, 40) + self.title.setText("Fast Creation") + + self.button_apply.setVisible(True) + self.button_apply.clicked.connect(self.create_profile) + + self.button_back.setVisible(True) + try: + self.button_back.clicked.disconnect() + except TypeError: + pass + self.button_back.clicked.connect(self.go_back) + + self.name_handle = QLabel(self) + self.name_handle.setGeometry(110, 70, 400, 30) + self.name_handle.setStyleSheet('color: #cacbcb;') + self.name_handle.setText("Profile Name:") + + self.name_hint = QLabel(self) + self.name_hint.setGeometry(265, 100, 190, 20) + self.name_hint.setStyleSheet( + 'color: #888888; font-size: 10px; font-style: italic;') + self.name_hint.setText("Click here to edit profile name") + self.name_hint.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + + self.name = QLineEdit(self) + self.name.setPlaceholderText("Enter name") + self.name.setGeometry(265, 70, 190, 30) + self.name.setStyleSheet( + "color: cyan; border: 1px solid #666666; border-radius: 3px; background-color: rgba(0, 0, 0, 0.3);") + self.name.setCursor(QtCore.Qt.CursorShape.IBeamCursor) + self.name.focusInEvent = lambda event: self.name_hint.hide() + self.name.focusOutEvent = lambda event: self.name_hint.show( + ) if not self.name.text() else self.name_hint.hide() + + self.name.textChanged.connect( + lambda text: self.name_hint.hide() if text else self.name_hint.show()) + + self.profile_data = {} + self.selected_values = { + 'protocol': 'wireguard', + 'connection': 'browser-only', + 'location': '', + 'browser': '', + 'resolution': '1024x760' + } + self.res_index = 1 + + self.initialize_default_selections() + + def initialize_default_selections(self): + if not self.selected_values['location']: + locations = self.connection_manager.get_location_list() + if locations: + random_index = random.randint(0, len(locations) - 1) + self.selected_values['location'] = locations[random_index] + + if not self.selected_values['browser']: + browsers = self.connection_manager.get_browser_list() + if browsers: + random_index = random.randint(0, len(browsers) - 1) + self.selected_values['browser'] = browsers[random_index] + + def get_next_available_profile_id(self, profiles=None) -> int: + if profiles is None: + profiles = ProfileController.get_all() + if not profiles: + return 1 + + existing_ids = sorted(profiles.keys()) + + for i in range(1, max(existing_ids) + 2): + if i not in existing_ids: + return i + + return 1 + + def showEvent(self, event): + super().showEvent(event) + self.initialize_default_selections() + self.create_interface_elements() + + def create_interface_elements(self): + for label in self.labels: + label.deleteLater() + self.labels = [] + for button in self.buttons: + button.deleteLater() + self.buttons = [] + + if not self.name.text(): + self.name_hint.show() + else: + self.name_hint.hide() + + self.host_info_label = QLabel( + f"Host Screen: {self.update_status.host_screen_width}x{self.update_status.host_screen_height}", self) + self.host_info_label.setGeometry(415, 470, 250, 20) + self.host_info_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + self.host_info_label.setStyleSheet( + "color: #00ffff; font-size: 13px; font-weight: bold;") + self.host_info_label.show() + self.labels.append(self.host_info_label) + + self.create_protocol_section() + self.create_connection_section() + self.create_location_section() + if self.selected_values['connection'] != 'system-wide': + self.create_browser_section() + self.create_resolution_section() + self.update_ui_state_for_connection() + + def create_protocol_section(self): + label = QLabel("Protocol", self) + label.setGeometry(300, 150, 185, 75) + + protocol_image = QPixmap(os.path.join( + self.btn_path, f"{self.selected_values['protocol']}_button.png")) + label.setPixmap(protocol_image) + label.setScaledContents(True) + label.show() + self.labels.append(label) + + prev_button = QPushButton(self) + prev_button.setGeometry(265, 150, 30, 75) + prev_button.clicked.connect( + lambda: self.show_previous_value('protocol')) + prev_button.show() + icon_path = os.path.join(self.btn_path, "UP_button.png") + icon = QPixmap(icon_path) + transform = QTransform().rotate(180) + rotated_icon = icon.transformed(transform) + prev_button.setIcon(QIcon(rotated_icon)) + prev_button.setIconSize(prev_button.size()) + self.buttons.append(prev_button) + + next_button = QPushButton(self) + next_button.setGeometry(490, 150, 30, 75) + next_button.clicked.connect(lambda: self.show_next_value('protocol')) + next_button.show() + next_button.setIcon( + QIcon(os.path.join(self.btn_path, "UP_button.png"))) + next_button.setIconSize(next_button.size()) + self.buttons.append(next_button) + + def create_connection_section(self): + label = QLabel("Connection", self) + label.setGeometry(150, 250, 185, 75) + + if self.selected_values['connection']: + connection_image = QPixmap(os.path.join( + self.btn_path, f"{self.selected_values['connection']}_button.png")) + if connection_image.isNull(): + fallback_path = os.path.join( + self.btn_path, "browser-only_button.png") + connection_image = QPixmap(fallback_path) + else: + connection_image = QPixmap(os.path.join( + self.btn_path, "browser-only_button.png")) + + label.setPixmap(connection_image) + label.setScaledContents(True) + label.show() + self.labels.append(label) + + prev_button = QPushButton(self) + prev_button.setGeometry(115, 250, 30, 75) + prev_button.clicked.connect( + lambda: self.show_previous_value('connection')) + prev_button.show() + icon_path = os.path.join(self.btn_path, "UP_button.png") + icon = QPixmap(icon_path) + transform = QTransform().rotate(180) + rotated_icon = icon.transformed(transform) + prev_button.setIcon(QIcon(rotated_icon)) + prev_button.setIconSize(prev_button.size()) + self.buttons.append(prev_button) + + next_button = QPushButton(self) + next_button.setGeometry(340, 250, 30, 75) + next_button.clicked.connect(lambda: self.show_next_value('connection')) + next_button.show() + next_button.setIcon( + QIcon(os.path.join(self.btn_path, "UP_button.png"))) + next_button.setIconSize(next_button.size()) + self.buttons.append(next_button) + + def create_location_section(self): + info_label = QLabel("Click on the location for more info", self) + info_label.setGeometry(480, 80, 300, 20) + info_label.setStyleSheet( + "color: #888888; font-size: 14px; font-style: italic;") + info_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight) + info_label.show() + self.labels.append(info_label) + + arrow_label = QLabel(self) + arrow_label.setGeometry(540, 100, 150, 150) + arrow_pixmap = QPixmap(os.path.join(self.btn_path, "arrow.png")) + transform = QTransform().rotate(270) + rotated_arrow = arrow_pixmap.transformed(transform) + arrow_label.setPixmap(rotated_arrow) + arrow_label.setScaledContents(True) + arrow_label.show() + self.labels.append(arrow_label) + + label = QPushButton(self) + label.setGeometry(435, 250, 185, 75) + label.setFlat(True) + label.setStyleSheet("background: transparent; border: none;") + + if self.selected_values['location']: + image_path = os.path.join( + self.btn_path, f"button_{self.selected_values['location']}.png") + location_image = QPixmap(image_path) + locations = self.connection_manager.get_location_info( + self.selected_values['location']) + provider = locations.operator.name if locations and hasattr( + locations, 'operator') else None + fallback_path = os.path.join( + self.btn_path, "default_location_button.png") + + if location_image.isNull(): + if locations and hasattr(locations, 'country_name'): + location_name = locations.country_name + location_image = LocationPage.create_location_button_image( + location_name, fallback_path, provider) + else: + location_image = LocationPage.create_location_button_image( + self.selected_values['location'], fallback_path, provider) + else: + if locations and hasattr(locations, 'country_name'): + location_image = LocationPage.create_location_button_image( + f'{locations.country_code}_{locations.code}', fallback_path, provider, image_path) + else: + location_image = LocationPage.create_location_button_image( + self.selected_values['location'], fallback_path, provider, image_path) + else: + location_image = QPixmap(os.path.join( + self.btn_path, "default_location_button.png")) + + label.setIcon(QIcon(location_image)) + label.setIconSize(QSize(185, 75)) + if self.selected_values['location']: + label.location_icon_name = self.selected_values['location'] + label.setCursor(QtCore.Qt.CursorShape.PointingHandCursor) + label.clicked.connect( + lambda checked, loc=self.selected_values['location']: self.show_location_verification(loc)) + + locations = self.connection_manager.get_location_info( + self.selected_values['location']) + if self.selected_values['protocol'] == 'hidetor' and locations and not (hasattr(locations, 'is_proxy_capable') and locations.is_proxy_capable): + label.hide() + else: + label.show() + self.labels.append(label) + + prev_button = QPushButton(self) + prev_button.setGeometry(400, 250, 30, 75) + prev_button.clicked.connect( + lambda: self.show_previous_value('location')) + prev_button.show() + icon_path = os.path.join(self.btn_path, "UP_button.png") + icon = QPixmap(icon_path) + transform = QTransform().rotate(180) + rotated_icon = icon.transformed(transform) + prev_button.setIcon(QIcon(rotated_icon)) + prev_button.setIconSize(prev_button.size()) + self.buttons.append(prev_button) + + next_button = QPushButton(self) + next_button.setGeometry(625, 250, 30, 75) + next_button.clicked.connect(lambda: self.show_next_value('location')) + next_button.show() + next_button.setIcon( + QIcon(os.path.join(self.btn_path, "UP_button.png"))) + next_button.setIconSize(next_button.size()) + self.buttons.append(next_button) + + def create_browser_section(self): + label = QLabel("Browser", self) + label.setGeometry(150, 350, 185, 75) + + if self.selected_values['browser']: + browser_image = BrowserPage.create_browser_button_image( + self.selected_values['browser'], self.btn_path) + if browser_image.isNull(): + fallback_path = os.path.join( + self.btn_path, "default_browser_button.png") + browser_image = BrowserPage.create_browser_button_image( + self.selected_values['browser'], fallback_path, True) + else: + browser_image = QPixmap() + + label.setPixmap(browser_image) + label.setScaledContents(True) + label.show() + self.labels.append(label) + + prev_button = QPushButton(self) + prev_button.setGeometry(115, 350, 30, 75) + prev_button.clicked.connect( + lambda: self.show_previous_value('browser')) + prev_button.show() + icon_path = os.path.join(self.btn_path, "UP_button.png") + icon = QPixmap(icon_path) + transform = QTransform().rotate(180) + rotated_icon = icon.transformed(transform) + prev_button.setIcon(QIcon(rotated_icon)) + prev_button.setIconSize(prev_button.size()) + self.buttons.append(prev_button) + + next_button = QPushButton(self) + next_button.setGeometry(340, 350, 30, 75) + next_button.clicked.connect(lambda: self.show_next_value('browser')) + next_button.show() + next_button.setIcon( + QIcon(os.path.join(self.btn_path, "UP_button.png"))) + next_button.setIconSize(next_button.size()) + self.buttons.append(next_button) + + def create_resolution_section(self): + from gui.v2.ui.pages.screen_page import ScreenPage + label = QLabel("Resolution", self) + label.setGeometry(435, 350, 185, 75) + screen_page = self.custom_window.navigator.get_cached("screen") + if screen_page is None: + self.custom_window.navigator.navigate("screen") + screen_page = self.custom_window.navigator.get_cached("screen") + self.custom_window.navigator.navigate("fast_registration") + if self.selected_values['resolution']: + resolution_image = screen_page.create_resolution_button_image( + self.selected_values['resolution']) + else: + resolution_image = screen_page.create_resolution_button_image( + "1024x760") + + label.setPixmap(resolution_image) + label.setScaledContents(True) + label.show() + self.labels.append(label) + + prev_button = QPushButton(self) + prev_button.setGeometry(400, 350, 30, 75) + prev_button.clicked.connect( + lambda: self.show_previous_value('resolution')) + prev_button.show() + icon_path = os.path.join(self.btn_path, "UP_button.png") + icon = QPixmap(icon_path) + transform = QTransform().rotate(180) + rotated_icon = icon.transformed(transform) + prev_button.setIcon(QIcon(rotated_icon)) + prev_button.setIconSize(prev_button.size()) + self.buttons.append(prev_button) + + next_button = QPushButton(self) + next_button.setGeometry(625, 350, 30, 75) + next_button.clicked.connect(lambda: self.show_next_value('resolution')) + next_button.show() + next_button.setIcon( + QIcon(os.path.join(self.btn_path, "UP_button.png"))) + next_button.setIconSize(next_button.size()) + self.buttons.append(next_button) + + custom_button = QPushButton("Custom", self) + custom_button.setGeometry(435, 430, 185, 30) + custom_button.setStyleSheet(""" + QPushButton { + background-color: rgba(0, 255, 255, 0.1); + color: #00ffff; + border: 1px solid #00ffff; + border-radius: 5px; + font-size: 12px; + font-weight: bold; + } + QPushButton:hover { + background-color: rgba(0, 255, 255, 0.2); + } + """) + custom_button.clicked.connect(self.show_custom_res_dialog) + custom_button.show() + self.buttons.append(custom_button) + + def show_custom_res_dialog(self): + dialog = QDialog(self) + dialog.setWindowTitle("Custom Resolution") + dialog.setFixedSize(300, 150) + dialog.setModal(True) + dialog.setStyleSheet(""" + QDialog { + background-color: #2c3e50; + border: 2px solid #00ffff; + border-radius: 10px; + } + QLabel { + color: white; + font-size: 12px; + } + QLineEdit { + background-color: #34495e; + color: white; + border: 1px solid #00ffff; + border-radius: 5px; + padding: 5px; + font-size: 12px; + } + QPushButton { + background-color: #2c3e50; + color: white; + border: 2px solid #00ffff; + border-radius: 5px; + padding: 8px; + font-size: 12px; + } + QPushButton:hover { + background-color: #34495e; + } + """) + + layout = QVBoxLayout() + input_layout = QHBoxLayout() + width_label = QLabel("Width:") + width_input = QLineEdit() + width_input.setPlaceholderText("1920") + height_label = QLabel("Height:") + height_input = QLineEdit() + height_input.setPlaceholderText("1080") + + input_layout.addWidget(width_label) + input_layout.addWidget(width_input) + input_layout.addWidget(height_label) + input_layout.addWidget(height_input) + + button_layout = QHBoxLayout() + ok_button = QPushButton("OK") + cancel_button = QPushButton("Cancel") + button_layout.addWidget(cancel_button) + button_layout.addWidget(ok_button) + + layout.addLayout(input_layout) + layout.addLayout(button_layout) + dialog.setLayout(layout) + + def validate_and_accept(): + try: + width = int(width_input.text()) + height = int(height_input.text()) + if width <= 0 or height <= 0: + return + host_w = self.update_status.host_screen_width + host_h = self.update_status.host_screen_height + adjusted = False + if width > host_w: + width = host_w - 50 + adjusted = True + if height > host_h: + height = host_h - 50 + adjusted = True + + if adjusted: + QMessageBox.information( + dialog, "Notice", "Adjusted to fit host size") + self.selected_values['resolution'] = f"{width}x{height}" + self.create_interface_elements() + dialog.accept() + except ValueError: + pass + + ok_button.clicked.connect(validate_and_accept) + cancel_button.clicked.connect(dialog.reject) + + width_input.returnPressed.connect(validate_and_accept) + height_input.returnPressed.connect(validate_and_accept) + + dialog.exec() + + def update_ui_state_for_connection(self): + is_system_wide = self.selected_values['connection'] == 'system-wide' + + for button in self.buttons: + if hasattr(button, 'geometry'): + button_geometry = button.geometry() + if button_geometry.y() == 350: + if button_geometry.x() in [115, 340, 400, 625]: + button.setEnabled(not is_system_wide) + + def show_previous_value(self, key): + if key == 'protocol': + protocols = ['wireguard', 'hidetor'] + current_index = protocols.index(self.selected_values[key]) + previous_index = (current_index - 1) % len(protocols) + self.selected_values[key] = protocols[previous_index] + if self.selected_values[key] == 'wireguard': + self.selected_values['connection'] = 'browser-only' + else: + self.selected_values['connection'] = 'tor' + loc_info = self.connection_manager.get_location_info( + self.selected_values['location']) + if not (loc_info and hasattr(loc_info, 'is_proxy_capable') and loc_info.is_proxy_capable): + locations = self.connection_manager.get_location_list() + proxy_locations = [loc for loc in locations if (l := self.connection_manager.get_location_info( + loc)) and hasattr(l, 'is_proxy_capable') and l.is_proxy_capable] + if proxy_locations: + self.selected_values['location'] = proxy_locations[0] + elif key == 'connection': + if self.selected_values['protocol'] == 'wireguard': + connections = ['browser-only', 'system-wide'] + else: + connections = ['tor', 'just proxy'] + current_index = connections.index(self.selected_values[key]) + previous_index = (current_index - 1) % len(connections) + self.selected_values[key] = connections[previous_index] + self.update_ui_state_for_connection() + elif key == 'location': + locations = self.connection_manager.get_location_list() + if self.selected_values['protocol'] == 'hidetor': + locations = [loc for loc in locations if (l := self.connection_manager.get_location_info( + loc)) and hasattr(l, 'is_proxy_capable') and l.is_proxy_capable] + + if locations and self.selected_values[key] in locations: + current_index = locations.index(self.selected_values[key]) + previous_index = (current_index - 1) % len(locations) + self.selected_values[key] = locations[previous_index] + elif locations: + self.selected_values[key] = locations[0] + elif key == 'browser': + browsers = self.connection_manager.get_browser_list() + if browsers and self.selected_values[key] in browsers: + current_index = browsers.index(self.selected_values[key]) + previous_index = (current_index - 1) % len(browsers) + self.selected_values[key] = browsers[previous_index] + elif browsers: + self.selected_values[key] = browsers[0] + elif key == 'resolution': + config = self.update_status._load_gui_config() + dynamic_enabled = config.get("registrations", {}).get( + "dynamic_larger_profiles", False) if config else False + + if dynamic_enabled: + resolutions = [] + host_w = self.update_status.host_screen_width + host_h = self.update_status.host_screen_height + for i in range(5): + resolutions.append( + f"{host_w - (50 * (i + 1))}x{host_h - (50 * (i + 1))}") + self.res_index = (self.res_index - 1) % len(resolutions) + self.selected_values[key] = resolutions[self.res_index] + else: + resolutions = ['800x600', '1024x760', '1152x1080', '1280x1024', '1920x1080', + '2560x1440', '2560x1600', '1920x1440', '1792x1344', '2048x1152'] + self.res_index = (self.res_index - 1) % len(resolutions) + choice = resolutions[self.res_index] + w, h = map(int, choice.split('x')) + host_w = self.update_status.host_screen_width + host_h = self.update_status.host_screen_height + new_w, new_h = w, h + if w > host_w: + new_w = host_w - 50 + if h > host_h: + new_h = host_h - 50 + self.selected_values[key] = f"{new_w}x{new_h}" + + self.create_interface_elements() + + def show_next_value(self, key): + if key == 'protocol': + protocols = ['wireguard', 'hidetor'] + current_index = protocols.index(self.selected_values[key]) + next_index = (current_index + 1) % len(protocols) + self.selected_values[key] = protocols[next_index] + if self.selected_values[key] == 'wireguard': + self.selected_values['connection'] = 'browser-only' + else: + self.selected_values['connection'] = 'tor' + loc_info = self.connection_manager.get_location_info( + self.selected_values['location']) + if not (loc_info and hasattr(loc_info, 'is_proxy_capable') and loc_info.is_proxy_capable): + locations = self.connection_manager.get_location_list() + proxy_locations = [loc for loc in locations if (l := self.connection_manager.get_location_info( + loc)) and hasattr(l, 'is_proxy_capable') and l.is_proxy_capable] + if proxy_locations: + self.selected_values['location'] = proxy_locations[0] + elif key == 'connection': + if self.selected_values['protocol'] == 'wireguard': + connections = ['browser-only', 'system-wide'] + else: + connections = ['tor', 'just proxy'] + current_index = connections.index(self.selected_values[key]) + next_index = (current_index + 1) % len(connections) + self.selected_values[key] = connections[next_index] + self.update_ui_state_for_connection() + elif key == 'location': + locations = self.connection_manager.get_location_list() + if self.selected_values['protocol'] == 'hidetor': + locations = [loc for loc in locations if (l := self.connection_manager.get_location_info( + loc)) and hasattr(l, 'is_proxy_capable') and l.is_proxy_capable] + + if locations and self.selected_values[key] in locations: + current_index = locations.index(self.selected_values[key]) + next_index = (current_index + 1) % len(locations) + self.selected_values[key] = locations[next_index] + elif locations: + self.selected_values[key] = locations[0] + elif key == 'browser': + browsers = self.connection_manager.get_browser_list() + if browsers and self.selected_values[key] in browsers: + current_index = browsers.index(self.selected_values[key]) + next_index = (current_index + 1) % len(browsers) + self.selected_values[key] = browsers[next_index] + elif browsers: + self.selected_values[key] = browsers[0] + elif key == 'resolution': + config = self.update_status._load_gui_config() + dynamic_enabled = config.get("registrations", {}).get( + "dynamic_larger_profiles", False) if config else False + + if dynamic_enabled: + resolutions = [] + host_w = self.update_status.host_screen_width + host_h = self.update_status.host_screen_height + for i in range(5): + resolutions.append( + f"{host_w - (50 * (i + 1))}x{host_h - (50 * (i + 1))}") + self.res_index = (self.res_index + 1) % len(resolutions) + self.selected_values[key] = resolutions[self.res_index] + else: + resolutions = ['800x600', '1024x760', '1152x1080', '1280x1024', '1920x1080', + '2560x1440', '2560x1600', '1920x1440', '1792x1344', '2048x1152'] + self.res_index = (self.res_index + 1) % len(resolutions) + choice = resolutions[self.res_index] + w, h = map(int, choice.split('x')) + host_w = self.update_status.host_screen_width + host_h = self.update_status.host_screen_height + new_w, new_h = w, h + if w > host_w: + new_w = host_w - 50 + if h > host_h: + new_h = host_h - 50 + self.selected_values[key] = f"{new_w}x{new_h}" + + self.create_interface_elements() + + def go_back(self): + menu_page = self.custom_window.navigator.get_cached("menu") + if menu_page is not None and hasattr(menu_page, 'refresh_menu_buttons'): + menu_page.refresh_menu_buttons() + self.custom_window.navigator.navigate("menu") + + def show_location_verification(self, location_icon_name): + verification_page = LocationVerificationPage( + self.page_stack, self.custom_window, location_icon_name, self) + self.page_stack.addWidget(verification_page) + self.page_stack.setCurrentIndex( + self.page_stack.indexOf(verification_page)) + + def create_profile(self): + profile_name = self.name.text() + if not profile_name: + self.update_status.update_status('Please enter a profile name') + return + + if not self.selected_values['location']: + self.update_status.update_status('Please select a location') + return + + if self.selected_values['connection'] != 'system-wide' and not self.selected_values['browser']: + self.update_status.update_status('Please select a browser') + return + + profile_data = { + 'name': profile_name, + 'protocol': self.selected_values['protocol'], + 'connection': self.selected_values['connection'], + 'location': self.selected_values['location'], + 'browser': self.selected_values['browser'], + 'resolution': self.selected_values['resolution'] + } + + self.profile_data = profile_data + self.update_status.write_data(profile_data) + + if self.selected_values['protocol'] == 'wireguard': + self.create_wireguard_profile(profile_data) + else: + self.create_tor_profile(profile_data) + + self.go_back() + + def _spawn_create_profile_worker(self, action, profile_data, profile_type): + self.worker_thread = WorkerThread(action, profile_data, profile_type) + self.worker_thread.text_output.connect( + self.update_status.update_status) + self.worker_thread.start() + self.worker_thread.wait() + + def create_wireguard_profile(self, profile_data): + location_info = self.connection_manager.get_location_info( + profile_data['location']) + if not location_info: + self.update_status.update_status('Invalid location selected') + return + + profiles = ProfileController.get_all() + profile_id = self.get_next_available_profile_id(profiles) + profile_data_for_resume = { + 'id': profile_id, + 'name': profile_data['name'], + 'country_code': location_info.country_code, + 'code': location_info.code, + 'application': profile_data['browser'], + 'connection_type': 'wireguard', + 'resolution': profile_data['resolution'], + } + + if profile_data['connection'] == 'system-wide': + self._spawn_create_profile_worker( + 'CREATE_SYSTEM_PROFILE', profile_data_for_resume, 'system') + else: + self._spawn_create_profile_worker( + 'CREATE_SESSION_PROFILE', profile_data_for_resume, 'session') + + if ProfileController.get(profile_id) is not None: + append_profile_to_visual_order( + getattr(self.update_status, 'gui_config_file', None), + profile_id, + profiles.keys()) + + def create_tor_profile(self, profile_data): + location_info = self.connection_manager.get_location_info( + profile_data['location']) + if not location_info: + self.update_status.update_status('Invalid location selected') + return + + connection_type = 'tor' if profile_data['connection'] == 'tor' else 'system' + + profiles = ProfileController.get_all() + profile_id = self.get_next_available_profile_id(profiles) + profile_data_for_resume = { + 'id': profile_id, + 'name': profile_data['name'], + 'country_code': location_info.country_code, + 'code': location_info.code, + 'application': profile_data['browser'], + 'connection_type': connection_type, + 'resolution': profile_data['resolution'], + } + + self._spawn_create_profile_worker( + 'CREATE_SESSION_PROFILE', profile_data_for_resume, 'session') + + if ProfileController.get(profile_id) is not None: + append_profile_to_visual_order( + getattr(self.update_status, 'gui_config_file', None), + profile_id, + profiles.keys()) + + def find_resume_page(self): + return self.custom_window.navigator.get_cached("resume") diff --git a/gui/v2/ui/pages/hidetor_page.py b/gui/v2/ui/pages/hidetor_page.py new file mode 100755 index 0000000..a17d822 --- /dev/null +++ b/gui/v2/ui/pages/hidetor_page.py @@ -0,0 +1,105 @@ +import os + +from PyQt6.QtWidgets import QPushButton, QButtonGroup +from PyQt6.QtGui import QPixmap, QIcon +from PyQt6.QtCore import Qt +from PyQt6 import QtCore + +from gui.v2.ui.pages.Page import Page +from gui.v2.ui.pages.location_page import LocationPage + + +class HidetorPage(Page): + def __init__(self, page_stack, main_window, parent=None): + super().__init__("HideTor", page_stack, main_window, parent) + self.selected_location_icon = None + self.connection_manager = main_window.connection_manager + self.update_status = main_window + self.button_next.clicked.connect(self.go_selected) + self.button_reverse.setVisible(True) + self.button_reverse.clicked.connect(self.reverse) + self.display.setGeometry(QtCore.QRect(5, 10, 390, 520)) + self.title.setGeometry(395, 40, 380, 40) + self.title.setText("Pick a location") + + if self.connection_manager.is_synced(): + from gui.v2.actions.sync import generate_grid_positions + locs = self.connection_manager.get_location_list() + positions = generate_grid_positions(len(locs)) + available = [(QPushButton, loc, positions[i]) for i, loc in enumerate(locs)] + self.create_interface_elements(available) + + def create_interface_elements(self, available_locations): + self.buttonGroup = QButtonGroup(self) + self.buttons = [] + + for j, (object_type, icon_name, geometry) in enumerate(available_locations): + boton = object_type(self) + boton.setGeometry(*geometry) + boton.setIconSize(boton.size()) + boton.setCheckable(True) + locations = self.connection_manager.get_location_info(icon_name) + if icon_name == 'my_14': + boton.setVisible(False) + if locations and not (hasattr(locations, 'is_proxy_capable') and locations.is_proxy_capable): + boton.setVisible(False) + icon_path = os.path.join(self.btn_path, f"button_{icon_name}.png") + boton.setIcon(QIcon(icon_path)) + fallback_path = os.path.join( + self.btn_path, "default_location_button.png") + provider = locations.operator.name if locations and hasattr( + locations, 'operator') else None + if boton.icon().isNull(): + if locations and hasattr(locations, 'country_name'): + location_name = locations.country_name + base_image = LocationPage.create_location_button_image( + location_name, fallback_path, provider) + boton.setIcon(QIcon(base_image)) + else: + base_image = LocationPage.create_location_button_image( + '', fallback_path, provider) + boton.setIcon(QIcon(base_image)) + else: + if locations and hasattr(locations, 'country_name'): + base_image = LocationPage.create_location_button_image( + f'{locations.country_code}_{locations.code}', fallback_path, provider, icon_path) + boton.setIcon(QIcon(base_image)) + else: + base_image = LocationPage.create_location_button_image( + '', fallback_path, provider, icon_path) + boton.setIcon(QIcon(base_image)) + self.buttons.append(boton) + self.buttonGroup.addButton(boton, j) + boton.location_icon_name = icon_name + boton.setCursor(QtCore.Qt.CursorShape.PointingHandCursor) + boton.clicked.connect( + lambda checked, loc=icon_name: self.show_location(loc)) + + def update_swarp_json(self): + inserted_data = { + "location": self.selected_location_icon, + "connection": "tor" + } + self.update_status.write_data(inserted_data) + + def show_location(self, location): + tor_hide_img = f'hdtor_{location}' + self.display.setPixmap(QPixmap(os.path.join(self.btn_path, f"{tor_hide_img}.png")).scaled( + self.display.size(), Qt.AspectRatioMode.KeepAspectRatio)) + self.selected_location_icon = location + self.button_next.setVisible(True) + self.update_swarp_json() + + def reverse(self): + self.custom_window.navigator.navigate("protocol") + + def go_selected(self): + self.custom_window.navigator.navigate("browser") + + def show_location_verification(self, location_icon_name): + from gui.v2.ui.pages.location_verification_page import LocationVerificationPage + verification_page = LocationVerificationPage( + self.page_stack, self.update_status, location_icon_name, self) + self.page_stack.addWidget(verification_page) + self.page_stack.setCurrentIndex( + self.page_stack.indexOf(verification_page)) diff --git a/gui/v2/ui/pages/id_page.py b/gui/v2/ui/pages/id_page.py new file mode 100755 index 0000000..bb44443 --- /dev/null +++ b/gui/v2/ui/pages/id_page.py @@ -0,0 +1,190 @@ +import os +import re + +from PyQt6.QtWidgets import ( + QButtonGroup, QFrame, QLabel, QPushButton, QTextEdit, +) +from PyQt6.QtGui import QIcon, QPixmap +from PyQt6.QtCore import Qt +from PyQt6 import QtCore + +from gui.v2.ui.pages.Page import Page + + +HOW_MANY_PROFILES_DEFAULT = 6 + + +class IdPage(Page): + def __init__(self, page_stack, main_window=None, parent=None): + super().__init__("Id", page_stack, main_window, parent) + self.update_status = main_window + self.btn_path = main_window.btn_path + self.buttonGroup = QButtonGroup(self) + self.display.setGeometry(QtCore.QRect(-10, 50, 550, 460)) + self.create_interface_elements() + + self.connect_button = QPushButton(self) + self.connect_button.setObjectName("connect_button") + self.connect_button.setGeometry(625, 450, 90, 50) + self.connect_button.setIconSize(self.connect_button.size()) + tor_icon = QIcon(os.path.join(self.btn_path, "connect.png")) + self.connect_button.setIcon(tor_icon) + self.connect_button.clicked.connect(self.on_connect) + self.connect_button.setDisabled(True) + + self.button_reverse.setVisible(True) + + def on_connect(self): + text = self.text_edit.toPlainText() + pattern = r'^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$' + if re.match(pattern, text): + self.update_status.update_status('Enabling profile in progress...') + profile_data = { + 'id': int(self.update_status.current_profile_id), + 'billing_code': str(text) + } + menu_page = self.find_menu_page() + if menu_page: + menu_page.enabling_profile(profile_data) + + else: + self.update_status.update_status( + 'Incorrect format for the billing code') + + def find_menu_page(self): + return self.custom_window.navigator.get_cached("menu") + + def create_interface_elements(self): + self.object_selected = None + self.display.setGeometry(QtCore.QRect(0, 0, 0, 0)) + + self.button_reverse.clicked.connect(self.reverse) + self.button_go.clicked.connect(self.go_selected) + + column_title_style = "color: #00ffff; font-size: 18px; font-weight: bold;" + column_button_style = """ + QPushButton { + background-color: #000000; + color: #00ffff; + border: 2px solid #00ffff; + border-radius: 10px; + font-size: 15px; + font-weight: bold; + padding: 10px; + } + QPushButton:hover { background-color: #003333; } + """ + column_desc_style = "color: #888888; font-size: 12px; font-style: italic;" + + self.single_title = QLabel("Single Profile", self) + self.single_title.setGeometry(15, 50, 240, 40) + self.single_title.setStyleSheet(column_title_style) + self.single_title.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + + self.single_button = QPushButton("One Profile Only", self) + self.single_button.setGeometry(40, 220, 200, 100) + self.single_button.setStyleSheet(column_button_style) + self.single_button.clicked.connect(self.go_single_profile) + + self.single_desc = QLabel("Buy a single billing for one profile.", self) + self.single_desc.setGeometry(15, 335, 240, 40) + self.single_desc.setStyleSheet(column_desc_style) + self.single_desc.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + self.single_desc.setWordWrap(True) + + self.multiple_title = QLabel("Multiple Profiles", self) + self.multiple_title.setGeometry(285, 50, 240, 40) + self.multiple_title.setStyleSheet(column_title_style) + self.multiple_title.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + + self.multiple_button = QPushButton("Multiple Profiles\nat Once", self) + self.multiple_button.setGeometry(310, 220, 200, 100) + self.multiple_button.setStyleSheet(column_button_style) + self.multiple_button.clicked.connect(self.go_multiple_profiles) + + self.multiple_desc = QLabel( + f"Buy a bundle of {HOW_MANY_PROFILES_DEFAULT} tickets at once, which are cryptographically seperated from each other.", self) + self.multiple_desc.setGeometry(285, 335, 240, 80) + self.multiple_desc.setStyleSheet(column_desc_style) + self.multiple_desc.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + self.multiple_desc.setWordWrap(True) + + self.title = QLabel("Use Existing", self) + self.title.setGeometry(QtCore.QRect(555, 50, 230, 40)) + self.title.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + self.title.setStyleSheet(column_title_style) + + self.note_label = QLabel( + "Billing IDs are tied to a single location", self) + self.note_label.setGeometry(555, 90, 230, 50) + self.note_label.setStyleSheet(column_desc_style) + self.note_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + self.note_label.setWordWrap(True) + self.note_label.show() + + self.id_bg_label = QLabel(self) + self.id_bg_label.setGeometry(550, 220, 250, 220) + self.id_bg_label.setPixmap(QPixmap(os.path.join(self.btn_path, "button230x220.png")).scaled( + self.id_bg_label.size(), Qt.AspectRatioMode.KeepAspectRatio)) + + self.text_edit = QTextEdit(self) + self.text_edit.setGeometry(550, 230, 230, 190) + self.text_edit.setPlaceholderText("Enter your existing billing Id here") + self.text_edit.textChanged.connect(self.toggle_button_state) + + separator_style = "color: #00ffff; background-color: #00ffff;" + self.separator_left = QFrame(self) + self.separator_left.setFrameShape(QFrame.Shape.VLine) + self.separator_left.setFrameShadow(QFrame.Shadow.Plain) + self.separator_left.setGeometry(270, 60, 2, 420) + self.separator_left.setStyleSheet(separator_style) + + self.separator_right = QFrame(self) + self.separator_right.setFrameShape(QFrame.Shape.VLine) + self.separator_right.setFrameShadow(QFrame.Shadow.Plain) + self.separator_right.setGeometry(540, 60, 2, 420) + self.separator_right.setStyleSheet(separator_style) + + def go_single_profile(self): + self.text_edit.clear() + self.custom_window.navigator.navigate("duration_selection") + + def go_multiple_profiles(self): + self.custom_window.navigator.navigate("plan_picker") + plan_page = self.custom_window.navigator.get_cached("plan_picker") + if plan_page is not None: + plan_page.start_sync() + + def toggle_button_state(self): + text = self.text_edit.toPlainText() + if text.strip(): + self.connect_button.setEnabled(True) + else: + self.connect_button.setEnabled(False) + + def show_next(self): + self.text_edit.clear() + self.custom_window.navigator.navigate("duration_selection") + + def validate_password(self): + self.button_go.setVisible(False) + text = self.text_edit.toPlainText().strip().lower() + + if text == "zenaku": + self.button_go.setVisible(True) + + def show_object_selected(self, function, page_class): + function() + self.object_selected = page_class + + def go_selected(self): + if self.object_selected: + self.custom_window.navigator.navigate(self.object_selected) + self.display.clear() + for boton in self.buttons: + boton.setChecked(False) + + self.button_go.setVisible(False) + + def reverse(self): + self.custom_window.navigator.navigate("menu") diff --git a/gui/v2/ui/pages/install_system_package.py b/gui/v2/ui/pages/install_system_package.py new file mode 100755 index 0000000..dcce2a5 --- /dev/null +++ b/gui/v2/ui/pages/install_system_package.py @@ -0,0 +1,488 @@ +import os +import subprocess + +from PyQt6.QtWidgets import QApplication, QPushButton, QLabel +from PyQt6.QtGui import QIcon +from PyQt6.QtCore import QSize, QTimer +from PyQt6 import QtCore + +from gui.v2.ui.pages.Page import Page +from gui.v2.workers.worker_thread import WorkerThread + + +class InstallSystemPackage(Page): + def __init__(self, page_stack, main_window=None, parent=None): + super().__init__("InstallPackage", page_stack, main_window, parent) + self.btn_path = main_window.btn_path + self.update_status = main_window + self.package_name = "" + self.install_command = "" + self.manual_install_command = "" + self.is_sync = False + self.ui_elements = [] + self.permanent_elements = [] + self.current_distro = "debian" + self.distro_buttons = {} + + self._setup_permanent_ui() + self._setup_distro_buttons() + + self.auto_install_package_commands = { + 'all': { + 'debian': 'pkexec apt install -y bubblewrap iproute2 microsocks proxychains4 ratpoison tor wireguard xserver-xephyr', + 'arch': 'pkexec pacman -S bubblewrap iproute2 microsocks proxychains-ng tor wireguard-tools xorg-server-xephyr', + 'fedora': 'pkexec dnf -y install bubblewrap iproute proxychains4 ratpoison tor wireguard-tools xorg-x11-server-Xephyr' + }, + 'bwrap': { + 'debian': 'pkexec apt -y install bubblewrap', + 'arch': 'pkexec pacman -S bubblewrap', + 'fedora': 'pkexec dnf -y install bubblewrap' + }, + 'ip': { + 'debian': 'pkexec apt -y install iproute2', + 'arch': 'pkexec pacman -S iproute2', + 'fedora': 'pkexec dnf -y install iproute' + }, + 'microsocks': { + 'debian': 'pkexec apt -y install microsocks', + 'arch': 'pkexec pacman -S microsocks', + 'fedora': 'zenity --warning --text="An essential dependency (microsocks) is not included in your distribution\'s default repository. Please build it from source or contact support."' + }, + 'proxychains4': { + 'debian': 'pkexec apt -y install proxychains4', + 'arch': 'pkexec pacman -S proxychains-ng', + 'fedora': 'pkexec dnf -y install proxychains4' + }, + 'ratpoison': { + 'debian': 'pkexec apt -y install ratpoison', + 'arch': 'zenity --warning --text="An essential dependency (ratpoison) is not included in your distribution\'s default repository. Please build it from source or contact support."', + 'fedora': 'pkexec dnf -y install ratpoison' + }, + 'tor': { + 'debian': 'pkexec apt -y install tor', + 'arch': 'pkexec pacman -S tor', + 'fedora': 'pkexec dnf -y install tor' + }, + 'wg-quick': { + 'debian': 'pkexec apt -y install wireguard', + 'arch': 'pkexec pacman -S wireguard-tools', + 'fedora': 'pkexec dnf -y install wireguard-tools' + }, + 'Xephyr': { + 'debian': 'pkexec apt -y install xserver-xephyr', + 'arch': 'pkexec pacman -S xorg-server-xephyr', + 'fedora': 'pkexec dnf -y install xorg-x11-server-Xephyr' + } + } + + self.manual_install_package_commands = { + 'all': { + 'debian': 'sudo apt install -y bubblewrap iproute2 microsocks proxychains4 ratpoison tor wireguard xserver-xephyr', + 'arch': 'sudo pacman -S bubblewrap iproute2 microsocks proxychains-ng tor wireguard-tools xorg-server-xephyr', + 'fedora': 'sudo dnf -y install bubblewrap iproute proxychains4 ratpoison tor wireguard-tools xorg-x11-server-Xephyr' + }, + 'bwrap': { + 'debian': 'sudo apt -y install bubblewrap', + 'arch': 'sudo pacman -S bubblewrap', + 'fedora': 'sudo dnf -y install bubblewrap' + }, + 'ip': { + 'debian': 'sudo apt -y install iproute2', + 'arch': 'sudo pacman -S iproute2', + 'fedora': 'sudo dnf -y install iproute' + }, + 'microsocks': { + 'debian': 'sudo apt -y install microsocks', + 'arch': 'sudo pacman -S microsocks', + 'fedora': '# Package not available.' + }, + 'proxychains4': { + 'debian': 'sudo apt -y install proxychains4', + 'arch': 'sudo pacman -S proxychains-ng', + 'fedora': 'sudo dnf -y install proxychains4' + }, + 'ratpoison': { + 'debian': 'sudo apt -y install ratpoison', + 'arch': '# Package not available.', + 'fedora': 'sudo dnf -y install ratpoison' + }, + 'tor': { + 'debian': 'sudo apt -y install tor', + 'arch': 'sudo pacman -S tor', + 'fedora': 'sudo dnf -y install tor' + }, + 'wg-quick': { + 'debian': 'sudo apt -y install wireguard', + 'arch': 'sudo pacman -S wireguard-tools', + 'fedora': 'sudo dnf -y install wireguard-tools' + }, + 'Xephyr': { + 'debian': 'sudo apt -y install xserver-xephyr', + 'arch': 'sudo pacman -S xorg-server-xephyr', + 'fedora': 'sudo dnf -y install xorg-x11-server-Xephyr' + } + } + + def _setup_permanent_ui(self): + self.title.setGeometry(20, 50, 300, 60) + self.title.setText("Package Installation") + self.title.setStyleSheet("font-size: 22px; font-weight: bold;") + self.permanent_elements.append(self.title) + + self.display.setGeometry(QtCore.QRect(100, 160, 580, 435)) + self.permanent_elements.append(self.display) + + def _setup_distro_buttons(self): + distros = ["debian", "arch", "fedora"] + + for i, distro in enumerate(distros): + btn = QPushButton(self) + btn.setGeometry(360 + i*140, 40, 120, 49) + btn.setIcon(QIcon(os.path.join(self.btn_path, f"{distro}.png"))) + btn.setIconSize(QSize(120, 49)) + btn.setCheckable(True) + btn.clicked.connect( + lambda checked, d=distro: self.switch_distro(d)) + self.distro_buttons[distro] = btn + self.permanent_elements.append(btn) + + self.distro_buttons[self.current_distro].setChecked(True) + + def switch_distro(self, distro): + self.current_distro = distro + + for d, btn in self.distro_buttons.items(): + btn.setChecked(d == distro) + + if self.package_name: + self.update_command() + self.setup_ui() + + def update_command(self): + if self.package_name in self.auto_install_package_commands: + if self.is_sync: + self.install_command = self.auto_install_package_commands[ + 'tor_sync'][self.current_distro] + self.manual_install_command = self.manual_install_package_commands[ + 'tor_sync'][self.current_distro] + else: + self.install_command = self.auto_install_package_commands[ + self.package_name][self.current_distro] + self.manual_install_command = self.manual_install_package_commands[ + self.package_name][self.current_distro] + else: + self.install_command = "" + self.manual_install_command = "" + + def configure(self, package_name, distro, is_sync=False): + self.package_name = package_name + self.current_distro = distro + self.is_sync = is_sync + self.update_command() + self.setup_ui() + return self + + def setup_ui(self): + self.button_back.setVisible(False) + self.button_go.setVisible(True) + if not self.package_name: + return + + self.clear_ui() + + self._setup_auto_install() + self._setup_manual_install() + + self._setup_status_and_nav() + + for element in self.ui_elements: + element.setParent(self) + element.setVisible(True) + element.raise_() + + def clear_ui(self): + for element in self.ui_elements: + element.setParent(None) + element.deleteLater() + self.ui_elements.clear() + + self.command_box = None + self.status_label = None + self.install_button = None + self.check_button = None + + def _setup_auto_install(self): + auto_install_label = QLabel( + f"Automatic Installation for {self.current_distro}", self) + auto_install_label.setGeometry(80, 130, 580, 30) + auto_install_label.setStyleSheet("font-size: 18px; font-weight: bold;") + auto_install_label.setVisible(True) + self.ui_elements.append(auto_install_label) + + auto_install_desc = QLabel( + "Click the button below to automatically install the missing packages:", self) + auto_install_desc.setStyleSheet("font-size: 14px; font-weight: bold;") + auto_install_desc.setGeometry(80, 170, 650, 30) + auto_install_desc.setVisible(True) + self.ui_elements.append(auto_install_desc) + + self.install_button = QPushButton(self) + self.install_button.setGeometry(135, 210, 100, 40) + self.install_button.setIcon( + QIcon(os.path.join(self.btn_path, "install.png"))) + self.install_button.setIconSize(self.install_button.size()) + self.install_button.clicked.connect(self.install_package) + self.install_button.setVisible(True) + self.ui_elements.append(self.install_button) + + if self.current_distro == 'arch': + auto_install_label.setGeometry(50, 110, 580, 30) + auto_install_desc.setGeometry(50, 140, 650, 30) + + self.install_button.setGeometry(150, 185, 100, 40) + + self.check_button = QPushButton(self) + self.check_button.setGeometry(270, 185, 100, 40) + self.check_button.setIcon( + QIcon(os.path.join(self.btn_path, "check.png"))) + self.check_button.setIconSize(self.check_button.size()) + self.check_button.clicked.connect(self.check_package) + self.check_button.setVisible(True) + self.ui_elements.append(self.check_button) + + def _setup_manual_install(self): + if self.current_distro != 'arch': + manual_install_label = QLabel( + f"Manual Installation for {self.current_distro}", self) + manual_install_label.setGeometry(80, 290, 650, 30) + manual_install_label.setStyleSheet( + "font-size: 18px; font-weight: bold;") + manual_install_label.setVisible(True) + self.ui_elements.append(manual_install_label) + + manual_install_desc = QLabel( + "Run this command in your terminal:", self) + manual_install_desc.setStyleSheet( + "font-size: 14px; font-weight: bold;") + manual_install_desc.setGeometry(80, 330, 380, 30) + manual_install_desc.setVisible(True) + + self.ui_elements.append(manual_install_desc) + + self.command_box = QLabel(self.manual_install_command, self) + + self.command_box.setGeometry(80, 370, 380, 40) + + if self.current_distro == 'arch': + self.command_box.setGeometry(50, 280, 380, 40) + + metrics = self.command_box.fontMetrics() + text_width = metrics.horizontalAdvance( + self.manual_install_command) + 100 + available_width = self.command_box.width() - 20 + font_size = 18 + MIN_FONT_SIZE = 10 + while text_width > available_width: + if font_size <= MIN_FONT_SIZE: + break + font_size -= 1 + text_width -= 10 + + self.command_box.setStyleSheet(f""" + background-color: #2b2b2b; + color: #ffffff; + padding: 10px; + border-radius: 5px; + font-family: monospace; + font-size: {font_size}px; + """) + + self.command_box.mousePressEvent = lambda _: self.copy_command() + self.command_box.setVisible(True) + self.ui_elements.append(self.command_box) + + if self.current_distro == 'arch': + pacman_label = QLabel("Pacman Installation:", self) + pacman_label.setGeometry(50, 240, 650, 30) + pacman_label.setStyleSheet( + "font-size: 18px; font-weight: bold; color: #00ffff;") + pacman_label.setVisible(True) + self.ui_elements.append(pacman_label) + + aur_label = QLabel("AUR / Yay Installation:", self) + aur_label.setGeometry(50, 350, 300, 30) + aur_label.setStyleSheet( + "font-size: 18px; font-weight: bold; color: #00ffff;") + self.ui_elements.append(aur_label) + + self.yay_command_box = QLabel("sudo yay -S ratpoison", self) + self.yay_command_box.setGeometry(50, 390, 300, 40) + self._setup_command_box_style(self.yay_command_box) + self.yay_command_box.mousePressEvent = lambda _: self.copy_command( + "sudo yay -S ratpoison", self.yay_command_box) + self.ui_elements.append(self.yay_command_box) + + aur_command = "git clone https://aur.archlinux.org/ratpoison.git\ncd ratpoison\nmakepkg -si --skippgpcheck" + self.aur_command_box = QLabel(aur_command, self) + self.aur_command_box.setGeometry(370, 390, 380, 60) + self._setup_command_box_style(self.aur_command_box) + self.aur_command_box.setWordWrap(True) + self.aur_command_box.mousePressEvent = lambda _: self.copy_command( + aur_command, self.aur_command_box) + self.ui_elements.append(self.aur_command_box) + + else: + self.command_box = QLabel(self.manual_install_command, self) + self.command_box.setGeometry(80, 370, 380, 40) + metrics = self.command_box.fontMetrics() + text_width = metrics.horizontalAdvance( + self.manual_install_command) + 100 + available_width = self.command_box.width() - 20 + font_size = 18 + MIN_FONT_SIZE = 10 + while text_width > available_width: + if font_size <= MIN_FONT_SIZE: + break + font_size -= 1 + text_width -= 10 + + self.command_box.setStyleSheet(f""" + background-color: #2b2b2b; + color: #ffffff; + padding: 10px; + border-radius: 5px; + font-family: monospace; + font-size: {font_size}px; + """) + + self.command_box.mousePressEvent = lambda _: self.copy_command() + self.command_box.setVisible(True) + self.ui_elements.append(self.command_box) + + def _setup_command_box_style(self, command_box): + metrics = command_box.fontMetrics() + text_width = metrics.horizontalAdvance(command_box.text()) + 100 + available_width = command_box.width() - 20 + font_size = 18 + MIN_FONT_SIZE = 10 + while text_width > available_width: + if font_size <= MIN_FONT_SIZE: + break + font_size -= 1 + text_width -= 10 + + command_box.setStyleSheet(f""" + background-color: #2b2b2b; + color: #ffffff; + padding: 10px; + border-radius: 5px; + font-family: monospace; + font-size: {font_size}px; + """) + command_box.setVisible(True) + + def copy_yay_command(self): + clipboard = QApplication.clipboard() + clipboard.setText("sudo yay -S ratpoison") + + original_style = self.yay_command_box.styleSheet() + self.yay_command_box.setStyleSheet( + original_style + "background-color: #27ae60;") + QTimer.singleShot( + 200, lambda: self.yay_command_box.setStyleSheet(original_style)) + self.update_status.update_status("Yay command copied to clipboard") + + def _setup_status_and_nav(self): + if self.current_distro != 'arch': + self.check_button = QPushButton(self) + self.check_button.setGeometry(130, 430, 100, 40) + self.check_button.setIcon( + QIcon(os.path.join(self.btn_path, "check.png"))) + self.check_button.setIconSize(self.check_button.size()) + self.check_button.clicked.connect(self.check_package) + self.ui_elements.append(self.check_button) + + self.button_go.clicked.connect(self.go_next) + self.button_back.clicked.connect(self.go_next) + + self.status_label = QLabel("", self) + self.status_label.setGeometry(300, 430, 250, 40) + if self.current_distro == 'arch': + self.status_label.setGeometry(450, 185, 250, 40) + self.ui_elements.append(self.status_label) + + def copy_command(self, command=None, command_box=None): + if command_box is None: + command_box = self.command_box + + clipboard = QApplication.clipboard() + if command: + clipboard.setText(command) + else: + clipboard.setText(self.manual_install_command) + + original_style = command_box.styleSheet() + command_box.setStyleSheet( + original_style + "background-color: #27ae60;") + QTimer.singleShot( + 200, lambda: command_box.setStyleSheet(original_style)) + self.update_status.update_status("Command copied to clipboard") + + def install_package(self): + if not self.install_command: + self.update_status.update_status( + f"Installation not supported for {self.current_distro}") + return + + if subprocess.getstatusoutput('pkexec --help')[0] == 127: + self.update_status.update_status("polkit toolkit is not installed") + self.install_button.setDisabled(True) + return + + self.worker_thread = WorkerThread('INSTALL_PACKAGE', + package_command=self.install_command, + package_name=self.package_name) + self.worker_thread.text_output.connect( + self.update_status.update_status) + self.worker_thread.finished.connect(self.installation_complete) + self.worker_thread.start() + + def installation_complete(self, success): + if success: + self.check_package() + + def check_package(self): + try: + if self.package_name.lower() == 'all': + packages = ['bwrap', 'ip', 'microsocks', + 'proxychains4', 'ratpoison', 'tor', 'Xephyr', 'wg'] + for package in packages: + if subprocess.getstatusoutput(f'{package} --help')[0] == 127: + self.status_label.setText( + f"{package} is not installed") + self.status_label.setStyleSheet("color: red;") + return + self.status_label.setText("All packages installed!") + self.status_label.setStyleSheet("color: green;") + elif self.package_name.lower() == 'wireguard': + self.install_name = 'wg' + elif self.package_name.lower() == 'bubblewrap': + self.install_name = 'bwrap' + elif self.package_name.lower() == 'xserver-xephyr': + self.install_name = 'Xephyr' + else: + self.install_name = self.package_name + if subprocess.getstatusoutput(f'{self.install_name.lower()} --help')[0] == 127: + self.status_label.setText( + f"{self.package_name} is not installed") + self.status_label.setStyleSheet("color: red;") + else: + self.status_label.setText( + f"{self.package_name} is installed!") + self.status_label.setStyleSheet("color: green;") + except Exception: + self.status_label.setText("Check failed") + self.status_label.setStyleSheet("color: red;") + + def go_next(self): + self.custom_window.navigator.navigate("menu") diff --git a/gui/v2/ui/pages/location_page.py b/gui/v2/ui/pages/location_page.py new file mode 100755 index 0000000..f6c8715 --- /dev/null +++ b/gui/v2/ui/pages/location_page.py @@ -0,0 +1,254 @@ +import os + +from PyQt6.QtWidgets import QApplication, QLabel, QPushButton, QButtonGroup +from PyQt6.QtGui import QPixmap, QIcon, QPainter, QColor, QFont, QTransform +from PyQt6.QtCore import Qt +from PyQt6 import QtCore + +from gui.v2.ui.pages.Page import Page + + +class LocationPage(Page): + def __init__(self, page_stack, main_window, parent=None): + super().__init__("Location", page_stack, main_window, parent) + self.selected_location_icon = None + self.update_status = main_window + self.button_reverse.setVisible(True) + self.connection_manager = main_window.connection_manager + self.button_reverse.clicked.connect(self.reverse) + self.display.setGeometry(QtCore.QRect(5, 10, 390, 520)) + self.title.setGeometry(395, 40, 380, 40) + self.title.setText("Pick a location") + + self.initial_display = QLabel(self) + self.initial_display.setGeometry(5, 22, 355, 485) + self.initial_display.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.initial_display.setWordWrap(True) + + self.verification_button = QPushButton("More Info", self) + self.verification_button.setGeometry(10, 70, 160, 40) + self.verification_button.setStyleSheet(f""" + QPushButton {{ + background-color: rgba(60, 80, 120, 1.0); + border: 2px solid rgba(100, 140, 200, 1.0); + border-radius: 5px; + color: rgb(255, 255, 255); + font-size: 15px; + font-weight: bold; + padding: 5px 10px; + }} + QPushButton:hover {{ + background-color: rgba(80, 100, 140, 1.0); + border: 2px solid rgba(120, 160, 220, 1.0); + }} + QPushButton:disabled {{ + background-color: rgba(40, 50, 70, 0.6); + border: 2px solid rgba(70, 90, 110, 0.6); + opacity: 0.5; + }} + """) + self.verification_button.setCursor( + QtCore.Qt.CursorShape.PointingHandCursor) + self.verification_button.setEnabled(False) + self.verification_button.show() + + if self.connection_manager.is_synced(): + from gui.v2.actions.sync import generate_grid_positions + locs = self.connection_manager.get_location_list() + positions = generate_grid_positions(len(locs)) + available = [(QPushButton, loc, positions[i]) for i, loc in enumerate(locs)] + self.create_interface_elements(available) + + def showEvent(self, event): + super().showEvent(event) + self.button_next.setVisible(False) + for button in self.buttons: + button.setChecked(False) + if hasattr(self, 'verification_button'): + self.verification_button.setEnabled(False) + + def create_interface_elements(self, available_locations): + self.buttonGroup = QButtonGroup(self) + self.buttons = [] + + for j, (object_type, icon_name, geometry) in enumerate(available_locations): + boton = object_type(self) + boton.setGeometry(*geometry) + boton.setIconSize(boton.size()) + boton.setCheckable(True) + + locations = self.connection_manager.get_location_info(icon_name) + if locations and not (hasattr(locations, 'is_wireguard_capable') and locations.is_wireguard_capable): + boton.setVisible(False) + icon_path = os.path.join(self.btn_path, f"button_{icon_name}.png") + boton.setIcon(QIcon(icon_path)) + fallback_path = os.path.join( + self.btn_path, "default_location_button.png") + provider = locations.operator.name if locations and hasattr( + locations, 'operator') else None + if boton.icon().isNull(): + if locations and hasattr(locations, 'country_name'): + location_name = locations.country_name + base_image = LocationPage.create_location_button_image( + location_name, fallback_path, provider) + boton.setIcon(QIcon(base_image)) + else: + base_image = LocationPage.create_location_button_image( + '', fallback_path, provider) + boton.setIcon(QIcon(base_image)) + else: + if locations and hasattr(locations, 'country_name'): + base_image = LocationPage.create_location_button_image( + f'{locations.country_code}_{locations.code}', fallback_path, provider, icon_path) + boton.setIcon(QIcon(base_image)) + else: + base_image = LocationPage.create_location_button_image( + '', fallback_path, provider, icon_path) + boton.setIcon(QIcon(base_image)) + + self.buttons.append(boton) + self.buttonGroup.addButton(boton, j) + boton.location_icon_name = icon_name + boton.setCursor(QtCore.Qt.CursorShape.PointingHandCursor) + boton.clicked.connect( + lambda checked, loc=icon_name: self.show_location(loc)) + + def update_swarp_json(self, get_connection=False): + profile_data = self.update_status.read_data() + self.connection_type = profile_data.get("connection", "") + if get_connection: + return self.connection_type + inserted_data = { + "location": self.selected_location_icon + } + self.update_status.write_data(inserted_data) + + def show_location(self, location): + self.initial_display.hide() + + max_size = QtCore.QSize(300, 300) + target_size = self.display.size().boundedTo(max_size) + + self.display.setPixmap(QPixmap(os.path.join(self.btn_path, f"icon_{location}.png")).scaled( + target_size, Qt.AspectRatioMode.KeepAspectRatio)) + self.selected_location_icon = location + self.button_next.setVisible(True) + self.button_next.clicked.connect(self.go_selected) + self.update_swarp_json() + + try: + self.verification_button.clicked.disconnect() + except TypeError: + pass + + self.verification_button.clicked.connect( + lambda: self.show_location_verification(location)) + self.verification_button.setEnabled(True) + + def reverse(self): + self.custom_window.navigator.navigate("protocol") + + def go_selected(self): + if self.connection_type == "system-wide": + self.custom_window.navigator.navigate("resume") + else: + self.custom_window.navigator.navigate("browser") + + def show_location_verification(self, location_icon_name): + from gui.v2.ui.pages.location_verification_page import LocationVerificationPage + verification_page = LocationVerificationPage( + self.page_stack, self.update_status, location_icon_name, self) + self.page_stack.addWidget(verification_page) + self.page_stack.setCurrentIndex( + self.page_stack.indexOf(verification_page)) + + @staticmethod + def create_location_button_image(location_name, fallback_image_path, provider=None, existing_icon_path=None): + if existing_icon_path: + base_image = QPixmap(existing_icon_path) + if base_image.isNull(): + base_image = QPixmap(fallback_image_path) + draw_location_text = False + else: + base_image = QPixmap(fallback_image_path) + draw_location_text = True + + if base_image.isNull(): + return base_image + + painter = QPainter(base_image) + try: + if draw_location_text: + font_size = 12 + app_font = QApplication.font() + app_font.setPointSize(font_size) + app_font.setWeight(QFont.Weight.Bold) + text_length = len(location_name) + + if text_length <= 10: + font_stretch = 100 + text_width_scale = 1.1 + text_height_scale = 1.4 + elif text_length <= 15: + font_stretch = 90 + text_width_scale = 1.0 + text_height_scale = 2 + else: + font_stretch = 100 + text_width_scale = 1.1 + text_height_scale = 4.5 + app_font.setStretch(font_stretch) + painter.setFont(app_font) + painter.setPen(QColor(0, 255, 255)) + + text_rect = painter.fontMetrics().boundingRect(location_name) + max_width = 110 + + while text_rect.width() > max_width: + font_size -= 1 + app_font.setPointSize(font_size) + app_font.setWeight(QFont.Weight.Bold) + painter.setFont(app_font) + text_rect = painter.fontMetrics().boundingRect(location_name) + + x = ((base_image.width() - text_rect.width()) // 2) - 30 + y = ((base_image.height() + text_rect.height()) // 2) - 15 + + transform = QTransform() + transform.scale(text_width_scale, text_height_scale) + painter.setTransform(transform) + + x_scaled = int(x / text_width_scale) + y_scaled = int(y / text_height_scale) + + shadow_offset = 1 + painter.setPen(QColor(0, 0, 0)) + painter.drawText(x_scaled + shadow_offset, + y_scaled + shadow_offset, location_name) + + painter.setPen(QColor(0, 255, 255)) + painter.drawText(x_scaled, y_scaled, location_name) + + painter.setTransform(QTransform()) + + if provider: + + painter.setPen(QColor(128, 128, 128)) + + provider_text_font = QApplication.font() + provider_text_font.setPointSize(7) + provider_text_font.setWeight(QFont.Weight.Normal) + painter.setFont(provider_text_font) + provider_text_rect = painter.fontMetrics().boundingRect(provider) + + provider_x = ( + (base_image.width() - provider_text_rect.width()) // 2) - 30 + provider_y = 50 + px = int(provider_x) + py = int(provider_y) + + painter.setFont(provider_text_font) + painter.drawText(px + 5, py + 2, provider) + finally: + painter.end() + return base_image diff --git a/gui/v2/ui/pages/location_verification_page.py b/gui/v2/ui/pages/location_verification_page.py new file mode 100755 index 0000000..458a9a1 --- /dev/null +++ b/gui/v2/ui/pages/location_verification_page.py @@ -0,0 +1,284 @@ +import os + +from PyQt6.QtWidgets import QApplication, QLabel, QPushButton +from PyQt6.QtGui import QPixmap, QIcon +from PyQt6.QtCore import Qt, QSize +from PyQt6 import QtCore + +from gui.v2.ui.pages.Page import Page + + +class LocationVerificationPage(Page): + def __init__(self, page_stack, main_window, location_icon_name, previous_page, parent=None): + super().__init__("LocationVerification", page_stack, main_window, parent) + self.location_icon_name = location_icon_name + self.previous_page = previous_page + self.connection_manager = main_window.connection_manager + self.font_style = f"font-family: '{main_window.open_sans_family}';" + self.verification_info = {} + self.verification_checkmarks = {} + self.verification_full_values = {} + self.verification_labels = {} + self.verification_copy_buttons = {} + self.verification_display_names = { + "operator_name": "Operator Name", + "provider_name": "Provider Name", + "nostr_public_key": "Nostr Key", + "hydraveil_public_key": "HydraVeil Key", + "nostr_attestation_event_reference": "Nostr Verification" + } + self.setup_ui() + + def setup_ui(self): + location_display = QLabel(self) + location_display.setGeometry(0, 0, 390, 520) + location_display.setAlignment(Qt.AlignmentFlag.AlignCenter) + + icon_path = os.path.join( + self.btn_path, f"icon_{self.location_icon_name}.png") + location_pixmap = QPixmap(icon_path) + fallback_path = os.path.join( + self.btn_path, "default_location_button.png") + + if location_pixmap.isNull(): + location_pixmap = QPixmap(fallback_path) + + locations = self.connection_manager.get_location_info( + self.location_icon_name) + operator_name = locations.operator.name if locations and hasattr( + locations, 'operator') else "" + + max_size = QtCore.QSize(300, 300) + target_size = location_display.size().boundedTo(max_size) + + location_display.setPixmap(location_pixmap.scaled( + target_size, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)) + location_display.show() + + title = QLabel("Operator Information", self) + title.setGeometry(350, 50, 350, 30) + title.setStyleSheet( + f"color: #808080; font-size: 20px; font-weight: bold; {self.font_style}") + title.show() + + info_items = [ + ("Operator Name", "operator_name", 130), + ("Provider Name", "provider_name", 180), + ("Nostr Key", "nostr_public_key", 230), + ("HydraVeil Key", "hydraveil_public_key", 280), + ("Nostr Verification", "nostr_attestation_event_reference", 330), + ] + + label_x = 350 + label_width = 150 + label_height = 30 + value_x = 510 + value_width = 210 + value_height = 30 + checkmark_x = 725 + checkmark_width = 30 + checkmark_height = 30 + copy_button_x = 750 + copy_button_width = 30 + copy_button_height = 30 + + for label_text, key, y_pos in info_items: + label_widget = QLabel(label_text + ":", self) + label_widget.setGeometry(label_x, y_pos, label_width, label_height) + label_widget.setStyleSheet( + f"color: white; font-size: 18px; {self.font_style}") + label_widget.setAlignment( + Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) + label_widget.show() + self.verification_labels[key] = label_widget + + value_widget = QLabel("N/A", self) + value_widget.setGeometry(value_x, y_pos, value_width, value_height) + value_widget.setStyleSheet( + f"color: white; font-size: 16px; {self.font_style}") + value_widget.setAlignment( + Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) + value_widget.show() + + checkmark_widget = QLabel("✓", self) + checkmark_widget.setGeometry( + checkmark_x, y_pos, checkmark_width, checkmark_height) + checkmark_widget.setStyleSheet( + f"color: #4CAF50; font-size: 28px; font-weight: bold; {self.font_style}") + checkmark_widget.setAlignment(Qt.AlignmentFlag.AlignCenter) + checkmark_widget.hide() + + self.verification_info[key] = value_widget + self.verification_checkmarks[key] = checkmark_widget + + copy_button = QPushButton(self) + copy_button.setGeometry( + copy_button_x, y_pos, copy_button_width, copy_button_height) + copy_button.setIcon( + QIcon(os.path.join(self.btn_path, "paste_button.png"))) + copy_button.setIconSize(QSize(24, 24)) + copy_button.setStyleSheet(f""" + QPushButton {{ + background: transparent; + border: none; + }} + QPushButton:hover {{ + background: rgba(255, 255, 255, 0.1); + border-radius: 4px; + }} + """) + copy_button.clicked.connect( + lambda checked=False, k=key: self.copy_verification_value(k)) + copy_button.show() + self.verification_copy_buttons[key] = copy_button + + self.back_button = QPushButton(self) + self.back_button.setGeometry(720, 533, 48, 35) + self.back_button.setIcon( + QIcon(os.path.join(self.btn_path, "back.png"))) + self.back_button.setIconSize(QSize(48, 35)) + self.back_button.setStyleSheet(f""" + QPushButton {{ + background: transparent; + border: none; + }} + QPushButton:hover {{ + background: rgba(255, 255, 255, 0.1); + border-radius: 4px; + }} + """) + self.back_button.clicked.connect(self.go_back) + self.back_button.show() + + self.update_verification_info() + + def truncate_text_by_width(self, text, widget, max_width): + if not text or text == "N/A": + return text + + metrics = widget.fontMetrics() + text_width = metrics.horizontalAdvance(text) + + if text_width <= max_width: + return text + + ellipsis = "..." + ellipsis_width = metrics.horizontalAdvance(ellipsis) + available_width = max_width - ellipsis_width + + if available_width <= 0: + return ellipsis + + start_chars = 1 + end_chars = 1 + + while True: + start_text = text[:start_chars] + end_text = text[-end_chars:] + combined_width = metrics.horizontalAdvance( + start_text + ellipsis + end_text) + + if combined_width <= max_width: + if start_chars + end_chars >= len(text): + return text + start_chars += 1 + if start_chars + end_chars >= len(text): + return text + end_chars += 1 + else: + if start_chars > 1: + start_chars -= 1 + if end_chars > 1: + end_chars -= 1 + break + + if start_chars + end_chars >= len(text): + return text + + return text[:start_chars] + ellipsis + text[-end_chars:] + + def update_verification_info(self): + locations = self.connection_manager.get_location_info( + self.location_icon_name) + value_width = 210 + + if not locations: + for key, widget in self.verification_info.items(): + widget.setText("N/A") + self.verification_full_values[key] = "N/A" + if key in self.verification_checkmarks: + self.verification_checkmarks[key].hide() + return + + operator = None + if locations.operator: + operator = locations.operator + + if operator: + operator_name = operator.name or "N/A" + self.verification_full_values["operator_name"] = operator_name + display_operator = self.truncate_text_by_width( + operator_name, self.verification_info["operator_name"], value_width) + self.verification_info["operator_name"].setText(display_operator) + + provider_name = locations.provider_name if locations and hasattr( + locations, 'provider_name') and locations.provider_name else "N/A" + self.verification_full_values["provider_name"] = provider_name + display_provider = self.truncate_text_by_width( + provider_name, self.verification_info["provider_name"], value_width) + self.verification_info["provider_name"].setText(display_provider) + + nostr_key = operator.nostr_public_key or "N/A" + self.verification_full_values["nostr_public_key"] = nostr_key + display_nostr = self.truncate_text_by_width( + nostr_key, self.verification_info["nostr_public_key"], value_width) + self.verification_info["nostr_public_key"].setText(display_nostr) + + hydraveil_key = operator.public_key or "N/A" + self.verification_full_values["hydraveil_public_key"] = hydraveil_key + display_hydraveil = self.truncate_text_by_width( + hydraveil_key, self.verification_info["hydraveil_public_key"], value_width) + self.verification_info["hydraveil_public_key"].setText( + display_hydraveil) + + nostr_verification = operator.nostr_attestation_event_reference or "N/A" + self.verification_full_values["nostr_attestation_event_reference"] = nostr_verification + display_nostr_verif = self.truncate_text_by_width( + nostr_verification, self.verification_info["nostr_attestation_event_reference"], value_width) + self.verification_info["nostr_attestation_event_reference"].setText( + display_nostr_verif) + else: + self.verification_info["operator_name"].setText("N/A") + self.verification_full_values["operator_name"] = "N/A" + self.verification_info["provider_name"].setText("N/A") + self.verification_full_values["provider_name"] = "N/A" + self.verification_info["nostr_public_key"].setText("N/A") + self.verification_full_values["nostr_public_key"] = "N/A" + self.verification_info["hydraveil_public_key"].setText("N/A") + self.verification_full_values["hydraveil_public_key"] = "N/A" + self.verification_info["nostr_attestation_event_reference"].setText( + "N/A") + self.verification_full_values["nostr_attestation_event_reference"] = "N/A" + + for key, widget in self.verification_info.items(): + if key in self.verification_checkmarks: + full_value = self.verification_full_values.get(key, "") + if full_value and full_value != "N/A": + self.verification_checkmarks[key].show() + else: + self.verification_checkmarks[key].hide() + + def copy_verification_value(self, key): + full_value = self.verification_full_values.get(key, "") + if full_value and full_value != "N/A": + clipboard = QApplication.clipboard() + clipboard.setText(full_value) + display_name = self.verification_display_names.get( + key, "Verification value") + self.custom_window.update_status( + f"{display_name} copied to clipboard") + + def go_back(self): + if self.previous_page: + self.page_stack.setCurrentIndex( + self.page_stack.indexOf(self.previous_page)) diff --git a/gui/v2/ui/pages/menu_page.py b/gui/v2/ui/pages/menu_page.py new file mode 100755 index 0000000..7e48766 --- /dev/null +++ b/gui/v2/ui/pages/menu_page.py @@ -0,0 +1,1247 @@ +import functools +import os +import threading + +from PyQt6 import QtCore, QtGui +from PyQt6.QtCore import Qt, QSize +from PyQt6.QtGui import QIcon, QPainter, QPixmap +from PyQt6.QtWidgets import ( + QApplication, QButtonGroup, QFrame, QHBoxLayout, QLabel, QLineEdit, + QMessageBox, QPushButton, QScrollArea, QTextEdit, QVBoxLayout, QWidget, +) + +from core.controllers.ConfigurationController import ConfigurationController +from core.controllers.ProfileController import ProfileController +from core.controllers.tickets.UseTicketController import ( + do_we_use_a_random_ticket, + get_unused_tickets, +) +from core.models.session.SessionProfile import SessionProfile +from core.models.system.SystemProfile import SystemProfile + +from gui.v2.infrastructure.setup_observers import ticket_observer +from gui.v2.actions.profile_order import normalize_profile_order +from gui.v2.ui.pages.Page import Page +from gui.v2.ui.pages.location_verification_page import LocationVerificationPage +from gui.v2.ui.styles.styles import SCROLLBAR_CYAN_QSS +from gui.v2.ui.popups.confirmation_popup import ConfirmationPopup +from gui.v2.ui.popups.endpoint_verification_popup import EndpointVerificationPopup +from gui.v2.ui.popups.ticket_data_loss_popup import TicketDataLossPopup +from gui.v2.workers.worker import Worker +from gui.v2.workers.worker_thread import WorkerThread + + +class MenuPage(Page): + def __init__(self, page_stack=None, main_window=None, parent=None): + super().__init__("Menu", page_stack, main_window, parent) + self.main_window = main_window + self.connection_manager = main_window.connection_manager + self.is_tor_mode = main_window.is_tor_mode + self.is_animating = main_window.is_animating + self.animation_step = main_window.animation_step + self.btn_path = main_window.btn_path + self.page_stack = page_stack + self.profile_info = {} + self.additional_labels = [] + self.update_status = main_window + self.title.setGeometry(400, 40, 380, 30) + self.title.setText("Load Profile") + + self.scroll_area = QScrollArea(self) + self.scroll_area.setGeometry(420, 80, 370, 350) + self.scroll_area.setWidgetResizable(True) + self.scroll_area.setHorizontalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.scroll_area.setVerticalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAsNeeded) + self.scroll_area.setStyleSheet(SCROLLBAR_CYAN_QSS) + + self.scroll_widget = QWidget() + self.scroll_widget.setStyleSheet("background-color: transparent;") + self.scroll_area.setWidget(self.scroll_widget) + + self.not_connected_profile = os.path.join( + self.btn_path, "button_profile.png") + self.connected_profile = os.path.join( + self.btn_path, "button_session_profile.png") + self.system_wide_connected_profile = os.path.join( + self.btn_path, "button_system_profile.png") + self.connected_tor_profile = os.path.join( + self.btn_path, "button_session_tor.png") + self.worker = None + self.IsSystem: int = 0 + self.button_states = {} + self.is_system_connected = False + self.profile_button_map = {} + self.font_style = f"font-family: '{main_window.open_sans_family}';" if main_window.open_sans_family else "" + + self.create_interface_elements() + + def showEvent(self, event): + super().showEvent(event) + if hasattr(self, 'buttons'): + for button in self.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( + self.connection_manager.is_synced()) + + def on_update_check_finished(self): + reply = QMessageBox.question(self, 'Update Available', + 'An update is available. Would you like to download it?', + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) + if reply == QMessageBox.StandardButton.Yes: + self.worker = WorkerThread('DOWNLOAD_UPDATE') + self.worker.start() + else: + self.update_status.update_status('Update canceled') + + def create_interface_elements(self): + for icon_name, function, position in [ + ("create_profile", self.create_prof, (410, 440, 110, 60)), + ("edit_profile", self.edit_prof, (540, 440, 110, 60)), + ("launch", self.launch, (0, 440, 185, 63)), + ("disconnect", self.disconnect, (200, 433, 185, 73)), + ("disconnect_system_wide", self.disconnect, (200, 433, 185, 73)), + ("just", self.connect, (200, 433, 185, 73)), + ("just_session", self.connect, (200, 433, 185, 73)), + ("settings", self.settings_gui, (670, 440, 110, 60)), + ]: + boton = QPushButton(self) + boton.setGeometry(QtCore.QRect(*position)) + boton.setObjectName("create_edit_settings_buttons") + icon = QtGui.QIcon(os.path.join(self.btn_path, f"{icon_name}.png")) + boton.setIcon(icon) + boton.setIconSize(boton.size()) + boton.clicked.connect(functools.partial(function)) + boton.setEnabled(False) + if icon_name == "disconnect": + boton.setEnabled(True) + boton.setVisible(False) + self.disconnect_button = boton + if icon_name == "launch": + self.boton_launch = boton + self.boton_launch.setVisible(False) + if icon_name == "create_profile": + self.boton_create = boton + self.boton_create.setEnabled(True) + if icon_name == "edit_profile": + self.boton_edit = boton + if icon_name == "just": + boton.setVisible(False) + self.boton_just = boton + self.connect_button = boton + if icon_name == "just_session": + self.boton_just_session = boton + self.connect_button = boton + if icon_name == "disconnect_system_wide": + boton.setEnabled(True) + boton.setVisible(False) + self.disconnect_system_wide_button = boton + if icon_name == "settings": + self.settings_button = boton + self.settings_button.setEnabled(True) + + self.create() + + def _make_confirmation_popup(self, message, button_text='Proceed'): + popup = ConfirmationPopup( + self, message=message, action_button_text=button_text, cancel_button_text="Cancel") + popup.setWindowModality(Qt.WindowModality.ApplicationModal) + return popup + + def _show_disconnect_confirmation(self, connected_profiles, button_text='Disable', message=None): + self.popup = self._make_confirmation_popup(message, button_text) + + def on_result(result): + if result: + self.update_status.update_status('Disabling profiles...') + self.worker_thread = WorkerThread( + 'DISABLE_ALL_PROFILES', profile_data=connected_profiles) + self.worker_thread.text_output.connect( + lambda text: self.update_status.update_status(text)) + self.worker_thread.start() + + self.popup.finished.connect(on_result) + self.popup.show() + + def delete_profile(self): + self.popup = ConfirmationPopup(self, message="Are you sure you want to\ndelete this profile?", + action_button_text="Delete", cancel_button_text="Cancel") + self.popup.setWindowModality(Qt.WindowModality.ApplicationModal) + self.popup.finished.connect( + lambda result: self.on_popup_finished(result)) + self.popup.show() + + def on_popup_finished(self, result): + if result: + profile_id = self.reverse_id + self.update_status.update_status(f'Deleting profile...') + self.worker_thread = WorkerThread( + 'DESTROY_PROFILE', profile_data={'id': profile_id}) + self.worker_thread.text_output.connect(self.delete_status_update) + self.worker_thread.start() + else: + pass + + def delete_status_update(self, text): + self.update_status.update_status(text) + self.eliminacion() + + def show_disconnect_button(self, toggle, profile_type, target_button): + try: + if target_button.isChecked(): + if profile_type == 1: + self.disconnect_button.setVisible(toggle) + self.boton_just_session.setVisible(not toggle) + else: + self.disconnect_system_wide_button.setVisible(toggle) + self.boton_just.setVisible(not toggle) + except RuntimeError: + pass + + def match_core_profiles(self, profiles_dict): + new_dict = {} + + profile_ids = normalize_profile_order( + getattr(self.update_status, 'gui_config_file', None), + profiles_dict.keys()) + + for idx in profile_ids: + profile = profiles_dict[idx] + new_profile = {} + + protocol = profile.connection.code + + location = f'{profile.location.country_code}_{profile.location.code}' + + new_profile['location'] = location + + if protocol == 'wireguard': + new_profile['protocol'] = 'wireguard' + else: + new_profile['protocol'] = 'hidetor' + + connection_type = 'browser-only' if isinstance( + profile, SessionProfile) else 'system-wide' + + if protocol == 'wireguard': + new_profile['connection'] = connection_type + elif protocol == 'tor': + new_profile['connection'] = protocol + else: + new_profile['connection'] = 'just proxy' + + if isinstance(profile, SessionProfile): + browser = profile.application_version.application_code + else: + browser = 'unknown' + if browser != 'unknown': + new_profile['browser'] = browser + new_profile['browser_version'] = profile.application_version.version_number + new_profile['browser_supported'] = profile.application_version.supported + else: + new_profile['browser'] = 'unknown browser' + new_profile['browser_supported'] = False + + resolution = profile.resolution if hasattr( + profile, 'resolution') else 'None' + new_profile['dimentions'] = resolution + + new_profile['name'] = profile.name + + new_dict[f'Profile_{idx}'] = new_profile + + return new_dict + + def create(self): + self.boton_just.setEnabled(False) + self.boton_just_session.setEnabled(False) + self.boton_edit.setEnabled(False) + if hasattr(self, 'verification_button'): + self.verification_button.setEnabled(False) + self.profiles_data = self.match_core_profiles( + ProfileController.get_all()) + + self.number_of_profiles = len(self.profiles_data) + self.profile_info.update(self.profiles_data) + + self.button_group = QButtonGroup(self) + self.buttons = [] + + for profile_name, profile_value in self.profiles_data.items(): + self.create_profile_widget(profile_name, profile_value) + + self.update_scroll_widget_size() + + def refresh_profiles_data(self): + self.profiles_data = self.match_core_profiles( + ProfileController.get_all()) + self.number_of_profiles = len(self.profiles_data) + self.profile_info.update(self.profiles_data) + for profile_name, profile_value in self.profiles_data.items(): + self.create_profile_widget(profile_name, profile_value) + self.update_scroll_widget_size() + + def refresh_menu_buttons(self): + profiles = ProfileController.get_all() + self.button_states.clear() + self.connection_manager._connected_profiles.clear() + self.IsSystem = 0 + for profile_id, profile in profiles.items(): + if ProfileController.is_enabled(profile): + self.connection_manager.add_connected_profile(profile_id) + if isinstance(profile, SessionProfile): + if profile.connection and profile.connection.code == 'tor': + self.button_states[profile_id] = self.connected_tor_profile + else: + self.button_states[profile_id] = self.connected_profile + elif isinstance(profile, SystemProfile): + self.button_states[profile_id] = self.system_wide_connected_profile + self.IsSystem += 1 + + if self.IsSystem > 0: + self.update_status.update_image( + appearance_value='background_connected') + else: + self.update_status.update_image() + + for widget in self.scroll_widget.findChildren(QLabel): + widget.setParent(None) + + for widget in self.scroll_widget.findChildren(QPushButton): + widget.setParent(None) + + self.profile_button_map.clear() + self.button_group = QButtonGroup(self) + self.buttons = [] + + self.create() + + def update_scroll_widget_size(self): + if self.number_of_profiles == 0: + return + + rows = (self.number_of_profiles + 1) // 2 + height = rows * 120 + self.scroll_widget.setFixedSize(370, height) + + def create_profile_widget(self, key, value): + profile_id = int(key.split('_')[1]) + + parent_label = self.create_parent_label(profile_id) + button = self.create_profile_button(parent_label, profile_id, key) + + self.profile_button_map[profile_id] = button + + self.create_profile_name_label(parent_label, value["name"]) + self.create_profile_icons(parent_label, value) + + def create_parent_label(self, profile_id): + parent_label = QLabel(self.scroll_widget) + profile_list = list(self.profiles_data.keys()) + index = profile_list.index(f'Profile_{profile_id}') + row, column = divmod(index, 2) + + parent_label.setGeometry(column * 179, row * 120, 175, 100) + parent_label.setVisible(True) + return parent_label + + def create_profile_button(self, parent_label, profile_id, key): + button = QPushButton(parent_label) + button.setGeometry(0, 0, 175, 60) + button.setStyleSheet(""" + QPushButton { + background-color: transparent; + border: none; + } + QPushButton:hover { + background-color: rgba(200, 200, 200, 50); + } + QPushButton:checked { + background-color: rgba(200, 200, 200, 80); + } + """) + + icon = QIcon(self.button_states.get( + profile_id, self.not_connected_profile)) + button.setIcon(icon) + button.setIconSize(QtCore.QSize(175, 60)) + + button.show() + button.setCheckable(True) + self.button_group.addButton(button) + self.buttons.append(button) + + button.clicked.connect( + lambda checked, pid=profile_id: self.print_profile_details(f"Profile_{pid}")) + + return button + + def create_profile_name_label(self, parent_label, name): + child_label = QLabel(parent_label) + child_label.setGeometry(0, 65, 175, 30) + child_label.setText(name) + child_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + child_label.setAttribute( + Qt.WidgetAttribute.WA_TransparentForMouseEvents) + child_label.show() + + def create_profile_icons(self, parent_label, value): + connection_type = value.get('connection', '') + if connection_type in ['tor', 'just proxy']: + self.create_tor_proxy_icons(parent_label, value, connection_type) + else: + self.create_other_icons(parent_label, value, connection_type) + + def create_tor_proxy_icons(self, parent_label, value, connection_type): + for j, label_name in enumerate(['protocol', 'location', 'browser']): + child_label = QLabel(parent_label) + child_label.setObjectName(f"label_{j}") + child_label.setGeometry(3 + j * 60, 5, 50, 50) + child_label.setObjectName("label_profiles") + child_label.setAttribute( + Qt.WidgetAttribute.WA_TransparentForMouseEvents) + child_label.setStyleSheet( + "background-color: rgba(128, 128, 128, 0.5); border-radius: 25px;") + image_path = self.get_icon_path(label_name, value, connection_type) + pixmap = QPixmap(image_path) + if pixmap.isNull() and label_name == 'location': + fallback_path = os.path.join( + self.btn_path, "default_location_mini.png") + pixmap = QPixmap(fallback_path) + if pixmap.isNull(): + pixmap = QPixmap(50, 50) + pixmap.fill(QtGui.QColor(200, 200, 200)) + if pixmap.isNull() and label_name == 'browser': + fallback_path = os.path.join( + self.btn_path, "default_browser_mini.png") + pixmap = QPixmap(fallback_path) + if pixmap.isNull(): + pixmap = QPixmap(50, 50) + pixmap.fill(QtGui.QColor(200, 200, 200)) + child_label.setPixmap(pixmap) + child_label.show() + + def create_other_icons(self, parent_label, value, connection_type): + for j, label_name in enumerate(['protocol', 'location', 'browser']): + child_label = QLabel(parent_label) + child_label.setObjectName(f"label_{j}") + child_label.setGeometry(3 + j * 60, 5, 50, 50) + child_label.setObjectName("label_profiles") + child_label.setAttribute( + Qt.WidgetAttribute.WA_TransparentForMouseEvents) + child_label.setStyleSheet( + "background-color: rgba(128, 128, 128, 0.5); border-radius: 25px;") + + image_path = self.get_icon_path(label_name, value, connection_type) + pixmap = QPixmap(image_path) + if pixmap.isNull() and label_name == 'location': + fallback_path = os.path.join( + self.btn_path, "default_location_mini.png") + pixmap = QPixmap(fallback_path) + if pixmap.isNull(): + pixmap = QPixmap(50, 50) + pixmap.fill(QtGui.QColor(200, 200, 200)) + if pixmap.isNull() and label_name == 'browser': + fallback_path = os.path.join( + self.btn_path, "default_browser_mini.png") + pixmap = QPixmap(fallback_path) + if pixmap.isNull(): + pixmap = QPixmap(50, 50) + pixmap.fill(QtGui.QColor(200, 200, 200)) + + if label_name == 'browser' and not value.get('browser_supported', True) and value.get('browser') != 'unknown browser': + warning_pixmap = QPixmap( + os.path.join(self.btn_path, 'warning.png')) + painter = QPainter(pixmap) + painter.drawPixmap(pixmap.width() - 22, + pixmap.height() - 21, warning_pixmap) + painter.end() + + child_label.setPixmap(pixmap) + child_label.show() + + def get_icon_path(self, label_name, value, connection_type): + if label_name == 'protocol': + if connection_type == 'tor': + return os.path.join(self.btn_path, "toricon_mini.png") + elif connection_type == 'just proxy': + return os.path.join(self.btn_path, "just proxy_mini.png") + + return os.path.join(self.btn_path, "wireguard_mini.png") + elif label_name == 'browser': + if connection_type == 'system-wide': + return os.path.join(self.btn_path, "wireguard_system_wide.png") + else: + return os.path.join(self.btn_path, f"{value[label_name]} latest_mini.png") + + return os.path.join(self.btn_path, f"icon_mini_{value[label_name]}.png") + + def truncate_key(self, text, max_length=50): + if not text or text == "N/A" or len(text) <= max_length: + return text + start_len = max_length // 2 - 2 + end_len = max_length // 2 - 2 + return text[:start_len] + "....." + text[-end_len:] + + def create_verification_widget(self, profile_obj): + verification_widget = QWidget(self) + verification_widget.setGeometry(0, 90, 400, 300) + verification_widget.setStyleSheet("background: transparent;") + + verification_layout = QVBoxLayout(verification_widget) + verification_layout.setContentsMargins(20, 20, 20, 20) + verification_layout.setSpacing(15) + + operator = None + if profile_obj.location and profile_obj.location.operator: + operator = profile_obj.location.operator + + info_items = [ + ("Operator Name", "operator_name"), + ("Nostr Key", "nostr_public_key"), + ("HydraVeil Key", "hydraveil_public_key"), + ] + + for label, key in info_items: + container = QWidget() + container.setStyleSheet("background: transparent;") + container_layout = QHBoxLayout(container) + container_layout.setContentsMargins(0, 0, 0, 0) + container_layout.setSpacing(10) + + label_widget = QLabel(label + ":") + label_widget.setStyleSheet( + f"color: white; font-size: 12px; {self.font_style}") + label_widget.setMinimumWidth(120) + container_layout.addWidget(label_widget) + + value_widget = QLineEdit() + value_widget.setReadOnly(True) + + if operator: + if key == "operator_name": + value = operator.name or "N/A" + elif key == "nostr_public_key": + value = operator.nostr_public_key or "N/A" + value = self.truncate_key( + value) if value != "N/A" else value + elif key == "hydraveil_public_key": + value = operator.public_key or "N/A" + value = self.truncate_key( + value) if value != "N/A" else value + else: + value = "N/A" + else: + value = "N/A" + + value_widget.setText(value) + value_widget.setCursorPosition(0) + value_widget.setStyleSheet(f""" + QLineEdit {{ + color: white; + background: transparent; + border: none; + border-bottom: 1px solid rgba(255, 255, 255, 0.3); + padding: 5px 0px; + font-size: 12px; + {self.font_style} + }} + """) + container_layout.addWidget(value_widget, 1) + + checkmark_widget = QLabel("✓") + checkmark_widget.setStyleSheet( + f"color: #4CAF50; font-size: 20px; font-weight: bold; {self.font_style}") + checkmark_widget.setFixedSize(20, 20) + checkmark_widget.setAlignment(Qt.AlignmentFlag.AlignCenter) + if value and value != "N/A": + checkmark_widget.show() + else: + checkmark_widget.hide() + container_layout.addWidget(checkmark_widget) + + copy_button = QPushButton() + copy_button.setIcon( + QIcon(os.path.join(self.btn_path, "paste_button.png"))) + copy_button.setIconSize(QSize(24, 24)) + copy_button.setFixedSize(30, 30) + copy_button.setStyleSheet(f""" + QPushButton {{ + background: transparent; + border: none; + }} + QPushButton:hover {{ + background: rgba(255, 255, 255, 0.1); + border-radius: 4px; + }} + """) + + if operator: + if key == "operator_name": + full_value = operator.name or "N/A" + elif key == "nostr_public_key": + full_value = operator.nostr_public_key or "N/A" + elif key == "hydraveil_public_key": + full_value = operator.public_key or "N/A" + else: + full_value = "N/A" + else: + full_value = "N/A" + + copy_button.clicked.connect( + lambda checked=False, val=full_value: self.copy_verification_value(val)) + container_layout.addWidget(copy_button) + + verification_layout.addWidget(container) + + verification_widget.show() + return verification_widget + + def copy_verification_value(self, value): + if value and value != "N/A": + clipboard = QApplication.clipboard() + clipboard.setText(value) + self.update_status.update_status( + "Verification value copied to clipboard") + + def print_profile_details(self, profile_name): + self.reverse_id = int(profile_name.split('_')[1]) + + target_button = self.profile_button_map.get(self.reverse_id) + if target_button: + self.connection_manager.set_profile_button_objects( + self.reverse_id, target_button) + else: + print(f"No button found for profile {self.reverse_id}") + is_connected = self.connection_manager.is_profile_connected( + int(self.reverse_id)) + if is_connected: + self.boton_edit.setEnabled(False) + else: + self.boton_edit.setEnabled(True) + + profile = self.profile_info.get(profile_name) + self.boton_launch.setEnabled(True) + self.boton_just.setEnabled(True) + self.boton_just_session.setEnabled(True) + self.boton_create.setEnabled(True) + if hasattr(self, 'buttons'): + for button in self.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( + self.connection_manager.is_synced()) + + for label in self.additional_labels: + label.deleteLater() + + self.additional_labels.clear() + + self.change_connect_button() + + if profile: + self.update_status.current_profile_id = self.reverse_id + profile_number = profile_name.split("_")[1] + name = profile.get("name", "") + protocol = profile.get("protocol", "") + location = profile.get("location", "") + connection = profile.get("connection", "") + country_garaje = profile.get("country_garaje", "") + + profile_obj = ProfileController.get(self.reverse_id) + is_profile_enabled = self.connection_manager.is_profile_connected( + self.reverse_id) + show_verification_widget = False + label_principal = None + label_tor = None + text_color = "white" + operator_name = profile_obj.location.operator.name if profile_obj.location and profile_obj.location.operator else "" + + if protocol.lower() == "wireguard" and is_profile_enabled and profile_obj and profile_obj.connection and profile_obj.connection.code == 'wireguard': + verification_widget = self.create_verification_widget( + profile_obj) + self.additional_labels.append(verification_widget) + show_verification_widget = True + label_principal = None + + elif operator_name != 'Simplified Privacy' and protocol.lower() == "wireguard": + if profile_obj.is_session_profile(): + text_color = "black" + label_background = QLabel(self) + label_background.setGeometry(0, 60, 410, 354) + pixmap_bg = QPixmap(os.path.join( + self.btn_path, "browser only.png")) + label_background.setPixmap(pixmap_bg) + label_background.setScaledContents(True) + label_background.show() + label_background.lower() + self.additional_labels.append(label_background) + + l_img = QLabel(self) + l_img.setGeometry(20, 125, 100, 100) + pixmap_loc = QPixmap(os.path.join( + self.btn_path, f"icon_{location}.png")) + l_img.setPixmap(pixmap_loc) + l_img.setScaledContents(True) + l_img.show() + self.additional_labels.append(l_img) + l_name = f"{profile_obj.location.country_name}, {profile_obj.location.name}" if profile_obj.location else "" + o_name = profile_obj.location.operator.name if profile_obj.location and profile_obj.location.operator else "" + n_key = profile_obj.location.operator.nostr_public_key if profile_obj.location and profile_obj.location.operator else "" + + info_txt = QTextEdit(self) + info_txt.setGeometry(130, 110, 260, 250) + info_txt.setReadOnly(True) + info_txt.setFrameStyle(QFrame.Shape.NoFrame) + info_txt.setStyleSheet( + f"background: transparent; border: none; color: {text_color}; font-size: 17px;") + info_txt.setVerticalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + info_txt.setHorizontalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + info_txt.setWordWrapMode( + QtGui.QTextOption.WrapMode.WrapAtWordBoundaryOrAnywhere) + info_txt.setText( + f"Location: {l_name}\n\nOperator: {o_name}") + info_txt.show() + self.additional_labels.append(info_txt) + + nostr_txt = QTextEdit(self) + nostr_txt.setGeometry(20, 260, 370, 80) + nostr_txt.setReadOnly(True) + nostr_txt.setFrameStyle(QFrame.Shape.NoFrame) + nostr_txt.setStyleSheet( + f"background: transparent; border: none; color: {text_color}; font-size: 17px;") + nostr_txt.setVerticalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + nostr_txt.setHorizontalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + nostr_txt.setWordWrapMode( + QtGui.QTextOption.WrapMode.WrapAnywhere) + nostr_txt.setText(f"Nostr: {n_key}") + nostr_txt.show() + self.additional_labels.append(nostr_txt) + + elif protocol.lower() in ["wireguard", "open", "residential", "hidetor"]: + label_principal = QLabel(self) + label_principal.setGeometry(0, 90, 400, 300) + pixmap = QPixmap(os.path.join( + self.btn_path, f"{protocol}_{location}.png")) + label_principal.setPixmap(pixmap) + label_principal.setScaledContents(True) + label_principal.show() + self.additional_labels.append(label_principal) + + if protocol.lower() in ["wireguard", "open", "residential", "hidetor"]: + + if protocol.lower() == "wireguard" and ConfigurationController.get_endpoint_verification_enabled(): + if is_profile_enabled and profile_obj and profile_obj.connection and profile_obj.connection.code == 'wireguard': + verified_icon = QLabel(self) + verified_icon.setGeometry(20, 50, 100, 80) + verified_pixmap = QPixmap(os.path.join( + self.btn_path, "verified_profile.png")) + verified_icon.setPixmap(verified_pixmap) + verified_icon.setScaledContents(True) + verified_icon.show() + self.additional_labels.append(verified_icon) + + if protocol.lower() == "hidetor" and not show_verification_widget: + + if label_principal: + label_principal.setGeometry(0, 80, 400, 300) + label_background = QLabel(self) + label_background.setGeometry(0, 60, 410, 354) + if connection == 'just proxy': + image_path = os.path.join( + self.btn_path, f"icon_{location}.png") + else: + image_path = os.path.join( + self.btn_path, f"hdtor_{location}.png") + pixmap = QPixmap(image_path) + label_background.setPixmap(pixmap) + label_background.show() + label_background.setScaledContents(True) + label_background.lower() + self.additional_labels.append(label_background) + + if protocol.lower() == "wireguard" and connection.lower() == "browser-only" and not show_verification_widget: + + if label_principal: + label_principal.setGeometry(0, 80, 400, 300) + + label_background = QLabel(self) + label_background.setGeometry(0, 60, 410, 354) + is_supported = profile.get('browser_supported', False) + image_name = "browser only.png" if is_supported else "unsupported_browser_only.png" + pixmap = QPixmap(os.path.join(self.btn_path, image_name)) + label_background.setPixmap(pixmap) + label_background.show() + label_background.setScaledContents(True) + label_background.lower() + self.additional_labels.append(label_background) + + if protocol.lower() == "residential" and connection.lower() == "tor": + + label_background = QLabel(self) + label_background.setGeometry(0, 60, 410, 354) + pixmap = QPixmap(os.path.join( + self.btn_path, "browser_tor.png")) + label_background.setPixmap(pixmap) + label_background.show() + label_background.setScaledContents(True) + self.additional_labels.append(label_background) + + label_garaje = QLabel(self) + label_garaje.setGeometry(5, 110, 400, 300) + pixmap = QPixmap(os.path.join( + self.btn_path, f"{country_garaje} garaje.png")) + label_garaje.setPixmap(pixmap) + label_garaje.show() + label_garaje.setScaledContents(True) + self.additional_labels.append(label_garaje) + + label_tor = QLabel(self) + label_tor.setGeometry(330, 300, 75, 113) + + pixmap = QPixmap(os.path.join(self.btn_path, "tor 86x130.png")) + if label_tor: + label_tor.setPixmap(pixmap) + label_tor.show() + label_tor.setScaledContents(True) + self.additional_labels.append(label_tor) + if protocol.lower() == "residential" and connection.lower() == "just proxy": + + label_background = QLabel(self) + label_background.setGeometry(0, 60, 410, 354) + pixmap = QPixmap(os.path.join( + self.btn_path, "browser_just_proxy.png")) + label_background.setPixmap(pixmap) + label_background.show() + label_background.setScaledContents(True) + self.additional_labels.append(label_background) + + label_garaje = QLabel(self) + label_garaje.setGeometry(5, 110, 400, 300) + pixmap = QPixmap(os.path.join( + self.btn_path, f"{country_garaje} garaje.png")) + label_garaje.setPixmap(pixmap) + label_garaje.show() + label_garaje.setScaledContents(True) + self.additional_labels.append(label_garaje) + + editar_profile = {"profile_number": profile_number, + "name": name, "protocol": protocol} + self.add_selected_profile(editar_profile) + + self.current_profile_location = location + + def show_profile_verification(self, profile_id): + profile = ProfileController.get(profile_id) + if not profile or not profile.location: + return + + location_key = f'{profile.location.country_code}_{profile.location.code}' + location_info = self.connection_manager.get_location_info(location_key) + if not location_info: + return + + location_icon_name = None + btn_path = self.btn_path + + for icon_file in os.listdir(btn_path): + if icon_file.startswith('button_') and icon_file.endswith('.png'): + icon_name = icon_file.replace( + 'button_', '').replace('.png', '') + test_loc = self.connection_manager.get_location_info(icon_name) + if test_loc and test_loc.country_code == location_info.country_code and test_loc.code == location_info.code: + location_icon_name = icon_name + break + + if location_icon_name: + verification_page = LocationVerificationPage( + self.page_stack, self.custom_window, location_icon_name, self) + self.page_stack.addWidget(verification_page) + self.page_stack.setCurrentIndex( + self.page_stack.indexOf(verification_page)) + + def launch(self): + pass + + def connect(self): + self.boton_just.setEnabled(False) + self.boton_just_session.setEnabled(False) + + profile = ProfileController.get(int(self.reverse_id)) + is_tor = self.update_status.get_current_connection() == 'tor' + if profile.connection.code == 'tor' and not profile.application_version.installed and not is_tor: + message = f'You are using a Tor profile, but the associated browser is not installed. If you want the browser to be downloaded with Tor, you must switch to a Tor connection. Otherwise, proceed with a clearweb connection.' + + else: + self.get_billing_code_by_id() + return + + self.popup = self._make_confirmation_popup(message, button_text='Proceed') + self.popup.finished.connect( + lambda result: self._handle_popup_result(result)) + self.popup.show() + + def _handle_popup_result(self, result, change_screen=False): + if result: + if change_screen: + self._route_to_billing_entry() + else: + self.get_billing_code_by_id() + else: + self.popup.close() + + def _route_to_billing_entry(self): + try: + unused = get_unused_tickets(ticket_observer) + random_setting, _ = do_we_use_a_random_ticket(ticket_observer) + except Exception: + unused = {'valid': False} + random_setting = None + + has_tickets = isinstance(unused, dict) and unused.get('valid') and len(unused.get('data', [])) > 0 + random_off = random_setting is None + + if has_tickets and random_off: + chooser = self.custom_window.navigator.get_cached("ticket_or_billing_choice") + if chooser: + chooser.populate() + self.page_stack.setCurrentWidget(chooser) + return + + id_page = self.custom_window.navigator.get_cached("id") + if id_page: + self.page_stack.setCurrentWidget(id_page) + else: + self.custom_window.navigator.navigate("id") + + def disconnect(self): + self.disconnect_button.setEnabled(False) + self.disconnect_system_wide_button.setEnabled(False) + self.update_status.update_status('Disconnecting...') + profile_data = { + 'id': int(self.reverse_id) + } + profile = ProfileController.get(int(self.reverse_id)) + is_session_profile = isinstance(profile, SessionProfile) + if not is_session_profile: + connected_profiles = self.connection_manager.get_connected_profiles() + if len(connected_profiles) > 1: + message = f'Profile{"" if len(connected_profiles) == 1 else "s"} {", ".join(str(profile_id) for profile_id in connected_profiles)} are still connected.\nAll connected session profiles will be disconnected. \nDo you want to proceed?' + self._show_disconnect_confirmation( + connected_profiles, button_text='Disable', message=message) + self.disconnect_button.setEnabled(True) + self.disconnect_system_wide_button.setEnabled(True) + return + self.worker_thread = WorkerThread( + 'DISABLE_PROFILE', profile_data=profile_data) + self.worker_thread.finished.connect(self.on_disconnect_done) + self.worker_thread.start() + + def on_disconnect_done(self): + self.disconnect_button.setEnabled(True) + self.disconnect_system_wide_button.setEnabled(True) + pass + + def _config_flag(self, section, key, default=False): + try: + config = self.update_status._load_gui_config() + if config and section in config: + return config[section].get(key, default) + except Exception: + pass + return default + + def create_prof(self): + fast_mode = self._config_flag("registrations", "fast_registration_enabled") + if fast_mode: + if not self.connection_manager.is_synced(): + self.update_status.update_status('Syncing in progress..') + self.update_status.sync() + self.update_status.worker_thread.sync_output.connect( + self.on_sync_complete_for_fast_registration) + else: + self.custom_window.navigator.navigate("fast_registration") + return + + if not self.connection_manager.is_synced(): + self.custom_window.navigator.navigate("sync_screen") + else: + self.custom_window.navigator.navigate("protocol") + + def on_sync_complete_for_fast_registration(self, available_locations, available_browsers, status, is_tor, locations, all_browsers): + if status: + self.custom_window.navigator.navigate("fast_registration") + else: + self.update_status.update_status( + 'Sync failed. Please try again later.') + + def change_connect_button(self): + profile = ProfileController.get(int(self.reverse_id)) + is_connected = self.connection_manager.is_profile_connected( + int(self.reverse_id)) + is_session_profile = isinstance(profile, SessionProfile) + + self.update_button_visibility(is_connected, is_session_profile) + self.update_status_message(profile, is_connected) + + def update_button_visibility(self, is_connected, is_session_profile): + self.boton_just_session.setVisible( + not is_connected and is_session_profile) + self.boton_just.setVisible(not is_connected and not is_session_profile) + self.disconnect_button.setVisible(is_connected and is_session_profile) + self.disconnect_system_wide_button.setVisible( + is_connected and not is_session_profile) + + def update_status_message(self, profile, is_connected): + if is_connected: + message = Worker.generate_profile_message(profile, is_enabled=True) + self.update_status.enable_marquee(message) + else: + message = Worker.generate_profile_message( + profile, is_enabled=True, idle=True) + self.update_status.update_status(message) + + def edit_prof(self): + auto_sync = self._config_flag("registrations", "auto_sync_enabled") + if auto_sync and not self.connection_manager.is_synced(): + self.update_status.update_status('Syncing in progress..') + self.update_status.sync() + self.update_status.worker_thread.sync_output.connect( + self.on_sync_complete_for_edit_profile) + else: + self.custom_window.navigator.navigate("editor") + + def on_sync_complete_for_edit_profile(self, available_locations, available_browsers, status, is_tor, locations, all_browsers): + if status: + self.custom_window.navigator.navigate("editor") + else: + self.update_status.update_status( + 'Sync failed. Please try again later.') + + def settings_gui(self): + self.custom_window.navigator.navigate("settings") + + def eliminacion(self): + current_state = { + 'is_tor_mode': self.is_tor_mode, + 'is_animating': self.is_animating, + 'animation_step': self.animation_step + } + + self.main_window.toggle_bg.setParent(None) + self.main_window.toggle_handle.setParent(None) + self.main_window.on_label.setParent(None) + self.main_window.off_label.setParent(None) + + self.refresh_menu_buttons() + + self.main_window.toggle_bg.setParent(self.main_window.toggle_button) + self.main_window.toggle_handle.setParent( + self.main_window.toggle_button) + self.main_window.on_label.setParent(self.main_window.toggle_button) + self.main_window.off_label.setParent(self.main_window.toggle_button) + + self.main_window.toggle_bg.show() + self.main_window.toggle_handle.show() + self.main_window.on_label.show() + self.main_window.off_label.show() + + self.is_tor_mode = current_state['is_tor_mode'] + self.is_animating = current_state['is_animating'] + self.animation_step = current_state['animation_step'] + + self.create() + + def get_billing_code_by_id(self, force: bool = False): + profile_id = self.update_status.current_profile_id + profile_data = { + 'id': profile_id, + 'force': force + } + self.update_status.update_status('Attempting to connect...') + + self.enabling_profile(profile_data) + + def enabling_profile(self, profile_data): + self.worker = Worker(profile_data) + self.worker.update_signal.connect(self.update_gui_main_thread) + self.worker.change_page.connect(self.change_app_page) + self.worker.ticket_data_loss.connect(self.show_ticket_data_loss_popup) + + thread = threading.Thread(target=self.worker.run) + thread.start() + + def show_ticket_data_loss_popup(self, ticket_number, billing_code): + self.custom_window.navigator.navigate("menu") + self.update_status.update_status('Critical: invalid billing code from ticket.') + self.popup = TicketDataLossPopup( + self, + ticket_number=ticket_number, + billing_code=billing_code, + ) + self.popup.show() + + def DisplayInstallScreen(self, package_name): + self.custom_window.navigator.navigate("install_system_package") + install_page = self.custom_window.navigator.get_cached("install_system_package") + if install_page is not None: + install_page.configure(package_name=package_name, distro='debian') + + def handle_enable_force_result(self, result): + if result: + profile_data = {'id': self.reverse_id, + 'ignore_profile_state_conflict': True} + if hasattr(self, '_pending_endpoint_verification_ignore') and self._pending_endpoint_verification_ignore: + profile_data['ignore_endpoint_verification'] = True + self.enabling_profile(profile_data) + else: + pass + if hasattr(self, '_pending_profile_state_conflict_ignore'): + delattr(self, '_pending_profile_state_conflict_ignore') + if hasattr(self, '_pending_endpoint_verification_ignore'): + delattr(self, '_pending_endpoint_verification_ignore') + self.popup.close() + + def handle_endpoint_verification_result(self, result, action, profile_id): + if action == "continue": + profile_data = {'id': profile_id, + 'ignore_endpoint_verification': True} + if hasattr(self, '_pending_profile_state_conflict_ignore') and self._pending_profile_state_conflict_ignore: + profile_data['ignore_profile_state_conflict'] = True + self.enabling_profile(profile_data) + elif action == "sync": + self.update_status.update_status("Syncing...") + self.worker_thread = WorkerThread('SYNC') + self.worker_thread.finished.connect( + lambda: self.handle_sync_after_verification(profile_id)) + self.worker_thread.sync_output.connect( + self.update_status.update_values) + self.worker_thread.start() + else: + self.update_status.update_status("Profile enable aborted") + if hasattr(self, 'popup'): + self.popup.close() + + def handle_sync_after_verification(self, profile_id): + profile_data = {'id': profile_id} + if hasattr(self, '_pending_profile_state_conflict_ignore') and self._pending_profile_state_conflict_ignore: + profile_data['ignore_profile_state_conflict'] = True + self.enabling_profile(profile_data) + + def update_gui_main_thread(self, text, is_enabled, profile_id, profile_type, profile_connection): + if text == "ENDPOINT_VERIFICATION_ERROR": + message = "Operator verification failed. Do you want to continue anyway?" + self.popup = EndpointVerificationPopup(self, message=message) + self.popup.finished.connect( + lambda result, action: self.handle_endpoint_verification_result(result, action, profile_id)) + self.popup.show() + return + + if text == "PROFILE_STATE_CONFLICT_ERROR": + message = f'The profile is already enabled. Do you want to force enable it anyway?' + self._pending_profile_state_conflict_ignore = True + self.popup = self._make_confirmation_popup(message, button_text='Proceed') + self.popup.finished.connect( + lambda result: self.handle_enable_force_result(result)) + self.popup.show() + return + + if profile_id < 0: + self.DisplayInstallScreen(text) + return + if is_enabled: + self.update_status.enable_marquee(str(text)) + else: + self.update_status.update_status(str(text)) + if profile_id is not None: + self.on_finished_update_gui( + is_enabled, profile_id, profile_type, profile_connection) + self.boton_just.setEnabled(True) + self.boton_just_session.setEnabled(True) + + def change_app_page(self, text, Is_changed): + self.update_status.update_status(str(text)) + if Is_changed: + current_connection = self.update_status.get_current_connection() + profile = ProfileController.get(int(self.reverse_id)) + if current_connection != 'tor' and profile.connection.code == 'tor': + message = f'You are using a Tor profile, but the profile subscription is missing or expired. If you want the billing to be done thourgh Tor, you must switch to a Tor connection. Otherwise, proceed with a clearweb connection.' + self.popup = self._make_confirmation_popup(message, button_text='Proceed') + self.popup.finished.connect( + lambda result: self._handle_popup_result(result, True)) + self.popup.show() + else: + self._route_to_billing_entry() + + self.boton_just.setEnabled(True) + self.boton_just_session.setEnabled(True) + + def on_finished_update_gui(self, is_enabled, profile_id, profile_type, profile_connection): + try: + if is_enabled: + self.connection_manager.add_connected_profile(profile_id) + target_button: QPushButton = self.connection_manager.get_profile_button_objects( + profile_id) + + if profile_type == 1: + if target_button: + icon_path = self.connected_tor_profile if profile_connection == 'tor' else self.connected_profile + try: + target_button.setIcon(QIcon(icon_path)) + except RuntimeError: + pass + self.button_states[profile_id] = icon_path + else: + if target_button: + try: + target_button.setIcon( + QIcon(self.system_wide_connected_profile)) + except RuntimeError: + pass + self.update_status.update_image( + appearance_value='background_connected') + self.button_states[profile_id] = self.system_wide_connected_profile + self.IsSystem += 1 + + if target_button: + self.show_disconnect_button( + True, profile_type, target_button) + self.boton_edit.setEnabled(False) + + if profile_id == self.reverse_id: + profile_obj = ProfileController.get(profile_id) + if profile_obj and profile_obj.connection and profile_obj.connection.code == 'wireguard' and ConfigurationController.get_endpoint_verification_enabled(): + self.print_profile_details(f"Profile_{profile_id}") + else: + self.connection_manager.remove_connected_profile(profile_id) + if profile_type == 1: + if profile_id in self.button_states: + del self.button_states[profile_id] + else: + self.IsSystem -= 1 + if profile_id in self.button_states: + del self.button_states[profile_id] + if self.IsSystem == 0: + self.update_status.update_image() + + self.boton_edit.setEnabled(True) + + if profile_id == self.reverse_id: + self.print_profile_details(f"Profile_{profile_id}") + + self.refresh_menu_buttons() + except IndexError: + self.update_status.update_status( + 'An error occurred while updating profile state.') diff --git a/gui/v2/ui/pages/payment_confirmed_page.py b/gui/v2/ui/pages/payment_confirmed_page.py new file mode 100755 index 0000000..34e1df1 --- /dev/null +++ b/gui/v2/ui/pages/payment_confirmed_page.py @@ -0,0 +1,149 @@ +import os + +from PyQt6.QtWidgets import QApplication, QButtonGroup, QFrame, QLabel, QPushButton +from PyQt6.QtGui import QIcon, QPainter +from PyQt6.QtCore import Qt, QPointF, QRect, QSize, QTimer + +from gui.v2.ui.pages.Page import Page +from gui.v2.ui.widgets.clickable_label import ClickableLabel +from gui.v2.ui.widgets.confetti import ConfettiThread + + +class PaymentConfirmed(Page): + def __init__(self, page_stack, main_window=None, parent=None): + super().__init__("Id", page_stack, main_window, parent) + + self.update_status = main_window + self.buttonGroup = QButtonGroup(self) + self.display.setGeometry(QRect(-10, 50, 550, 460)) + + self.button_reverse.setVisible(False) + + self.confetti = [] + + self.icon_label = QLabel(self) + icon = QIcon(os.path.join(self.btn_path, "check_icon.png")) + pixmap = icon.pixmap(QSize(64, 64)) + self.icon_label.setPixmap(pixmap) + self.icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + self.label = QLabel("Payment Completed!", + alignment=Qt.AlignmentFlag.AlignCenter, parent=self) + self.label.setStyleSheet( + "font-size: 24px; color: #2ecc71; font-weight: bold;") + + self.billing_label = QLabel( + "Your billing code:", alignment=Qt.AlignmentFlag.AlignCenter, parent=self) + self.billing_label.setStyleSheet("font-size: 18px; color: white;") + + self.billing_code = ClickableLabel( + "", alignment=Qt.AlignmentFlag.AlignCenter, parent=self) + self.billing_code.setStyleSheet(""" + font-size: 24px; + color: white; + font-weight: bold; + padding: 5px; + border-radius: 5px; + """) + self.billing_code.clicked.connect(self.copy_billing_code) + self.billing_code.set_hover_width(450) + + self.top_line = QFrame(self) + self.top_line.setFrameShape(QFrame.Shape.HLine) + self.top_line.setStyleSheet("color: #bdc3c7;") + + self.bottom_line = QFrame(self) + self.bottom_line.setFrameShape(QFrame.Shape.HLine) + self.bottom_line.setStyleSheet("color: #bdc3c7;") + + self.next_button = QPushButton("Next", self) + self.next_button.setStyleSheet(""" + QPushButton { + background-color: #3498db; + color: white; + border: none; + padding: 10px; + font-size: 18px; + border-radius: 5px; + } + QPushButton:hover { + background-color: #2980b9; + } + """) + self.next_button.clicked.connect(self.on_next_button) + + self.confetti_thread = ConfettiThread(self.width(), self.height()) + self.confetti_thread.update_signal.connect(self.update_confetti) + self.confetti_thread.start() + + def copy_billing_code(self): + clipboard = QApplication.clipboard() + clipboard.setText(self.billing_code.text()) + + original_style = self.billing_code.styleSheet() + + self.billing_code.setText("Copied!") + self.billing_code.setStyleSheet( + original_style + "background-color: #27ae60;") + + QTimer.singleShot( + 1000, lambda: self.reset_billing_code_style(original_style)) + + def reset_billing_code_style(self, original_style): + self.billing_code.setStyleSheet(original_style) + self.billing_code.setText(self.current_billing_code) + + def set_billing_code(self, code): + self.current_billing_code = code + self.billing_code.setText(code) + + def update_confetti(self, confetti): + self.confetti = confetti + self.update() + + def paintEvent(self, event): + super().paintEvent(event) + + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + + for particle in self.confetti: + painter.setBrush(particle.color) + painter.setPen(Qt.PenStyle.NoPen) + painter.drawEllipse(QPointF(particle.x, particle.y), + particle.size, particle.size) + + painter.end() + + def resizeEvent(self, event): + super().resizeEvent(event) + width = event.size().width() + height = event.size().height() + + self.icon_label.setGeometry( + QRect(width // 2 - 32, height // 8, 64, 64)) + self.label.setGeometry(QRect(0, height // 4, width, 50)) + self.billing_label.setGeometry(QRect(0, height // 2 - 50, width, 30)) + self.top_line.setGeometry( + QRect(width // 4, height // 2 + 20, width // 2, 1)) + self.billing_code.setGeometry(QRect(0, height // 2 + 28, width, 40)) + self.bottom_line.setGeometry( + QRect(width // 4, height // 2 + 75, width // 2, 1)) + self.next_button.setGeometry( + QRect(width // 4, height * 3 // 4, width // 2, 50)) + + self.confetti_thread.update_dimensions(width, height) + + def closeEvent(self, event): + self.confetti_thread.stop() + self.confetti_thread.wait() + super().closeEvent(event) + + def on_next_button(self): + self.custom_window.navigator.navigate("menu") + + def find_menu_page(self): + return self.custom_window.navigator.get_cached("menu") + + def reverse(self): + self.custom_window.navigator.navigate("wireguard") diff --git a/gui/v2/ui/pages/payment_details_page.py b/gui/v2/ui/pages/payment_details_page.py new file mode 100755 index 0000000..18cdf24 --- /dev/null +++ b/gui/v2/ui/pages/payment_details_page.py @@ -0,0 +1,419 @@ +import os + +from PyQt6.QtWidgets import QApplication, QLabel, QLineEdit, QPushButton +from PyQt6.QtGui import QIcon, QPixmap +from PyQt6.QtCore import QSize, QTimer +from PyQt6 import QtCore + +from gui.v2.ui.pages.Page import Page +from gui.v2.ui.popups.qrcode_dialog import QRCodeDialog +from gui.v2.workers.ticketing_worker_thread import TicketingWorkerThread +from gui.v2.workers.worker_thread import WorkerThread + + +class PaymentDetailsPage(Page): + def __init__(self, page_stack, main_window=None, parent=None): + super().__init__("Payment Details", page_stack, main_window, parent) + self.update_status = main_window + self.text_fields = [] + self.invoice_data = {} + self.selected_duration = None + self.selected_currency = None + + self.ticketing = False + self.temp_billing_code = None + self._ticket_check_running = False + self._ticket_worker = None + self.ticket_poll_timer = QTimer(self) + self.ticket_poll_timer.timeout.connect(self._tick_check_paid) + + self.create_interface_elements() + self.button_reverse.setVisible(True) + + def create_interface_elements(self): + self.title = QLabel("Payment Details", self) + self.title.setGeometry(QtCore.QRect(530, 30, 210, 40)) + self.title.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + self.button_reverse.clicked.connect(self.reverse) + + self.qr_code_button = QPushButton(self) + self.qr_code_button.setGeometry(360, 435, 50, 50) + qr_icon = QIcon(os.path.join(self.btn_path, "qr-code.png")) + self.qr_code_button.setIcon(qr_icon) + self.qr_code_button.setIconSize(self.qr_code_button.size()) + self.qr_code_button.clicked.connect(self.show_qr_code) + self.qr_code_button.setToolTip("Show QR Code") + self.qr_code_button.setDisabled(True) + + labels_info = [ + ("Your new billing ID", None, (20, 40), (240, 50)), + ("", "cuadro400x50", (20, 90), (400, 50)), + ("Duration", None, (20, 155), (150, 50)), + ("", "cuadro150x50", (200, 155), (150, 50)), + ("Amount", None, (10, 220), (150, 50)), + ("", "cuadro150x50", (200, 220), (150, 50)), + ("Hit this white icon to copy paste the address:", + None, (15, 305), (500, 30)), + ("", "cuadro400x50", (20, 350), (400, 50)), + ("Hit this icon to Scan the QR code:", None, (15, 450), (360, 30)), + ] + + for text, image_name, position, size in labels_info: + label = QLabel(self) + label.setGeometry(position[0], position[1], size[0], size[1]) + + if "Hit" in text: + label.setStyleSheet("font-size: 16px;") + label.setAlignment( + QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter) + else: + label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + + if image_name: + pixmap = QPixmap(os.path.join( + self.btn_path, f"{image_name}.png")) + label.setPixmap(pixmap) + label.setScaledContents(True) + else: + label.setText(text) + + line_edit_info = [ + (20, 90, 400, 50), + (200, 155, 150, 50), + (200, 220, 150, 50), + (20, 350, 400, 50), + ] + + for x, y, width, height in line_edit_info: + line_edit = QLineEdit(self) + line_edit.setGeometry(x, y, width, height) + line_edit.setReadOnly(True) + self.text_fields.append(line_edit) + line_edit.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + + copy_button_info = [ + (430, 90, 0), + (360, 220, 2), + (480, 290, 3), + ] + + for x, y, field_index in copy_button_info: + button = QPushButton(self) + button.setGeometry(x, y, 50, 50) + icon = QIcon(os.path.join(self.btn_path, "paste_button.png")) + button.setIcon(icon) + button.setIconSize(button.size()) + button.clicked.connect( + lambda checked, index=field_index: self.copy_text(index)) + + currency_button_info = [ + ("monero", (545, 75)), + ("bitcoin", (545, 290)), + ("lightnering", (545, 180)), + ("litecoin", (545, 395)) + ] + + self.currency_display_buttons = [] + for icon_name, position in currency_button_info: + button = QPushButton(self) + button.setGeometry(position[0], position[1], 185, 75) + button.setIconSize(QSize(190, 120)) + button.setCheckable(True) + button.setEnabled(False) + self.currency_display_buttons.append(button) + button.setIcon( + QIcon(os.path.join(self.btn_path, f"{icon_name}.png"))) + + def fetch_invoice_duration(self): + duration_month_num = int(self.selected_duration.split()[0]) + if duration_month_num == 12: + total_hours = 365 * 24 + else: + total_hours = duration_month_num * (30 * 24) + return total_hours + + def display_selected_options(self): + if self.selected_duration: + self.text_fields[1].setText(self.selected_duration) + self.text_fields[1].setStyleSheet('font-size: 13px;') + + if self.selected_currency: + currency_index_map = { + "monero": 0, + "bitcoin": 1, + "lightning": 2, + "litecoin": 3 + } + currency_index = currency_index_map.get(self.selected_currency, -1) + if currency_index >= 0: + for i, button in enumerate(self.currency_display_buttons): + button.setChecked(i == currency_index) + button.setEnabled(i == currency_index) + + def on_request_invoice(self): + total_hours = self.fetch_invoice_duration() + if self.selected_currency is None: + self.update_status.update_status('No currency selected') + return + + profile_data = { + 'id': int(self.update_status.current_profile_id), + 'duration': total_hours, + 'currency': 'xmr' if self.selected_currency == 'monero' else 'btc' if self.selected_currency == 'bitcoin' else 'btc-ln' if self.selected_currency == 'lightning' else 'ltc' if self.selected_currency == 'litecoin' else None + } + + self.update_status.update_status('Generating Invoice...') + self.worker_thread = WorkerThread( + 'GET_SUBSCRIPTION', profile_data=profile_data) + self.worker_thread.text_output.connect(self.invoice_update_text_output) + self.worker_thread.invoice_output.connect( + self.on_invoice_generation_finished) + self.worker_thread.invoice_finished.connect(self.on_invoice_finished) + self.worker_thread.start() + + def invoice_update_text_output(self, text): + self.update_status.update_status(text) + + if 'No compatible' in text: + for line in self.text_fields: + line.setText('') + + def store_invoice_data(self, billing_details: dict, duration: int): + self.invoice_data = { + 'billing_details': billing_details, + 'duration': duration + } + + def check_invoice(self): + self.display_selected_options() + total_hours = self.fetch_invoice_duration() + if self.invoice_data and self.invoice_data['duration'] == total_hours: + self.update_status.update_status('Checking invoice status...') + self.check_invoice_status( + self.invoice_data['billing_details'].billing_code) + else: + self.on_request_invoice() + + def check_invoice_status(self, billing_code: str): + self.worker_thread = WorkerThread('CHECK_INVOICE_STATUS', profile_data={ + 'billing_code': billing_code}) + self.worker_thread.invoice_finished.connect( + self.on_invoice_status_finished) + self.worker_thread.start() + + def on_invoice_status_finished(self, result): + if result: + self.parse_invoice_data(self.invoice_data['billing_details']) + else: + self.update_status.update_status( + 'Invoice has expired. Generating new invoice...') + self.on_request_invoice() + + def on_invoice_generation_finished(self, billing_details: object, text: str): + total_hours = self.fetch_invoice_duration() + self.store_invoice_data(billing_details, duration=total_hours) + self.parse_invoice_data(billing_details) + + def parse_invoice_data(self, invoice_data: object): + billing_details = { + 'billing_code': invoice_data.billing_code + } + + if self.selected_currency.lower() == 'lightning': + currency = 'Bitcoin Lightning' + else: + currency = self.selected_currency + + preferred_method = next( + (pm for pm in invoice_data.payment_methods if pm.name.lower() == currency.lower()), None) + + if preferred_method: + billing_details['due_amount'] = preferred_method.due + billing_details['address'] = preferred_method.address + else: + self.update_status.update_status( + 'No payment method found for the selected currency.') + return + + billing_values = list(billing_details.values()) + text_field_indices = [0, 2, 3] + + for i, dict_value in enumerate(billing_values): + if i < 3: + field_index = text_field_indices[i] + if i == 2: + text = str(dict_value) + self.full_address = text + metrics = self.text_fields[field_index].fontMetrics() + width = self.text_fields[field_index].width() + elided_text = metrics.elidedText( + text, QtCore.Qt.TextElideMode.ElideMiddle, width) + self.text_fields[field_index].setText(elided_text) + elif i == 1: + text = str(dict_value) + self.text_fields[field_index].setProperty("fullText", text) + self.text_fields[field_index].setText(text) + font_size = 14 + if len(text) > 9: + font_size = 15 + if len(text) > 13: + font_size = 10 + if len(text) > 16: + font_size = 10 + self.text_fields[field_index].setStyleSheet( + f"font-size: {font_size}px; font-weight: bold;") + else: + self.text_fields[field_index].setProperty( + "fullText", str(dict_value)) + self.text_fields[field_index].setText(str(dict_value)) + + self.qr_code_button.setDisabled(False) + self.update_status.update_status( + 'Invoice generated. Awaiting payment...') + + def on_invoice_finished(self, result): + if result: + self.invoice_data.clear() + self.show_payment_confirmed(self.text_fields[0].text()) + return + self.update_status.update_status( + 'An error occurred when generating invoice') + + def show_payment_confirmed(self, billing_code): + self.custom_window.navigator.navigate("payment_confirmed") + payment_page = self.custom_window.navigator.get_cached("payment_confirmed") + if payment_page is not None: + for line in self.text_fields: + line.setText('') + payment_page.set_billing_code(billing_code) + else: + print("PaymentConfirmed page not found") + + def find_payment_confirmed_page(self): + return self.custom_window.navigator.get_cached("payment_confirmed") + + def copy_text(self, field: int): + try: + original_status = self.update_status.status_label.text() + original_status = original_status.replace("Status: ", "") + + if int(field) == 3: + text = self.full_address + else: + text = self.text_fields[int(field)].text() + QApplication.clipboard().setText(text) + + if field == 0: + self.update_status.update_status( + 'Billing code copied to clipboard!') + elif field == 3: + self.update_status.update_status( + 'Address copied to clipboard!') + else: + self.update_status.update_status( + 'Pay amount copied to clipboard!') + + QTimer.singleShot( + 2000, lambda: self.update_status.update_status(original_status)) + except AttributeError: + self.update_status.update_status( + 'No content available for copying') + except Exception as e: + self.update_status.update_status( + f'An error occurred when copying the text') + + def reverse(self): + if self.ticket_poll_timer.isActive(): + self.ticket_poll_timer.stop() + if self.ticketing: + self.ticketing = False + self.custom_window.navigator.navigate("ticket_crypto_picker") + return + self.custom_window.navigator.navigate("currency_selection") + + def set_ticket_invoice(self, invoice, plan_key): + self.ticketing = True + self.temp_billing_code = getattr(invoice, 'temp_billing_code', None) + raw_currency = getattr(invoice, 'selected_currency', None) or '' + self.selected_currency = self._normalize_currency(raw_currency) + self.selected_duration = f"Plan {plan_key}" if plan_key else "Plan" + self._populate_ticket_fields(invoice) + if self.ticket_poll_timer.isActive(): + self.ticket_poll_timer.stop() + self.ticket_poll_timer.start(3000) + self.update_status.update_status('Awaiting ticket payment...') + + def _populate_ticket_fields(self, invoice): + self.text_fields[0].setText(str(getattr(invoice, 'temp_billing_code', '') or '')) + self.text_fields[1].setText(self.selected_duration) + self.text_fields[1].setStyleSheet('font-size: 13px;') + amount = getattr(invoice, 'due_amount', '') or '' + self.text_fields[2].setText(str(amount)) + addr = str(getattr(invoice, 'address', '') or '') + self.full_address = addr + metrics = self.text_fields[3].fontMetrics() + width = self.text_fields[3].width() + elided = metrics.elidedText(addr, QtCore.Qt.TextElideMode.ElideMiddle, width) + self.text_fields[3].setText(elided) + currency_index_map = {'monero': 0, 'bitcoin': 1, 'lightning': 2, 'litecoin': 3} + idx = currency_index_map.get(self.selected_currency, -1) + if idx >= 0: + for i, btn in enumerate(self.currency_display_buttons): + btn.setChecked(i == idx) + btn.setEnabled(i == idx) + self.qr_code_button.setDisabled(False) + + def _normalize_currency(self, raw): + if not raw: + return None + raw = str(raw).lower() + m = { + 'monero': 'monero', 'xmr': 'monero', + 'bitcoin': 'bitcoin', 'btc': 'bitcoin', + 'lightning': 'lightning', 'btc-ln': 'lightning', 'ln': 'lightning', + 'litecoin': 'litecoin', 'ltc': 'litecoin', + } + return m.get(raw, raw) + + def _tick_check_paid(self): + if not self.temp_billing_code or self._ticket_check_running: + return + self._ticket_check_running = True + self._ticket_worker = TicketingWorkerThread('CHECK_PAID', params={ + 'temp_billing_code': self.temp_billing_code + }) + self._ticket_worker.paid.connect(self._on_ticket_paid) + self._ticket_worker.not_paid.connect(self._on_ticket_not_paid) + self._ticket_worker.paid_check_failed.connect(self._on_ticket_check_failed) + self._ticket_worker.error.connect(self._on_ticket_check_failed) + self._ticket_worker.finished.connect(self._on_ticket_check_done) + self._ticket_worker.start() + + def _on_ticket_check_done(self): + self._ticket_check_running = False + + def _on_ticket_paid(self): + self.ticket_poll_timer.stop() + self.ticketing = False + self.update_status.update_status('Payment received.') + self.custom_window.navigator.navigate("ticket_prep") + prep_page = self.custom_window.navigator.get_cached("ticket_prep") + if prep_page is not None: + prep_page.start_prep() + + def _on_ticket_not_paid(self): + self.update_status.update_status('Awaiting ticket payment...') + + def _on_ticket_check_failed(self, msg): + self.update_status.update_status(f'Payment check failed: {msg}') + + def show_qr_code(self): + full_amount = self.text_fields[2].text() + if hasattr(self, 'full_address') and self.full_address: + currency_type = getattr(self, 'selected_currency', None) + qr_dialog = QRCodeDialog( + self.full_address, full_amount, currency_type, self) + qr_dialog.exec() + else: + self.update_status.update_status( + 'No address available for QR code') diff --git a/gui/v2/ui/pages/plan_picker_page.py b/gui/v2/ui/pages/plan_picker_page.py new file mode 100755 index 0000000..2a51ec0 --- /dev/null +++ b/gui/v2/ui/pages/plan_picker_page.py @@ -0,0 +1,101 @@ +from PyQt6.QtWidgets import QLabel, QListWidget, QListWidgetItem +from PyQt6 import QtCore + +from gui.v2.ui.pages.Page import Page +from gui.v2.ui.styles.styles import TERMINAL_LIST_QSS +from gui.v2.workers.ticketing_worker_thread import TicketingWorkerThread + + +class PlanPickerPage(Page): + def __init__(self, page_stack, main_window=None, parent=None): + super().__init__("PickPlan", page_stack, main_window, parent) + self.update_status = main_window + self.pricing_data = None + self.worker = None + + self.title.setText("Pick a Plan") + self.title.setGeometry(QtCore.QRect(290, 30, 220, 40)) + self.title.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + + self.button_reverse.setVisible(True) + self.button_reverse.clicked.connect(self.reverse) + + self.intro_label = QLabel( + "All members of the group expire at the same time. How much you pay depends on when you join.", + self) + self.intro_label.setGeometry(40, 90, 720, 40) + self.intro_label.setStyleSheet("color: white; font-size: 13px;") + self.intro_label.setWordWrap(True) + self.intro_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + + self.plan_list = QListWidget(self) + self.plan_list.setGeometry(60, 140, 680, 320) + self.plan_list.setStyleSheet(TERMINAL_LIST_QSS) + self.plan_list.itemClicked.connect(self.on_plan_clicked) + + self.status_label = QLabel("", self) + self.status_label.setGeometry(60, 470, 680, 30) + self.status_label.setStyleSheet("color: #cccccc; font-size: 13px;") + self.status_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + + def start_sync(self): + self.plan_list.clear() + self.status_label.setText("Syncing prices...") + self.update_status.update_status("Syncing ticket prices...") + self.worker = TicketingWorkerThread('SYNC_PRICES') + self.worker.sync_done.connect(self.on_sync_done) + self.worker.error.connect(self.on_error) + self.worker.start() + + def on_sync_done(self, pricing_data): + if isinstance(pricing_data, dict) and pricing_data.get('valid') is False: + err = pricing_data.get('error_code', 'unknown') + self.status_label.setText(f"Sync failed: {err}") + self.update_status.update_status(f"Sync failed: {err}") + return + + if isinstance(pricing_data, dict) and isinstance(pricing_data.get('data'), dict): + plans = pricing_data['data'] + elif isinstance(pricing_data, dict): + plans = pricing_data + else: + self.status_label.setText("Unexpected pricing data.") + return + + self.pricing_data = plans + self.populate_plans(plans) + self.status_label.setText("Pick a plan above.") + self.update_status.update_status("Plans loaded.") + + def populate_plans(self, pricing_data): + self.plan_list.clear() + if not isinstance(pricing_data, dict): + self.status_label.setText("Unexpected pricing data.") + return + for plan_key, plan_data in pricing_data.items(): + if not isinstance(plan_data, dict): + continue + cost = plan_data.get('cost', '?') + months = plan_data.get('months', '?') + days = plan_data.get('days', '?') + profiles = plan_data.get('profiles', '?') + label = f" Plan {plan_key} | €{cost} | {months}months {days}d left | {profiles} profiles" + item = QListWidgetItem(label) + item.setData(QtCore.Qt.ItemDataRole.UserRole, plan_key) + self.plan_list.addItem(item) + + def on_plan_clicked(self, item): + plan_key = item.data(QtCore.Qt.ItemDataRole.UserRole) + if not plan_key: + return + self.custom_window.navigator.navigate("ticket_crypto_picker") + crypto_page = self.custom_window.navigator.get_cached("ticket_crypto_picker") + if crypto_page is not None: + crypto_page.set_selected_plan(plan_key) + + def on_error(self, msg): + self.status_label.setText(f"Error: {msg}") + self.update_status.update_status(f"Sync error: {msg}") + + def reverse(self): + self.custom_window.navigator.navigate("id") diff --git a/gui/v2/ui/pages/policy_suggestion_page.py b/gui/v2/ui/pages/policy_suggestion_page.py new file mode 100755 index 0000000..d2958cd --- /dev/null +++ b/gui/v2/ui/pages/policy_suggestion_page.py @@ -0,0 +1,136 @@ +from PyQt6.QtWidgets import QLabel, QPushButton, QTextEdit + +from core.controllers.PolicyController import PolicyController +from core.Errors import ( + CommandNotFoundError, + PolicyAssignmentError, + PolicyInstatementError, +) + +from gui.v2.ui.pages.Page import Page + + +class PolicySuggestionPage(Page): + def __init__(self, page_stack, main_window=None, parent=None, policy_type='capability'): + super().__init__("PolicySuggestion", page_stack, main_window, parent) + self.btn_path = main_window.btn_path + self.update_status = main_window + self.policy_type = policy_type + self.policy = PolicyController.get(policy_type) + self.button_back.setVisible(False) + self.button_next.setVisible(False) + self.button_go.setVisible(False) + self.status_label = QLabel(self) + self.status_label.setGeometry(80, 430, 640, 40) + self.status_label.setStyleSheet("font-size: 14px; color: cyan;") + self.setup_ui() + + def setup_ui(self): + policy_name = "Capability" if self.policy_type == 'capability' else "Privilege" + self.title.setGeometry(20, 50, 760, 40) + self.title.setText(f"Policy Suggestion: {policy_name} Policy") + + description = QLabel(self) + description.setGeometry(80, 100, 640, 60) + description.setWordWrap(True) + description.setStyleSheet("font-size: 14px; color: cyan;") + description.setText( + f"A {policy_name.lower()} policy is available and can be instated to improve functionality. Review the policy details below before proceeding.") + + preview_label = QLabel(self) + preview_label.setGeometry(80, 170, 640, 30) + preview_label.setStyleSheet( + "font-size: 13px; color: white; font-weight: bold;") + preview_label.setText("Policy Preview:") + + preview_text = QTextEdit(self) + preview_text.setGeometry(80, 200, 640, 150) + preview_text.setReadOnly(True) + preview_text.setStyleSheet(""" + QTextEdit { + background-color: #1a1a1a; + color: #00ffff; + border: 1px solid #333; + border-radius: 4px; + font-family: monospace; + font-size: 11px; + padding: 5px; + } + """) + try: + preview_content = PolicyController.preview(self.policy) + preview_text.setText(preview_content) + except Exception as e: + preview_text.setText(f"Error loading preview: {str(e)}") + + yes_button = QPushButton(f"Instate {policy_name} Policy", self) + yes_button.setGeometry(170, 370, 250, 50) + yes_button.setStyleSheet(""" + QPushButton { + background: #007AFF; + color: white; + border: none; + border-radius: 4px; + font-size: 13px; + font-weight: bold; + } + QPushButton:hover { + background: #0056CC; + } + """) + yes_button.clicked.connect(self.instate_policy) + + no_button = QPushButton("Skip", self) + no_button.setGeometry(440, 370, 120, 50) + no_button.setStyleSheet(""" + QPushButton { + background: #666; + color: white; + border: none; + border-radius: 4px; + font-size: 13px; + font-weight: bold; + } + QPushButton:hover { + background: #555; + } + """) + no_button.clicked.connect(self.skip_policy) + + self.refresh_status() + + def refresh_status(self): + try: + if PolicyController.is_instated(self.policy): + self.status_label.setText( + f"Current status: {self.policy_type} policy is instated.") + self.status_label.setStyleSheet( + "font-size: 14px; color: #2ecc71;") + else: + self.status_label.setText( + f"Current status: {self.policy_type} policy is not instated.") + self.status_label.setStyleSheet( + "font-size: 14px; color: #e67e22;") + except Exception: + self.status_label.setText("Unable to determine policy status.") + self.status_label.setStyleSheet("font-size: 14px; color: #e67e22;") + + def instate_policy(self): + try: + PolicyController.instate(self.policy) + self.refresh_status() + policy_name = "Capability" if self.policy_type == 'capability' else "Privilege" + self.update_status.update_status(f"{policy_name} policy instated") + self.custom_window.navigator.navigate("menu") + except CommandNotFoundError as e: + self.status_label.setText(str(e)) + self.status_label.setStyleSheet("font-size: 14px; color: red;") + except (PolicyAssignmentError, PolicyInstatementError) as e: + self.status_label.setText(str(e)) + self.status_label.setStyleSheet("font-size: 14px; color: red;") + except Exception as e: + self.status_label.setText(f"Failed to instate policy: {str(e)}") + self.status_label.setStyleSheet("font-size: 14px; color: red;") + + def skip_policy(self): + self.custom_window.navigator.navigate("menu") diff --git a/gui/v2/ui/pages/protocol_page.py b/gui/v2/ui/pages/protocol_page.py new file mode 100755 index 0000000..a4d5b22 --- /dev/null +++ b/gui/v2/ui/pages/protocol_page.py @@ -0,0 +1,87 @@ +import os + +from PyQt6.QtWidgets import QPushButton, QLabel, QButtonGroup +from PyQt6.QtGui import QPixmap, QIcon +from PyQt6.QtCore import Qt +from PyQt6 import QtCore + +from gui.v2.ui.pages.Page import Page + + +class ProtocolPage(Page): + def __init__(self, page_stack, main_window=None, parent=None): + super().__init__("Protocol", page_stack, main_window, parent) + self.main_window = main_window + self.selected_protocol = None + self.btn_path = main_window.btn_path + self.selected_protocol_icon = None + self.connection_manager = main_window.connection_manager + self.button_back.setVisible(True) + self.update_status = main_window + self.button_go.clicked.connect(self.go_selected) + self.coming_soon_label = QLabel("Coming soon", self) + self.coming_soon_label.setGeometry(210, 50, 200, 40) + self.coming_soon_label.setStyleSheet("font-size: 22px;") + + self.coming_soon_label.setVisible(False) + self.title.setGeometry(585, 40, 185, 40) + self.title.setText("Pick a Protocol") + self.display.setGeometry(QtCore.QRect(0, 50, 580, 435)) + self.create_interface_elements() + if self.connection_manager.is_synced(): + self.enable_protocol_buttons() + + def create_interface_elements(self): + self.buttonGroup = QButtonGroup(self) + self.buttons = [] + self.selected_page_name = None + for j, (object_type, icon_name, page_name, geometry) in enumerate([ + (QPushButton, "wireguard", "wireguard", (585, 90, 185, 75)), + (QPushButton, "residential", "residential", (585, 90+30+75, 185, 75)), + (QPushButton, "hidetor", "hidetor", (585, 90+30+75+30+75, 185, 75)) + ]): + boton = object_type(self) + boton.setGeometry(*geometry) + boton.setIconSize(boton.size()) + boton.setCheckable(True) + boton.setDisabled(True) + boton.setIcon( + QIcon(os.path.join(self.btn_path, f"{icon_name}_button.png"))) + self.buttons.append(boton) + self.buttonGroup.addButton(boton, j) + boton.clicked.connect( + lambda _, name=page_name, protocol=icon_name: self.show_protocol(name, protocol)) + + def enable_protocol_buttons(self): + for button in self.buttons: + button.setDisabled(False) + + def update_swarp_json(self): + self.update_status.write_data( + {"protocol": self.selected_protocol_icon}) + + def show_protocol(self, page_name, protocol): + self.update_status.clear_data() + self.display.setPixmap(QPixmap(os.path.join(self.btn_path, f"{protocol}.png")).scaled( + self.display.size(), Qt.AspectRatioMode.KeepAspectRatio)) + self.selected_protocol_icon = protocol + self.selected_page_name = page_name + + if protocol in ["wireguard", "hidetor"]: + self.button_go.setVisible(True) + self.coming_soon_label.setVisible(False) + else: + self.button_go.setVisible(False) + self.coming_soon_label.setVisible(True) + self.update_swarp_json() + + def go_selected(self): + if self.selected_page_name: + self.custom_window.navigator.navigate(self.selected_page_name) + self.display.clear() + for boton in self.buttons: + boton.setChecked(False) + self.button_go.setVisible(False) + + def find_menu_page(self): + return self.custom_window.navigator.get_cached("menu") diff --git a/gui/v2/ui/pages/residential_page.py b/gui/v2/ui/pages/residential_page.py new file mode 100755 index 0000000..60a907c --- /dev/null +++ b/gui/v2/ui/pages/residential_page.py @@ -0,0 +1,101 @@ +import os + +from PyQt6.QtWidgets import QPushButton, QLabel, QButtonGroup +from PyQt6.QtGui import QPixmap, QIcon +from PyQt6 import QtCore + +from gui.v2.ui.pages.Page import Page + + +class ResidentialPage(Page): + def __init__(self, page_stack, main_window, parent=None): + super().__init__("Wireguard", page_stack, main_window, parent) + self.title.setGeometry(585, 40, 185, 40) + self.title.setText("Pick a Protocol") + self.update_status = main_window + self.connection_choice = None + self.button_reverse.setVisible(True) + self.button_reverse.clicked.connect(self.reverse) + self.button_go.clicked.connect(self.go_selected) + + self.display_1 = QLabel(self) + self.display_1.setGeometry(QtCore.QRect( + 15, 50, 550, 465)) + self.display_1.setPixmap( + QPixmap(os.path.join(self.btn_path, "browser only.png"))) + self.label = QLabel(self) + self.label.setGeometry(440, 370, 86, 130) + self.label.setPixmap( + QPixmap(os.path.join(self.btn_path, "tor 86x130.png"))) + self.label.hide() + + def showEvent(self, event): + super().showEvent(event) + self.create_interface_elements() + + def create_interface_elements(self): + self.buttonGroup = QButtonGroup(self) + self.buttons = [] + + for j, (object_type, icon_name, page_class, geometry) in enumerate([ + (QPushButton, "tor", self.set_connection_choice( + 'tor'), (585, 90, 185, 75)), + (QPushButton, "just proxy", self.set_connection_choice( + 'just proxy'), (585, 90+30+75+30+75, 185, 75)), + (QLabel, None, None, (570, 170, 210, 105)), + (QLabel, None, None, (570, 385, 210, 115)) + ]): + if object_type == QPushButton: + boton = object_type(self) + boton.setGeometry(*geometry) + boton.setIconSize(boton.size()) + boton.setCheckable(True) + boton.setIcon( + QIcon(os.path.join(self.btn_path, f"{icon_name}_button.png"))) + self.buttons.append(boton) + self.buttonGroup.addButton(boton, j) + + boton.show() + + boton.clicked.connect( + lambda checked, page=page_class, name=icon_name: self.show_residential(name, page)) + elif object_type == QLabel: + label = object_type(self) + label.setGeometry(*geometry) + label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + label.setWordWrap(True) + label.show() + if geometry == (570, 170, 210, 105): + text1 = "Connect to tor first\nThen the residential proxy\nThis hides Tor from websites" + label.setText(text1) + elif geometry == (570, 385, 210, 115): + text2 = "Connect directly\nTo the Proxy\nin a browser\nThis has no encryption" + label.setText(text2) + + def set_connection_choice(self, connection_choice): + pass + + def show_residential(self, icon_name, page_class): + self.connection_choice = icon_name + self.selected_page_class = page_class + + if icon_name == "tor": + self.label.show() + if icon_name == "just proxy": + self.label.hide() + + self.button_go.setVisible(True) + + def go_selected(self): + self.update_swarp_json(self.connection_choice) + self.custom_window.navigator.navigate("hidetor") + self.button_go.setVisible(False) + + def update_swarp_json(self, icon_name): + inserted_data = { + "connection": icon_name + } + self.update_status.write_data(inserted_data) + + def reverse(self): + self.custom_window.navigator.navigate("protocol") diff --git a/gui/v2/ui/pages/resume_page.py b/gui/v2/ui/pages/resume_page.py new file mode 100755 index 0000000..8bce4ed --- /dev/null +++ b/gui/v2/ui/pages/resume_page.py @@ -0,0 +1,463 @@ +import os + +from PyQt6.QtWidgets import ( + QLabel, QPushButton, QButtonGroup, QLineEdit, QTextEdit, QFrame +) +from PyQt6.QtGui import QPixmap, QIcon +from PyQt6.QtCore import Qt, QSize +from PyQt6 import QtCore, QtGui + +from core.controllers.ProfileController import ProfileController + +from gui.v2.actions.profile_order import append_profile_to_visual_order +from gui.v2.ui.pages.Page import Page +from gui.v2.ui.pages.location_page import LocationPage +from gui.v2.ui.pages.screen_page import ScreenPage + + +class ResumePage(Page): + def __init__(self, page_stack, main_window=None, parent=None): + super().__init__("Resume", page_stack, main_window, parent) + self.update_status = main_window + self.connection_manager = main_window.connection_manager + self.btn_path = main_window.btn_path + self.labels_creados = [] + self.additional_labels = [] + self.button_go.clicked.connect(self.copy_profile) + self.button_back.setVisible(True) + self.title.setGeometry(585, 40, 185, 40) + self.title.setText("Profile Summary") + self.display.setGeometry(QtCore.QRect(5, 50, 580, 435)) + self.buttonGroup = QButtonGroup(self) + self.button_back.clicked.connect(self.reverse) + self.create_arrow() + self.create_interface_elements() + + def reverse(self): + if self.connection_type == "system-wide": + self.custom_window.navigator.navigate("location") + else: + self.custom_window.navigator.navigate("screen") + + def show_location_verification(self, location_icon_name): + from gui.v2.ui.pages.location_verification_page import LocationVerificationPage + verification_page = LocationVerificationPage( + self.page_stack, self.update_status, location_icon_name, self) + self.page_stack.addWidget(verification_page) + self.page_stack.setCurrentIndex( + self.page_stack.indexOf(verification_page)) + + def create_arrow(self): + self.arrow_label = QLabel(self) + self.arrow_label.setGeometry(400, 115, 200, 200) + arrow_pixmap = QPixmap(os.path.join(self.btn_path, "arrow.png")) + + self.arrow_label.setPixmap(arrow_pixmap) + self.arrow_label.setScaledContents(True) + + self.arrow_label.raise_() + + def showEvent(self, event): + super().showEvent(event) + self.arrow_label.show() + self.arrow_label.raise_() + + def create_interface_elements(self): + for j, (object_type, icon_name, geometry) in enumerate([ + (QLineEdit, None, (130, 73, 300, 24)), + (QLabel, None, (0, 0, 0, 0)) + ]): + + if object_type == QLabel: + label = object_type(self) + label.setGeometry(*geometry) + icon_path = os.path.join( + self.btn_path, f"{icon_name}_button.png") + if os.path.exists(icon_path): + label.setPixmap(QPixmap(icon_path)) + locations = self.connection_manager.get_location_info( + icon_name) + provider = locations.operator.name if locations and hasattr( + locations, 'operator') else None + if label.pixmap().isNull(): + if locations and hasattr(locations, 'country_name'): + location_name = locations.country_name + label.setPixmap(LocationPage.create_location_button_image( + location_name, os.path.join(self.btn_path, "default_location_button.png"), provider)) + else: + label.setPixmap(LocationPage.create_location_button_image( + '', os.path.join(self.btn_path, "default_location_button.png"), provider)) + else: + if locations and hasattr(locations, 'country_name'): + label.setPixmap(LocationPage.create_location_button_image(f'{locations.country_code}_{locations.code}', os.path.join( + self.btn_path, "default_location_button.png"), provider, icon_path)) + else: + label.setPixmap(LocationPage.create_location_button_image('', os.path.join( + self.btn_path, "default_location_button.png"), provider, icon_path)) + elif object_type == QLineEdit: + self.line_edit = object_type(self) + self.line_edit.setGeometry(*geometry) + self.line_edit.setMaxLength(13) + self.line_edit.textChanged.connect( + self.toggle_button_visibility) + self.line_edit.setStyleSheet( + "background-color: transparent; border: none; color: cyan;") + self.line_edit.setPlaceholderText("Enter profile name") + self.line_edit.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.create() + + def create(self): + for label in self.additional_labels: + label.deleteLater() + self.additional_labels.clear() + + profile_1 = self.update_status.read_data() + self.connection_type = profile_1.get("connection", "") + connection_exists = 'connection' in profile_1 + + if connection_exists and profile_1['connection'] not in ["tor", "just proxy"]: + items = ["protocol", "connection", + "location", "browser", "dimentions"] + initial_y = 90 + label_height = 80 + elif connection_exists: + items = ["protocol", "connection", + "location", "browser", "dimentions"] + initial_y = 90 + label_height = 80 + else: + items = ["protocol", "location", "browser", "dimentions"] + initial_y = 90 + label_height = 105 + for i, item in enumerate(items): + text = profile_1.get(item, "") + if text: + if item == 'browser': + from gui.v2.ui.pages.browser_page import BrowserPage + base_image = BrowserPage.create_browser_button_image( + text, self.btn_path) + geometry = (585, initial_y + i * label_height, 185, 75) + parent_label = QLabel(self) + parent_label.setGeometry(*geometry) + parent_label.setPixmap(base_image) + if parent_label.pixmap().isNull(): + fallback_path = os.path.join( + self.btn_path, "default_browser_button.png") + base_image = BrowserPage.create_browser_button_image( + text, fallback_path, True) + parent_label.setPixmap(base_image) + parent_label.show() + self.labels_creados.append(parent_label) + elif item == 'location': + icon_path = os.path.join( + self.btn_path, f"button_{text}.png") + geometry = (585, initial_y + i * label_height, 185, 75) + parent_label = QPushButton(self) + parent_label.setGeometry(*geometry) + parent_label.setFlat(True) + parent_label.setStyleSheet( + "background: transparent; border: none;") + location_pixmap = QPixmap(icon_path) + locations = self.connection_manager.get_location_info(text) + provider = locations.operator.name if locations and hasattr( + locations, 'operator') else None + fallback_path = os.path.join( + self.btn_path, "default_location_button.png") + if location_pixmap.isNull(): + if locations and hasattr(locations, 'country_name'): + location_name = locations.country_name + base_image = LocationPage.create_location_button_image( + location_name, fallback_path, provider) + parent_label.setIcon(QIcon(base_image)) + else: + base_image = LocationPage.create_location_button_image( + '', fallback_path, provider) + parent_label.setIcon(QIcon(base_image)) + else: + if locations and hasattr(locations, 'country_name'): + base_image = LocationPage.create_location_button_image( + f'{locations.country_code}_{locations.code}', fallback_path, provider, icon_path) + parent_label.setIcon(QIcon(base_image)) + else: + base_image = LocationPage.create_location_button_image( + '', fallback_path, provider, icon_path) + parent_label.setIcon(QIcon(base_image)) + parent_label.setIconSize(QSize(185, 75)) + parent_label.location_icon_name = text + parent_label.setCursor( + QtCore.Qt.CursorShape.PointingHandCursor) + parent_label.clicked.connect( + lambda checked, loc=text: self.show_location_verification(loc)) + parent_label.show() + self.labels_creados.append(parent_label) + elif item == 'dimentions': + base_image = ScreenPage.create_resolution_button_image( + self, text) + geometry = (585, initial_y + i * label_height, 185, 75) + parent_label = QLabel(self) + parent_label.setGeometry(*geometry) + parent_label.setPixmap(base_image) + parent_label.show() + self.labels_creados.append(parent_label) + else: + icon_path = os.path.join( + self.btn_path, f"{text}_button.png") + geometry = (585, initial_y + i * label_height, 185, 75) + parent_label = QLabel(self) + parent_label.setGeometry(*geometry) + parent_label.setPixmap(QPixmap(icon_path)) + parent_label.show() + self.labels_creados.append(parent_label) + location_text = profile_1.get("location", "") + location_info = self.connection_manager.get_location_info( + location_text) + operator_name = "" + if location_info and hasattr(location_info, 'operator') and location_info.operator: + operator_name = location_info.operator.name or "" + + if operator_name != 'Simplified Privacy' and operator_name != "": + text_color = "white" + if self.connection_type != "system-wide": + text_color = "black" + image_name = "browser only.png" + image_path = os.path.join(self.btn_path, image_name) + label_background = QLabel(self) + label_background.setGeometry(10, 50, 535, 460) + label_background.setPixmap(QPixmap(image_path)) + label_background.setScaledContents(True) + label_background.show() + label_background.lower() + self.labels_creados.append(label_background) + + l_img = QLabel(self) + l_img.setGeometry(30, 135, 150, 150) + pixmap_loc = QPixmap(os.path.join( + self.btn_path, f"icon_{location_text}.png")) + l_img.setPixmap(pixmap_loc) + l_img.setScaledContents(True) + l_img.show() + self.labels_creados.append(l_img) + + l_name = f"{location_info.country_name}, {location_info.name}" if location_info and hasattr( + location_info, 'country_name') else "" + o_name = operator_name + n_key = location_info.operator.nostr_public_key if location_info and hasattr( + location_info, 'operator') and location_info.operator else "" + + info_txt = QTextEdit(self) + info_txt.setGeometry(190, 130, 310, 250) + info_txt.setReadOnly(True) + info_txt.setFrameStyle(QFrame.Shape.NoFrame) + info_txt.setStyleSheet( + f"background: transparent; border: none; color: {text_color}; font-size: 19px;") + info_txt.setVerticalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + info_txt.setHorizontalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + info_txt.setWordWrapMode( + QtGui.QTextOption.WrapMode.WrapAtWordBoundaryOrAnywhere) + info_txt.setText(f"Location: {l_name}\n\nOperator: {o_name}") + info_txt.show() + self.labels_creados.append(info_txt) + + nostr_txt = QTextEdit(self) + nostr_txt.setGeometry(30, 310, 480, 170) + nostr_txt.setReadOnly(True) + nostr_txt.setFrameStyle(QFrame.Shape.NoFrame) + nostr_txt.setStyleSheet( + f"background: transparent; border: none; color: {text_color}; font-size: 21px;") + nostr_txt.setVerticalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + nostr_txt.setHorizontalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + nostr_txt.setWordWrapMode( + QtGui.QTextOption.WrapMode.WrapAnywhere) + nostr_txt.setText(f"Nostr: {n_key}") + nostr_txt.show() + self.labels_creados.append(nostr_txt) + + elif connection_exists: + if profile_1.get("connection", "") == "system-wide": + image_path = os.path.join( + self.btn_path, f"wireguard_{profile_1.get('location', '')}.png") + main_label = QLabel(self) + main_label.setGeometry(10, 130, 500, 375) + main_label.setPixmap(QPixmap(image_path)) + main_label.setScaledContents(True) + main_label.show() + self.labels_creados.append(main_label) + + if profile_1.get("connection", "") == "just proxy": + image_path = os.path.join( + self.btn_path, f"icon_{profile_1.get('location', '')}.png") + main_label = QLabel(self) + main_label.setGeometry(10, 105, 530, 398) + main_label.setPixmap(QPixmap(image_path)) + main_label.setScaledContents(True) + main_label.show() + self.labels_creados.append(main_label) + if profile_1.get("connection", "") == "tor": + image_path = os.path.join( + self.btn_path, f"hdtor_{profile_1.get('location', '')}.png") + main_label = QLabel(self) + main_label.setGeometry(10, 105, 530, 398) + main_label.setPixmap(QPixmap(image_path)) + main_label.setScaledContents(True) + main_label.show() + self.labels_creados.append(main_label) + if profile_1.get("connection", "") == "browser-only": + image_name = "browser only.png" + image_path = os.path.join(self.btn_path, image_name) + label_background = QLabel(self) + label_background.setGeometry(10, 50, 535, 460) + label_background.setPixmap(QPixmap(image_path)) + label_background.setScaledContents(True) + label_background.show() + label_background.lower() + self.labels_creados.append(label_background) + image_path = os.path.join( + self.btn_path, f"wireguard_{profile_1.get('location', '')}.png") + main_label = QLabel(self) + main_label.setGeometry(10, 130, 500, 375) + main_label.setPixmap(QPixmap(image_path)) + main_label.setScaledContents(True) + main_label.show() + self.labels_creados.append(main_label) + else: + image_path = os.path.join( + self.btn_path, f"icon_{profile_1.get('location', '')}.png") + main_label = QLabel(self) + main_label.setGeometry(10, 130, 500, 375) + main_label.setPixmap(QPixmap(image_path)) + main_label.setScaledContents(True) + main_label.show() + self.labels_creados.append(main_label) + + if hasattr(self, 'arrow_label'): + self.arrow_label.raise_() + + def toggle_button_visibility(self): + self.button_go.setVisible(bool(self.line_edit.text())) + + def find_menu_page(self): + return self.custom_window.navigator.get_cached("menu") + + def copy_profile(self): + profile_name = self.line_edit.text() + menu_page = self.find_menu_page() + if menu_page: + number_of_profiles = menu_page.number_of_profiles + + profile_data = self.update_status.read_data() + + required_fields = [profile_data.get("protocol"), profile_name] + if not all(required_fields): + print("Error: Some required fields are empty!") + return + + profile_data["name"] = profile_name + profile_data["visible"] = "yes" + + profiles = ProfileController.get_all() + profile_id = self.get_next_available_id(profiles) + new_profile = profile_data + + self.create_core_profiles(new_profile, profile_id) + if ProfileController.get(profile_id) is not None: + append_profile_to_visual_order( + getattr(self.update_status, 'gui_config_file', None), + profile_id, + profiles.keys()) + + main = self.update_status + if hasattr(main, 'navigate_after_profile_created'): + main.navigate_after_profile_created() + else: + self.custom_window.navigator.navigate("menu") + + self.update_status.clear_data() + + self.line_edit.clear() + self.display.clear() + self.button_go.setVisible(False) + + def get_next_available_id(self, profiles: dict) -> int: + if not profiles: + return 1 + + existing_ids = sorted(profiles.keys()) + + for i in range(1, max(existing_ids) + 2): + if i not in existing_ids: + return i + + return None + + def create_core_profiles(self, profile, id): + if profile.get('connection') != 'system-wide': + browser_info = profile.get('browser', '').split() + if len(browser_info) < 2: + self.update_status.update_status( + 'Application version not supported') + return + + application = f"{browser_info[0].lower()}:{browser_info[1]}" + else: + application = '' + + parts = profile.get('location').split('_') + country_code = parts[0] + location_code = parts[1] + if profile.get('protocol') == 'wireguard': + connection_type = 'wireguard' + elif profile.get('protocol') == 'hidetor' or profile.get('protocol') == 'residential': + if profile.get('connection') == 'tor': + connection_type = 'tor' + elif profile.get('connection') == 'just proxy': + connection_type = 'system' + else: + self.update_status.update_status('Connection type not supported') + return + + profile_data = { + 'id': int(id), + 'name': profile.get('name'), + 'country_code': country_code, + 'code': location_code, + 'application': application, + 'connection_type': connection_type, + 'resolution': profile.get('dimentions', ''), + } + profile_type = 'system' if profile.get( + 'connection') == 'system-wide' else 'session' + action = f'CREATE_{profile_type.upper()}_PROFILE' + + self.handle_core_action_create_profile( + action, profile_data, profile_type) + + def eliminacion(self): + for label in self.labels_creados: + label.deleteLater() + + self.labels_creados = [] + self.create() + if hasattr(self, 'arrow_label'): + self.arrow_label.show() + self.arrow_label.raise_() + + def handle_core_action_create_profile(self, action, profile_data=None, profile_type=None): + from gui.v2.workers.worker_thread import WorkerThread + self.worker_thread = WorkerThread(action, profile_data, profile_type) + self.worker_thread.text_output.connect(self.update_output) + self.worker_thread.start() + self.worker_thread.wait() + + def update_output(self, text): + self.update_status.update_status(text) + + def on_profile_creation(self, result): + + if self.worker_thread: + self.worker_thread.quit() + self.worker_thread.wait() + self.worker_thread = None diff --git a/gui/v2/ui/pages/screen_page.py b/gui/v2/ui/pages/screen_page.py new file mode 100755 index 0000000..d6a091d --- /dev/null +++ b/gui/v2/ui/pages/screen_page.py @@ -0,0 +1,347 @@ +import os + +from PyQt6.QtWidgets import ( + QPushButton, QLabel, QButtonGroup, QDialog, QLineEdit, + QVBoxLayout, QHBoxLayout, QMessageBox +) +from PyQt6.QtGui import QPixmap, QIcon, QPainter, QColor, QFont +from PyQt6.QtCore import Qt, QRect +from PyQt6 import QtCore + +from gui.v2.ui.pages.Page import Page + + +class ScreenPage(Page): + def __init__(self, page_stack, main_window, parent=None): + super().__init__("Screen", page_stack, main_window, parent) + self.selected_dimentions = None + self.update_status = main_window + self.selected_dimentions_icon = None + self.button_back.setVisible(True) + self.title.setGeometry(585, 40, 200, 40) + self.title.setText("Pick a Resolution") + self.host_screen_info = QLabel( + f"Host: {self.custom_window.host_screen_width}x{self.custom_window.host_screen_height}", self) + self.host_screen_info.setGeometry(QtCore.QRect(355, 30, 200, 30)) + self.host_screen_info.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + self.host_screen_info.setStyleSheet( + "color: #00ffff; font-size: 14px; font-weight: bold;") + self.host_screen_info.show() + + self.display.setGeometry(QtCore.QRect(5, 50, 580, 435)) + self.showing_larger_resolutions = False + self.larger_resolution_buttons = [] + self.create_interface_elements() + + def create_interface_elements(self): + self.buttonGroup = QButtonGroup(self) + self.buttons = [] + resolutions = [ + ("800x600", (595, 90, 180, 70)), + ("1024x760", (595, 170, 180, 70)), + ("1152x1080", (595, 250, 180, 70)), + ("1280x1024", (595, 330, 180, 70)), + ("1920x1080", (595, 410, 180, 70)) + ] + valid_resolutions = [] + for res, geom in resolutions: + w, h = map(int, res.split('x')) + if w < self.custom_window.host_screen_width and h < self.custom_window.host_screen_height: + valid_resolutions.append((QPushButton, res, geom)) + + for j, (object_type, icon_name, geometry) in enumerate(valid_resolutions): + boton = object_type(self) + boton.setGeometry(*geometry) + boton.setIconSize(boton.size()) + boton.setCheckable(True) + boton.setIcon( + QIcon(self.create_resolution_button_image(icon_name))) + self.buttons.append(boton) + self.buttonGroup.addButton(boton, j) + boton.clicked.connect( + lambda _, dimentions=icon_name: self.show_dimentions(dimentions)) + + self.larger_resolutions_button = QPushButton(self) + self.larger_resolutions_button.setGeometry(100, 450, 185, 50) + self.larger_resolutions_button.setIconSize( + self.larger_resolutions_button.size()) + self.larger_resolutions_button.setIcon( + QIcon(self.create_resolution_button_image("Larger Resolutions"))) + self.larger_resolutions_button.clicked.connect( + self.show_larger_resolutions) + + self.custom_resolution_button = QPushButton(self) + self.custom_resolution_button.setGeometry(300, 450, 195, 50) + self.custom_resolution_button.setIconSize( + self.custom_resolution_button.size()) + self.custom_resolution_button.setIcon( + QIcon(self.create_resolution_button_image("Custom Resolution"))) + self.custom_resolution_button.clicked.connect( + self.show_custom_resolution_dialog) + + def create_resolution_button_image(self, resolution_text): + template_path = os.path.join( + self.btn_path, "resolution_template_button.png") + + if os.path.exists(template_path): + base_image = QPixmap(template_path) + if 'Resolution' in resolution_text: + base_image = base_image.scaled(195, 50) + font_size = 10 + else: + base_image = base_image.scaled(187, 75) + font_size = 13 + else: + base_image = QPixmap(185, 75) + base_image.fill(QColor(44, 62, 80)) + font_size = 13 + + painter = QPainter(base_image) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + + font = QFont() + font.setPointSize(font_size) + font.setBold(True) + painter.setFont(font) + + painter.setPen(QColor(0, 255, 255)) + + text_rect = base_image.rect() + painter.drawText(text_rect, Qt.AlignmentFlag.AlignCenter | + Qt.AlignmentFlag.AlignVCenter, resolution_text) + + painter.end() + return base_image + + def show_larger_resolutions(self): + if self.showing_larger_resolutions: + self.hide_larger_resolutions() + else: + self.hide_standard_resolutions() + self.showing_larger_resolutions = True + self.larger_resolutions_button.setIcon( + QIcon(self.create_resolution_button_image("Standard Resolutions"))) + + larger_resolutions_list = [ + ("2560x1600", (595, 90, 180, 70)), + ("2560x1440", (595, 170, 180, 70)), + ("1920x1440", (595, 250, 180, 70)), + ("1792x1344", (595, 330, 180, 70)), + ("2048x1152", (595, 410, 180, 70)) + ] + + valid_larger = list(larger_resolutions_list) + + for i, (resolution, geometry) in enumerate(valid_larger): + button = QPushButton(self) + button.setGeometry(*geometry) + button.setIconSize(button.size()) + button.setCheckable(True) + button.setVisible(True) + button.setIcon( + QIcon(self.create_resolution_button_image(resolution))) + button.clicked.connect( + lambda _, res=resolution: self.show_dimentions(res)) + self.larger_resolution_buttons.append(button) + self.buttonGroup.addButton(button, len(self.buttons) + i) + + def hide_larger_resolutions(self): + for button in self.larger_resolution_buttons: + self.buttonGroup.removeButton(button) + button.deleteLater() + self.larger_resolution_buttons.clear() + self.showing_larger_resolutions = False + self.larger_resolutions_button.setIcon( + QIcon(self.create_resolution_button_image("Larger Resolutions"))) + self.show_standard_resolutions() + + def hide_standard_resolutions(self): + for button in self.buttons: + button.setVisible(False) + + def show_standard_resolutions(self): + for button in self.buttons: + button.setVisible(True) + + def show_custom_resolution_dialog(self): + dialog = QDialog(self) + dialog.setWindowTitle("Custom Resolution") + dialog.setFixedSize(300, 150) + dialog.setModal(True) + dialog.setStyleSheet(""" + QDialog { + background-color: #2c3e50; + border: 2px solid #00ffff; + border-radius: 10px; + } + QLabel { + color: white; + font-size: 12px; + } + QLineEdit { + background-color: #34495e; + color: white; + border: 1px solid #00ffff; + border-radius: 5px; + padding: 5px; + font-size: 12px; + } + QPushButton { + background-color: #2c3e50; + color: white; + border: 2px solid #00ffff; + border-radius: 5px; + padding: 8px; + font-size: 12px; + } + QPushButton:hover { + background-color: #34495e; + } + """) + + layout = QVBoxLayout() + + input_layout = QHBoxLayout() + width_label = QLabel("Width:") + width_input = QLineEdit() + width_input.setPlaceholderText("1920") + height_label = QLabel("Height:") + height_input = QLineEdit() + height_input.setPlaceholderText("1080") + + input_layout.addWidget(width_label) + input_layout.addWidget(width_input) + input_layout.addWidget(height_label) + input_layout.addWidget(height_input) + + button_layout = QHBoxLayout() + ok_button = QPushButton("OK") + cancel_button = QPushButton("Cancel") + + button_layout.addWidget(cancel_button) + button_layout.addWidget(ok_button) + + layout.addLayout(input_layout) + layout.addLayout(button_layout) + dialog.setLayout(layout) + + def validate_and_accept(): + try: + width = int(width_input.text()) + height = int(height_input.text()) + + if width <= 0 or height <= 0: + QMessageBox.warning( + dialog, "Invalid Resolution", "Width and height must be positive numbers.") + return + + host_w = self.custom_window.host_screen_width + host_h = self.custom_window.host_screen_height + + adjusted = False + if width > host_w: + width = host_w - 50 + adjusted = True + if height > host_h: + height = host_h - 50 + adjusted = True + + if adjusted: + QMessageBox.information( + dialog, "Notice", "Adjusted to fit host size") + + if width > 10000 or height > 10000: + QMessageBox.warning( + dialog, "Invalid Resolution", "Resolution too large. Maximum 10000x10000.") + return + + custom_resolution = f"{width}x{height}" + self.show_dimentions(custom_resolution) + dialog.accept() + + except ValueError: + QMessageBox.warning( + dialog, "Invalid Input", "Please enter valid numbers for width and height.") + + ok_button.clicked.connect(validate_and_accept) + cancel_button.clicked.connect(dialog.reject) + + width_input.returnPressed.connect(validate_and_accept) + height_input.returnPressed.connect(validate_and_accept) + + dialog.exec() + + def update_swarp_json(self): + inserted_data = { + "dimentions": self.selected_dimentions_icon + } + + self.update_status.write_data(inserted_data) + + def show_dimentions(self, dimentions): + try: + image_path = os.path.join(self.btn_path, f"{dimentions}.png") + if os.path.exists(image_path): + self.display.setPixmap(QPixmap(image_path).scaled( + self.display.size(), Qt.AspectRatioMode.KeepAspectRatio)) + else: + self.create_resolution_preview_image(dimentions) + except: + self.display.clear() + + self.selected_dimentions_icon = dimentions + self.button_next.setVisible(True) + self.update_swarp_json() + + def create_resolution_preview_image(self, resolution_text): + template_path = os.path.join(self.btn_path, "resolution_template.png") + + if os.path.exists(template_path): + base_image = QPixmap(template_path) + base_image = base_image.scaled( + 580, 435, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) + else: + base_image = QPixmap(580, 435) + base_image.fill(QColor(44, 62, 80)) + + painter = QPainter(base_image) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + + try: + width, height = map(int, resolution_text.split('x')) + gcd = self._gcd(width, height) + aspect_ratio = f"{width//gcd}:{height//gcd}" + except: + aspect_ratio = "" + + font = QFont() + font.setPointSize(24) + font.setBold(True) + painter.setFont(font) + painter.setPen(QColor(0, 255, 255)) + + text_rect = base_image.rect() + painter.drawText( + text_rect, Qt.AlignmentFlag.AlignCenter, resolution_text) + + if aspect_ratio: + font.setPointSize(16) + painter.setFont(font) + painter.setPen(QColor(200, 200, 200)) + + aspect_rect = QRect( + text_rect.x(), text_rect.y() + 80, text_rect.width(), 40) + painter.drawText( + aspect_rect, Qt.AlignmentFlag.AlignCenter, f"({aspect_ratio})") + + painter.end() + + self.display.setPixmap(base_image) + + def _gcd(self, a, b): + while b: + a, b = b, a % b + return a + + def gestionar_next(self): + self.custom_window.navigator.navigate("resume") diff --git a/gui/v2/ui/pages/settings_page.py b/gui/v2/ui/pages/settings_page.py new file mode 100755 index 0000000..d610778 --- /dev/null +++ b/gui/v2/ui/pages/settings_page.py @@ -0,0 +1,2325 @@ +import json +import logging +import os +import subprocess +import sys +import threading +from datetime import datetime, timezone +from typing import Union + +from PyQt6.QtWidgets import ( + QApplication, QButtonGroup, QCheckBox, QComboBox, QFrame, QGridLayout, + QGroupBox, QHBoxLayout, QLabel, QLineEdit, QMessageBox, QPushButton, + QScrollArea, QStackedLayout, QVBoxLayout, QWidget, +) +from PyQt6.QtGui import QIcon +from PyQt6.QtCore import Qt, QSize + +from core.Constants import Constants +from core.controllers.ConfigurationController import ConfigurationController +from core.controllers.PolicyController import PolicyController +from core.controllers.ProfileController import ProfileController +from core.controllers.tickets.UseTicketController import ( + do_we_use_a_random_ticket, + get_unused_tickets, + modify_random_tickets_setting, +) +from core.models.session.SessionProfile import SessionProfile +from core.models.system.SystemProfile import SystemProfile +from core.Errors import ( + CommandNotFoundError, + PolicyAssignmentError, + PolicyInstatementError, + PolicyRevocationError, +) +from core.errors.logger import logger as core_logger + +from gui.v2.actions.key_interpretation import interpret_key_results +from gui.v2.actions.profile_order import normalize_profile_order +from gui.v2.infrastructure.setup_observers import ticket_observer +from gui.v2.ui.pages.Page import Page +from gui.v2.ui.popups.confirmation_popup import ConfirmationPopup +from gui.v2.ui.styles.styles import ( + SCROLLBAR_CYAN_QSS, + TERMINAL_LIST_QSS, + checkbox_style, +) +from gui.v2.ui.widgets.clickable_label import ClickableValueLabel +from gui.v2.ui.widgets.terminal_widget import TerminalWidget +from gui.v2.workers.ticketing_worker_thread import TicketingWorkerThread +from gui.v2.workers.worker_thread import WorkerThread + + +class Settings(Page): + def __init__(self, page_stack, main_window, parent=None): + super().__init__("Settings", page_stack, main_window, parent) + self.font_style = f"font-family: '{self.custom_window.open_sans_family}';" + + self.update_status = main_window + self.update_logging = main_window + self.button_reverse.setVisible(True) + self.button_reverse.setEnabled(True) + self.button_reverse.clicked.connect(self.reverse) + self.title.setGeometry(585, 40, 185, 40) + self.title.setText("Settings") + self.setup_ui() + + def setup_ui(self): + main_container = QWidget(self) + main_container.setGeometry(0, 0, 800, 520) + + self.layout = QHBoxLayout(main_container) + self.layout.setContentsMargins(0, 60, 0, 0) + + self.left_panel = QWidget() + self.left_panel.setFixedWidth(200) + self.left_layout = QVBoxLayout(self.left_panel) + self.left_layout.setContentsMargins(0, 0, 0, 0) + self.left_layout.setSpacing(0) + + self.content_widget = QWidget() + self.content_layout = QStackedLayout(self.content_widget) + + self.layout.addWidget(self.left_panel) + self.layout.addWidget(self.content_widget) + + self.setup_menu_buttons() + self.setup_pages() + + def setup_menu_buttons(self): + menu_items = [ + ("Overview", self.show_account_page), + ("Subscriptions", self.show_subscription_page), + ("Tickets", self.show_tickets_page), + ("Create/Edit", self.show_registrations_page), + ("Verification", self.show_verification_page), + ("Legacy-Version", self.show_systemwide_page), + ("Bwrap Permission", self.show_bwrap_page), + ("Delete Profile", self.show_delete_page), + ("Error Logs", self.show_logs_page), + ("Debug Help", self.show_debug_page) + ] + + self.menu_buttons = [] + for text, callback in menu_items: + btn = QPushButton(text) + btn.setFixedHeight(40) + btn.setCursor(Qt.CursorShape.PointingHandCursor) + btn.setCheckable(True) + btn.clicked.connect(callback) + + btn.setStyleSheet(f""" + QPushButton {{ + text-align: left; + padding-left: 20px; + border: none; + background: transparent; + color: #808080; + font-size: 14px; + {self.font_style} + }} + QPushButton:checked {{ + background: rgba(255, 255, 255, 0.1); + color: white; + border-left: 3px solid #007AFF; + }} + QPushButton:hover:!checked {{ + background: rgba(255, 255, 255, 0.05); + }} + """) + + self.left_layout.addWidget(btn) + self.menu_buttons.append(btn) + + self.left_layout.addStretch() + + def create_delete_page(self): + page = QWidget() + layout = QVBoxLayout(page) + layout.setContentsMargins(20, 20, 20, 20) + layout.setSpacing(15) + + title = QLabel("DELETE PROFILE") + title.setStyleSheet( + f"color: #808080; font-size: 12px; font-weight: bold; {self.font_style}") + layout.addWidget(title) + + self.delete_scroll_area = QScrollArea() + self.delete_scroll_area.setFixedSize(510, 300) + self.delete_scroll_area.setWidgetResizable(True) + self.delete_scroll_area.setHorizontalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.delete_scroll_area.setVerticalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAsNeeded) + self.delete_scroll_area.setStyleSheet(SCROLLBAR_CYAN_QSS) + + self.delete_scroll_widget = QWidget() + self.delete_scroll_widget.setStyleSheet( + "background-color: transparent;") + self.delete_scroll_area.setWidget(self.delete_scroll_widget) + + self.profile_buttons = QButtonGroup() + self.profile_buttons.setExclusive(True) + + self.create_delete_profile_buttons() + + layout.addWidget(self.delete_scroll_area) + + self.delete_button = QPushButton("Delete") + self.delete_button.setEnabled(False) + self.delete_button.setFixedSize(120, 40) + self.delete_button.setStyleSheet(f""" + QPushButton {{ + background: #ff4d4d; + color: white; + border: none; + border-radius: 5px; + font-weight: bold; + {self.font_style} + }} + QPushButton:disabled {{ + background: #666666; + }} + QPushButton:hover:!disabled {{ + background: #ff3333; + }} + """) + self.delete_button.clicked.connect(self.delete_selected_profile) + self.profile_buttons.buttonClicked.connect(self.on_profile_selected) + + button_layout = QHBoxLayout() + button_layout.addStretch() + button_layout.addWidget(self.delete_button) + button_layout.addStretch() + + layout.addStretch() + layout.addLayout(button_layout) + + return page + + def create_delete_profile_buttons(self): + profiles = ProfileController.get_all() + + profile_ids = normalize_profile_order( + getattr(self.update_status, 'gui_config_file', None), + profiles.keys()) + + for index, profile_id in enumerate(profile_ids): + profile = profiles[profile_id] + row = index // 2 + col = index % 2 + + btn = QPushButton(f"Profile {profile_id}\n{profile.name}") + btn.setCheckable(True) + btn.setFixedSize(180, 60) + btn.setParent(self.delete_scroll_widget) + btn.setGeometry(col * 220 + 50, row * 100, 180, 60) + btn.setStyleSheet(f""" + QPushButton {{ + background: rgba(255, 255, 255, 0.1); + border: none; + color: white; + border-radius: 5px; + text-align: center; + {self.font_style} + }} + QPushButton:checked {{ + background: rgba(255, 255, 255, 0.3); + border: 2px solid #007AFF; + }} + QPushButton:hover:!checked {{ + background: rgba(255, 255, 255, 0.2); + }} + """) + self.profile_buttons.addButton(btn, profile_id) + + if profiles: + rows = (len(profiles) + 1) // 2 + height = rows * 100 + self.delete_scroll_widget.setFixedSize(510, height) + + def create_debug_page(self): + page = QWidget() + layout = QVBoxLayout(page) + layout.setContentsMargins(20, 20, 20, 20) + layout.setSpacing(15) + + title = QLabel("DEBUG HELP") + title.setStyleSheet( + f"color: #808080; font-size: 12px; font-weight: bold; {self.font_style}") + layout.addWidget(title) + + scroll_area = QScrollArea() + scroll_area.setWidgetResizable(True) + scroll_area.setStyleSheet(f""" + QScrollArea {{ + border: none; + background: transparent; + }} + QScrollArea > QWidget > QWidget {{ + background: transparent; + }} + """) + + scroll_content = QWidget() + scroll_layout = QVBoxLayout(scroll_content) + scroll_layout.setSpacing(15) + + profile_selection_group = QGroupBox("Profile Selection") + profile_selection_group.setStyleSheet(f""" + QGroupBox {{ + color: white; + font-weight: bold; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + padding: 15px; + font-size: 10px; + margin-top: 15px; + {self.font_style} + }} + QGroupBox::title {{ + subcontrol-origin: margin; + left: 10px; + padding: 0 5px; + }} + """) + profile_selection_layout = QVBoxLayout(profile_selection_group) + + profile_label = QLabel("Select System-wide Profile:") + profile_label.setStyleSheet( + f"color: white; font-size: 12px; {self.font_style}") + profile_selection_layout.addWidget(profile_label) + + self.debug_profile_selector = QComboBox() + self.debug_profile_selector.setStyleSheet(self.get_combobox_style()) + self.debug_profile_selector.currentTextChanged.connect( + self.on_debug_profile_selected) + profile_selection_layout.addWidget(self.debug_profile_selector) + + scroll_layout.addWidget(profile_selection_group) + + ping_group = QGroupBox("Ping Test") + ping_group.setStyleSheet(f""" + QGroupBox {{ + color: white; + font-weight: bold; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + padding: 15px; + font-size: 10px; + margin-top: 15px; + {self.font_style} + }} + QGroupBox::title {{ + subcontrol-origin: margin; + left: 10px; + padding: 0 5px; + }} + """) + ping_layout = QVBoxLayout(ping_group) + + self.ping_instruction_label = QLabel("") + self.ping_instruction_label.setStyleSheet( + f"color: white; font-size: 12px; {self.font_style}") + self.ping_instruction_label.setWordWrap(True) + self.ping_instruction_label.hide() + ping_layout.addWidget(self.ping_instruction_label) + + ping_buttons_layout = QHBoxLayout() + + self.copy_ping_button = QPushButton("Copy Ping Command") + self.copy_ping_button.setFixedSize(140, 40) + self.copy_ping_button.setStyleSheet(f""" + QPushButton {{ + font-size: 12px; + background: #007AFF; + color: white; + border: none; + border-radius: 5px; + font-weight: bold; + {self.font_style} + }} + QPushButton:hover {{ + background: #0056CC; + }} + QPushButton:disabled {{ + background: #666666; + }} + """) + self.copy_ping_button.clicked.connect(self.copy_ping_command) + self.copy_ping_button.setEnabled(False) + ping_buttons_layout.addWidget(self.copy_ping_button) + + self.test_ping_button = QPushButton("Test Ping") + self.test_ping_button.setFixedSize(120, 40) + self.test_ping_button.setStyleSheet(f""" + QPushButton {{ + font-size: 12px; + background: #4CAF50; + color: white; + border: none; + border-radius: 5px; + font-weight: bold; + {self.font_style} + }} + QPushButton:hover {{ + background: #45a049; + }} + QPushButton:disabled {{ + background: #666666; + }} + """) + self.test_ping_button.clicked.connect(self.test_ping_connection) + self.test_ping_button.setEnabled(False) + ping_buttons_layout.addWidget(self.test_ping_button) + + ping_layout.addLayout(ping_buttons_layout) + + self.ping_result_label = QLabel("") + self.ping_result_label.setStyleSheet( + f"color: white; font-size: 12px; font-weight: bold; {self.font_style}") + self.ping_result_label.setWordWrap(True) + self.ping_result_label.hide() + ping_layout.addWidget(self.ping_result_label) + + scroll_layout.addWidget(ping_group) + + command_group = QGroupBox("CLI Commands") + command_group.setStyleSheet(f""" + QGroupBox {{ + color: white; + font-weight: bold; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + padding: 15px; + font-size: 10px; + margin-top: 15px; + {self.font_style} + }} + QGroupBox::title {{ + subcontrol-origin: margin; + left: 10px; + padding: 0 5px; + }} + """) + command_layout = QVBoxLayout(command_group) + + self.cli_command = QLineEdit() + self.cli_command.setReadOnly(True) + self.cli_command.setText("Select a profile above to see command") + self.cli_command.setStyleSheet(f""" + QLineEdit {{ + color: white; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 4px; + padding: 8px; + font-family: 'Courier New', monospace; + font-size: 12px; + }} + """) + command_layout.addWidget(self.cli_command) + + cli_buttons_layout = QHBoxLayout() + + cli_copy_button = QPushButton("Copy CLI Command") + cli_copy_button.setFixedSize(140, 40) + cli_copy_button.setStyleSheet(f""" + QPushButton {{ + font-size: 12px; + background: #007AFF; + color: white; + border: none; + border-radius: 5px; + font-weight: bold; + {self.font_style} + }} + QPushButton:hover {{ + background: #0056CC; + }} + QPushButton:disabled {{ + background: #666666; + }} + """) + cli_copy_button.clicked.connect(self.copy_cli_command) + cli_copy_button.setEnabled(False) + cli_buttons_layout.addWidget(cli_copy_button) + + command_layout.addLayout(cli_buttons_layout) + + find_buttons_layout = QHBoxLayout() + + command_layout.addLayout(find_buttons_layout) + + scroll_layout.addWidget(command_group) + + self.cli_copy_button = cli_copy_button + + wg_quick_group = QGroupBox("Direct Systemwide Wireguard") + wg_quick_group.setStyleSheet(f""" + QGroupBox {{ + color: white; + font-weight: bold; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + padding: 15px; + font-size: 10px; + margin-top: 15px; + {self.font_style} + }} + QGroupBox::title {{ + subcontrol-origin: margin; + left: 10px; + padding: 0 5px; + }} + """) + wg_quick_layout = QVBoxLayout(wg_quick_group) + + wg_quick_subtitle = QLabel("This uses your Linux system's Wireguard") + wg_quick_subtitle.setStyleSheet( + f"font-size: 10px; color: #CCCCCC; {self.font_style}") + wg_quick_subtitle.setWordWrap(True) + wg_quick_layout.addWidget(wg_quick_subtitle) + + self.wg_quick_up_command = QLineEdit() + self.wg_quick_up_command.setReadOnly(True) + self.wg_quick_up_command.setText( + "Select a profile above to see commands") + self.wg_quick_up_command.setStyleSheet(f""" + QLineEdit {{ + color: black; + background: white; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 4px; + padding: 8px; + font-family: 'Courier New', monospace; + font-size: 12px; + }} + """) + wg_quick_layout.addWidget(self.wg_quick_up_command) + + copy_wg_up_button = QPushButton("Copy UP command") + copy_wg_up_button.setFixedSize(140, 40) + copy_wg_up_button.setStyleSheet(f""" + QPushButton {{ + font-size: 10px; + background: #4CAF50; + color: white; + border: none; + border-radius: 5px; + font-weight: bold; + {self.font_style} + }} + QPushButton:hover {{ + background: #45a049; + }} + """) + copy_wg_up_button.clicked.connect(self.copy_wg_quick_up_command) + copy_wg_up_button.setEnabled(False) + wg_quick_layout.addWidget(copy_wg_up_button) + + self.wg_quick_down_command = QLineEdit() + self.wg_quick_down_command.setReadOnly(True) + self.wg_quick_down_command.setText( + "Select a profile above to see commands") + self.wg_quick_down_command.setStyleSheet(f""" + QLineEdit {{ + color: black; + background: white; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 4px; + padding: 8px; + font-family: 'Courier New', monospace; + font-size: 12px; + }} + """) + wg_quick_layout.addWidget(self.wg_quick_down_command) + + copy_wg_down_button = QPushButton("Copy DOWN command") + copy_wg_down_button.setFixedSize(140, 40) + copy_wg_down_button.setStyleSheet(f""" + QPushButton {{ + font-size: 10px; + background: #F44336; + color: white; + border: none; + border-radius: 5px; + font-weight: bold; + {self.font_style} + }} + QPushButton:hover {{ + background: #d32f2f; + }} + """) + copy_wg_down_button.clicked.connect(self.copy_wg_quick_down_command) + copy_wg_down_button.setEnabled(False) + wg_quick_layout.addWidget(copy_wg_down_button) + + self.wg_quick_up_button = copy_wg_up_button + self.wg_quick_down_button = copy_wg_down_button + + self.wg_quick_up_command_widget = self.wg_quick_up_command + self.wg_quick_down_command_widget = self.wg_quick_down_command + + scroll_layout.addWidget(wg_quick_group) + + self.update_debug_profile_list() + + scroll_area.setWidget(scroll_content) + layout.addWidget(scroll_area) + + return page + + def copy_cli_command(self): + clipboard = QApplication.clipboard() + clipboard.setText(self.cli_command.text()) + self.update_status.update_status("CLI command copied to clipboard") + + def execute_cli_command(self): + try: + command = self.cli_command.text().split() + subprocess.Popen(command) + self.update_status.update_status("CLI command executed") + except Exception as e: + self.update_status.update_status( + f"Error executing command: {str(e)}") + + def copy_find_command(self, command_text): + clipboard = QApplication.clipboard() + clipboard.setText(command_text) + self.update_status.update_status("Find command copied to clipboard") + + def execute_find_command(self, command_text): + try: + command = command_text.split() + subprocess.Popen(command) + self.update_status.update_status("Find command executed") + except Exception as e: + self.update_status.update_status( + f"Error executing find command: {str(e)}") + + def update_debug_profile_list(self): + self.debug_profile_selector.clear() + profiles = ProfileController.get_all() + system_profiles = {pid: profile for pid, profile in profiles.items( + ) if isinstance(profile, SystemProfile)} + + if system_profiles: + for profile_id, profile in system_profiles.items(): + self.debug_profile_selector.addItem( + f"Profile {profile_id}: {profile.name}", profile_id) + else: + self.debug_profile_selector.addItem( + "No system-wide profiles found", None) + + def on_debug_profile_selected(self, text): + profile_id = self.debug_profile_selector.currentData() + if profile_id is None: + self.ping_instruction_label.hide() + self.copy_ping_button.setEnabled(False) + self.test_ping_button.setEnabled(False) + self.ping_result_label.hide() + self.cli_command.setText("Select a profile above to see command") + self.cli_copy_button.setEnabled(False) + if hasattr(self, 'wg_quick_up_command_widget'): + self.wg_quick_up_command_widget.setText( + "Select a profile above to see commands") + self.wg_quick_down_command_widget.setText( + "Select a profile above to see commands") + self.wg_quick_up_button.setEnabled(False) + self.wg_quick_down_button.setEnabled(False) + return + + profile = ProfileController.get(profile_id) + + if profile and isinstance(profile, SystemProfile): + app_path = os.environ.get('APPIMAGE') or "/path/to/hydra-veil" + self.cli_command.setText( + f"'{app_path}' --cli profile enable -i {profile_id}") + self.cli_copy_button.setEnabled(True) + ip_address = self.extract_endpoint_ip(profile) + if ip_address: + self.ping_instruction_label.setText( + f"Step 1, Can you Ping it? Copy-paste the command below into your terminal. This VPN Node's IP address is {ip_address}") + self.ping_instruction_label.show() + self.copy_ping_button.setEnabled(True) + self.test_ping_button.setEnabled(True) + self.ping_result_label.hide() + + if hasattr(self, 'wg_quick_up_command_widget'): + self.wg_quick_up_command_widget.setText( + f"sudo wg-quick up '/etc/hydra-veil/profiles/{profile_id}/wg.conf'") + self.wg_quick_down_command_widget.setText( + f"sudo wg-quick down '/etc/hydra-veil/profiles/{profile_id}/wg.conf'") + self.wg_quick_up_button.setEnabled(True) + self.wg_quick_down_button.setEnabled(True) + else: + self.ping_instruction_label.setText( + "Could not extract IP address from WireGuard configuration") + self.ping_instruction_label.show() + self.copy_ping_button.setEnabled(False) + self.test_ping_button.setEnabled(False) + self.ping_result_label.hide() + + if hasattr(self, 'wg_quick_up_command_widget'): + self.wg_quick_up_command_widget.setText( + "Could not load profile configuration") + self.wg_quick_down_command_widget.setText( + "Could not load profile configuration") + self.wg_quick_up_button.setEnabled(False) + self.wg_quick_down_button.setEnabled(False) + + def extract_endpoint_ip(self, profile): + try: + profile_path = Constants.HV_PROFILE_CONFIG_HOME + f'/{profile.id}' + wg_conf_path = f'{profile_path}/wg.conf.bak' + if not os.path.exists(wg_conf_path): + return None + + with open(wg_conf_path, 'r') as f: + content = f.read() + + for line in content.split('\n'): + if line.strip().startswith('Endpoint = '): + endpoint = line.strip().split(' = ')[1] + ip_address = endpoint.split(':')[0] + return ip_address + return None + except Exception: + return None + + def copy_ping_command(self): + profile_id = self.debug_profile_selector.currentData() + if profile_id is None: + return + + profile = ProfileController.get(profile_id) + if profile and isinstance(profile, SystemProfile): + ip_address = self.extract_endpoint_ip(profile) + if ip_address: + ping_command = f"ping {ip_address}" + clipboard = QApplication.clipboard() + clipboard.setText(ping_command) + self.update_status.update_status( + "Ping command copied to clipboard") + + def test_ping_connection(self): + profile_id = self.debug_profile_selector.currentData() + if profile_id is None: + return + + profile = ProfileController.get(profile_id) + if profile and isinstance(profile, SystemProfile): + ip_address = self.extract_endpoint_ip(profile) + if ip_address: + self.test_ping_button.setEnabled(False) + self.ping_result_label.setText("Testing ping...") + self.ping_result_label.setStyleSheet( + f"color: #FFA500; font-size: 12px; font-weight: bold; {self.font_style}") + self.ping_result_label.show() + + def ping_test(): + try: + if sys.platform == "win32": + result = subprocess.run(['ping', '-n', '4', ip_address], + capture_output=True, text=True, timeout=10) + else: + result = subprocess.run(['ping', '-c', '4', ip_address], + capture_output=True, text=True, timeout=10) + if result.returncode == 0: + self.ping_result_label.setText( + "✅ Ping successful - Connection is working!") + self.ping_result_label.setStyleSheet( + f"color: #4CAF50; font-size: 12px; font-weight: bold; {self.font_style}") + else: + self.ping_result_label.setText( + "❌ Ping failed - Connection issue detected") + self.ping_result_label.setStyleSheet( + f"color: #F44336; font-size: 12px; font-weight: bold; {self.font_style}") + except subprocess.TimeoutExpired: + self.ping_result_label.setText( + "❌ Ping timeout - Connection issue detected") + self.ping_result_label.setStyleSheet( + f"color: #F44336; font-size: 12px; font-weight: bold; {self.font_style}") + except Exception as e: + self.ping_result_label.setText( + f"❌ Ping error: {str(e)}") + self.ping_result_label.setStyleSheet( + f"color: #F44336; font-size: 12px; font-weight: bold; {self.font_style}") + finally: + self.test_ping_button.setEnabled(True) + + threading.Thread(target=ping_test, daemon=True).start() + + def copy_wg_quick_up_command(self): + clipboard = QApplication.clipboard() + clipboard.setText(self.wg_quick_up_command_widget.text()) + self.update_status.update_status( + "wg-quick UP command copied to clipboard") + + def copy_wg_quick_down_command(self): + clipboard = QApplication.clipboard() + clipboard.setText(self.wg_quick_down_command_widget.text()) + self.update_status.update_status( + "wg-quick DOWN command copied to clipboard") + + def on_profile_selected(self, button): + self.delete_button.setEnabled(True) + self.selected_profile_id = self.profile_buttons.id(button) + + def delete_selected_profile(self): + if hasattr(self, 'selected_profile_id'): + self.popup = ConfirmationPopup( + self, + message=f"Are you sure you want to delete Profile {self.selected_profile_id}?", + action_button_text="Delete", + cancel_button_text="Cancel" + ) + self.popup.setWindowModality(Qt.WindowModality.ApplicationModal) + self.popup.finished.connect(self.handle_delete_confirmation) + self.popup.show() + + def handle_delete_confirmation(self, confirmed): + if confirmed: + self.update_status.update_status(f'Deleting profile...') + self.worker_thread = WorkerThread('DESTROY_PROFILE', profile_data={ + 'id': self.selected_profile_id}) + self.worker_thread.text_output.connect(self.delete_status_update) + self.worker_thread.start() + + def delete_status_update(self, message): + self.update_status.update_status(message) + self.content_layout.removeWidget(self.delete_page) + self.delete_page = self.create_delete_page() + self.content_layout.addWidget(self.delete_page) + self.content_layout.setCurrentWidget(self.delete_page) + + def get_combobox_style(self) -> str: + return f""" + QComboBox {{ + color: black; + background: #f0f0f0; + padding: 5px 30px 5px 10px; + border: 1px solid #ccc; + border-radius: 4px; + min-width: 120px; + margin-bottom: 10px; + {self.font_style} + }} + QComboBox:disabled {{ + color: #666; + background: #e0e0e0; + }} + QComboBox::drop-down {{ + border: none; + width: 30px; + }} + QComboBox::down-arrow {{ + image: url(assets/down_arrow.png); + width: 12px; + height: 12px; + }} + QComboBox QAbstractItemView {{ + color: black; + background: white; + selection-background-color: #007bff; + selection-color: white; + border: 1px solid #ccc; + {self.font_style} + }} + """ + + def get_checkbox_style(self) -> str: + return checkbox_style(self.font_style) + + def populate_wireguard_profiles(self) -> None: + self.wireguard_profile_selector.clear() + profiles = ProfileController.get_all() + for profile_id, profile in profiles.items(): + if profile.connection.code == 'wireguard': + + self.wireguard_profile_selector.addItem( + f"Profile {profile_id}: {profile.name}", profile_id) + + def convert_duration(self, value: Union[str, int], to_hours: bool = True) -> Union[str, int]: + if to_hours: + number, unit = value.split(' ') + number = int(number) + if unit in ['day', 'days']: + return number * 24 + elif unit in ['week', 'weeks']: + return number * 7 * 24 + else: + raise ValueError(f"Unsupported duration unit: {unit}") + else: + hours = int(value) + if hours % (7 * 24) == 0: + weeks = hours // (7 * 24) + return f"{weeks} {'week' if weeks == 1 else 'weeks'}" + elif hours % 24 == 0: + days = hours // 24 + return f"{days} {'day' if days == 1 else 'days'}" + else: + raise ValueError( + f"Hours value {hours} cannot be converted to days or weeks cleanly") + + def reverse(self): + self.custom_window.navigator.navigate("menu") + + def create_subscription_page(self): + page = QWidget() + layout = QVBoxLayout(page) + layout.setSpacing(20) + layout.setContentsMargins(20, 20, 20, 20) + + title = QLabel("Subscription Info") + title.setStyleSheet( + f"color: #808080; font-size: 12px; font-weight: bold; {self.font_style}") + layout.addWidget(title) + + profile_group = QGroupBox("Profile Selection") + profile_group.setStyleSheet(f""" + QGroupBox {{ + color: white; + font-weight: bold; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + padding: 15px; + margin-top: 15px; + {self.font_style} + }} + QGroupBox::title {{ + subcontrol-origin: margin; + left: 10px; + padding: 0 5px; + }} + """) + profile_layout = QVBoxLayout(profile_group) + + self.profile_selector = QComboBox() + self.profile_selector.setStyleSheet(self.get_combobox_style()) + profiles = ProfileController.get_all() + if profiles: + for profile_id, profile in profiles.items(): + self.profile_selector.addItem( + f"Profile {profile_id}: {profile.name}", profile_id) + profile_layout.addWidget(self.profile_selector) + layout.addWidget(profile_group) + + subscription_group = QGroupBox("Subscription Details") + subscription_group.setStyleSheet(f""" + QGroupBox {{ + color: white; + font-weight: bold; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + padding: 15px; + margin-top: 15px; + {self.font_style} + }} + QGroupBox::title {{ + subcontrol-origin: margin; + left: 10px; + padding: 0 5px; + }} + """) + subscription_layout = QGridLayout(subscription_group) + subscription_layout.setSpacing(10) + + self.subscription_info = {} + info_items = [ + ("Billing Code", "billing_code"), + ("Expires At", "expires_at") + ] + + for i, (label, key) in enumerate(info_items): + if key == "billing_code": + billing_container = QWidget() + billing_layout = QVBoxLayout(billing_container) + billing_layout.setContentsMargins(0, 0, 0, 0) + billing_layout.setSpacing(5) + + stat_widget = self.create_stat_widget(label, "N/A") + billing_layout.addWidget(stat_widget) + + copy_button = QPushButton("Copy") + copy_button.setFixedSize(60, 30) + copy_button.setStyleSheet(f""" + QPushButton {{ + background: #007AFF; + color: white; + border: none; + border-radius: 4px; + font-size: 11px; + font-weight: bold; + {self.font_style} + }} + QPushButton:hover {{ + background: #0056CC; + }} + """) + copy_button.clicked.connect(lambda: self.copy_billing_code()) + billing_layout.addWidget(copy_button) + + row, col = divmod(i, 2) + subscription_layout.addWidget(billing_container, row, col) + + old_label = stat_widget.findChild(QLabel, "value_label") + if old_label: + old_label.setParent(None) + old_label.deleteLater() + + value_label = ClickableValueLabel("N/A", stat_widget) + value_label.setObjectName("value_label") + value_label.setStyleSheet( + f"color: #00ffff; font-size: 12px; font-family: 'Retro Gaming', sans-serif;") + value_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + stat_widget.layout().insertWidget(0, value_label) + + self.subscription_info[key] = value_label + else: + stat_widget = self.create_stat_widget(label, "N/A") + row, col = divmod(i, 2) + subscription_layout.addWidget(stat_widget, row, col) + + old_label = stat_widget.findChild(QLabel, "value_label") + if old_label: + old_label.setParent(None) + old_label.deleteLater() + + value_label = ClickableValueLabel("N/A", stat_widget) + value_label.setObjectName("value_label") + value_label.setStyleSheet( + f"color: #00ffff; font-size: 12px; font-family: 'Retro Gaming', sans-serif;") + value_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + stat_widget.layout().insertWidget(0, value_label) + + self.subscription_info[key] = value_label + + layout.addWidget(subscription_group) + + clipboard_hint = QLabel( + "💡 Click on the billing code or use the Copy button to copy it to clipboard") + clipboard_hint.setStyleSheet( + f"color: #666666; font-size: 11px; font-style: italic; {self.font_style}") + clipboard_hint.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(clipboard_hint) + + layout.addStretch() + + self.profile_selector.currentIndexChanged.connect( + self.update_subscription_info) + if self.profile_selector.count() > 0: + self.update_subscription_info(0) + + return page + + def create_verification_page(self): + page = QWidget() + layout = QVBoxLayout(page) + layout.setSpacing(20) + layout.setContentsMargins(20, 20, 20, 20) + + title = QLabel("Verification Information") + title.setStyleSheet( + f"color: #808080; font-size: 12px; font-weight: bold; {self.font_style}") + layout.addWidget(title) + + profile_label = QLabel("Profile Selection:") + profile_label.setStyleSheet( + f"color: white; font-size: 14px; {self.font_style}") + layout.addWidget(profile_label) + + self.verification_profile_selector = QComboBox() + self.verification_profile_selector.setStyleSheet( + self.get_combobox_style()) + profiles = ProfileController.get_all() + if profiles: + for profile_id, profile in profiles.items(): + self.verification_profile_selector.addItem( + f"Profile {profile_id}: {profile.name}", profile_id) + layout.addWidget(self.verification_profile_selector) + + details_label = QLabel("Verification Details:") + details_label.setStyleSheet( + f"color: white; font-size: 14px; margin-top: 20px; {self.font_style}") + layout.addWidget(details_label) + + verification_layout = QVBoxLayout() + verification_layout.setSpacing(15) + self.verification_info = {} + self.verification_checkmarks = {} + self.verification_full_values = {} + info_items = [ + ("Operator Name", "operator_name"), + ("Nostr Key", "nostr_public_key"), + ("HydraVeil Key", "hydraveil_public_key"), + ("Nostr Verification", "nostr_attestation_event_reference"), + ] + + for label, key in info_items: + container = QWidget() + container_layout = QHBoxLayout(container) + container_layout.setContentsMargins(0, 0, 0, 0) + container_layout.setSpacing(10) + + label_widget = QLabel(label + ":") + label_widget.setStyleSheet( + f"color: white; font-size: 12px; {self.font_style}") + label_widget.setMinimumWidth(120) + container_layout.addWidget(label_widget) + + value_widget = QLineEdit() + value_widget.setReadOnly(True) + value_widget.setText("N/A") + value_widget.setStyleSheet(f""" + QLineEdit {{ + color: white; + background: transparent; + border: none; + border-bottom: 1px solid rgba(255, 255, 255, 0.3); + padding: 5px 0px; + font-size: 12px; + {self.font_style} + }} + """) + container_layout.addWidget(value_widget, 1) + + checkmark_widget = QLabel("✓") + checkmark_widget.setStyleSheet( + f"color: #4CAF50; font-size: 20px; font-weight: bold; {self.font_style}") + checkmark_widget.setFixedSize(20, 20) + checkmark_widget.setAlignment(Qt.AlignmentFlag.AlignCenter) + checkmark_widget.hide() + container_layout.addWidget(checkmark_widget) + + copy_button = QPushButton() + copy_button.setIcon( + QIcon(os.path.join(self.btn_path, "paste_button.png"))) + copy_button.setIconSize(QSize(24, 24)) + copy_button.setFixedSize(30, 30) + copy_button.setStyleSheet(f""" + QPushButton {{ + background: transparent; + border: none; + }} + QPushButton:hover {{ + background: rgba(255, 255, 255, 0.1); + border-radius: 4px; + }} + """) + copy_button.clicked.connect( + lambda checked=False, k=key: self.copy_verification_value(k)) + container_layout.addWidget(copy_button) + + verification_layout.addWidget(container) + self.verification_info[key] = value_widget + self.verification_checkmarks[key] = checkmark_widget + + layout.addLayout(verification_layout) + layout.addStretch() + + endpoint_verification_label = QLabel("Endpoint Verification:", page) + endpoint_verification_label.setGeometry(20, 420, 150, 20) + endpoint_verification_label.setStyleSheet( + f"color: white; font-size: 15px; {self.font_style}") + endpoint_verification_label.show() + + self.endpoint_verification_checkbox = QCheckBox(page) + self.endpoint_verification_checkbox.setGeometry(180, 415, 30, 30) + self.endpoint_verification_checkbox.setChecked( + ConfigurationController.get_endpoint_verification_enabled()) + self.endpoint_verification_checkbox.setStyleSheet( + self.get_checkbox_style()) + self.endpoint_verification_checkbox.show() + + save_button = QPushButton(page) + save_button.setGeometry(350, 410, 60, 31) + save_button.setIcon(QIcon(os.path.join(self.btn_path, "save.png"))) + save_button.setIconSize(QSize(60, 31)) + save_button.clicked.connect(self.save_verification_settings) + save_button.show() + + self.verification_profile_selector.currentIndexChanged.connect( + self.update_verification_info) + self.verification_profile_selector.activated.connect( + self.update_verification_info) + if self.verification_profile_selector.count() > 0: + self.update_verification_info(0) + + return page + + def truncate_key(self, text, max_length=50): + if not text or text == "N/A" or len(text) <= max_length: + return text + start_len = max_length // 2 - 2 + end_len = max_length // 2 - 2 + return text[:start_len] + "....." + text[-end_len:] + + def update_verification_info(self, index): + if index < 0: + return + + profile_id = self.verification_profile_selector.itemData(index) + if profile_id is None: + for key, widget in self.verification_info.items(): + widget.setText("N/A") + self.verification_full_values[key] = "N/A" + if key in self.verification_checkmarks: + self.verification_checkmarks[key].hide() + return + + profile = ProfileController.get(profile_id) + if not profile: + for key, widget in self.verification_info.items(): + widget.setText("N/A") + self.verification_full_values[key] = "N/A" + if key in self.verification_checkmarks: + self.verification_checkmarks[key].hide() + return + + operator = None + if profile.location and profile.location.operator: + operator = profile.location.operator + + if operator: + operator_name = operator.name or "N/A" + self.verification_full_values["operator_name"] = operator_name + self.verification_info["operator_name"].setText(operator_name) + + nostr_key = operator.nostr_public_key or "N/A" + self.verification_full_values["nostr_public_key"] = nostr_key + self.verification_info["nostr_public_key"].setText( + self.truncate_key(nostr_key) if nostr_key != "N/A" else nostr_key) + + hydraveil_key = operator.public_key or "N/A" + self.verification_full_values["hydraveil_public_key"] = hydraveil_key + self.verification_info["hydraveil_public_key"].setText( + self.truncate_key(hydraveil_key) if hydraveil_key != "N/A" else hydraveil_key) + + nostr_verification = operator.nostr_attestation_event_reference or "N/A" + self.verification_full_values["nostr_attestation_event_reference"] = nostr_verification + self.verification_info["nostr_attestation_event_reference"].setText(self.truncate_key( + nostr_verification) if nostr_verification != "N/A" else nostr_verification) + else: + self.verification_info["operator_name"].setText("N/A") + self.verification_full_values["operator_name"] = "N/A" + self.verification_info["nostr_public_key"].setText("N/A") + self.verification_full_values["nostr_public_key"] = "N/A" + self.verification_info["hydraveil_public_key"].setText("N/A") + self.verification_full_values["hydraveil_public_key"] = "N/A" + self.verification_info["nostr_attestation_event_reference"].setText( + "N/A") + self.verification_full_values["nostr_attestation_event_reference"] = "N/A" + + for key, widget in self.verification_info.items(): + if key in self.verification_checkmarks: + full_value = self.verification_full_values.get(key, "") + if full_value and full_value != "N/A": + self.verification_checkmarks[key].show() + else: + self.verification_checkmarks[key].hide() + + def save_verification_settings(self): + try: + ConfigurationController.set_endpoint_verification_enabled( + self.endpoint_verification_checkbox.isChecked()) + self.update_status.update_status( + "Verification settings saved successfully") + except Exception as e: + logging.error(f"Error saving verification settings: {str(e)}") + self.update_status.update_status( + "Error saving verification settings") + + def copy_verification_value(self, key): + full_value = self.verification_full_values.get(key, "") + if full_value and full_value != "N/A": + clipboard = QApplication.clipboard() + clipboard.setText(full_value) + verification_display_names = { + "operator_name": "Operator Name", + "nostr_public_key": "Nostr Key", + "hydraveil_public_key": "HydraVeil Key", + "nostr_attestation_event_reference": "Nostr Verification" + } + display_name = verification_display_names.get( + key, "Verification value") + self.update_status.update_status( + f"{display_name} copied to clipboard") + + def update_subscription_info(self, index): + if index < 0: + return + + profile_id = self.profile_selector.itemData(index) + if profile_id is None: + return + + profile = ProfileController.get(profile_id) + + if profile and hasattr(profile, 'subscription') and profile.subscription: + try: + self.subscription_info["billing_code"].setText( + str(profile.subscription.billing_code)) + + if hasattr(profile.subscription, 'expires_at') and profile.subscription.expires_at: + expires_at = profile.subscription.expires_at.strftime( + "%Y-%m-%d %H:%M:%S UTC") + self.subscription_info["expires_at"].setText(expires_at) + else: + self.subscription_info["expires_at"].setText( + "Not available") + except Exception as e: + print(f"Error updating subscription info: {e}") + else: + for label in self.subscription_info.values(): + label.setText("N/A") + + def copy_billing_code(self): + if "billing_code" in self.subscription_info: + billing_code = self.subscription_info["billing_code"].text() + if billing_code and billing_code != "N/A": + clipboard = QApplication.clipboard() + clipboard.setText(billing_code) + self.update_status.update_status( + "Billing code copied to clipboard") + else: + self.update_status.update_status( + "No billing code available to copy") + + def showEvent(self, event): + super().showEvent(event) + + current_index = self.content_layout.currentIndex() + + self.content_layout.removeWidget(self.account_page) + self.account_page = self.create_account_page() + self.content_layout.addWidget(self.account_page) + + self.content_layout.removeWidget(self.subscription_page) + self.subscription_page = self.create_subscription_page() + self.content_layout.addWidget(self.subscription_page) + + self.content_layout.removeWidget(self.tickets_page) + self.tickets_page = self.create_tickets_page() + self.content_layout.addWidget(self.tickets_page) + + self.content_layout.removeWidget(self.registrations_page) + self.registrations_page = self.create_registrations_page() + self.content_layout.addWidget(self.registrations_page) + + self.content_layout.removeWidget(self.verification_page) + self.verification_page = self.create_verification_page() + self.content_layout.addWidget(self.verification_page) + + self.content_layout.removeWidget(self.systemwide_page) + self.systemwide_page = self.create_systemwide_page() + self.content_layout.addWidget(self.systemwide_page) + + self.content_layout.removeWidget(self.bwrap_page) + self.bwrap_page = self.create_bwrap_page() + self.content_layout.addWidget(self.bwrap_page) + + self.content_layout.removeWidget(self.delete_page) + self.delete_page = self.create_delete_page() + self.content_layout.addWidget(self.delete_page) + + self.content_layout.removeWidget(self.logs_page) + self.logs_page = self.create_logs_page() + self.content_layout.addWidget(self.logs_page) + + self.content_layout.removeWidget(self.debug_page) + self.debug_page = self.create_debug_page() + self.content_layout.addWidget(self.debug_page) + + self.content_layout.setCurrentIndex(current_index) + + if self.content_layout.currentWidget() == self.subscription_page: + if self.profile_selector.count() > 0: + self.update_subscription_info(0) + + def create_account_page(self): + page = QWidget() + layout = QVBoxLayout(page) + layout.setSpacing(20) + layout.setContentsMargins(20, 20, 20, 20) + + title = QLabel("Account Overview") + title.setStyleSheet( + f"color: #808080; font-size: 12px; font-weight: bold; {self.font_style}") + layout.addWidget(title) + + scroll_area = QScrollArea() + scroll_area.setWidgetResizable(True) + scroll_area.setStyleSheet(f""" + QScrollArea {{ + border: none; + background: transparent; + }} + QScrollArea > QWidget > QWidget {{ + background: transparent; + }} + """) + + scroll_content = QWidget() + scroll_layout = QVBoxLayout(scroll_content) + scroll_layout.setSpacing(15) + + info_sections = [ + ("Profile Statistics", self.create_profile_stats()), + ("System Resources", self.create_system_stats()) + ] + + for section_title, content_widget in info_sections: + group = QGroupBox(section_title) + group.setStyleSheet(f""" + QGroupBox {{ + color: white; + font-weight: bold; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + padding: 15px; + margin-top: 15px; + {self.font_style} + }} + QGroupBox::title {{ + subcontrol-origin: margin; + left: 10px; + padding: 0 5px; + }} + """) + group_layout = QVBoxLayout(group) + group_layout.addWidget(content_widget) + scroll_layout.addWidget(group) + + scroll_area.setWidget(scroll_content) + layout.addWidget(scroll_area) + return page + + def create_profile_stats(self): + widget = QWidget() + layout = QGridLayout(widget) + layout.setSpacing(10) + + profiles = ProfileController.get_all() + total_profiles = len(profiles) + session_profiles = sum(1 for p in profiles.values() + if isinstance(p, SessionProfile)) + system_profiles = sum(1 for p in profiles.values() + if isinstance(p, SystemProfile)) + active_profiles = sum( + 1 for p in profiles.values() + if p.subscription and p.subscription.expires_at and p.subscription.expires_at > datetime.now(timezone.utc) + ) + + stats = [ + ("Total Profiles", total_profiles), + ("Browser-only", session_profiles), + ("System-wide", system_profiles), + ("Active Profiles", active_profiles) + ] + + for i, (label, value) in enumerate(stats): + stat_widget = self.create_stat_widget(label, value) + row, col = divmod(i, 2) + layout.addWidget(stat_widget, row, col) + + return widget + + def create_system_stats(self): + widget = QWidget() + layout = QGridLayout(widget) + layout.setSpacing(10) + + config_path = Constants.HV_CONFIG_HOME + + current_connection = self.update_status.get_current_connection() + + if current_connection is not None: + current_connection = current_connection.capitalize() + + stats = [ + ("Config Path", config_path), + ("Client Version", Constants.HV_CLIENT_VERSION_NUMBER), + ("Connection", current_connection), + ] + + for i, (label, value) in enumerate(stats): + info_widget = QLabel(f"{label}: {value}") + info_widget.setStyleSheet( + f"font-size: 14px; color: white; padding: 5px; {self.font_style}") + info_widget.setWordWrap(True) + layout.addWidget(info_widget, i, 0) + + return widget + + def create_stat_widget(self, label, value): + widget = QFrame() + widget.setStyleSheet(f""" + QFrame {{ + background: rgba(255, 255, 255, 0.05); + border-radius: 8px; + padding: 10px; + {self.font_style} + }} + """) + layout = QVBoxLayout(widget) + + value_label = QLabel(str(value)) + value_label.setObjectName("value_label") + value_label.setStyleSheet( + "font-family: 'Retro Gaming', sans-serif; color: #00ffff; font-size: 22px; font-weight: bold") + value_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + desc_label = QLabel(label) + desc_label.setStyleSheet( + f"color: white; font-size: 12px; {self.font_style}") + desc_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + layout.addWidget(value_label) + layout.addWidget(desc_label) + return widget + + def create_horizontal_stat(self, label, value, total): + widget = QWidget() + layout = QHBoxLayout(widget) + layout.setContentsMargins(0, 5, 0, 5) + + text_label = QLabel(f"{label}") + text_label.setStyleSheet( + f"color: white; font-size: 12px; {self.font_style}") + text_label.setFixedWidth(100) + + progress = QFrame() + progress.setStyleSheet(""" + QFrame { + background: rgba(0, 255, 255, 0.3); + border-radius: 3px; + } + """) + percentage = (value / total) * 100 if total > 0 else 0 + progress.setFixedSize(int(200 * (percentage / 100)), 20) + + value_label = QLabel(f"{value} ({percentage:.1f}%)") + value_label.setStyleSheet( + f"color: white; font-size: 12px; {self.font_style}") + value_label.setAlignment(Qt.AlignmentFlag.AlignRight) + + progress_container = QFrame() + progress_container.setStyleSheet(""" + QFrame { + background: rgba(255, 255, 255, 0.1); + border-radius: 3px; + } + """) + progress_container.setFixedSize(200, 20) + progress_layout = QHBoxLayout(progress_container) + progress_layout.setContentsMargins(0, 0, 0, 0) + progress_layout.addWidget(progress) + progress.setFixedHeight(20) + + layout.addWidget(text_label) + layout.addWidget(progress_container) + layout.addWidget(value_label) + layout.addStretch() + + return widget + + def setup_pages(self): + self.account_page = self.create_account_page() + self.subscription_page = self.create_subscription_page() + self.tickets_page = self.create_tickets_page() + self.registrations_page = self.create_registrations_page() + self.verification_page = self.create_verification_page() + self.systemwide_page = self.create_systemwide_page() + self.bwrap_page = self.create_bwrap_page() + self.logs_page = self.create_logs_page() + self.delete_page = self.create_delete_page() + self.debug_page = self.create_debug_page() + + self.content_layout.addWidget(self.account_page) + self.content_layout.addWidget(self.subscription_page) + self.content_layout.addWidget(self.tickets_page) + self.content_layout.addWidget(self.registrations_page) + self.content_layout.addWidget(self.verification_page) + self.content_layout.addWidget(self.systemwide_page) + self.content_layout.addWidget(self.bwrap_page) + self.content_layout.addWidget(self.logs_page) + self.content_layout.addWidget(self.delete_page) + self.content_layout.addWidget(self.debug_page) + + self.content_layout.setCurrentIndex(0) + self.show_account_page() + + def show_account_page(self): + core_logger.info("User navigated to Settings -> Overview") + self.content_layout.setCurrentWidget(self.account_page) + self._select_menu_button("Overview") + + def show_subscription_page(self): + core_logger.info("User navigated to Settings -> Subscriptions") + self.content_layout.setCurrentWidget(self.subscription_page) + self._select_menu_button("Subscriptions") + + def show_tickets_page(self): + core_logger.info("User navigated to Settings -> Tickets") + self.content_layout.setCurrentWidget(self.tickets_page) + self._select_menu_button("Tickets") + self._load_random_tickets_state() + self._refresh_tickets_inventory() + self._refresh_ticket_recovery_controls() + + def _load_random_tickets_state(self): + if getattr(self, '_random_tickets_state_loaded', False): + return + try: + which_ticket, error_msg = do_we_use_a_random_ticket(ticket_observer) + checked = which_ticket is not None and which_ticket != 'error' + except Exception: + checked = False + self.random_tickets_checkbox.blockSignals(True) + self.random_tickets_checkbox.setChecked(checked) + self.random_tickets_checkbox.blockSignals(False) + self._random_tickets_state_loaded = True + + def show_registrations_page(self): + core_logger.info("User navigated to Settings -> Create/Edit") + self.content_layout.setCurrentWidget(self.registrations_page) + self._select_menu_button("Create/Edit") + + def show_verification_page(self): + core_logger.info("User navigated to Settings -> Verification") + self.content_layout.setCurrentWidget(self.verification_page) + self._select_menu_button("Verification") + + def show_systemwide_page(self): + core_logger.info("User navigated to Settings -> Legacy-Version") + self.content_layout.setCurrentWidget(self.systemwide_page) + self._select_menu_button("Legacy-Version") + + def show_bwrap_page(self): + core_logger.info("User navigated to Settings -> Bwrap Permission") + self.content_layout.setCurrentWidget(self.bwrap_page) + self._select_menu_button("Bwrap Permission") + + def show_logs_page(self): + core_logger.info("User navigated to Settings -> Error Logs") + self.content_layout.setCurrentWidget(self.logs_page) + self._select_menu_button("Error Logs") + + def show_delete_page(self): + core_logger.info("User navigated to Settings -> Delete Profile") + self.content_layout.setCurrentWidget(self.delete_page) + self._select_menu_button("Delete Profile") + + def show_debug_page(self): + core_logger.info("User navigated to Settings -> Debug Help") + self.content_layout.setCurrentWidget(self.debug_page) + self._select_menu_button("Debug Help") + + def _select_menu_button(self, label): + for btn in self.menu_buttons: + if btn.text() == label: + btn.setChecked(True) + else: + btn.setChecked(False) + + def create_tickets_page(self): + page = QWidget() + page_layout = QVBoxLayout(page) + page_layout.setSpacing(15) + page_layout.setContentsMargins(20, 20, 20, 20) + + title = QLabel("TICKETS") + title.setStyleSheet( + f"color: #808080; font-size: 12px; font-weight: bold; {self.font_style}") + page_layout.addWidget(title) + + scroll_area = QScrollArea() + scroll_area.setWidgetResizable(True) + scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + scroll_area.setStyleSheet(""" + QScrollArea { + background-color: transparent; + border: none; + } + QScrollArea > QWidget > QWidget { + background-color: transparent; + } + """) + + scroll_content = QWidget() + layout = QVBoxLayout(scroll_content) + layout.setSpacing(15) + layout.setContentsMargins(0, 0, 8, 0) + + random_group = QGroupBox("Random Ticket Use") + random_group.setStyleSheet( + f"QGroupBox {{ color: white; padding: 15px; {self.font_style} }}") + random_layout = QVBoxLayout(random_group) + + self.random_tickets_checkbox = QCheckBox("Use a random ticket automatically when enabling a profile") + self.random_tickets_checkbox.setStyleSheet(f"color: white; {self.font_style}") + self.random_tickets_checkbox.setChecked(False) + self.random_tickets_checkbox.toggled.connect(self._on_random_toggle) + self._random_tickets_state_loaded = False + random_layout.addWidget(self.random_tickets_checkbox) + layout.addWidget(random_group) + + inventory_group = QGroupBox("Unused Tickets") + inventory_group.setStyleSheet( + f"QGroupBox {{ color: white; padding: 15px; {self.font_style} }}") + inventory_layout = QVBoxLayout(inventory_group) + + self.tickets_inventory_label = QLabel("Loading tickets...") + self.tickets_inventory_label.setStyleSheet( + f"color: white; font-size: 13px; {self.font_style}") + self.tickets_inventory_label.setWordWrap(True) + inventory_layout.addWidget(self.tickets_inventory_label) + + self.refresh_tickets_button = QPushButton("Refresh") + self.refresh_tickets_button.setFixedSize(120, 32) + self.refresh_tickets_button.setStyleSheet(f""" + QPushButton {{ + background: #00aaff; color: white; border: none; + border-radius: 5px; font-weight: bold; {self.font_style} + }} + QPushButton:hover {{ background: #0088cc; }} + """) + self.refresh_tickets_button.clicked.connect(self._refresh_tickets_inventory) + inventory_layout.addWidget(self.refresh_tickets_button) + layout.addWidget(inventory_group) + + saved_failure = self.update_status.get_ticket_verification_failure() + has_failure = saved_failure is not None + + recovery_group = QGroupBox("Verification Failure Recovery") + recovery_group.setStyleSheet( + f"QGroupBox {{ color: white; padding: 15px; {self.font_style} }}") + recovery_layout = QVBoxLayout(recovery_group) + + self.ticket_recovery_status_label = QLabel(self._format_ticket_failure_status(saved_failure)) + self.ticket_recovery_status_label.setStyleSheet( + f"color: white; font-size: 12px; {self.font_style}") + self.ticket_recovery_status_label.setWordWrap(True) + recovery_layout.addWidget(self.ticket_recovery_status_label) + + self.evaluate_public_key_button = QPushButton("Evaluate Public Key") + self.evaluate_public_key_button.setFixedSize(180, 36) + self.evaluate_public_key_button.setEnabled(has_failure) + self.evaluate_public_key_button.setStyleSheet(f""" + QPushButton {{ + background: #00aaff; color: white; border: none; + border-radius: 5px; font-weight: bold; {self.font_style} + }} + QPushButton:hover:!disabled {{ background: #0088cc; }} + QPushButton:disabled {{ background: #666666; color: #bbbbbb; }} + """) + self.evaluate_public_key_button.clicked.connect(self.evaluate_ticket_public_key) + recovery_layout.addWidget(self.evaluate_public_key_button) + + self.ticket_recovery_output = TerminalWidget() + self.ticket_recovery_output.setFixedHeight(130) + if not has_failure: + self.ticket_recovery_output.setPlainText("No saved verification failure.") + recovery_layout.addWidget(self.ticket_recovery_output) + + layout.addWidget(recovery_group) + + debug_group = QGroupBox("Debug Recovery") + debug_group.setStyleSheet( + f"QGroupBox {{ color: white; padding: 15px; {self.font_style} }}") + debug_layout = QVBoxLayout(debug_group) + + self.prepare_saved_blind_sigs_button = QPushButton( + "Prepare Tickets even if validation of the server's signature failed") + self.prepare_saved_blind_sigs_button.setFixedSize(500, 40) + self.prepare_saved_blind_sigs_button.setEnabled(has_failure) + self.prepare_saved_blind_sigs_button.setStyleSheet(f""" + QPushButton {{ + background: #c0392b; color: white; border: none; + border-radius: 5px; font-size: 10px; font-weight: bold; {self.font_style} + }} + QPushButton:hover:!disabled {{ background: #a93226; }} + QPushButton:disabled {{ background: #666666; color: #bbbbbb; }} + """) + self.prepare_saved_blind_sigs_button.clicked.connect(self.prepare_tickets_with_saved_blind_signatures) + debug_layout.addWidget(self.prepare_saved_blind_sigs_button) + + layout.addWidget(debug_group) + layout.addStretch() + scroll_area.setWidget(scroll_content) + page_layout.addWidget(scroll_area) + return page + + def _format_ticket_failure_status(self, failure): + if not failure: + return "No saved verification failure. If ticket preparation fails validation, recovery data will appear here." + failed_validations = failure.get("failed_validations", []) + how_many_failed = failure.get("how_many_failed", len(failed_validations)) + updated_at = failure.get("updated_at", "unknown time") + failed_text = ", ".join(str(item) for item in failed_validations) + return ( + f"Saved verification failure: {how_many_failed} failed. " + f"Failed validation indices: {failed_text}. Saved: {updated_at}" + ) + + def _refresh_ticket_recovery_controls(self): + failure = self.update_status.get_ticket_verification_failure() + has_failure = failure is not None + if hasattr(self, 'ticket_recovery_status_label'): + self.ticket_recovery_status_label.setText(self._format_ticket_failure_status(failure)) + if hasattr(self, 'evaluate_public_key_button'): + self.evaluate_public_key_button.setEnabled(has_failure) + if hasattr(self, 'prepare_saved_blind_sigs_button'): + self.prepare_saved_blind_sigs_button.setEnabled(has_failure) + + def _set_ticket_recovery_busy(self, busy): + if hasattr(self, 'evaluate_public_key_button'): + self.evaluate_public_key_button.setEnabled(not busy and self.update_status.get_ticket_verification_failure() is not None) + if hasattr(self, 'prepare_saved_blind_sigs_button'): + self.prepare_saved_blind_sigs_button.setEnabled(not busy and self.update_status.get_ticket_verification_failure() is not None) + + def _write_ticket_recovery_output(self, text): + if hasattr(self, 'ticket_recovery_output'): + self.ticket_recovery_output.setPlainText(text) + + def _format_ticket_recovery_result(self, label, result): + try: + payload = json.dumps(result, indent=2, default=str) + except TypeError: + payload = str(result) + return f"{label}:\n{payload}" + + def _get_ticket_failure_for_action(self): + failure = self.update_status.get_ticket_verification_failure() + if failure is None: + self._write_ticket_recovery_output("No saved verification failure.") + self.update_status.update_status("No ticket verification failure is saved.") + self._refresh_ticket_recovery_controls() + return None + return failure + + def evaluate_ticket_public_key(self): + core_logger.info("User clicked 'Evaluate Public Key' button") + failure = self._get_ticket_failure_for_action() + if failure is None: + return + failed_validations = list(failure.get("failed_validations", [])) + self._write_ticket_recovery_output("Evaluating public key...") + self.update_status.update_status("Evaluating ticket public key...") + self._set_ticket_recovery_busy(True) + + self.ticket_recovery_worker = TicketingWorkerThread( + 'EVALUATE_FAILED_VERIFICATION', + params={'failed_validations': failed_validations}, + ) + self.ticket_recovery_worker.failed_verification_evaluated.connect(self.on_failed_verification_evaluated) + self.ticket_recovery_worker.error.connect(self.on_ticket_recovery_error) + self.ticket_recovery_worker.start() + + def on_failed_verification_evaluated(self, result): + message = interpret_key_results(result) + self._write_ticket_recovery_output(message) + core_logger.info(f"Public key evaluation result: {message}") + self.update_status.update_status("Ticket public key evaluation complete.") + self._set_ticket_recovery_busy(False) + self._refresh_ticket_recovery_controls() + + def prepare_tickets_with_saved_blind_signatures(self): + core_logger.info("User clicked 'Prepare Tickets even if validation of the server's signature failed' button") + failure = self._get_ticket_failure_for_action() + if failure is None: + return + + reply = QMessageBox.warning( + self, + "Prepare Tickets Anyway", + "This will prepare tickets even though server signature validation failed. Continue only if you have evaluated the situation.", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if reply != QMessageBox.StandardButton.Yes: + return + + self._write_ticket_recovery_output("Preparing tickets with saved blind signatures...") + self.update_status.update_status("Preparing tickets with saved blind signatures...") + self._set_ticket_recovery_busy(True) + + self.ticket_recovery_worker = TicketingWorkerThread('PREPARE_SAVED_BLIND_SIGS') + self.ticket_recovery_worker.saved_blind_prep_done.connect(self.on_saved_blind_prep_done) + self.ticket_recovery_worker.error.connect(self.on_ticket_recovery_error) + self.ticket_recovery_worker.start() + + def on_saved_blind_prep_done(self, result): + self._write_ticket_recovery_output(self._format_ticket_recovery_result("Results of preparation", result)) + if isinstance(result, dict) and result.get('valid') is True: + self.update_status.clear_ticket_verification_failure() + self.update_status.update_status("Tickets prepared from saved blind signatures.") + self._refresh_tickets_inventory() + else: + msg = result.get('message', 'failed') if isinstance(result, dict) else 'failed' + self.update_status.update_status(f"Saved blind signature prep failed: {msg}") + self._set_ticket_recovery_busy(False) + self._refresh_ticket_recovery_controls() + + def on_ticket_recovery_error(self, msg): + core_logger.error(f"Ticket recovery error: {msg}") + self._write_ticket_recovery_output(f"EXCEPTION: {msg}") + self.update_status.update_status(f"Ticket recovery error: {msg}") + self._set_ticket_recovery_busy(False) + self._refresh_ticket_recovery_controls() + + def _on_random_toggle(self, checked): + core_logger.info(f"User toggled random ticket setting to {'on' if checked else 'off'}") + try: + result = modify_random_tickets_setting('on' if checked else 'off', ticket_observer) + if not (isinstance(result, dict) and result.get('valid')): + msg = result.get('message', 'failed') if isinstance(result, dict) else 'failed' + core_logger.error(f"Could not change random ticket setting: {msg}") + self.update_status.update_status(f'Could not change setting: {msg}') + except Exception as e: + core_logger.error(f"Exception changing random ticket setting: {e}") + self.update_status.update_status(f'Could not change setting: {e}') + + def _refresh_tickets_inventory(self): + if not hasattr(self, 'tickets_inventory_label'): + return + core_logger.info("Refreshing tickets inventory") + try: + unused = get_unused_tickets(ticket_observer) + except Exception as e: + core_logger.error(f"Error fetching unused tickets: {e}") + self.tickets_inventory_label.setText(f"Error: {e}") + return + if isinstance(unused, dict) and unused.get('valid'): + data = unused.get('data', []) + if data: + self.tickets_inventory_label.setText( + f"You have {len(data)} unused tickets: " + ", ".join(f"#{t}" for t in data)) + else: + self.tickets_inventory_label.setText("No unused tickets.") + else: + msg = unused.get('message', 'No tickets found.') if isinstance(unused, dict) else 'No tickets found.' + self.tickets_inventory_label.setText(msg) + + def create_logs_page(self): + page = QWidget() + layout = QVBoxLayout(page) + layout.setSpacing(20) + layout.setContentsMargins(20, 20, 20, 20) + + title = QLabel("LOGGING SETTINGS") + title.setStyleSheet( + f"color: #808080; font-size: 12px; font-weight: bold; {self.font_style}") + layout.addWidget(title) + + log_text = QLabel( + f"Enabling this feature means error logs will be stored on your local\nmachine at: '{Constants.HV_CACHE_HOME}/gui'.") + log_text.setStyleSheet( + f"color: white; font-size: 14px; {self.font_style}") + layout.addWidget(log_text) + + logs_group = QGroupBox("Log Configuration") + logs_group.setStyleSheet( + f"QGroupBox {{ color: white; padding: 15px; {self.font_style} }}") + logs_layout = QVBoxLayout(logs_group) + + self.enable_gui_logging = QCheckBox("Enable GUI logging") + self.enable_gui_logging.setStyleSheet(self.get_checkbox_style()) + logs_layout.addWidget(self.enable_gui_logging) + layout.addWidget(logs_group) + + save_button = QPushButton() + save_button.setFixedSize(75, 46) + save_button.setIcon(QIcon(os.path.join(self.btn_path, f"save.png"))) + save_button.setIconSize(QSize(75, 46)) + save_button.clicked.connect(self.save_logs_settings) + + button_layout = QHBoxLayout() + button_layout.addStretch() + button_layout.addWidget(save_button) + layout.addLayout(button_layout) + + layout.addStretch() + self.load_logs_settings() + return page + + def load_logs_settings(self) -> None: + try: + config = self.update_status._load_gui_config() + if config and "logging" in config: + self.enable_gui_logging.setChecked( + config["logging"]["gui_logging_enabled"]) + except Exception as e: + logging.error(f"Error loading logging settings: {str(e)}") + self.enable_gui_logging.setChecked(True) + + def save_logs_settings(self) -> None: + try: + config = self.update_status._load_gui_config() + if config is None: + config = { + "logging": { + "gui_logging_enabled": True, + "log_level": "INFO" + } + } + + config["logging"]["gui_logging_enabled"] = self.enable_gui_logging.isChecked() + + self.update_status._save_gui_config(config) + + if self.enable_gui_logging.isChecked(): + self.update_status._setup_gui_logging() + core_logger.info("User enabled GUI logging") + else: + core_logger.info("User disabled GUI logging") + self.update_status.stop_gui_logging() + + self.update_status.update_status( + "Logging settings saved successfully") + + except Exception as e: + logging.error(f"Error saving logging settings: {str(e)}") + self.update_status.update_status("Error saving logging settings") + + def create_registrations_page(self): + page = QWidget() + layout = QVBoxLayout(page) + layout.setSpacing(20) + layout.setContentsMargins(20, 20, 20, 20) + + title = QLabel("Create/Edit SETTINGS") + title.setStyleSheet( + f"color: #808080; font-size: 15px; font-weight: bold; {self.font_style}") + layout.addWidget(title) + + registrations_group = QGroupBox("Create/Edit Configuration") + registrations_group.setStyleSheet( + f"QGroupBox {{ color: white; font-size: 15px; padding: 15px; {self.font_style} }}") + registrations_layout = QVBoxLayout(registrations_group) + + self.enable_auto_sync = QCheckBox("Enable auto-sync on Edit") + self.enable_auto_sync.setStyleSheet(self.get_checkbox_style()) + registrations_layout.addWidget(self.enable_auto_sync) + + self.enable_fast_registration = QCheckBox( + "Enable Fast-mode for Profile Creation") + self.enable_fast_registration.setStyleSheet(self.get_checkbox_style()) + registrations_layout.addWidget(self.enable_fast_registration) + + self.dynamic_profiles_container = QWidget() + dynamic_layout = QVBoxLayout(self.dynamic_profiles_container) + dynamic_layout.setContentsMargins(20, 0, 0, 0) + + self.enable_dynamic_profiles = QCheckBox( + "Enable Dynamic Larger Profiles") + self.enable_dynamic_profiles.setStyleSheet(self.get_checkbox_style()) + dynamic_layout.addWidget(self.enable_dynamic_profiles) + + dynamic_desc = QLabel("This is for fast mode profile creation. Some users may wish to have screen sizes that are nearly as large as their host, but slightly smaller by differing amounts. This fools fingerprinters but still gives larger sizes. So for example if your host is 1200 x 800, then fast mode would offer 1150 x 750, 1100 x 700, 1050 x 650, etc.") + dynamic_desc.setWordWrap(True) + dynamic_desc.setStyleSheet( + f"color: #888888; font-size: 11px; font-style: italic; {self.font_style}") + dynamic_layout.addWidget(dynamic_desc) + + registrations_layout.addWidget(self.dynamic_profiles_container) + + self.enable_fast_registration.toggled.connect( + lambda checked: self.dynamic_profiles_container.setVisible(checked)) + + layout.addWidget(registrations_group) + + save_button = QPushButton() + save_button.setFixedSize(75, 46) + save_button.setIcon(QIcon(os.path.join(self.btn_path, f"save.png"))) + save_button.setIconSize(QSize(75, 46)) + save_button.clicked.connect(self.save_registrations_settings) + + button_layout = QHBoxLayout() + button_layout.addWidget(save_button) + button_layout.addStretch() + layout.addLayout(button_layout) + + layout.addStretch() + self.load_registrations_settings() + return page + + def create_systemwide_page(self): + page = QWidget() + layout = QVBoxLayout(page) + layout.setSpacing(20) + layout.setContentsMargins(20, 20, 20, 20) + + title = QLabel("SYSTEM-WIDE PROFILES") + title.setStyleSheet( + f"color: #808080; font-size: 12px; font-weight: bold; {self.font_style}") + layout.addWidget(title) + + description = QLabel( + "This is for the old version of the app! The new version prior to 2.1.0 does not need it. This allows you to enable or disable a legacy systemwide policy for sudo.") + description.setWordWrap(True) + description.setStyleSheet( + f"color: white; font-size: 14px; {self.font_style}") + layout.addWidget(description) + + status_layout = QHBoxLayout() + status_label = QLabel("Current status:") + status_label.setStyleSheet( + f"color: white; font-size: 14px; {self.font_style}") + self.systemwide_status_value = QLabel("") + self.systemwide_status_value.setStyleSheet( + f"color: #e67e22; font-size: 14px; {self.font_style}") + status_layout.addWidget(status_label) + status_layout.addWidget(self.systemwide_status_value) + status_layout.addStretch() + layout.addLayout(status_layout) + + toggle_layout = QHBoxLayout() + self.systemwide_toggle = QCheckBox( + "Enable \"No Sudo\" Systemwide Policy") + self.systemwide_toggle.setStyleSheet(self.get_checkbox_style()) + toggle_layout.addWidget(self.systemwide_toggle) + toggle_layout.addStretch() + layout.addLayout(toggle_layout) + + save_button = QPushButton() + save_button.setFixedSize(75, 46) + save_button.setIcon(QIcon(os.path.join(self.btn_path, "save.png"))) + save_button.setIconSize(QSize(75, 46)) + save_button.clicked.connect(self.save_systemwide_settings) + + button_layout = QHBoxLayout() + button_layout.addWidget(save_button) + button_layout.addStretch() + layout.addLayout(button_layout) + + layout.addStretch() + self.load_systemwide_settings() + return page + + def create_bwrap_page(self): + page = QWidget() + layout = QVBoxLayout(page) + layout.setSpacing(20) + layout.setContentsMargins(20, 20, 20, 20) + + title = QLabel("BWRAP PERMISSION") + title.setStyleSheet( + f"color: #808080; font-size: 12px; font-weight: bold; {self.font_style}") + layout.addWidget(title) + + description = QLabel( + "Control whether HydraVeil configures a capability policy so bwrap can be used without requiring additional permissions.") + description.setWordWrap(True) + description.setStyleSheet( + f"color: white; font-size: 14px; {self.font_style}") + layout.addWidget(description) + + status_layout = QHBoxLayout() + status_label = QLabel("Current status:") + status_label.setStyleSheet( + f"color: white; font-size: 14px; {self.font_style}") + self.bwrap_status_value = QLabel("") + self.bwrap_status_value.setStyleSheet( + f"color: #e67e22; font-size: 14px; {self.font_style}") + status_layout.addWidget(status_label) + status_layout.addWidget(self.bwrap_status_value) + status_layout.addStretch() + layout.addLayout(status_layout) + + toggle_layout = QHBoxLayout() + self.bwrap_toggle = QCheckBox("Enable capability policy") + self.bwrap_toggle.setStyleSheet(self.get_checkbox_style()) + toggle_layout.addWidget(self.bwrap_toggle) + toggle_layout.addStretch() + layout.addLayout(toggle_layout) + + save_button = QPushButton() + save_button.setFixedSize(75, 46) + save_button.setIcon(QIcon(os.path.join(self.btn_path, "save.png"))) + save_button.setIconSize(QSize(75, 46)) + save_button.clicked.connect(self.save_bwrap_settings) + + button_layout = QHBoxLayout() + button_layout.addWidget(save_button) + button_layout.addStretch() + layout.addLayout(button_layout) + + layout.addStretch() + self.load_bwrap_settings() + return page + + def load_systemwide_settings(self): + enabled = False + try: + privilege_policy = PolicyController.get('privilege') + if privilege_policy is not None: + enabled = PolicyController.is_instated(privilege_policy) + except Exception: + enabled = False + self.systemwide_toggle.setChecked(enabled) + if enabled: + self.systemwide_status_value.setText("Enabled") + self.systemwide_status_value.setStyleSheet( + f"color: #2ecc71; font-size: 14px; {self.font_style}") + else: + self.systemwide_status_value.setText("Disabled") + self.systemwide_status_value.setStyleSheet( + f"color: #e67e22; font-size: 14px; {self.font_style}") + + def save_systemwide_settings(self): + enable = self.systemwide_toggle.isChecked() + try: + privilege_policy = PolicyController.get('privilege') + if privilege_policy is not None: + if enable: + PolicyController.instate(privilege_policy) + else: + if PolicyController.is_instated(privilege_policy): + PolicyController.revoke(privilege_policy) + self.load_systemwide_settings() + self.update_status.update_status( + "System-wide policy settings updated") + except CommandNotFoundError as e: + self.systemwide_status_value.setText(str(e)) + self.systemwide_status_value.setStyleSheet( + f"color: red; font-size: 14px; {self.font_style}") + except (PolicyAssignmentError, PolicyInstatementError, PolicyRevocationError) as e: + self.systemwide_status_value.setText(str(e)) + self.systemwide_status_value.setStyleSheet( + f"color: red; font-size: 14px; {self.font_style}") + except Exception: + self.systemwide_status_value.setText("Failed to update policy") + self.systemwide_status_value.setStyleSheet( + f"color: red; font-size: 14px; {self.font_style}") + + def load_bwrap_settings(self): + enabled = False + try: + capability_policy = PolicyController.get('capability') + if capability_policy is not None: + enabled = PolicyController.is_instated(capability_policy) + except Exception: + enabled = False + self.bwrap_toggle.setChecked(enabled) + if enabled: + self.bwrap_status_value.setText("Enabled") + self.bwrap_status_value.setStyleSheet( + f"color: #2ecc71; font-size: 14px; {self.font_style}") + else: + self.bwrap_status_value.setText("Disabled") + self.bwrap_status_value.setStyleSheet( + f"color: #e67e22; font-size: 14px; {self.font_style}") + + def save_bwrap_settings(self): + enable = self.bwrap_toggle.isChecked() + try: + capability_policy = PolicyController.get('capability') + if capability_policy is not None: + if enable: + PolicyController.instate(capability_policy) + else: + if PolicyController.is_instated(capability_policy): + PolicyController.revoke(capability_policy) + self.load_bwrap_settings() + self.update_status.update_status( + "Capability policy settings updated") + except CommandNotFoundError as e: + self.bwrap_status_value.setText(str(e)) + self.bwrap_status_value.setStyleSheet( + f"color: red; font-size: 14px; {self.font_style}") + except (PolicyAssignmentError, PolicyInstatementError, PolicyRevocationError) as e: + self.bwrap_status_value.setText(str(e)) + self.bwrap_status_value.setStyleSheet( + f"color: red; font-size: 14px; {self.font_style}") + except Exception: + self.bwrap_status_value.setText("Failed to update policy") + self.bwrap_status_value.setStyleSheet( + f"color: red; font-size: 14px; {self.font_style}") + + def load_registrations_settings(self) -> None: + try: + config = self.update_status._load_gui_config() + if config and "registrations" in config: + registrations = config["registrations"] + auto_sync = registrations.get("auto_sync_enabled", False) + fast_reg = registrations.get( + "fast_registration_enabled", False) + dynamic_profiles = registrations.get( + "dynamic_larger_profiles", False) + if "auto_sync_enabled" not in registrations or "fast_registration_enabled" not in registrations or "dynamic_larger_profiles" not in registrations: + registrations["auto_sync_enabled"] = auto_sync + registrations["fast_registration_enabled"] = fast_reg + registrations["dynamic_larger_profiles"] = dynamic_profiles + self.update_status._save_gui_config(config) + self.enable_auto_sync.setChecked(auto_sync) + self.enable_fast_registration.setChecked(fast_reg) + self.enable_dynamic_profiles.setChecked(dynamic_profiles) + self.dynamic_profiles_container.setVisible(fast_reg) + else: + self.enable_auto_sync.setChecked(False) + self.enable_fast_registration.setChecked(False) + self.enable_dynamic_profiles.setChecked(False) + self.dynamic_profiles_container.setVisible(False) + except Exception as e: + logging.error(f"Error loading registration settings: {str(e)}") + self.enable_auto_sync.setChecked(False) + self.enable_fast_registration.setChecked(False) + self.enable_dynamic_profiles.setChecked(False) + self.dynamic_profiles_container.setVisible(False) + + def save_registrations_settings(self) -> None: + try: + config = self.update_status._load_gui_config() + if config is None: + config = { + "logging": { + "gui_logging_enabled": True, + "log_level": "INFO" + }, + "registrations": { + "auto_sync_enabled": False, + "fast_registration_enabled": False + } + } + + if "registrations" not in config: + config["registrations"] = {} + + config["registrations"]["auto_sync_enabled"] = self.enable_auto_sync.isChecked() + config["registrations"]["fast_registration_enabled"] = self.enable_fast_registration.isChecked() + config["registrations"]["dynamic_larger_profiles"] = self.enable_dynamic_profiles.isChecked() + + self.update_status._save_gui_config(config) + self.update_status.update_status("Registration settings saved") + + except Exception as e: + logging.error(f"Error saving registration settings: {str(e)}") + self.update_status.update_status( + "Error saving registration settings") + + def is_auto_sync_enabled(self) -> bool: + try: + config = self.update_status._load_gui_config() + if config and "registrations" in config: + registrations = config["registrations"] + return registrations.get("auto_sync_enabled", False) + return False + except Exception as e: + logging.error(f"Error checking auto-sync setting: {str(e)}") + return False + + def is_fast_registration_enabled(self) -> bool: + try: + config = self.update_status._load_gui_config() + if config and "registrations" in config: + registrations = config["registrations"] + return registrations.get("fast_registration_enabled", False) + return False + except Exception as e: + logging.error( + f"Error checking fast registration setting: {str(e)}") + return False diff --git a/gui/v2/ui/pages/sync_screen.py b/gui/v2/ui/pages/sync_screen.py new file mode 100755 index 0000000..0bae2e8 --- /dev/null +++ b/gui/v2/ui/pages/sync_screen.py @@ -0,0 +1,177 @@ +import os + +from PyQt6.QtWidgets import QLabel, QPushButton +from PyQt6.QtGui import QPixmap + +from core.controllers.ClientController import ClientController + +from gui.v2.ui.pages.Page import Page +from gui.v2.workers.worker_thread import WorkerThread + + +class SyncScreen(Page): + def __init__(self, page_stack, main_window=None, parent=None): + super().__init__("SyncScreen", page_stack, main_window, parent) + self.main_window = main_window + self.btn_path = main_window.btn_path + self.update_status = main_window + self.connection_manager = main_window.connection_manager + self.is_tor_mode = main_window.is_tor_mode + + self.heading_label = QLabel( + "You're about to fetch data from\nSimplified Privacy.", self) + self.heading_label.setGeometry(15, 80, 750, 120) + font_style = "font-size: 34px; font-weight: bold; color: white;" + if self.custom_window.open_sans_family: + font_style += f" font-family: '{self.custom_window.open_sans_family}';" + self.heading_label.setStyleSheet(font_style) + + self.data_description = QLabel( + "This data includes what plans, countries,\nbrowsers, and version upgrades are\navailable. As well as coordinating billing.", self) + self.data_description.setGeometry(22, 190, 750, 120) + font_style = "font-size: 24px; font-weight: bold; color: #ffff00;" + if self.custom_window.open_sans_family: + font_style += f" font-family: '{self.custom_window.open_sans_family}';" + self.data_description.setStyleSheet(font_style) + + self.instructions = QLabel( + "Use the toggle in the bottom right to\ndecide if you want to use Tor or not.\nThen hit \"Next\"", self) + self.instructions.setGeometry(22, 345, 750, 120) + font_style = "font-size: 28px; font-weight: bold; color: white;" + if self.custom_window.open_sans_family: + font_style += f" font-family: '{self.custom_window.open_sans_family}';" + self.instructions.setStyleSheet(font_style) + + self.arrow_label = QLabel(self) + self.arrow_label.setGeometry(520, 400, 180, 130) + arrow_pixmap = QPixmap(os.path.join(self.btn_path, "arrow-down.png")) + + self.arrow_label.setPixmap(arrow_pixmap) + self.arrow_label.raise_() + + self.button_go.setVisible(True) + self.button_back.setVisible(True) + self.button_go.clicked.connect(self.perform_sync) + self.button_back.clicked.connect(self.go_back) + + def go_back(self): + self.custom_window.navigator.navigate("menu") + + def perform_sync(self): + self.button_go.setEnabled(False) + self.button_back.setEnabled(False) + self.update_status.update_status('Sync in progress...') + if self.main_window.get_current_connection() == 'tor': + self.worker_thread = WorkerThread('SYNC_TOR') + else: + self.worker_thread = WorkerThread('SYNC') + + self.worker_thread.sync_output.connect(self.update_output) + self.worker_thread.start() + + def update_output(self, available_locations, available_browsers, status, is_tor, locations, all_browsers): + if isinstance(all_browsers, bool) and not all_browsers: + self.custom_window.navigator.navigate("install_system_package") + install_page = self.custom_window.navigator.get_cached("install_system_package") + if install_page is not None: + install_page.configure( + package_name='tor', distro='debian', is_sync=True) + self.button_go.setEnabled(True) + self.button_back.setEnabled(True) + return + + if status is False: + self.button_go.setEnabled(True) + self.button_back.setEnabled(True) + self.update_status.update_status('An error occurred during sync') + return + + self.update_status.update_status('Sync complete') + + update_available = ClientController.can_be_updated() + + if update_available: + menu_page = self.find_menu_page() + if menu_page is not None: + menu_page.on_update_check_finished() + + self.custom_window.update_values( + available_locations, available_browsers, status, is_tor, locations, all_browsers) + + self.custom_window.navigator.navigate("protocol") + + def update_after_sync(self, available_locations, available_browsers, locations, all_browsers=None): + self.connection_manager.set_synced(True) + + available_locations_list = [] + available_browsers_list = [] + + self.connection_manager.store_locations(locations) + self.connection_manager.store_browsers(available_browsers) + + browser_positions = self.generate_grid_positions( + len(available_browsers)) + location_positions = self.generate_grid_positions( + len(available_locations)) + + for i, location in enumerate(available_locations): + available_locations_list.append( + (QPushButton, location, location_positions[i])) + + for i, browser in enumerate(available_browsers): + available_browsers_list.append( + (QPushButton, browser, browser_positions[i])) + + location_page = self.find_location_page() + hidetor_page = self.find_hidetor_page() + protocol_page = self.find_protocol_page() + browser_page = self.find_browser_page() + + if browser_page: + browser_page.create_interface_elements(available_browsers_list) + + if location_page: + location_page.create_interface_elements(available_locations_list) + if hidetor_page: + hidetor_page.create_interface_elements(available_locations_list) + if protocol_page: + protocol_page.enable_protocol_buttons() + + menu_page = self.find_menu_page() + if menu_page: + menu_page.refresh_profiles_data() + + def generate_grid_positions(self, num_items): + positions = [] + start_x = 395 + start_y = 90 + button_width = 185 + button_height = 75 + h_spacing = 10 + v_spacing = 105 + + for i in range(num_items): + col = i % 2 + row = i // 2 + + x = start_x + (col * (button_width + h_spacing)) + y = start_y + (row * v_spacing) + + positions.append((x, y, button_width, button_height)) + + return positions + + def find_browser_page(self): + return self.custom_window.navigator.get_cached("browser") + + def find_location_page(self): + return self.custom_window.navigator.get_cached("location") + + def find_hidetor_page(self): + return self.custom_window.navigator.get_cached("hidetor") + + def find_protocol_page(self): + return self.custom_window.navigator.get_cached("protocol") + + def find_menu_page(self): + return self.custom_window.navigator.get_cached("menu") diff --git a/gui/v2/ui/pages/systemwide_prompt_page.py b/gui/v2/ui/pages/systemwide_prompt_page.py new file mode 100755 index 0000000..3ddf18a --- /dev/null +++ b/gui/v2/ui/pages/systemwide_prompt_page.py @@ -0,0 +1,115 @@ +import os + +from PyQt6.QtWidgets import QLabel, QPushButton +from PyQt6.QtGui import QPixmap +from PyQt6.QtCore import Qt + +from core.controllers.PolicyController import PolicyController +from core.Errors import ( + CommandNotFoundError, + PolicyAssignmentError, + PolicyInstatementError, +) + +from gui.v2.ui.pages.Page import Page + + +class SystemwidePromptPage(Page): + def __init__(self, page_stack, main_window=None, parent=None): + super().__init__("Systemwide", page_stack, main_window, parent) + self.btn_path = main_window.btn_path + self.update_status = main_window + self.button_back.setVisible(True) + self.button_back.clicked.connect(self.back_to_install) + self.button_next.setVisible(False) + self.button_go.setVisible(False) + self.status_label = QLabel(self) + self.status_label.setGeometry(80, 430, 640, 40) + self.status_label.setStyleSheet("font-size: 14px; color: cyan;") + self.setup_ui() + + def setup_ui(self): + self.title.setGeometry(20, 50, 760, 40) + self.title.setText("Enable \"no sudo\" systemwide") + description = QLabel(self) + description.setGeometry(80, 100, 640, 120) + description.setWordWrap(True) + description.setStyleSheet("font-size: 14px; color: cyan;") + description.setText("If you're using Systemwide profiles, you may wish to enable them without having to enter the sudo password each time for convenience. This requires the sudo password to setup profiles and disable them (for security), but not to turn it on each time. You can choose to set this up now or later in the options menu.") + + icon_label = QLabel(self) + icon_label.setGeometry(80, 300, 64, 64) + icon_pix = QPixmap(os.path.join( + self.btn_path, "wireguard_system_wide.png")) + icon_label.setPixmap(icon_pix.scaled( + 64, 64, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)) + yes_button = QPushButton("Yes, enable system-wide profiles", self) + yes_button.setGeometry(170, 300, 300, 50) + yes_button.setStyleSheet(""" + QPushButton { + background: #007AFF; + color: white; + border: none; + border-radius: 4px; + font-size: 13px; + font-weight: bold; + } + QPushButton:hover { + background: #0056CC; + } + """) + yes_button.clicked.connect(self.enable_systemwide) + no_button = QPushButton("Not now", self) + no_button.setGeometry(170, 370, 120, 40) + no_button.setStyleSheet(""" + QPushButton { + background: #007AFF; + color: white; + border: none; + border-radius: 4px; + font-size: 13px; + font-weight: bold; + } + QPushButton:hover { + background: #0056CC; + } + """) + no_button.clicked.connect(self.skip_systemwide) + self.refresh_status() + + def refresh_status(self): + privilege_policy = PolicyController.get('privilege') + if privilege_policy is not None and PolicyController.is_instated(privilege_policy): + self.status_label.setText( + "Current status: system-wide policy is enabled.") + self.status_label.setStyleSheet("font-size: 14px; color: #2ecc71;") + else: + self.status_label.setText( + "Current status: system-wide policy is disabled.") + self.status_label.setStyleSheet("font-size: 14px; color: #e67e22;") + + def enable_systemwide(self): + try: + privilege_policy = PolicyController.get('privilege') + if privilege_policy is not None: + PolicyController.instate(privilege_policy) + self.update_status.mark_systemwide_prompt_shown() + self.refresh_status() + self.update_status.update_status("System-wide policy enabled") + self.custom_window.navigator.navigate("menu") + except CommandNotFoundError as e: + self.status_label.setText(str(e)) + self.status_label.setStyleSheet("font-size: 14px; color: red;") + except (PolicyAssignmentError, PolicyInstatementError) as e: + self.status_label.setText(str(e)) + self.status_label.setStyleSheet("font-size: 14px; color: red;") + except Exception: + self.status_label.setText("Failed to enable system-wide policy") + self.status_label.setStyleSheet("font-size: 14px; color: red;") + + def skip_systemwide(self): + self.update_status.mark_systemwide_prompt_shown() + self.custom_window.navigator.navigate("menu") + + def back_to_install(self): + self.custom_window.navigator.navigate("install_system_package") diff --git a/gui/v2/ui/pages/ticket_crypto_picker_page.py b/gui/v2/ui/pages/ticket_crypto_picker_page.py new file mode 100755 index 0000000..36bd719 --- /dev/null +++ b/gui/v2/ui/pages/ticket_crypto_picker_page.py @@ -0,0 +1,112 @@ +import os + +from PyQt6.QtWidgets import QButtonGroup, QMessageBox, QPushButton +from PyQt6.QtGui import QIcon +from PyQt6.QtCore import QSize +from PyQt6 import QtCore + +from gui.v2.ui.pages.Page import Page +from gui.v2.workers.ticketing_worker_thread import TicketingWorkerThread + + +HOW_MANY_PROFILES_DEFAULT = 6 + + +class TicketCryptoPickerPage(Page): + def __init__(self, page_stack, main_window=None, parent=None): + super().__init__("TicketCrypto", page_stack, main_window, parent) + self.update_status = main_window + self.selected_plan = None + self.bypass_existing = False + self.worker = None + + self.title.setText("Payment Method") + self.title.setGeometry(QtCore.QRect(510, 30, 250, 40)) + self.title.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + + self.button_reverse.setVisible(True) + self.button_reverse.clicked.connect(self.reverse) + + button_info = [ + ("monero", "monero", (545, 75)), + ("bitcoin", "bitcoin", (545, 290)), + ("lightning", "lightnering", (545, 180)), + ("litecoin", "litecoin", (545, 395)), + ] + self.buttonGroup = QButtonGroup(self) + self.buttons = [] + for j, (currency, icon_name, position) in enumerate(button_info): + button = QPushButton(self) + button.setGeometry(position[0], position[1], 185, 75) + button.setIconSize(QSize(190, 120)) + button.setCheckable(True) + button.setIcon(QIcon(os.path.join(self.btn_path, f"{icon_name}.png"))) + button.setProperty('currency', currency) + self.buttons.append(button) + self.buttonGroup.addButton(button, j) + button.clicked.connect(self.on_currency_selected) + + def set_selected_plan(self, plan_key): + self.selected_plan = plan_key + self.bypass_existing = False + self.update_status.update_status(f"Selected plan: {plan_key}") + for btn in self.buttons: + btn.setChecked(False) + + def on_currency_selected(self): + selected_button = self.buttonGroup.checkedButton() + if not selected_button or not self.selected_plan: + return + currency = selected_button.property('currency') + self.start_initiate_payment(currency) + + def start_initiate_payment(self, currency): + self.update_status.update_status("Initiating payment...") + self.worker = TicketingWorkerThread('INITIATE_PAYMENT', params={ + 'how_many_profiles': HOW_MANY_PROFILES_DEFAULT, + 'which_key': self.selected_plan, + 'which_cryptocurrency': currency, + 'bypass_existing': self.bypass_existing, + }) + self.worker.invoice_ready.connect(self.on_invoice_ready) + self.worker.error.connect(self.on_error) + self.worker.start() + + def on_invoice_ready(self, invoice): + if invoice is None or invoice is False: + self.update_status.update_status("Could not initiate payment.") + return + + error_code = getattr(invoice, 'error_code', None) + if error_code == 'already_exists' and not self.bypass_existing: + self._prompt_wipe_existing(invoice) + return + if error_code: + msg = getattr(invoice, 'final_error_msg', None) or error_code + self.update_status.update_status(f"Payment error: {msg}") + return + + self.custom_window.navigator.navigate("payment_details") + payment_page = self.custom_window.navigator.get_cached("payment_details") + if payment_page is not None: + payment_page.set_ticket_invoice(invoice, self.selected_plan) + + def _prompt_wipe_existing(self, invoice): + msg = QMessageBox(self) + msg.setWindowTitle("Existing tickets found") + msg.setText("You already have ticket data. Wipe it and start over?") + msg.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) + result = msg.exec() + if result == QMessageBox.StandardButton.Yes: + self.bypass_existing = True + currency_btn = self.buttonGroup.checkedButton() + if currency_btn: + self.start_initiate_payment(currency_btn.property('currency')) + else: + self.update_status.update_status("Cancelled.") + + def on_error(self, msg): + self.update_status.update_status(f"Payment error: {msg}") + + def reverse(self): + self.custom_window.navigator.navigate("plan_picker") diff --git a/gui/v2/ui/pages/ticket_or_billing_choice_page.py b/gui/v2/ui/pages/ticket_or_billing_choice_page.py new file mode 100755 index 0000000..7b13173 --- /dev/null +++ b/gui/v2/ui/pages/ticket_or_billing_choice_page.py @@ -0,0 +1,117 @@ +from PyQt6.QtWidgets import QLabel, QListWidget, QListWidgetItem, QPushButton +from PyQt6 import QtCore + +from core.controllers.ProfileController import ProfileController +from core.controllers.tickets.UseTicketController import get_unused_tickets + +from gui.v2.actions.locations import location_candidates +from gui.v2.infrastructure.setup_observers import ticket_observer +from gui.v2.ui.pages.Page import Page + + +class TicketOrBillingChoicePage(Page): + def __init__(self, page_stack, main_window=None, parent=None): + super().__init__("TicketOrBilling", page_stack, main_window, parent) + self.update_status = main_window + + self.title.setText("Pick a Ticket or Use Billing") + self.title.setGeometry(QtCore.QRect(220, 30, 360, 40)) + self.title.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + + self.button_reverse.setVisible(True) + self.button_reverse.clicked.connect(self.reverse) + + self.intro = QLabel( + "Random ticket use is OFF. Pick a ticket to use, or fall back to a billing code.", + self) + self.intro.setGeometry(40, 90, 720, 40) + self.intro.setStyleSheet("color: white; font-size: 13px;") + self.intro.setWordWrap(True) + self.intro.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + + self.ticket_list = QListWidget(self) + self.ticket_list.setGeometry(60, 140, 380, 320) + self.ticket_list.setStyleSheet(""" + QListWidget { + background-color: #000000; color: #00ffff; + border: 2px solid #00ffff; border-radius: 8px; + font-size: 14px; + } + QListWidget::item { padding: 10px; } + QListWidget::item:hover { background-color: #003333; } + QListWidget::item:selected { background-color: #006666; color: white; } + """) + + self.use_ticket_button = QPushButton("Use Selected Ticket", self) + self.use_ticket_button.setGeometry(60, 470, 380, 40) + self.use_ticket_button.setStyleSheet(""" + QPushButton { + background-color: #00aaff; color: white; border: none; + border-radius: 5px; font-size: 14px; font-weight: bold; + } + QPushButton:disabled { background-color: #555555; } + """) + self.use_ticket_button.clicked.connect(self.on_use_ticket) + + self.use_billing_button = QPushButton("Use a Billing Code Instead", self) + self.use_billing_button.setGeometry(470, 200, 280, 60) + self.use_billing_button.setStyleSheet(""" + QPushButton { + background-color: #000000; color: #00ffff; + border: 2px solid #00ffff; border-radius: 10px; + font-size: 14px; font-weight: bold; + } + QPushButton:hover { background-color: #003333; } + """) + self.use_billing_button.clicked.connect(self.on_use_billing) + + self.status_label = QLabel("", self) + self.status_label.setGeometry(20, 520, 760, 20) + self.status_label.setStyleSheet("color: #cccccc; font-size: 12px;") + self.status_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + + def populate(self): + self.ticket_list.clear() + try: + unused = get_unused_tickets(ticket_observer) + except Exception as e: + self.status_label.setText(f"Error reading tickets: {e}") + return + if unused.get('valid'): + for t in unused.get('data', []): + item = QListWidgetItem(f"Ticket #{t}") + item.setData(QtCore.Qt.ItemDataRole.UserRole, t) + self.ticket_list.addItem(item) + else: + self.status_label.setText(unused.get('message', 'No tickets available.')) + + def on_use_ticket(self): + item = self.ticket_list.currentItem() + if not item: + self.status_label.setText("Pick a ticket from the list first.") + return + which_ticket = item.data(QtCore.Qt.ItemDataRole.UserRole) + profile_id = self.update_status.current_profile_id + try: + profile = ProfileController.get(int(profile_id)) + except Exception: + profile = None + candidates = location_candidates(profile) + if not candidates: + self.status_label.setText("Could not determine profile location.") + return + profile_data = { + 'id': int(profile_id), + 'use_ticket': which_ticket, + 'ticket_location': candidates[0], + } + menu_page = self.custom_window.navigator.get_cached("menu") + if menu_page: + self.update_status.update_status(f"Using ticket #{which_ticket}...") + menu_page.enabling_profile(profile_data) + + def on_use_billing(self): + self.custom_window.navigator.navigate("id") + + def reverse(self): + self.custom_window.navigator.navigate("menu") diff --git a/gui/v2/ui/pages/ticket_prep_page.py b/gui/v2/ui/pages/ticket_prep_page.py new file mode 100755 index 0000000..91bb30d --- /dev/null +++ b/gui/v2/ui/pages/ticket_prep_page.py @@ -0,0 +1,117 @@ +from PyQt6.QtWidgets import QLabel, QPushButton +from PyQt6 import QtCore + +from gui.v2.infrastructure.setup_observers import ticket_observer +from gui.v2.ui.pages.Page import Page +from gui.v2.ui.widgets.terminal_widget import TerminalWidget +from gui.v2.workers.ticketing_worker_thread import TicketingWorkerThread + + +HOW_MANY_PROFILES_DEFAULT = 6 + + +class TicketPrepPage(Page): + def __init__(self, page_stack, main_window=None, parent=None): + super().__init__("TicketPrep", page_stack, main_window, parent) + self.update_status = main_window + self.worker = None + self._terminal_bound = False + self._tickets_ready = False + + self.title.setText("Preparing Tickets") + self.title.setGeometry(QtCore.QRect(280, 20, 240, 40)) + self.title.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + + self.terminal = TerminalWidget(self) + self.terminal.setGeometry(20, 70, 760, 380) + + self.status_label = QLabel("", self) + self.status_label.setGeometry(20, 460, 760, 30) + self.status_label.setStyleSheet("color: #00ffff; font-size: 14px;") + self.status_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + + self.continue_button = QPushButton("Continue", self) + self.continue_button.setGeometry(340, 490, 120, 35) + self.continue_button.setStyleSheet(""" + QPushButton { + background-color: #2ecc71; color: white; border: none; + border-radius: 5px; font-size: 14px; font-weight: bold; + } + QPushButton:disabled { background-color: #555555; } + """) + self.continue_button.setEnabled(True) + self.continue_button.clicked.connect(self.on_continue) + + def _bind_terminal_once(self): + if self._terminal_bound: + return + self.terminal.bind_observer(ticket_observer, [ + 'preparing', 'connecting', 'sync_done', 'waiting', 'paid', + 'ticket_ready', 'used', 'failed_input', 'failed_output', + 'connection_error', 'unknown_error', 'error', + ]) + self._terminal_bound = True + + def start_prep(self): + self._bind_terminal_once() + self.terminal.append("=== Starting ticket preparation ===") + self._tickets_ready = False + self.continue_button.setEnabled(True) + self.status_label.setText("Preparing tickets...") + self.update_status.update_status("Preparing tickets...") + + self.worker = TicketingWorkerThread('PREPARE_TICKETS', params={ + 'how_many_profiles': HOW_MANY_PROFILES_DEFAULT, + }) + self.worker.prep_done.connect(self.on_prep_done) + self.worker.error.connect(self.on_error) + self.worker.start() + + def on_prep_done(self, result): + if not isinstance(result, dict): + self.terminal.append("ERROR: invalid result format.") + self.status_label.setText("Preparation failed.") + return + if result.get('valid') is True: + self._tickets_ready = True + self.update_status.clear_ticket_verification_failure() + self.terminal.append("=== Ticket preparation complete ===") + self.status_label.setText("Tickets ready. Click Continue to apply one to your profile.") + self.update_status.update_status("Tickets ready.") + self.continue_button.setEnabled(True) + else: + msg = result.get('message', 'failed') + self.terminal.append(f"ERROR: {msg}") + if msg == 'verification_failed': + how_many_failed = result.get('how_many_failed', '?') + failed_validations = result.get('failed_validations', []) + self.terminal.append(f" failed count: {how_many_failed}") + self.terminal.append(f" failed indices: {failed_validations}") + if self.update_status.save_ticket_verification_failure(result): + self.terminal.append("Recovery data saved. Open Settings > Tickets to evaluate or resume.") + self.status_label.setText("Verification failed. Open Settings > Tickets for recovery.") + self.update_status.update_status("Ticket verification failed") + self.continue_button.setEnabled(True) + return + self.status_label.setText(f"Preparation failed: {msg}") + self.update_status.update_status(f"An error occurred") + self.continue_button.setEnabled(True) + + def on_error(self, msg): + self.terminal.append(f"EXCEPTION: {msg}") + self.status_label.setText(f"An unkown error occured") + self.continue_button.setEnabled(True) + + def on_continue(self): + menu_page = self.custom_window.navigator.get_cached("menu") + profile_id = getattr(self.update_status, 'current_profile_id', None) + if menu_page: + self.custom_window.navigator.navigate("menu") + if not self._tickets_ready: + self.update_status.update_status("Ticket preparation was not completed.") + return + if profile_id is None or menu_page is None: + self.update_status.update_status("Tickets ready. Random ticket use is ON.") + return + self.update_status.update_status("Applying random ticket to profile...") + menu_page.enabling_profile({'id': int(profile_id)}) diff --git a/gui/v2/ui/pages/tor_page.py b/gui/v2/ui/pages/tor_page.py new file mode 100755 index 0000000..a1dae48 --- /dev/null +++ b/gui/v2/ui/pages/tor_page.py @@ -0,0 +1,84 @@ +import os + +from PyQt6.QtWidgets import QLabel, QButtonGroup +from PyQt6.QtGui import QPixmap +from PyQt6.QtCore import Qt +from PyQt6 import QtCore + +from gui.v2.ui.pages.Page import Page + + +class TorPage(Page): + def __init__(self, page_stack, main_window, parent=None): + super().__init__("TorPage", page_stack, main_window, parent) + self.update_status = main_window + self.button_back.setVisible(True) + self.title.setGeometry(585, 40, 185, 40) + self.title.setText("Pick a Country") + self.display.setGeometry(QtCore.QRect( + 0, 100, 540, 405)) + self.display0 = QLabel(self) + self.display0.setGeometry(QtCore.QRect( + 0, 50, 600, 465)) + self.display0.setPixmap( + QPixmap(os.path.join(self.btn_path, "browser only.png"))) + self.display0.lower() + + self.button_go.clicked.connect(self.go_selected) + + self.button_reverse.setVisible(True) + self.button_reverse.clicked.connect(self.reverse_selected) + + self.label = QLabel(self) + self.label.setGeometry(440, 370, 86, 130) + pixmap = QPixmap(os.path.join(self.btn_path, "tor 86x130.png")) + self.label.setPixmap(pixmap) + + self.label.hide() + + def showEvent(self, event): + super().showEvent(event) + self.extraccion() + + def extraccion(self): + self.data_profile = {} + profile = self.update_status.read_data() + profile_1 = profile.get('Profile_1') + self.verificate() + + def verificate(self, value='tor'): + if value == "just proxy": + self.display0.show() + self.label.hide() + elif value == "tor": + self.display0.show() + self.label.show() + else: + pass + self.buttonGroup = QButtonGroup(self) + self.buttons = [] + + def show_dimentions(self, dimentions): + self.display.setPixmap(QPixmap(os.path.join(self.btn_path, f"{dimentions} garaje.png")).scaled( + self.display.size(), Qt.AspectRatioMode.KeepAspectRatio)) + self.selected_dimentions_icon = dimentions + self.button_go.setVisible(True) + self.update_swarp_json() + + def update_swarp_json(self): + inserted_data = { + "country_garaje": self.selected_dimentions_icon + } + self.update_status.write_data(inserted_data) + + def reverse_selected(self): + self.custom_window.navigator.navigate("residential") + + def go_selected(self): + self.custom_window.navigator.navigate("browser") + + self.display.clear() + for boton in self.buttons: + boton.setChecked(False) + + self.button_go.setVisible(False) diff --git a/gui/v2/ui/pages/welcome_page.py b/gui/v2/ui/pages/welcome_page.py new file mode 100755 index 0000000..c7d0c83 --- /dev/null +++ b/gui/v2/ui/pages/welcome_page.py @@ -0,0 +1,124 @@ +import os + +from PyQt6.QtWidgets import QLabel, QWidget, QGridLayout, QHBoxLayout, QVBoxLayout +from PyQt6.QtGui import QPixmap +from PyQt6.QtCore import Qt + +from gui.v2.ui.pages.Page import Page + + +class WelcomePage(Page): + def __init__(self, page_stack, main_window=None, parent=None): + super().__init__("Welcome", page_stack, main_window, parent) + self.btn_path = main_window.btn_path + self.update_status = main_window + self.ui_elements = [] + self.button_next.clicked.connect(self.go_to_install) + self.button_next.setVisible(True) + self._setup_welcome_ui() + self._setup_stats_display() + + def _setup_welcome_ui(self): + welcome_title = QLabel('Welcome to HydraVeil', self) + welcome_title.setGeometry(20, 45, 760, 60) + welcome_title.setStyleSheet(""" + font-size: 32px; + font-weight: bold; + color: cyan; + """) + welcome_title.setAlignment(Qt.AlignmentFlag.AlignCenter) + + welcome_msg = QLabel( + "Before we begin your journey, we need to set up a few essential components. " + "Click 'Next' to take you to the installation page.", self) + welcome_msg.setGeometry(40, 100, 720, 80) + welcome_msg.setWordWrap(True) + welcome_msg.setStyleSheet(""" + font-size: 16px; + color: cyan; + """) + welcome_msg.setAlignment(Qt.AlignmentFlag.AlignCenter) + + def _setup_stats_display(self): + stats_container = QWidget(self) + stats_container.setGeometry(70, 200, 730, 240) + + stats_title = QLabel(stats_container) + stats_title.setText("Features & Services") + stats_title.setGeometry(190, 10, 300, 30) + stats_title.setStyleSheet(""" + font-size: 22px; + font-weight: bold; + color: cyan; + """) + + grid_widget = QWidget(stats_container) + grid_widget.setGeometry(0, 70, 700, 190) + grid_layout = QGridLayout(grid_widget) + grid_layout.setSpacing(30) + + stats = [ + ("Browsers", "browsers_mini.png", "10 Supported Browsers", True), + ("WireGuard", "wireguard_mini.png", "6 Global Locations", True), + ("Proxy", "just proxy_mini.png", "5 Proxy Locations", True), + ("Tor", "toricon_mini.png", "Tor Network Access", True) + ] + + for i, (title, icon_name, count, available) in enumerate(stats): + row = i // 2 + col = i % 2 + + stat_widget = QWidget() + stat_layout = QHBoxLayout(stat_widget) + stat_layout.setContentsMargins(20, 10, 2, 10) + stat_layout.setSpacing(15) + + icon_label = QLabel() + icon_path = os.path.join(self.btn_path, icon_name) + icon_label.setPixmap(QPixmap(icon_path).scaled( + 30, 30, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)) + icon_label.setFixedSize(30, 30) + stat_layout.addWidget(icon_label) + + text_widget = QWidget() + text_layout = QVBoxLayout(text_widget) + text_layout.setSpacing(5) + text_layout.setContentsMargins(0, 0, 30, 0) + + title_widget = QWidget() + title_layout = QHBoxLayout(title_widget) + title_layout.setContentsMargins(0, 0, 0, 0) + title_layout.setSpacing(10) + + title_label = QLabel(title) + title_label.setStyleSheet( + "font-size: 16px; font-weight: bold; color: #2c3e50;") + title_layout.addWidget(title_label) + + status_indicator = QLabel("●") + status_indicator.setStyleSheet("color: #2ecc71; font-size: 16px;") + title_layout.addWidget(status_indicator) + + title_layout.addStretch() + text_layout.addWidget(title_widget) + + count_label = QLabel(count) + count_label.setStyleSheet("font-size: 16px; color: #34495e;") + text_layout.addWidget(count_label) + + stat_layout.addWidget(text_widget, stretch=1) + + stat_widget.setStyleSheet(""" + QWidget { + background-color: transparent; + border-radius: 10px; + } + """) + + grid_layout.addWidget(stat_widget, row, col) + + def go_to_install(self): + self.custom_window.navigator.navigate("install_system_package") + install_page = self.custom_window.navigator.get_cached("install_system_package") + if install_page is not None: + install_page.configure(package_name='all', distro='debian') diff --git a/gui/v2/ui/pages/wireguard_page.py b/gui/v2/ui/pages/wireguard_page.py new file mode 100755 index 0000000..646c4b4 --- /dev/null +++ b/gui/v2/ui/pages/wireguard_page.py @@ -0,0 +1,136 @@ +import os + +from PyQt6.QtWidgets import QPushButton, QLabel, QButtonGroup +from PyQt6.QtGui import QPixmap, QIcon +from PyQt6 import QtCore + +from gui.v2.ui.pages.Page import Page + + +class WireGuardPage(Page): + browser_label_created = False + + def __init__(self, page_stack, main_window, parent=None): + super().__init__("Wireguard", page_stack, main_window, parent) + self.buttonGroup = QButtonGroup(self) + self.btn_path = main_window.btn_path + self.update_status = main_window + self.buttons = [] + self.selected_protocol = None + self.selected_protocol_icon = None + self.button_back.setVisible(True) + self.button_go.clicked.connect(self.go_selected) + self.additional_labels = [] + self.title.setGeometry(585, 40, 185, 40) + self.title.setText("Pick a Protocol") + self.sudo_warning = QLabel("Requires Sudo", self) + self.sudo_warning.setGeometry(165, 90, 300, 40) + self.sudo_warning.setStyleSheet(""" + font-size: 24px; + font-weight: bold; + color: cyan; + """) + self.sudo_warning.setVisible(False) + + self.create_interface_elements() + + def create_interface_elements(self): + for j, (object_type, icon_name, page_name, geometry) in enumerate([ + (QPushButton, "system-wide", "location", (585, 90, 185, 75)), + (QPushButton, "browser-only", "location", + (585, 90+30+75+30+75, 185, 75)), + (QLabel, None, None, (570, 170, 210, 105)), + (QLabel, None, None, (570, 385, 210, 115)) + ]): + if object_type == QPushButton: + boton = object_type(self) + boton.setGeometry(*geometry) + boton.setIconSize(boton.size()) + boton.setCheckable(True) + boton.setIcon( + QIcon(os.path.join(self.btn_path, f"{icon_name}.png"))) + self.buttons.append(boton) + self.buttonGroup.addButton(boton, j) + boton.clicked.connect( + lambda _, name=page_name, protocol=icon_name: self.show_protocol(name, protocol)) + elif object_type == QLabel: + label = object_type(self) + label.setGeometry(*geometry) + label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + label.setWordWrap(True) + if geometry == (570, 170, 210, 105): + text1 = "VPN for the whole PC\n(€1/month - 1 location)" + label.setText(text1) + elif geometry == (570, 385, 210, 115): + text2 = "1 Profile.\n1 Country.\nIsolated VPN for just the browser\n(1€/month)" + label.setText(text2) + + def update_swarp_json(self): + inserted_data = { + "protocol": "wireguard", + "connection": self.selected_protocol_icon + } + self.update_status.write_data(inserted_data) + + def show_protocol(self, page_name, protocol): + if protocol == "browser-only": + self.sudo_warning.setVisible(False) + else: + self.sudo_warning.setVisible(True) + self.protocol_chosen = protocol + self.selected_protocol_icon = protocol + self.selected_page_name = page_name + + self.button_go.setVisible(True) + self.update_swarp_json() + for label in self.additional_labels: + label.deleteLater() + self.additional_labels.clear() + if protocol == "system-wide": + + label_principal = QLabel(self) + label_principal.setGeometry(0, 60, 540, 460) + pixmap = QPixmap(os.path.join(self.btn_path, "wireguard.png")) + label_principal.setPixmap(pixmap) + label_principal.show() + label_principal.setScaledContents(True) + label_principal.lower() + self.additional_labels.append(label_principal) + + if protocol == "browser-only": + label_principal = QLabel(self) + label_principal.setGeometry(0, 80, 530, 380) + pixmap = QPixmap(os.path.join(self.btn_path, "wireguard.png")) + label_principal.setPixmap(pixmap) + label_principal.show() + label_principal.setScaledContents(True) + + label_principal.show() + self.additional_labels.append(label_principal) + + label_background = QLabel(self) + label_background.setGeometry(0, 60, 540, 460) + pixmap = QPixmap(os.path.join(self.btn_path, "browser only.png")) + label_background.setPixmap(pixmap) + label_background.show() + label_background.setScaledContents(True) + label_background.lower() + self.additional_labels.append(label_background) + + def go_selected(self): + if self.selected_page_name: + self.custom_window.navigator.navigate("location") + + self.display.clear() + for boton in self.buttons: + boton.setChecked(False) + + self.button_go.setVisible(False) + + def reverse(self): + self.display.clear() + for boton in self.buttons: + boton.setChecked(False) + + self.button_go.setVisible(False) + self.custom_window.navigator.navigate("protocol") diff --git a/gui/v2/ui/popups/__init__.py b/gui/v2/ui/popups/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/gui/v2/ui/popups/__pycache__/__init__.cpython-312.pyc b/gui/v2/ui/popups/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..47540042c0371277a63dacf2dcb60bc0a182a07e GIT binary patch literal 163 zcmX@j%ge<81njmdSs?l`h(HIQS%4zb87dhx8U0o=6fpsLpFwJVIq7HQ=cekHq^9e; zlYX3W>lsWCF+)?X6ERomuBjNaG8-lkSxeAC@m<~kB`sH%PfhH*DI*J g#bJ}1pHiBWYFESxG?EdBi$RQ!%#4hTMa)1J04FIYAOHXW literal 0 HcmV?d00001 diff --git a/gui/v2/ui/popups/__pycache__/confirmation_popup.cpython-312.pyc b/gui/v2/ui/popups/__pycache__/confirmation_popup.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1df0752891fe7a8b845bd5819b7039f7f3a3dd5f GIT binary patch literal 5739 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 literal 0 HcmV?d00001 diff --git a/gui/v2/ui/popups/__pycache__/endpoint_verification_popup.cpython-312.pyc b/gui/v2/ui/popups/__pycache__/endpoint_verification_popup.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7e16c724fd2d7e01810dd2e237056276b775b5b1 GIT binary patch literal 7572 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 literal 0 HcmV?d00001 diff --git a/gui/v2/ui/popups/__pycache__/ticket_data_loss_popup.cpython-312.pyc b/gui/v2/ui/popups/__pycache__/ticket_data_loss_popup.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d2e6410faac805fa4645904ccd3ae37433bad2ba GIT binary patch literal 14407 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 XOObz}0{89DSWE{Ncm9?_DW>(m39Ggf literal 0 HcmV?d00001 diff --git a/gui/v2/ui/popups/confirmation_popup.py b/gui/v2/ui/popups/confirmation_popup.py new file mode 100755 index 0000000..6ac12f5 --- /dev/null +++ b/gui/v2/ui/popups/confirmation_popup.py @@ -0,0 +1,91 @@ +from PyQt6.QtWidgets import ( + QWidget, QPushButton, QLabel, QFrame, QVBoxLayout, QHBoxLayout, QScrollArea +) +from PyQt6.QtGui import QFont +from PyQt6.QtCore import Qt, pyqtSignal + +from gui.v2.ui.styles.styles import ( + POPUP_ACTION_BUTTON_RED_QSS, + POPUP_BG_QSS, + POPUP_CANCEL_BUTTON_QSS, + POPUP_CLOSE_BUTTON_QSS, +) + + +class ConfirmationPopup(QWidget): + finished = pyqtSignal(bool) + + def __init__(self, parent=None, message="", action_button_text="", cancel_button_text="Cancel"): + super().__init__(parent) + self.parent_window = parent + self.message = message + self.action_button_text = action_button_text + self.cancel_button_text = cancel_button_text + self.initUI() + + def initUI(self): + self.setMinimumSize(400, 200) + self.setWindowFlags(Qt.WindowType.FramelessWindowHint) + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + + main_layout = QVBoxLayout() + self.setLayout(main_layout) + + bg_widget = QWidget(self) + bg_widget.setStyleSheet(POPUP_BG_QSS) + main_layout.addWidget(bg_widget) + + content_layout = QVBoxLayout(bg_widget) + + close_button = QPushButton("✕", self) + close_button.setStyleSheet(POPUP_CLOSE_BUTTON_QSS) + close_button.setFixedSize(30, 30) + close_button.clicked.connect(self.close) + content_layout.addWidget( + close_button, alignment=Qt.AlignmentFlag.AlignRight) + + scroll_area = QScrollArea() + scroll_area.setWidgetResizable(True) + scroll_area.setFrameShape(QFrame.Shape.NoFrame) + content_layout.addWidget(scroll_area) + + scroll_content = QWidget() + scroll_layout = QVBoxLayout(scroll_content) + + message_label = QLabel(self.message) + message_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + message_label.setFont(QFont("Arial", 14)) + message_label.setStyleSheet("color: #333333; margin: 10px 0;") + message_label.setWordWrap(True) + scroll_layout.addWidget(message_label) + + scroll_area.setWidget(scroll_content) + + button_layout = QHBoxLayout() + content_layout.addLayout(button_layout) + + cancel_button = QPushButton(self.cancel_button_text) + cancel_button.setFixedSize(150, 50) + cancel_button.setFont(QFont("Arial", 12)) + cancel_button.setStyleSheet(POPUP_CANCEL_BUTTON_QSS) + cancel_button.clicked.connect(self.close) + button_layout.addWidget(cancel_button) + + action_button = QPushButton(self.action_button_text) + action_button.setFixedSize(150, 50) + action_button.setFont(QFont("Arial", 12)) + action_button.setStyleSheet(POPUP_ACTION_BUTTON_RED_QSS) + action_button.clicked.connect(self.perform_action) + button_layout.addWidget(action_button) + + def perform_action(self): + self.finished.emit(True) + self.close() + + def mousePressEvent(self, event): + self.oldPos = event.globalPosition().toPoint() + + def mouseMoveEvent(self, event): + delta = event.globalPosition().toPoint() - self.oldPos + self.move(self.x() + delta.x(), self.y() + delta.y()) + self.oldPos = event.globalPosition().toPoint() diff --git a/gui/v2/ui/popups/endpoint_verification_popup.py b/gui/v2/ui/popups/endpoint_verification_popup.py new file mode 100755 index 0000000..613f184 --- /dev/null +++ b/gui/v2/ui/popups/endpoint_verification_popup.py @@ -0,0 +1,122 @@ +from PyQt6.QtWidgets import ( + QWidget, QPushButton, QLabel, QFrame, QVBoxLayout, QHBoxLayout, QScrollArea +) +from PyQt6.QtGui import QFont +from PyQt6.QtCore import Qt, pyqtSignal + +from gui.v2.ui.styles.styles import ( + POPUP_BG_QSS, + POPUP_CANCEL_BUTTON_QSS, + POPUP_CLOSE_BUTTON_QSS, +) + + +class EndpointVerificationPopup(QWidget): + finished = pyqtSignal(bool, str) + + def __init__(self, parent=None, message=""): + super().__init__(parent) + self.parent_window = parent + self.message = message + self.initUI() + + def initUI(self): + self.setMinimumSize(500, 250) + self.setWindowFlags(Qt.WindowType.FramelessWindowHint) + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + + main_layout = QVBoxLayout() + self.setLayout(main_layout) + + bg_widget = QWidget(self) + bg_widget.setStyleSheet(POPUP_BG_QSS) + main_layout.addWidget(bg_widget) + + content_layout = QVBoxLayout(bg_widget) + + close_button = QPushButton("✕", self) + close_button.setStyleSheet(POPUP_CLOSE_BUTTON_QSS) + close_button.setFixedSize(30, 30) + close_button.clicked.connect(lambda: self.close_with_action("abort")) + content_layout.addWidget( + close_button, alignment=Qt.AlignmentFlag.AlignRight) + + scroll_area = QScrollArea() + scroll_area.setWidgetResizable(True) + scroll_area.setFrameShape(QFrame.Shape.NoFrame) + content_layout.addWidget(scroll_area) + + scroll_content = QWidget() + scroll_layout = QVBoxLayout(scroll_content) + + message_label = QLabel(self.message) + message_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + message_label.setFont(QFont("Arial", 14)) + message_label.setStyleSheet("color: #333333; margin: 10px 0;") + message_label.setWordWrap(True) + scroll_layout.addWidget(message_label) + + scroll_area.setWidget(scroll_content) + + button_layout = QHBoxLayout() + content_layout.addLayout(button_layout) + + sync_button = QPushButton("Sync") + sync_button.setFixedSize(120, 50) + sync_button.setFont(QFont("Arial", 12)) + sync_button.setStyleSheet(""" + QPushButton { + background-color: #4CAF50; + border: none; + color: white; + border-radius: 5px; + font-weight: bold; + } + QPushButton:hover { + background-color: #45a049; + } + """) + sync_button.clicked.connect(lambda: self.close_with_action("sync")) + button_layout.addWidget(sync_button) + + abort_button = QPushButton("Abort") + abort_button.setFixedSize(120, 50) + abort_button.setFont(QFont("Arial", 12)) + abort_button.setStyleSheet(POPUP_CANCEL_BUTTON_QSS) + abort_button.clicked.connect(lambda: self.close_with_action("abort")) + button_layout.addWidget(abort_button) + + continue_button = QPushButton("Continue Anyway") + continue_button.setFixedSize(150, 50) + continue_button.setFont(QFont("Arial", 12)) + continue_button.setStyleSheet(""" + QPushButton { + background-color: #ff9800; + border: none; + color: white; + border-radius: 5px; + font-weight: bold; + } + QPushButton:hover { + background-color: #fb8c00; + } + """) + continue_button.clicked.connect( + lambda: self.close_with_action("continue")) + button_layout.addWidget(continue_button) + + def close_with_action(self, action): + self.finished.emit(True, action) + self.close() + + def closeEvent(self, event): + self.finished.emit(False, "abort") + event.accept() + + def mousePressEvent(self, event): + self.oldPos = event.globalPosition().toPoint() + + def mouseMoveEvent(self, event): + delta = event.globalPosition().toPoint() - self.oldPos + self.move(self.x() + delta.x(), self.y() + delta.y()) + self.oldPos = event.globalPosition().toPoint() diff --git a/gui/v2/ui/popups/qrcode_dialog.py b/gui/v2/ui/popups/qrcode_dialog.py new file mode 100755 index 0000000..984ae25 --- /dev/null +++ b/gui/v2/ui/popups/qrcode_dialog.py @@ -0,0 +1,105 @@ +from io import BytesIO + +import qrcode + +from PyQt6.QtWidgets import QDialog, QLabel, QPushButton, QVBoxLayout +from PyQt6.QtGui import QPixmap +from PyQt6.QtCore import Qt + + +class QRCodeDialog(QDialog): + def __init__(self, address_text, full_amount, currency_type=None, parent=None): + super().__init__(parent) + self.currency_type = currency_type + self.setWindowTitle("Payment Address QR Code") + self.setFixedSize(400, 450) + self.setModal(True) + + layout = QVBoxLayout(self) + + title_label = QLabel("Scan QR Code to Copy Address", self) + title_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + title_label.setStyleSheet( + "font-size: 16px; font-weight: bold; color: #00ffff; margin: 10px;") + layout.addWidget(title_label) + + qr_label = QLabel(self) + qr_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + qr_label.setStyleSheet( + "margin: 10px; border: 2px solid #00ffff; border-radius: 10px;") + + qr_pixmap = self.generate_qr_code(address_text, full_amount) + qr_label.setPixmap(qr_pixmap) + layout.addWidget(qr_label) + + amount_label = QLabel("Amount:", self) + amount_label.setStyleSheet( + "font-size: 12px; color: #ffffff; margin-top: 10px;") + layout.addWidget(amount_label) + + amount_text_label = QLabel(full_amount, self) + amount_text_label.setStyleSheet( + "font-size: 10px; color: #cccccc; margin: 5px; padding: 5px; border: 1px solid #666; border-radius: 5px;") + layout.addWidget(amount_text_label) + + address_label = QLabel("Address:", self) + address_label.setStyleSheet( + "font-size: 12px; color: #ffffff; margin-top: 10px;") + layout.addWidget(address_label) + + address_text_label = QLabel(address_text, self) + address_text_label.setWordWrap(True) + address_text_label.setStyleSheet( + "font-size: 10px; color: #cccccc; margin: 5px; padding: 5px; border: 1px solid #666; border-radius: 5px;") + layout.addWidget(address_text_label) + + close_button = QPushButton("Close", self) + close_button.setStyleSheet( + "font-size: 14px; padding: 8px; margin: 10px;") + close_button.clicked.connect(self.close) + layout.addWidget(close_button) + + self.setStyleSheet(""" + QDialog { + background-color: #2c3e50; + border: 2px solid #00ffff; + border-radius: 10px; + } + """) + + def generate_qr_code(self, text, full_amount): + qr = qrcode.QRCode( + version=1, + error_correction=qrcode.constants.ERROR_CORRECT_L, + box_size=8, + border=4, + ) + + if self.currency_type: + currency_lower = self.currency_type.lower() + if currency_lower == 'bitcoin': + qr_data = f"bitcoin:{text}?amount={full_amount}" + elif currency_lower == 'monero': + qr_data = f"monero:{text}?amount={full_amount}" + elif currency_lower == 'lightning': + qr_data = f"bitcoin:{text}?amount={full_amount}&lightning=ln" + elif currency_lower == 'litecoin': + qr_data = f"litecoin:{text}?amount={full_amount}" + else: + qr_data = f"{text}?amount={full_amount}" + else: + qr_data = text + + qr.add_data(qr_data) + qr.make(fit=True) + + qr_image = qr.make_image(fill_color="black", back_color="white") + + buffer = BytesIO() + qr_image.save(buffer, format='PNG') + buffer.seek(0) + + pixmap = QPixmap() + pixmap.loadFromData(buffer.getvalue()) + + return pixmap.scaled(150, 150, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) diff --git a/gui/v2/ui/popups/ticket_data_loss_popup.py b/gui/v2/ui/popups/ticket_data_loss_popup.py new file mode 100755 index 0000000..4e24f98 --- /dev/null +++ b/gui/v2/ui/popups/ticket_data_loss_popup.py @@ -0,0 +1,232 @@ +from PyQt6.QtWidgets import ( + QApplication, QDialog, QWidget, QPushButton, QLabel, QVBoxLayout, QHBoxLayout +) +from PyQt6.QtGui import QFont, QGuiApplication +from PyQt6.QtCore import Qt, QTimer + + +class TicketDataLossPopup(QDialog): + def __init__(self, parent=None, ticket_number="", billing_code=""): + super().__init__(parent) + self.parent_window = parent + self._use_parent_local_geometry = QGuiApplication.platformName().lower().startswith("wayland") + self.ticket_number = str(ticket_number) + self.billing_code = str(billing_code) + self.initUI() + + def initUI(self): + self.setFixedSize(640, 380) + if self._use_parent_local_geometry: + if self.parent_window is not None: + self.setParent(self.parent_window) + self.setWindowFlags(Qt.WindowType.Widget) + else: + self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint) + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) + + outer = QVBoxLayout(self) + outer.setContentsMargins(0, 0, 0, 0) + + bg = QWidget(self) + bg.setStyleSheet(""" + background-color: white; + border-radius: 12px; + border: 2px solid #ff4d4d; + """) + outer.addWidget(bg) + + content = QVBoxLayout(bg) + content.setContentsMargins(24, 10, 24, 14) + content.setSpacing(8) + + top_row = QHBoxLayout() + top_row.setContentsMargins(0, 0, 0, 0) + + header = QLabel("Critical Error") + header.setFont(QFont("Arial", 18, QFont.Weight.Bold)) + header.setStyleSheet( + "color: #ff3333; border: none; background: transparent;") + header.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) + top_row.addWidget(header) + + top_row.addStretch() + + close_button = QPushButton("✕") + close_button.setStyleSheet(""" + QPushButton { + background-color: transparent; + color: #888888; + font-size: 18px; + font-weight: bold; + border: none; + } + QPushButton:hover { color: #ff4d4d; } + """) + close_button.setFixedSize(28, 28) + close_button.clicked.connect(self.close) + top_row.addWidget(close_button) + + content.addLayout(top_row) + + ticket_html = ( + f"" + f"#{self.ticket_number}" + ) + code_html = ( + f"" + f"{self.billing_code}" + ) + + message_html = ( + "
" + f"

There was a serious error. " + f"You used up ticket {ticket_html}, but the subscription code you " + f"received {code_html} is invalid.

" + "

Please contact customer support " + "immediately with this data.

" + "

Do not hit Enable on this " + "profile again until this is resolved — every retry burns another " + "ticket.

" + "
" + ) + + message_label = QLabel(message_html) + message_label.setFont(QFont("Arial", 11)) + message_label.setTextFormat(Qt.TextFormat.RichText) + message_label.setWordWrap(True) + message_label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop) + message_label.setStyleSheet("border: none; background: transparent;") + content.addWidget(message_label, 1) + + copy_row = QHBoxLayout() + copy_row.addStretch() + + self.copy_button = QPushButton("Copy Billing Code") + self.copy_button.setFixedSize(200, 36) + self.copy_button.setFont(QFont("Arial", 10, QFont.Weight.Bold)) + self.copy_button.setCursor(Qt.CursorShape.PointingHandCursor) + self._copy_button_default_style = """ + QPushButton { + background-color: #ffffff; + border: 2px solid #ff4d4d; + color: #ff4d4d; + border-radius: 6px; + padding: 0 12px; + } + QPushButton:hover { + background-color: #fff0f0; + } + """ + self._copy_button_copied_style = """ + QPushButton { + background-color: #28a745; + border: 2px solid #28a745; + color: white; + border-radius: 6px; + padding: 0 12px; + } + """ + self.copy_button.setStyleSheet(self._copy_button_default_style) + self.copy_button.clicked.connect(self._on_copy_billing_code) + copy_row.addWidget(self.copy_button) + copy_row.addStretch() + content.addLayout(copy_row) + + button_row = QHBoxLayout() + button_row.addStretch() + + action_button = QPushButton("Acknowledge") + action_button.setFixedSize(160, 40) + action_button.setFont(QFont("Arial", 11, QFont.Weight.Bold)) + action_button.setStyleSheet(""" + QPushButton { + background-color: #ff4d4d; + border: none; + color: white; + border-radius: 6px; + font-weight: bold; + } + QPushButton:hover { background-color: #ff3333; } + """) + action_button.clicked.connect(self._on_acknowledge) + button_row.addWidget(action_button) + + button_row.addStretch() + content.addLayout(button_row) + + if not self._use_parent_local_geometry: + self.setWindowModality(Qt.WindowModality.ApplicationModal) + + def _on_acknowledge(self): + self.accept() + + def _on_copy_billing_code(self): + QApplication.clipboard().setText(self.billing_code) + self.copy_button.setText("Copied!") + self.copy_button.setStyleSheet(self._copy_button_copied_style) + QTimer.singleShot(1500, self._reset_copy_button) + + def _reset_copy_button(self): + self.copy_button.setText("Copy Billing Code") + self.copy_button.setStyleSheet(self._copy_button_default_style) + + def show(self): + self._center_on_parent() + super().show() + + def showEvent(self, event): + super().showEvent(event) + self._center_on_parent() + QTimer.singleShot(0, self._center_on_parent) + + def _center_on_parent(self): + parent = self.parent_window + if parent is None: + return + try: + if not hasattr(parent, 'mapToGlobal') or not hasattr(parent, 'rect'): + return + parent_local_rect = parent.rect() + if parent_local_rect.isEmpty(): + return + my_size = self.size() + if my_size.isEmpty() or my_size.width() <= 0 or my_size.height() <= 0: + my_size = self.sizeHint() + if self._use_parent_local_geometry: + target_x = parent_local_rect.x() + (parent_local_rect.width() - my_size.width()) // 2 + target_y = parent_local_rect.y() + (parent_local_rect.height() - my_size.height()) // 2 + target_x = max(parent_local_rect.left(), min(target_x, parent_local_rect.right() - my_size.width() + 1)) + target_y = max(parent_local_rect.top(), min(target_y, parent_local_rect.bottom() - my_size.height() + 1)) + else: + parent_center = parent.mapToGlobal(parent_local_rect.center()) + target_x = parent_center.x() - my_size.width() // 2 + target_y = parent_center.y() - my_size.height() // 2 + screen = None + if hasattr(parent, 'screen'): + try: + screen = parent.screen() + except Exception: + screen = None + if screen is not None: + avail = screen.availableGeometry() + target_x = max(avail.left(), min(target_x, avail.right() - my_size.width())) + target_y = max(avail.top(), min(target_y, avail.bottom() - my_size.height())) + self.setGeometry(target_x, target_y, my_size.width(), my_size.height()) + if self._use_parent_local_geometry: + self.raise_() + self.setFocus(Qt.FocusReason.PopupFocusReason) + except Exception: + pass + + def mousePressEvent(self, event): + self.oldPos = event.globalPosition().toPoint() + + def mouseMoveEvent(self, event): + delta = event.globalPosition().toPoint() - self.oldPos + self.move(self.x() + delta.x(), self.y() + delta.y()) + self.oldPos = event.globalPosition().toPoint() diff --git a/gui/v2/ui/styles/__init__.py b/gui/v2/ui/styles/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/gui/v2/ui/styles/__pycache__/__init__.cpython-312.pyc b/gui/v2/ui/styles/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8135f699554622de385c9023f998a03652b7eae0 GIT binary patch literal 163 zcmX@j%ge<81SwCovOx4>5P=Rpvj9b=GgLBYGWxA#C}INgK7-W!a?;Pp&rQ`YNln*x z$xklLP0cGQ)-Nv5&8SQ%O4Kb&&CJnFFU`~k;W8tAAX!{enUh+qA0MBYmst`YuUAlc gi^C>2KczG$)vkyYXe1*L7lRldnHd=wi3t^Yr*jfu`I?<5bl|bD;4TUXtdeM}T zHn1e&A=Sm4kh-#gboB<#z)_PvV7D9)G1Z~;yM&o z!G;YhTI^#>vo#EHw6JaCUN|{32%DN4YR?ThDZ){38Q%>X_ix~X{5?+@s-ju;a}9M= zn+ZS8P^&q&g>(4K1@bnuQ0^^YSfBx55{3lKvP)Mo$#EMJELz&R3Usth6@KkPS0xEB z*kc^-!q)xcW-An7+Brn003z)xrv**l5=Q5cik2il&wgx2?V7& z@%gxgbrgO>K@i)zLI-JfgYAya-)?WCZQmB897_F^u5oMW>iI8$uZN|ss=O%TQwGe*kr54?p%jk>0^TQL(mWCt zfhU0nlC%L)#0IddAmAm>vLMJ=x;GPvhY0c%1OR+xAULSmm_3J(A#{%%B0-{{)V~?i z(8a|tETI!1lpu4&K%~-JES6n;>=e41m&q?QGddDUA>|<)H#~3L?slp0`3|!vL-&UE ziRiFvrXLt%I}i;l!GUNRuW=xHu1v5RB~;Jq&)gb+F+*V0wCpoowa5|?{{1Z5#XU7^ ztDWosYJ z>h*ok+LA9XR7>Rwt626Fg;ISFhWcL|m7fr6Za5~P4|AGylh9pcKIvgJ(A7`P1$ZTi zvUxe{Tuycb(n&cj129`6#_G@p{&oz4bo4E;=i>mf^kx_YdK| M{Ljh?pK+)756DR5DgXcg literal 0 HcmV?d00001 diff --git a/gui/v2/ui/styles/styles.py b/gui/v2/ui/styles/styles.py new file mode 100755 index 0000000..3b8137d --- /dev/null +++ b/gui/v2/ui/styles/styles.py @@ -0,0 +1,147 @@ +SCROLLBAR_CYAN_QSS = """ + QScrollArea { + background-color: transparent; + border: none; + } + QScrollBar:vertical { + background-color: rgba(128, 128, 128, 0.3); + width: 12px; + border-radius: 6px; + } + QScrollBar::handle:vertical { + background-color: rgba(0, 255, 255, 0.7); + border-radius: 6px; + min-height: 20px; + } + QScrollBar::handle:vertical:hover { + background-color: rgba(0, 255, 255, 0.9); + } + QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { + background-color: transparent; + height: 0px; + } +""" + + +SCROLLBAR_WHITE_QSS = """ + QScrollArea { + background: transparent; + border: none; + } + QScrollBar:vertical { + border: 1px solid white; + background: white; + width: 10px; + margin: 0px 0px 0px 0px; + } + QScrollBar::handle:vertical { + background: cyan; + min-height: 0px; + } + QScrollBar::add-line:vertical { + height: 0px; + subcontrol-position: bottom; + subcontrol-origin: margin; + } + QScrollBar::sub-line:vertical { + height: 0px; + subcontrol-position: top; + subcontrol-origin: margin; + } +""" + + +TERMINAL_LIST_QSS = """ + QListWidget { + background-color: #000000; + color: #00ffff; + border: 2px solid #00ffff; + border-radius: 8px; + font-size: 14px; + } + QListWidget::item { + padding: 12px; + border-bottom: 1px solid #003333; + } + QListWidget::item:hover { background-color: #003333; } + QListWidget::item:selected { background-color: #006666; color: white; } +""" + + +POPUP_BG_QSS = """ + background-color: white; + border-radius: 10px; +""" + + +POPUP_CLOSE_BUTTON_QSS = """ + QPushButton { + background-color: transparent; + color: #888888; + font-size: 16px; + font-weight: bold; + border: none; + } + QPushButton:hover { + color: #ff4d4d; + } +""" + + +POPUP_CANCEL_BUTTON_QSS = """ + QPushButton { + background-color: #e0e0e0; + border: none; + color: #333333; + border-radius: 5px; + font-weight: bold; + } + QPushButton:hover { + background-color: #d0d0d0; + } +""" + + +POPUP_ACTION_BUTTON_RED_QSS = """ + QPushButton { + background-color: #ff4d4d; + border: none; + color: white; + border-radius: 5px; + font-weight: bold; + } + QPushButton:hover { + background-color: #ff3333; + } +""" + + +def checkbox_style(font_style=""): + return f""" + QCheckBox {{ + color: white; + spacing: 10px; + margin-top: 10px; + margin-bottom: 10px; + font-size: 14px; + {font_style} + }} + QCheckBox::indicator {{ + width: 18px; + height: 18px; + border: 2px solid #666666; + border-radius: 3px; + background-color: #2a2a2a; + }} + QCheckBox::indicator:checked {{ + background-color: #4CAF50; + border: 2px solid #4CAF50; + }} + QCheckBox::indicator:unchecked {{ + background-color: #2a2a2a; + border: 2px solid #666666; + }} + QCheckBox::indicator:hover {{ + border: 2px solid #888888; + }} + """ diff --git a/gui/v2/ui/widgets/__init__.py b/gui/v2/ui/widgets/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/gui/v2/ui/widgets/__pycache__/__init__.cpython-312.pyc b/gui/v2/ui/widgets/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0dfcc26d9850553d1f19a154ec7608ecba30d96e GIT binary patch literal 164 zcmX@j%ge<81nkBtSs?l`h(HIQS%4zb87dhx8U0o=6fpsLpFwJVIqPTS=cekHq^9e; zlYX3W>lsWCF+)?X6ERomuBjNaG8-lkSxzkNlyi;iI30B%PfhH*DI*J g#bJ}1pHiBWYFESxG?NjCi$RQ!%#4hTMa)1J08Bb3X8-^I literal 0 HcmV?d00001 diff --git a/gui/v2/ui/widgets/__pycache__/clickable_label.cpython-312.pyc b/gui/v2/ui/widgets/__pycache__/clickable_label.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b9f7f9d96b2518fffc46392ce53d2e7e0a6271bc GIT binary patch literal 5003 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 Yt(R#0C7Sv!ILExb{oC&m49?#F0b;A-oB#j- literal 0 HcmV?d00001 diff --git a/gui/v2/ui/widgets/clickable_label.py b/gui/v2/ui/widgets/clickable_label.py new file mode 100755 index 0000000..cf4e7dd --- /dev/null +++ b/gui/v2/ui/widgets/clickable_label.py @@ -0,0 +1,68 @@ +from PyQt6.QtWidgets import QLabel, QApplication +from PyQt6.QtGui import QPainter, QColor +from PyQt6.QtCore import Qt, QTimer, pyqtSignal + + +class ClickableLabel(QLabel): + clicked = pyqtSignal() + + def __init__(self, text, parent=None, **kwargs): + super().__init__(text, parent, **kwargs) + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.hover_width = self.width() + self.is_hovered = False + + def mousePressEvent(self, event): + self.clicked.emit() + super().mousePressEvent(event) + + def enterEvent(self, event): + self.is_hovered = True + self.update() + super().enterEvent(event) + + def leaveEvent(self, event): + self.is_hovered = False + self.update() + super().leaveEvent(event) + + def set_hover_width(self, width): + self.hover_width = width + self.update() + + def paintEvent(self, event): + super().paintEvent(event) + if self.is_hovered: + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + hover_color = QColor(255, 255, 255, 25) + + center_x = self.width() // 2 + start_x = center_x - (self.hover_width // 2) + painter.fillRect(start_x, 0, self.hover_width, + self.height(), hover_color) + painter.end() + + +class ClickableValueLabel(QLabel): + def __init__(self, text: str, parent=None): + super().__init__(text, parent) + self.setObjectName("value_label") + self.setStyleSheet( + "color: #00ffff; font-size: 13px; font-weight: bold;") + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.timer = QTimer(self) + self.timer.setSingleShot(True) + self.timer.timeout.connect(self.reset_style) + self.default_style = "color: #00ffff; font-size: 13px; font-weight: bold;" + self.success_style = "color: #00ff00; font-size: 13px; font-weight: bold;" + + def mousePressEvent(self, event): + if event.button() == Qt.MouseButton.LeftButton: + clipboard = QApplication.clipboard() + clipboard.setText(self.text()) + self.setStyleSheet(self.success_style) + self.timer.start(100) + + def reset_style(self): + self.setStyleSheet(self.default_style) diff --git a/gui/v2/ui/widgets/confetti.py b/gui/v2/ui/widgets/confetti.py new file mode 100755 index 0000000..0021a4d --- /dev/null +++ b/gui/v2/ui/widgets/confetti.py @@ -0,0 +1,50 @@ +import random + +from PyQt6.QtGui import QColor +from PyQt6.QtCore import QThread, QMutex, QMutexLocker, pyqtSignal + + +class ConfettiParticle: + def __init__(self, x, y): + self.x = x + self.y = y + self.color = QColor(random.randint(0, 255), random.randint( + 0, 255), random.randint(0, 255)) + self.size = random.randint(5, 15) + self.speed = random.uniform(1, 3) + + def update(self): + self.y += self.speed + + +class ConfettiThread(QThread): + update_signal = pyqtSignal(list) + + def __init__(self, width, height): + super().__init__() + self.width = width + self.height = height + self.running = True + self.mutex = QMutex() + self.confetti = [ConfettiParticle(random.randint( + 0, width), random.randint(-height, 0)) for _ in range(100)] + + def run(self): + while self.running: + with QMutexLocker(self.mutex): + for particle in self.confetti: + particle.update() + if particle.y > self.height: + particle.y = random.randint(-100, 0) + particle.x = random.randint(0, self.width) + + self.update_signal.emit(self.confetti.copy()) + self.msleep(16) + + def stop(self): + self.running = False + + def update_dimensions(self, width, height): + with QMutexLocker(self.mutex): + self.width = width + self.height = height diff --git a/gui/v2/ui/widgets/terminal_widget.py b/gui/v2/ui/widgets/terminal_widget.py new file mode 100755 index 0000000..abcf924 --- /dev/null +++ b/gui/v2/ui/widgets/terminal_widget.py @@ -0,0 +1,52 @@ +from PyQt6.QtWidgets import QPlainTextEdit +from PyQt6.QtGui import QFont +from PyQt6.QtCore import pyqtSignal + + +class TerminalWidget(QPlainTextEdit): + line_appended = pyqtSignal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self.setReadOnly(True) + font = QFont() + font.setFamily("Courier New") + font.setPointSize(11) + font.setWeight(QFont.Weight.Bold) + self.setFont(font) + self.setStyleSheet(""" + QPlainTextEdit { + background-color: #0a0e27; + color: #0fff50; + border: 3px solid #0fff50; + border-radius: 8px; + padding: 10px; + font-family: 'Courier New', 'Consolas', monospace; + font-size: 11pt; + font-weight: bold; + } + """) + self.line_appended.connect(self._append_on_main_thread) + self._observer = None + self._topics = [] + self._callbacks = [] + + def _append_on_main_thread(self, text): + self.appendPlainText(text) + + def bind_observer(self, observer, topics): + self._observer = observer + self._topics = list(topics) + for topic in self._topics: + cb = lambda event, t=topic: self.line_appended.emit(self._format_event(t, event)) + observer.subscribe(topic, cb) + self._callbacks.append((topic, cb)) + + def _format_event(self, topic, event): + subject = getattr(event, 'subject', None) + if subject is None: + return f"[{topic}]" + return f"[{topic}] {subject}" + + def append(self, text): + self.line_appended.emit(str(text)) diff --git a/gui/v2/workers/__init__.py b/gui/v2/workers/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/gui/v2/workers/__pycache__/__init__.cpython-312.pyc b/gui/v2/workers/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b0d7edfaf869e2ac23c2ee51f994fa9ef3b05ee0 GIT binary patch literal 161 zcmX@j%ge<81T~XXvq1D?5P=Rpvj9b=GgLBYGWxA#C}INgK7-W!a?sDn&rQ`YNln*x z$xklLP0cGQ)-Nv5&8SQ%O4Kb&&CJnFFU`~k;W8ur^8BLg)S_bj`1s7c%#!$cy@JYH g95%W6DWy57c15f}0~vw17{vI<%*e=C#0+Es07^+GLI3~& literal 0 HcmV?d00001 diff --git a/gui/v2/workers/__pycache__/ticketing_worker_thread.cpython-312.pyc b/gui/v2/workers/__pycache__/ticketing_worker_thread.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..452a06a06b562414942c00c7340f5ce31b91d0d8 GIT binary patch literal 4812 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

bgcSbUrE2!l>c_7ULUN6uoOo<__~ zBkz^S3zBMh7<~>S&<{Z256h*gXkHlQ%C0YF@$ zHrA}Q?*F+h+j;`7%gpF?-ye0Dr7G|1$_%brTJsir&f?)Mo}8tDw={6gy(`A$*SN-k z+ksWf2wdQxX>sQI#l6eDcbodqx6P5WHSxBl#nI*FtgV+b^nUfY0<*gjgVfw}xDbQX ze9a(@^ZS+kF6>j6vA;?EX(J9WQrJtVMM6R3iUrtlh$~@bLoTXYxWXCW98)vR$dw$D zUCp8^xUfIapPb4G91vM0u1C?qp-HO;v6ucC_)QBU5m}020w=4MEeIvnqLkm1N1E3a zovCSE(LJhg4WlMC^P0E1(zWohffh8^zODLVt7~H4x$dj(cfJ zYcAsqteP~h?|BXh`NA~;XSRA?UqV5>Lcw{B=B$9;Kf>6qRRvLwh)=Lt^y^@?kz6?p&9D)+_G@pxcD;Ai)^x+N*50u+nr+{`pjoxIRrjC+tEzwC$6Do(oxzeb)Ii`bXYld{FGp-&K76-loI5biZ4Ypp zpXXk9an*1Rr0`ddyD+C0>FVyay3N;LeRt=A;rI3$P?4eJYX)(_r+D>XtLo>i#v!ft z=iTNZO#2H=3;FV5cpvNo3hz%t%k^nq%@#yAV3ZTN$i82fiboBEdqKPwkwDNEL<0B+ z3sJx{ULtg5MVmH=%0NMrOw>PtU(o6lAq@CPxD@gABwR7YZ@s|-OU+l^Oe=P%5J{0 zds)9yo2?wo=sz=8Elj@m`l{KVCmJ$?zq5GPtgh?&kG!kaZl&Ztgs4Fg@!m&#U&|Y6 zbB21}P@lIr7J`elifzvwI0crxF@Q_9O@S9-Vu(_r8Zn}%5m2x`iE%ZVlS=F0f-P&x zmE&-*aZt>-OfTiI;p)U`6{$*q!U-ODO*xh1n=O|T8%PIg#^1mON`ZWy*QfR6BFyuK z5~W5zU5Kz1sRfFQymnDVH_n^>n))(t{@cv^0*ng^yB0?`z;Ck9{mve|!R5_X)~RfLFQH z;R%;LxR(924~C^TBk&@Cs2wCks-op=S4+IBg(O-I_q81BYZ*^97Wx@4dCy0HjYmj! zCb-F+1)L$t^h6H%l#U?7bBsS_I3b;GLcocX)lVom;HyWU0eD_W#Hb0jk4WjoCY>lV zm7Jv+KPU{MAVQX{Xn)c#qa2dqGo^kO^-w>(3E03jK{0Pg4s z^n{V#4_H2nWc?xwL(oLwofDl#+0sWg;Fp%)JlVt*iXQ(Ea!U(F)KLgcRxqOBq^wDt zt6WS|@o+Rj!W$hYL^tAKaKj^;Vm}NLiA6O-Q8ZFenyQzD+0&%!Zvlzv5;YF+p;mh( zi3S<~7&UioO-sGGj{SVc{#?h%-HwsGWygKj=A5g8cXi}kJ9*d6jOE_u_IKP%(;40U z+SXjHpRe`*Wa_7}AIG>_f3|iYW4rHc`e^Fo*oU#7?E2~6AMee=%PQxAj4|)@UK@L7 zEMr_FS{LVXZM*okT`SF5Vn5vR8Pmd%`}O`@eJ@|%%k4UvtsmtKp8Mw7_s)E9?nmct zwSUriqciL6y=(5xJ9_V#ZTTwSl5eSQ861;)ILn^@GFRoRyzfO8x4fI=y#A%|vTo_} z@;o+3_i&jqIL9u|vP+b@ z^l6}-4(5tiIm{dcfyGFYFH0P7an8-TPJtmsJ zC>Th0ArL@7RA~>Q+yDao2%y^u0uA7BiALfSJC!79w8w%~ep?lxVzE#N)O0a)0DFoZ zkW^0OoS+eCgg*w|d|zF~?tvD_EDkO;EiuajS?8`R$L>{nu8%J^XDi#T9KGjiSZrEk zvaSwJ<9w_!YFi#wkKo!c{^*Pf*Y16&vuNE9J!b8$hqVn_=fh##stw=|He^@Rb$_x&F0`%-1XRbOHN gz7RnThVTATrNvc`FaVG3!)knY=7ldX^ec|_fArX)djJ3c literal 0 HcmV?d00001 diff --git a/gui/v2/workers/__pycache__/worker_thread.cpython-312.pyc b/gui/v2/workers/__pycache__/worker_thread.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8a56a88befe1560cc8585e491909e9e1ee0eaf62 GIT binary patch literal 19434 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 SokJ$jY#tu^HANz%wEqVKD+7rD literal 0 HcmV?d00001 diff --git a/gui/v2/workers/ticketing_worker_thread.py b/gui/v2/workers/ticketing_worker_thread.py new file mode 100755 index 0000000..524ee6f --- /dev/null +++ b/gui/v2/workers/ticketing_worker_thread.py @@ -0,0 +1,83 @@ +from PyQt6.QtCore import QThread, pyqtSignal + +from core.controllers.tickets.TicketSyncController import sync_ticket_prices +from core.controllers.tickets.TicketPayController import initiate_payment, check_if_paid +from core.controllers.tickets.TicketPrepController import prepare_tickets +from core.controllers.tickets.FailedVerificationController import ( + evaluate_if_its_the_key, + prepare_tickets_with_saved_blind_sigs, +) +from core.controllers.tickets.UseTicketController import use_ticket + +from gui.v2.infrastructure.setup_observers import ( + connection_observer, + ticket_observer, +) + + +class TicketingWorkerThread(QThread): + sync_done = pyqtSignal(object) + invoice_ready = pyqtSignal(object) + paid = pyqtSignal() + not_paid = pyqtSignal() + paid_check_failed = pyqtSignal(str) + prep_done = pyqtSignal(object) + use_done = pyqtSignal(object) + failed_verification_evaluated = pyqtSignal(object) + saved_blind_prep_done = pyqtSignal(object) + error = pyqtSignal(str) + + def __init__(self, action, params=None): + super().__init__() + self.action = action + self.params = params or {} + + def run(self): + try: + if self.action == 'SYNC_PRICES': + result = sync_ticket_prices(ticket_observer, connection_observer) + self.sync_done.emit(result) + elif self.action == 'INITIATE_PAYMENT': + result = initiate_payment( + self.params['how_many_profiles'], + self.params['which_key'], + self.params['which_cryptocurrency'], + ticket_observer, + connection_observer, + self.params.get('bypass_existing', False), + ) + self.invoice_ready.emit(result) + elif self.action == 'CHECK_PAID': + result = check_if_paid(self.params['temp_billing_code'], ticket_observer, connection_observer) + if isinstance(result, dict) and result.get('valid') and result.get('payment_status') == 'paid': + self.paid.emit() + elif isinstance(result, dict) and result.get('valid'): + self.not_paid.emit() + else: + err = 'unknown' + if isinstance(result, dict): + err = str(result.get('error_code') or result.get('message') or 'unknown') + self.paid_check_failed.emit(err) + elif self.action == 'PREPARE_TICKETS': + result = prepare_tickets(self.params['how_many_profiles'], ticket_observer, connection_observer) + self.prep_done.emit(result) + elif self.action == 'EVALUATE_FAILED_VERIFICATION': + result = evaluate_if_its_the_key( + self.params['failed_validations'], + ticket_observer, + connection_observer, + ) + self.failed_verification_evaluated.emit(result) + elif self.action == 'PREPARE_SAVED_BLIND_SIGS': + result = prepare_tickets_with_saved_blind_sigs(ticket_observer, connection_observer) + self.saved_blind_prep_done.emit(result) + elif self.action == 'USE_TICKET': + result = use_ticket( + self.params['which_ticket'], + self.params['which_location'], + ticket_observer, + connection_observer, + ) + self.use_done.emit(result) + except Exception as e: + self.error.emit(str(e)) diff --git a/gui/v2/workers/worker.py b/gui/v2/workers/worker.py new file mode 100755 index 0000000..0415579 --- /dev/null +++ b/gui/v2/workers/worker.py @@ -0,0 +1,232 @@ +from datetime import datetime, timezone + +from PyQt6.QtCore import QObject, pyqtSignal + +from core.controllers.ProfileController import ProfileController +from core.controllers.SubscriptionController import SubscriptionController +from core.controllers.tickets.UseTicketController import ( + use_ticket, + do_we_use_a_random_ticket, +) +from core.models.session.SessionProfile import SessionProfile +from core.models.system.SystemProfile import SystemProfile +from core.Errors import ( + CommandNotFoundError, + EndpointVerificationError, + FileIntegrityError, + InvalidSubscriptionError, + MissingSubscriptionError, + ProfileActivationError, + ProfileModificationError, + ProfileStateConflictError, + UnsupportedApplicationVersionError, +) + +from gui.v2.actions.locations import location_candidates +from gui.v2.infrastructure.setup_observers import ( + application_version_observer, + connection_observer, + profile_observer, + ticket_observer, +) + + +class Worker(QObject): + update_signal = pyqtSignal(str, bool, int, int, str) + change_page = pyqtSignal(str, bool) + ticket_data_loss = pyqtSignal(str, str) + + def __init__(self, profile_data): + self.profile_data = profile_data + super().__init__() + profile_observer.subscribe( + 'disabled', lambda event: self.handle_profile_status(event.subject, False)) + profile_observer.subscribe( + 'enabled', lambda event: self.handle_profile_status(event.subject, True)) + self.profile_type = None + self._ticket_error_emitted = False + self._consumed_ticket = None + + def run(self): + self.profile = ProfileController.get(int(self.profile_data['id'])) + if 'use_ticket' in self.profile_data: + ticket_billing_code = self._consume_ticket( + self.profile_data['use_ticket'], + self.profile_data.get('ticket_location')) + if ticket_billing_code is None: + return + self.profile_data['billing_code'] = ticket_billing_code + elif 'billing_code' not in self.profile_data and self.profile is not None: + if not self._profile_has_valid_subscription(): + ticket_billing_code = self._maybe_auto_use_ticket() + if ticket_billing_code is None and self._ticket_error_emitted: + return + if ticket_billing_code: + self.profile_data['billing_code'] = ticket_billing_code + + if 'billing_code' in self.profile_data: + subscription = SubscriptionController.get( + self.profile_data['billing_code'], connection_observer=connection_observer) + if subscription is not None: + ProfileController.attach_subscription( + self.profile, subscription) + else: + if self._consumed_ticket is not None: + self._ticket_error_emitted = True + self.ticket_data_loss.emit( + self._consumed_ticket, + str(self.profile_data.get('billing_code', '')), + ) + return + self.change_page.emit('The billing code is invalid.', True) + return + if self.profile: + try: + ignore_exceptions = [] + if self.profile_data.get('ignore_endpoint_verification', False): + ignore_exceptions.append(EndpointVerificationError) + if self.profile_data.get('ignore_profile_state_conflict', False): + ignore_exceptions.append(ProfileStateConflictError) + ignore_tuple = tuple(ignore_exceptions) + ProfileController.enable(self.profile, ignore=ignore_tuple, profile_observer=profile_observer, + application_version_observer=application_version_observer, + connection_observer=connection_observer) + except EndpointVerificationError: + self.update_signal.emit( + "ENDPOINT_VERIFICATION_ERROR", False, self.profile_data['id'], None, None) + except (InvalidSubscriptionError, MissingSubscriptionError) as e: + self.change_page.emit( + f"Subscription missing or invalid for profile {self.profile_data['id']}", True) + except ProfileActivationError: + self.update_signal.emit( + "The profile could not be enabled", False, None, None, None) + except UnsupportedApplicationVersionError: + self.update_signal.emit( + "The application version in question is not supported", False, None, None, None) + except FileIntegrityError: + self.update_signal.emit( + "Application version file integrity could not be verified.", False, None, None, None) + except ProfileModificationError: + self.update_signal.emit( + "WireGuard configuration could not be attached.", False, None, None, None) + except ProfileStateConflictError: + self.update_signal.emit( + "PROFILE_STATE_CONFLICT_ERROR", False, self.profile_data['id'], None, None) + except CommandNotFoundError as e: + self.update_signal.emit(str(e.subject), False, -1, None, None) + except Exception as e: + print(e) + self.update_signal.emit( + "An unknown error occurred", False, None, None, None) + + else: + self.update_signal.emit( + f"No profile found with ID: {self.profile_data['id']}", False, None, None, None) + + def _location_candidates(self, preferred=None): + return location_candidates(self.profile, preferred) + + def _profile_has_valid_subscription(self): + try: + sub = getattr(self.profile, 'subscription', None) + if not sub: + return False + expires_at = getattr(sub, 'expires_at', None) + if not expires_at: + return False + if expires_at.tzinfo is None: + expires_at = expires_at.replace(tzinfo=timezone.utc) + return expires_at > datetime.now(timezone.utc) + except Exception: + return False + + def _maybe_auto_use_ticket(self): + try: + which_ticket, error_msg = do_we_use_a_random_ticket(ticket_observer) + except Exception: + return None + if error_msg: + self._ticket_error_emitted = True + self.change_page.emit(f'Ticket use failed: {error_msg}', True) + return None + if which_ticket is None or which_ticket == 'error': + return None + return self._consume_ticket(which_ticket, None) + + def _consume_ticket(self, which_ticket, which_location): + candidates = self._location_candidates(preferred=which_location) + if not candidates: + self._ticket_error_emitted = True + self.change_page.emit('Could not determine profile location for ticket use.', True) + return None + + last_msg = None + for cand in candidates: + try: + outcome = use_ticket(which_ticket, cand, ticket_observer, connection_observer) + except Exception as e: + last_msg = str(e) + continue + if not isinstance(outcome, dict): + last_msg = 'invalid_response' + continue + billing_code = outcome.get('billing_code') + if outcome.get('valid') and billing_code: + self._consumed_ticket = str(which_ticket) + return billing_code + if billing_code: + self._consumed_ticket = str(which_ticket) + return billing_code + msg = outcome.get('message', 'failed') + last_msg = msg + if msg != 'invalid_location': + self._ticket_error_emitted = True + self.change_page.emit(f'Ticket use failed: {msg}', True) + return None + + self._ticket_error_emitted = True + self.change_page.emit(f'Ticket use failed: {last_msg or "no valid location"}', True) + return None + + def handle_profile_status(self, profile, is_enabled): + profile_id = profile.id + profile_connection = str(profile.connection.code) + message = self.generate_profile_message(profile, is_enabled) + + if isinstance(profile, SessionProfile): + self.profile_type = 1 + elif isinstance(profile, SystemProfile): + self.profile_type = 2 + else: + self.profile_type = None + + self.update_signal.emit( + message, is_enabled, profile_id, self.profile_type, profile_connection) + + @staticmethod + def generate_profile_message(profile, is_enabled, idle=False): + + profile_id = profile.id + if not profile.subscription or not profile.subscription.expires_at: + return f"Offline. No subscription found." + + profile_date = profile.subscription.expires_at + status = 'enabled' if is_enabled else 'disabled' + + expiration_date = profile_date.replace(tzinfo=timezone.utc) + + time_left = expiration_date - datetime.now(timezone.utc) + days_left = time_left.days + hours_left, remainder = divmod(time_left.seconds, 3600) + + formatted_expiration = expiration_date.strftime("%Y-%m-%d %H:%M:%S") + + if expiration_date < datetime.now(timezone.utc): + return "Offline. Subscription has expired." + if idle: + return f"Offline. Expires in {days_left} days." + + if is_enabled: + return f"Profile {int(profile_id)} {status}. Expires on {formatted_expiration}. Time left: {days_left} days, {hours_left} hours." + else: + return f"Profile {int(profile_id)} {status}" diff --git a/gui/v2/workers/worker_thread.py b/gui/v2/workers/worker_thread.py new file mode 100755 index 0000000..7c7f905 --- /dev/null +++ b/gui/v2/workers/worker_thread.py @@ -0,0 +1,295 @@ +import shlex +import subprocess + +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.InvoiceController import InvoiceController +from core.controllers.LocationController import LocationController +from core.controllers.ProfileController import ProfileController +from core.controllers.SubscriptionController import SubscriptionController +from core.controllers.SubscriptionPlanController import SubscriptionPlanController +from core.models.session.SessionConnection import SessionConnection +from core.models.session.SessionProfile import SessionProfile +from core.models.system.SystemConnection import SystemConnection +from core.models.system.SystemProfile import SystemProfile + +from gui.v2.infrastructure.setup_observers import ( + client_observer, + connection_observer, + invoice_observer, + profile_observer, +) + + +class WorkerThread(QThread): + text_output = pyqtSignal(str) + sync_output = pyqtSignal(list, list, bool, bool, list, list) + invoice_output = pyqtSignal(object, str) + invoice_finished = pyqtSignal(bool) + profiles_output = pyqtSignal(dict) + special_output = pyqtSignal(str) + finished = pyqtSignal(bool) + update_finished = pyqtSignal(dict) + + def __init__(self, action=None, profile_data=None, profile_type=None, package_name=None, package_command=None): + super().__init__() + self.action = action + self.profile_data = profile_data + self.profile_type = profile_type + self.package_name = package_name + self.package_command = package_command + self.is_running = True + self.is_disabling = False + + def run(self): + if self.action == 'LIST_PROFILES': + self.list_profiles() + elif self.action == 'CREATE_SESSION_PROFILE': + self.create_profile(self.profile_type) + elif self.action == 'CREATE_SYSTEM_PROFILE': + self.create_profile(self.profile_type) + elif self.action == 'GET_SUBSCRIPTION': + self.get_subscription() + elif self.action == 'DISABLE_PROFILE': + self.disable_profile() + elif self.action == 'SYNC' or self.action == 'SYNC_TOR': + self.sync() + elif self.action == 'DESTROY_PROFILE': + self.destroy_profile() + elif self.action == 'DISABLE_ALL_PROFILES': + self.disable_all_profiles() + elif self.action == 'INSTALL_PACKAGE': + self.install_package() + elif self.action == 'CHECK_FOR_UPDATE': + self.check_for_update() + elif self.action == 'DOWNLOAD_UPDATE': + self.download_update() + elif self.action == 'CHECK_INVOICE_STATUS': + self.check_invoice_status() + + def check_invoice_status(self): + try: + invoice = InvoiceController.get(self.profile_data['billing_code']) + if invoice: + status = invoice.status + if status == "expired": + self.invoice_finished.emit(False) + else: + self.invoice_finished.emit(True) + else: + self.invoice_finished.emit(False) + except Exception as e: + print(f"Error retrieving invoice: {str(e)}") + self.invoice_finished.emit(False) + + def download_update(self): + self.text_output.emit("Starting update process...") + ClientController.update(client_observer=client_observer) + client_observer.subscribe('update_progressing', lambda event: self.text_output.emit( + f"Downloading: {event.meta.get('progress'):.1f}%")) + client_observer.subscribe( + 'updated', lambda event: self.text_output.emit("Update process completed")) + + def check_for_update(self): + self.text_output.emit("Checking for updates...") + ClientController.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...") + self.finished.emit(True) + else: + self.text_output.emit("No updates available.") + self.finished.emit(False) + + def install_package(self): + try: + self.text_output.emit(f"Installing {self.package_name}...") + subprocess.run(shlex.split(self.package_command), check=True) + self.text_output.emit(f"{self.package_name} installed!") + self.finished.emit(True) + except subprocess.CalledProcessError: + self.text_output.emit("Installation failed") + self.finished.emit(False) + except Exception as e: + self.text_output.emit( + f"An error occurred when installing {self.package_name}: {e}") + self.finished.emit(False) + + def disable_all_profiles(self): + try: + for profile_id in self.profile_data: + profile = ProfileController.get(int(profile_id)) + if isinstance(profile, SessionProfile): + ProfileController.disable( + profile, ignore=True, profile_observer=profile_observer) + for profile_id in self.profile_data: + profile = ProfileController.get(int(profile_id)) + if isinstance(profile, SystemProfile): + ProfileController.disable( + profile, ignore=True, profile_observer=profile_observer) + self.text_output.emit("All profiles were successfully disabled") + except Exception: + self.text_output.emit("An error occurred when disabling profile") + finally: + self.finished.emit(True) + + def destroy_profile(self): + profile_id = int(self.profile_data['id']) + profile = ProfileController.get(profile_id) + + if profile is not None: + ProfileController.destroy(profile) + self.text_output.emit(f'Profile {profile_id} deleted') + else: + self.text_output.emit(f'Profile {profile_id} does not exist') + + def list_profiles(self): + profiles = ProfileController.get_all() + self.profiles_output.emit(profiles) + + def create_profile(self, profile_type): + location = LocationController.get( + self.profile_data['country_code'], self.profile_data['code']) + if location is None: + self.text_output.emit( + f"Invalid location code: {self.profile_data['location_code']}") + return + + profile_id = int( + self.profile_data['id']) if self.profile_data['id'] else None + name = self.profile_data['name'] + connection_type = self.profile_data['connection_type'] + + if profile_type == "session": + + application_details = self.profile_data['application'].split( + ':', 1) + application_version = ApplicationVersionController.get( + application_details[0], application_details[1] if len(application_details) > 1 else None) + if application_version is None: + self.text_output.emit( + f"Invalid application: {self.profile_data['application']}") + return + + mask_connection = True if connection_type == 'tor' or connection_type == 'system' else False + resolution = self.profile_data['resolution'] + connection = SessionConnection(connection_type, mask_connection) + profile = SessionProfile( + profile_id, name, None, location, resolution, application_version, connection) + elif profile_type == "system": + connection = SystemConnection(connection_type) + profile = SystemProfile( + profile_id, name, None, location, connection) + + else: + self.text_output.emit(f"Invalid profile type: {profile_type}") + return + try: + ProfileController.create( + profile, profile_observer=profile_observer) + except Exception as e: + self.text_output.emit( + f"An error occurred when creating profile {profile.id}") + + self.text_output.emit( + f"{profile_type.capitalize()} Profile created with ID: {profile.id}") + + def disable_profile(self): + try: + profile = ProfileController.get(int(self.profile_data['id'])) + if profile: + ProfileController.disable( + profile, profile_observer=profile_observer) + else: + self.text_output.emit( + f"No profile found with ID: {self.profile_data['id']}") + except Exception as e: + self.text_output.emit("An error occurred when disabling profile") + finally: + self.finished.emit(True) + + def sync(self): + try: + if self.action == 'SYNC_TOR': + ConfigurationController.set_connection('tor') + else: + ConfigurationController.set_connection('system') + self.check_for_update() + locations = LocationController.get_all() + browser = ApplicationVersionController.get_all() + all_browser_versions = [ + f"{browser.application_code}:{browser.version_number}" for browser in browser if browser.supported] + all_location_codes = [ + f"{location.country_code}_{location.code}" for location in locations] + self.sync_output.emit( + all_location_codes, all_browser_versions, True, False, locations, browser) + except Exception as e: + print(f'the error is: {e}') + self.sync_output.emit([], [], False, False, [], []) + + def get_connection(self): + connection = ConfigurationController.get_connection() + self.text_output.emit(f"Current connection: {connection}") + + def set_connection(self): + ConfigurationController.set_connection( + self.profile_data['connection_type']) + self.text_output.emit( + f"Connection set to '{self.profile_data['connection_type']}'") + + def get_subscription(self): + try: + invoice_observer.subscribe('retrieved', lambda event: self.handle_event( + event, self.profile_data['currency'])) + invoice_observer.subscribe('processing', lambda event: self.text_output.emit( + 'A payment has been detected and is being verified...')) + invoice_observer.subscribe('settled', lambda event: self.text_output.emit( + 'The payment has been successfully verified.')) + + profile = ProfileController.get(int(self.profile_data['id'])) + subscription_plan = SubscriptionPlanController.get( + profile.connection, self.profile_data['duration']) + if subscription_plan is None: + self.text_output.emit( + 'No compatible subscription plan was found.') + return + potential_subscription = SubscriptionController.create( + subscription_plan, profile, connection_observer=connection_observer) + if potential_subscription is not None: + ProfileController.attach_subscription( + profile, potential_subscription) + else: + self.text_output.emit( + 'The subscription could not be created. Try again later.') + return + + subscription = InvoiceController.handle_payment( + potential_subscription.billing_code, invoice_observer=invoice_observer, connection_observer=connection_observer) + if subscription is not None: + ProfileController.attach_subscription(profile, subscription) + self.text_output.emit( + 'Successfully activated the subscription') + self.invoice_finished.emit(True) + else: + self.text_output.emit( + 'The subscription could not be activated. Try again later.') + self.invoice_finished.emit(False) + + except Exception as e: + self.text_output.emit('An unknown error occurred') + self.invoice_finished.emit(False) + + def handle_connection_events(self, event): + self.text_output.emit(f'Profile disabled') + + def handle_event(self, event, currency=None): + invoice = event.subject + if isinstance(invoice, object): + self.invoice_output.emit(invoice, '') + self.text_output.emit("Invoice generated. Awaiting payment...") + else: + self.text_output.emit("Invalid invoice data received.")