import os import sys _WORKSPACE_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) if _WORKSPACE_ROOT not in sys.path: sys.path.insert(0, _WORKSPACE_ROOT) import logging import traceback from PyQt6.QtWidgets import ( QApplication, QMainWindow, QStackedWidget, QLabel, QPushButton, QDialog, QVBoxLayout, QHBoxLayout, QMessageBox ) from PyQt6.QtGui import QPixmap, QFont, QFontDatabase, QIcon from PyQt6 import QtGui from PyQt6.QtCore import Qt, QTimer, QEvent from core.Constants import Constants from core.controllers.ConfigurationController import ConfigurationController from core.Errors import UnknownConnectionTypeError from core.errors.logger import logger as core_logger core_logger.propagate = False from gui.v2.infrastructure import orm from gui.v2.infrastructure.navigator import Navigator from gui.v2.infrastructure.setup_observers import setup_observers from gui.v2.infrastructure.connection_manager import ConnectionManager from gui.v2.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 from gui.v2.ui.popups.message_box import style_message_box, mark_confirm_button class CustomWindow(QMainWindow): def __init__(self, force_sync=False): super().__init__() sys.excepthook = self._handle_exception self.setWindowFlags(Qt.WindowType.Window) gui_dir = os.path.dirname(os.path.abspath(__file__)) font_path = os.path.join(gui_dir, 'resources', 'fonts') # # Get's SQLAlchemy going, # orm.start() retro_gaming_path = os.path.join(font_path, 'retro-gaming.ttf') font_id = QFontDatabase.addApplicationFont(retro_gaming_path) font_family = QFontDatabase.applicationFontFamilies(font_id)[0] app_font = QFont(font_family) app_font.setPointSize(10) QApplication.setFont(app_font) self.open_sans_family = None open_sans_path = os.path.join(font_path, 'open-sans.ttf') if os.path.exists(open_sans_path): open_sans_id = QFontDatabase.addApplicationFont(open_sans_path) if open_sans_id != -1: self.open_sans_family = QFontDatabase.applicationFontFamilies(open_sans_id)[0] self.setAttribute(Qt.WidgetAttribute.WA_NoSystemBackground) self.setWindowTitle('HydraVeil') screen = QApplication.primaryScreen() ratio = screen.devicePixelRatio() self.host_screen_width = int(screen.size().width() * ratio) self.host_screen_height = int(screen.size().height() * ratio) self._data = {"Profile_1": {}} self.gui_config_home = os.path.join(Constants.HV_CONFIG_HOME, 'gui') self.gui_config_file = os.path.join(self.gui_config_home, 'config.json') self.gui_cache_home = os.path.join(Constants.HV_CACHE_HOME, 'gui') self.gui_log_file = os.path.join(self.gui_cache_home, 'gui.log') self._gui_log_handlers = [] setup_gui_directory( self.gui_cache_home, self.gui_config_home, self.gui_config_file) self.btn_path = os.getenv('BTN_PATH', os.path.join( gui_dir, 'resources', 'images')) self.css_path = os.getenv('CSS_PATH', os.path.join( gui_dir, 'resources', 'styles')) self.force_sync = force_sync self.is_downloading = False self.current_profile_id = None self.connection_manager = ConnectionManager() current_connection = None self.is_tor_mode = current_connection == 'tor' self.is_animating = False self.animation_step = 0 self.page_history = [] self.log_path = None self.setFixedSize(800, 570) top = create_top_section(self, self.css_path) self.__dict__.update(top) self.marquee_text = "" self.marquee_position = 0 self.marquee_enabled = False self.scroll_speed = 1 self.page_stack = QStackedWidget(self) self.page_stack.setGeometry(0, 0, 800, 570) bottom = create_bottom_section( self, self.btn_path, Constants.HV_CLIENT_VERSION_NUMBER, should_be_synchronized(), ) self.__dict__.update(bottom) self.status_label = bottom['status_label'] self.tor_text = bottom['tor_text'] self.toggle_button = bottom['toggle_button'] self.tor_icon = bottom['tor_icon'] self.sync_button = bottom['sync_button'] self.page_stack.raise_() self.observers = setup_observers(self.update_status) self.update_image() self.set_scroll_speed(7) self.toggle_button.clicked.connect(self.toggle_mode) self.sync_button.clicked.connect(self.sync) QApplication.instance().installEventFilter(self) self.create_toggle(self.is_tor_mode) self.animation_timer = QTimer() self.animation_timer.timeout.connect(self.animate_toggle) self.check_logging() self.navigator = Navigator(self) self.page_stack.currentChanged.connect(self.page_changed) self.show() self.init_ui() self.tray = None self.tray = init_system_tray(self, self.btn_path) current_connection = self.get_current_connection() self.is_tor_mode = current_connection == 'tor' self.set_toggle_state(self.is_tor_mode) core_logger.info( f"HydraVeil application opened (client version {Constants.HV_CLIENT_VERSION_NUMBER}, " f"connection={current_connection}, tor_mode={self.is_tor_mode})" ) def init_ui(self): if self.check_first_launch(): self.navigator.navigate("welcome") else: self.navigator.navigate("menu") self.navigator.start_preload() if self.force_sync: QTimer.singleShot(0, self._prompt_force_sync) def _prompt_force_sync(self): """ Popup to encourage remote database sync Context: Database loader in __main__ passes in the 'forced_sync' variable, If true, this popup encourages sync to avoid NoneType errors. """ connection = ConfigurationController.get_connection() box = QMessageBox(self) box.setWindowTitle("Data Update Needed") box.setText( "Please download the new data to avoid errors. You have profiles that need the data to function.\n\n" f"Connection type: {connection}") # Apply styling # import it inside the function to reduce load on non-display flows. from gui.v2.ui.styles.css.main_ui_css import forced_sync_popup_style stylesheet = forced_sync_popup_style box.setStyleSheet(stylesheet) # Add styled buttons sync_button = box.addButton("Ok Fetch", QMessageBox.ButtonRole.AcceptRole) sync_button.setCursor(Qt.CursorShape.PointingHandCursor) cancel_button = box.addButton("Cancel", QMessageBox.ButtonRole.RejectRole) cancel_button.setCursor(Qt.CursorShape.PointingHandCursor) box.setMinimumWidth(450) # button size box.exec() if box.clickedButton() == sync_button: self.sync() def perform_update_check(self): from core.controllers.ClientController import ClientController update_available = ClientController.can_be_updated() if update_available: self.navigator.update_if_cached( "menu", lambda p: p.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) ] self.navigator.update_if_cached( "browser", lambda p: p.create_interface_elements(available_browsers_list)) self.navigator.update_if_cached( "location", lambda p: p.create_interface_elements(available_locations_list)) self.navigator.update_if_cached( "hidetor", lambda p: p.create_interface_elements(available_locations_list)) self.navigator.update_if_cached( "protocol", lambda p: p.enable_protocol_buttons()) self.navigator.update_if_cached("menu", self._enable_menu_verification_icons) def _enable_menu_verification_icons(self, menu_page): if hasattr(menu_page, 'refresh_profiles_data'): menu_page.refresh_profiles_data() if hasattr(menu_page, 'buttons'): for button in menu_page.buttons: parent = button.parent() if parent: verification_icons = parent.findChildren(QPushButton) for icon in verification_icons: if icon.geometry().width() == 20 and icon.geometry().height() == 20: icon.setEnabled(True) def sync(self): core_logger.info("User clicked sync button") if ConfigurationController.get_connection() == 'tor': self.worker_thread = WorkerThread('SYNC_TOR') else: self.worker_thread = WorkerThread('SYNC') self.sync_button.setIcon(QtGui.QIcon( os.path.join(self.btn_path, 'icon_sync.png'))) self.worker_thread.finished.connect( lambda: self.perform_update_check()) self.worker_thread.sync_output.connect(self.update_values) self.worker_thread.start() def _handle_exception(self, identifier, message, trace): if issubclass(identifier, UnknownConnectionTypeError): self.setup_popup() os.execv(sys.executable, [sys.executable] + sys.argv) else: config = self._load_gui_config() if config and config["logging"]["gui_logging_enabled"] == True: logging.error( f"Uncaught exception:\n" f"Type: {identifier.__name__}\n" f"Value: {str(message)}\n" f"Traceback:\n{''.join(traceback.format_tb(trace))}" ) sys.__excepthook__(identifier, message, trace) def get_current_connection(self): return ConfigurationController.get_connection() def setup_popup(self): connection_dialog = QDialog(self) connection_dialog.setWindowTitle("Connection Type") connection_dialog.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.WindowStaysOnTopHint | Qt.WindowType.CustomizeWindowHint | Qt.WindowType.WindowTitleHint) connection_dialog.setModal(True) connection_dialog.setFixedSize(400, 200) layout = QVBoxLayout(connection_dialog) label = QLabel( "Please set your preference for connecting to Simplified Privacy's billing server.") label.setWordWrap(True) label.setAlignment(Qt.AlignmentFlag.AlignCenter) label.setStyleSheet( "font-size: 12px; margin-bottom: 20px; color: black;") button_layout = QHBoxLayout() regular_btn = QPushButton("Regular") tor_btn = QPushButton("Tor") button_style = """ QPushButton { background-color: #2b2b2b; color: white; border: none; padding: 10px 20px; border-radius: 5px; font-size: 12px; } QPushButton:hover { background-color: #3b3b3b; } QPushButton:pressed { background-color: #1b1b1b; } """ regular_btn.setStyleSheet(button_style) tor_btn.setStyleSheet(button_style) button_layout.addWidget(regular_btn) button_layout.addWidget(tor_btn) layout.addWidget(label) layout.addLayout(button_layout) def set_regular_connection(): ConfigurationController.set_connection('system') self.is_tor_mode = False self.set_toggle_state(False) core_logger.info("User selected 'Regular' connection type from popup") connection_dialog.accept() def set_tor_connection(): ConfigurationController.set_connection('tor') self.is_tor_mode = True self.set_toggle_state(True) core_logger.info("User selected 'Tor' connection type from popup") connection_dialog.accept() regular_btn.clicked.connect(set_regular_connection) tor_btn.clicked.connect(set_tor_connection) connection_dialog.exec() return ConfigurationController.get_connection() def _setup_gui_directory(self): setup_gui_directory( self.gui_cache_home, self.gui_config_home, self.gui_config_file) def _load_gui_config(self): return load_gui_config(self.gui_config_file) def _save_gui_config(self, config): save_gui_config(self.gui_config_file, config) def _default_gui_config(self): return default_gui_config() def save_ticket_verification_failure(self, result): return save_ticket_verification_failure(self.gui_config_file, result) def get_ticket_verification_failure(self): return get_ticket_verification_failure(self.gui_config_file) def clear_ticket_verification_failure(self): clear_ticket_verification_failure(self.gui_config_file) def check_logging(self): config = self._load_gui_config() if is_logging_enabled(config): self._setup_gui_logging() def has_shown_systemwide_prompt(self): return has_shown_systemwide_prompt(self.gui_config_file) def mark_systemwide_prompt_shown(self): mark_systemwide_prompt_shown(self.gui_config_file) def stop_gui_logging(self): teardown_gui_logging(self.gui_log_file, self._gui_log_handlers) self._gui_log_handlers = [] config = self._load_gui_config() if config: config["logging"]["gui_logging_enabled"] = False self._save_gui_config(config) def _setup_gui_logging(self): self._gui_log_handlers = setup_gui_logging( self.gui_cache_home, self.gui_log_file, self._gui_log_handlers) def set_tor_icon(self): if self.is_tor_mode: icon_path = os.path.join(self.btn_path, "toricon_mini.png") else: icon_path = os.path.join(self.btn_path, "toricon_mini_false.png") pixmap = QPixmap(icon_path) self.tor_icon.setPixmap(pixmap) self.tor_icon.setScaledContents(True) def create_toggle(self, is_tor_mode): handle_x = 30 if is_tor_mode else 3 colors = self._get_toggle_colors(is_tor_mode) if not hasattr(self, 'animation_timer'): self.animation_timer = QTimer() self.animation_timer.timeout.connect(self.animate_toggle) self.toggle_bg = QLabel(self.toggle_button) self.toggle_bg.setGeometry(0, 0, 50, 22) self.toggle_handle = QLabel(self.toggle_button) self.toggle_handle.setGeometry(handle_x, 3, 16, 16) self.toggle_handle.setStyleSheet(""" QLabel { background-color: white; border-radius: 8px; } """) self.on_label = QLabel("ON", self.toggle_button) self.on_label.setGeometry(8, 4, 15, 14) self.off_label = QLabel("OFF", self.toggle_button) self.off_label.setGeometry(25, 4, 40, 14) for widget in (self.toggle_bg, self.toggle_handle, self.on_label, self.off_label): widget.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents) self._update_toggle_colors(colors) if is_tor_mode: self.set_tor_icon() self.raise_bottom_controls() def _get_toggle_colors(self, is_tor_mode): return { 'bg': "#00a8ff" if is_tor_mode else "#2c3e50", 'tor': "white" if is_tor_mode else "transparent", 'sys': "transparent" if is_tor_mode else "white" } def _update_toggle_colors(self, colors): self.toggle_bg.setStyleSheet(f""" QLabel {{ background-color: {colors['bg']}; border-radius: 11px; }} """) self.on_label.setStyleSheet(f""" QLabel {{ color: {colors['tor']}; font-size: 9px; font-weight: bold; }} """) self.off_label.setStyleSheet(f""" QLabel {{ color: {colors['sys']}; font-size: 9px; font-weight: bold; }} """) def set_toggle_state(self, is_tor_mode): self.is_tor_mode = is_tor_mode self.toggle_button.setChecked(is_tor_mode) handle_x = 30 if is_tor_mode else 3 self.toggle_handle.setGeometry(handle_x, 3, 16, 16) self._update_toggle_colors(self._get_toggle_colors(is_tor_mode)) self.set_tor_icon() def toggle_mode(self, *_args): if self.is_animating and not self.animation_timer.isActive(): self.is_animating = False if not self.is_animating: target_is_tor = not self.is_tor_mode new_connection = 'tor' if target_is_tor else 'system' core_logger.info(f"Tor toggle clicked; switching connection mode to '{new_connection}'") self.update_status(f"Tor toggle clicked: switching to {new_connection}") try: ConfigurationController.set_connection(new_connection) except Exception as error: core_logger.exception( f"Tor toggle failed while switching connection mode to '{new_connection}'") self.update_status(f"Tor toggle failed: {error}") self.set_toggle_state(self.is_tor_mode) return self.set_toggle_state(target_is_tor) self.is_animating = True self.animation_step = 0 self.animation_timer.start(16) return core_logger.info("Tor toggle clicked while animation was still running") self.update_status("Tor toggle clicked: still switching") def raise_bottom_controls(self): for widget in (self.status_label, self.tor_text, self.toggle_button, self.tor_icon, self.sync_button): widget.raise_() def eventFilter(self, source, event): if event.type() == QEvent.Type.MouseButtonPress and event.button() == Qt.MouseButton.LeftButton: if self._is_toggle_event(event): self.toggle_mode() return True return super().eventFilter(source, event) def _is_toggle_event(self, event): if not hasattr(self, 'toggle_button') or not self.toggle_button.isVisible(): return False if hasattr(event, 'globalPosition'): global_pos = event.globalPosition().toPoint() else: global_pos = event.globalPos() for widget in (self.tor_text, self.toggle_button, self.tor_icon): if widget.isVisible() and widget.rect().contains(widget.mapFromGlobal(global_pos)): return True return False def animate_toggle(self): TOTAL_STEPS = 5 if self.animation_step <= TOTAL_STEPS: progress = self.animation_step / TOTAL_STEPS if self.is_tor_mode: new_x = 20 + (10 * progress) start_color = "#2c3e50" end_color = "#00a8ff" else: new_x = 31 - (28 * progress) start_color = "#00a8ff" end_color = "#2c3e50" self.toggle_handle.setGeometry(int(new_x), 3, 16, 16) bg_color = self.interpolate_color(start_color, end_color, progress) self.toggle_bg.setStyleSheet(f""" QLabel {{ background-color: {bg_color}; border-radius: 11px; }} """) self.off_label.setStyleSheet(f""" QLabel {{ color: rgba(255, 255, 255, {1 - progress if self.is_tor_mode else progress}); font-size: 9px; font-weight: bold; }} """) self.on_label.setStyleSheet(f""" QLabel {{ color: rgba(255, 255, 255, {progress if self.is_tor_mode else 1 - progress}); font-size: 9px; font-weight: bold; }} """) self.animation_step += 1 else: self.animation_timer.stop() self.is_animating = False self.set_tor_icon() def interpolate_color(self, start_color, end_color, progress): def hex_to_rgb(hex_color): hex_color = hex_color.lstrip('#') return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) start_rgb = hex_to_rgb(start_color) end_rgb = hex_to_rgb(end_color) current_rgb = tuple( int(start_rgb[i] + (end_rgb[i] - start_rgb[i]) * progress) for i in range(3) ) return f"rgb{current_rgb}" def closeEvent(self, event=None): core_logger.info("HydraVeil application closing") # Close SQLAlchemy, orm.stop() connected_profiles = self.connection_manager.get_connected_profiles() if connected_profiles: self.update_status('Profiles are still connected (disconnect flow lands in Chunk 9).') if event is not None: event.accept() else: if event is not None: event.accept() def update_image(self, appearance_value="original"): image_path = os.path.join(self.btn_path, f"{appearance_value}.png") pixmap = QPixmap(image_path) self.label.setPixmap(pixmap) self.label.setScaledContents(True) def read_data(self): return read_profile_data(self._data) def write_data(self, data): write_profile_data(self._data, data) def clear_data(self): self._data = clear_profile_data() def _set_status_font_size(self, font_size): self.status_label.setStyleSheet( f"color: rgb(0, 255, 255); font-size: {font_size}px;") def update_status(self, text, clear=False): if text is None: self._set_status_font_size(16) self.status_label.setText('Status:') self.disable_marquee() return if clear: self._set_status_font_size(16) self.status_label.setText('') return full_text = 'Status: ' + text metrics = self.status_label.fontMetrics() available_width = self.status_label.width() - 10 if metrics.horizontalAdvance(full_text) > available_width: self.enable_marquee(text) else: self.status_label.setText(full_text) self.disable_marquee() def check_first_launch(self): return check_first_launch(self.gui_config_file) def has_shown_fast_mode_prompt(self): return has_shown_fast_mode_prompt(self.gui_config_file) def mark_fast_mode_prompt_shown(self): mark_fast_mode_prompt_shown(self.gui_config_file) def set_fast_mode_enabled(self, enabled): set_fast_mode_enabled(self.gui_config_file, enabled) def navigate_after_profile_created(self): if not self.has_shown_fast_mode_prompt(): self.navigator.navigate("fast_mode_prompt") else: self.navigator.update_if_cached( "menu", lambda p: p.refresh_menu_buttons() if hasattr(p, 'refresh_menu_buttons') else None) self.navigator.navigate("menu") def enable_marquee(self, text): self.marquee_text = text + " " self.marquee_position = 0 self.marquee_enabled = True self.marquee_timer.start(500) def disable_marquee(self): self.marquee_enabled = False self.marquee_timer.stop() def update_marquee(self): if not self.marquee_enabled: return metrics = self.status_label.fontMetrics() text_width = metrics.horizontalAdvance(self.marquee_text) self.marquee_position += self.scroll_speed if self.marquee_position >= text_width: self.marquee_position = 0 looped_text = self.marquee_text * 2 chars_that_fit = metrics.horizontalAdvance( looped_text[:self.text_width]) visible_text = looped_text[self.marquee_position: self.marquee_position + chars_that_fit] while metrics.horizontalAdvance(visible_text) < self.text_width: visible_text += self.marquee_text while metrics.horizontalAdvance(visible_text) > self.text_width: visible_text = visible_text[:-1] display_text = 'Status: ' + ' ' * \ (self.text_start_x - metrics.horizontalAdvance('Status: ')) display_text += visible_text self.status_label.setText(display_text) def set_scroll_speed(self, speed): self.scroll_speed = speed def page_changed(self, index): if should_be_synchronized(): sync_icon = QtGui.QIcon(os.path.join( self.btn_path, 'icon_sync_urgent.png')) else: sync_icon = QtGui.QIcon(os.path.join( self.btn_path, 'icon_sync.png')) self.sync_button.setIcon(sync_icon) current_page = self.page_stack.widget(index) if self.page_history: previous_page = self.page_history[-1] else: previous_page = None current_name = getattr(current_page, 'name', None) previous_name = getattr(previous_page, 'name', None) if current_name == 'Menu': self.update_status(None) if previous_name in ('Resume', 'Editor', 'Settings', 'FastRegistration', 'FastModePrompt'): if hasattr(current_page, 'eliminacion'): current_page.eliminacion() elif current_name == 'Resume': self.update_status('Choose a name for your profile') if hasattr(current_page, 'eliminacion'): current_page.eliminacion() elif current_name == 'Id': pass elif current_name == 'Location': self.update_status(None, clear=True) elif current_name == 'InstallPackage': self.update_status('Please check package availability.') else: self.update_status(None) if current_name == 'Menu': self.sync_button.setVisible(True) self.tor_text.setVisible(True) self.toggle_button.setGeometry(670, 541, 50, 22) self.tor_icon.setGeometry(730, 537, 25, 25) else: self.sync_button.setVisible(False) self.tor_text.setVisible(False) self.toggle_button.setGeometry(540, 541, 50, 22) self.tor_icon.setGeometry(600, 537, 25, 25) self.raise_bottom_controls() if current_page != previous_page: self.page_history.append(current_page) def start_ui(force_sync): app = QApplication(sys.argv) window = CustomWindow(force_sync) sys.exit(app.exec())