hydraveil-gui/gui/__main__.py
2026-06-18 19:35:31 -04:00

763 lines
27 KiB
Python
Executable file

import os
import sys
_WORKSPACE_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _WORKSPACE_ROOT not in sys.path:
sys.path.insert(0, _WORKSPACE_ROOT)
import logging
import traceback
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QStackedWidget, QLabel, QPushButton,
QDialog, QVBoxLayout, QHBoxLayout
)
from PyQt6.QtGui import QPixmap, QFont, QFontDatabase
from PyQt6 import QtGui
from PyQt6.QtCore import Qt, QTimer, QEvent
from core.Constants import Constants
from core.controllers.ConfigurationController import ConfigurationController
from core.Errors import UnknownConnectionTypeError
from core.errors.logger import logger as core_logger
core_logger.propagate = False
from gui.v2.infrastructure.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)
gui_dir = os.path.dirname(os.path.abspath(__file__))
font_path = os.path.join(gui_dir, 'resources', 'fonts')
retro_gaming_path = os.path.join(font_path, 'retro-gaming.ttf')
font_id = QFontDatabase.addApplicationFont(retro_gaming_path)
font_family = QFontDatabase.applicationFontFamilies(font_id)[0]
app_font = QFont(font_family)
app_font.setPointSize(10)
QApplication.setFont(app_font)
self.open_sans_family = None
open_sans_path = os.path.join(font_path, 'open-sans.ttf')
if os.path.exists(open_sans_path):
open_sans_id = QFontDatabase.addApplicationFont(open_sans_path)
if open_sans_id != -1:
self.open_sans_family = QFontDatabase.applicationFontFamilies(open_sans_id)[0]
self.setAttribute(Qt.WidgetAttribute.WA_NoSystemBackground)
self.setWindowTitle('HydraVeil')
screen = QApplication.primaryScreen()
ratio = screen.devicePixelRatio()
self.host_screen_width = int(screen.size().width() * ratio)
self.host_screen_height = int(screen.size().height() * ratio)
self._data = {"Profile_1": {}}
self.gui_config_home = os.path.join(Constants.HV_CONFIG_HOME, 'gui')
self.gui_config_file = os.path.join(self.gui_config_home, 'config.json')
self.gui_cache_home = os.path.join(Constants.HV_CACHE_HOME, 'gui')
self.gui_log_file = os.path.join(self.gui_cache_home, 'gui.log')
self._gui_log_handlers = []
setup_gui_directory(
self.gui_cache_home, self.gui_config_home, self.gui_config_file)
self.btn_path = os.getenv('BTN_PATH', os.path.join(
gui_dir, 'resources', 'images'))
self.css_path = os.getenv('CSS_PATH', os.path.join(
gui_dir, 'resources', 'styles'))
self.is_downloading = False
self.current_profile_id = None
self.connection_manager = ConnectionManager()
current_connection = None
self.is_tor_mode = current_connection == 'tor'
self.is_animating = False
self.animation_step = 0
self.page_history = []
self.log_path = None
self.setFixedSize(800, 570)
top = create_top_section(self, self.css_path)
self.__dict__.update(top)
self.marquee_text = ""
self.marquee_position = 0
self.marquee_enabled = False
self.scroll_speed = 1
self.page_stack = QStackedWidget(self)
self.page_stack.setGeometry(0, 0, 800, 570)
bottom = create_bottom_section(
self,
self.btn_path,
Constants.HV_CLIENT_VERSION_NUMBER,
should_be_synchronized(),
)
self.__dict__.update(bottom)
self.status_label = bottom['status_label']
self.tor_text = bottom['tor_text']
self.toggle_button = bottom['toggle_button']
self.tor_icon = bottom['tor_icon']
self.sync_button = bottom['sync_button']
self.page_stack.raise_()
self.observers = setup_observers(self.update_status)
self.update_image()
self.set_scroll_speed(7)
self.toggle_button.clicked.connect(self.toggle_mode)
self.sync_button.clicked.connect(self.sync)
QApplication.instance().installEventFilter(self)
self.create_toggle(self.is_tor_mode)
self.animation_timer = QTimer()
self.animation_timer.timeout.connect(self.animate_toggle)
self.check_logging()
self.navigator = Navigator(self.page_stack, self)
self.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)
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")
connected_profiles = self.connection_manager.get_connected_profiles()
if connected_profiles:
self.update_status('Profiles are still connected (disconnect flow lands in Chunk 9).')
if event is not None:
event.accept()
else:
if event is not None:
event.accept()
def update_image(self, appearance_value="original"):
image_path = os.path.join(self.btn_path, f"{appearance_value}.png")
pixmap = QPixmap(image_path)
self.label.setPixmap(pixmap)
self.label.setScaledContents(True)
def read_data(self):
return read_profile_data(self._data)
def write_data(self, data):
write_profile_data(self._data, data)
def clear_data(self):
self._data = clear_profile_data()
def _set_status_font_size(self, font_size):
self.status_label.setStyleSheet(
f"color: rgb(0, 255, 255); font-size: {font_size}px;")
def update_status(self, text, clear=False):
if text is None:
self._set_status_font_size(16)
self.status_label.setText('Status:')
self.disable_marquee()
return
if clear:
self._set_status_font_size(16)
self.status_label.setText('')
return
full_text = 'Status: ' + text
metrics = self.status_label.fontMetrics()
available_width = self.status_label.width() - 10
if metrics.horizontalAdvance(full_text) > available_width:
self.enable_marquee(text)
else:
self.status_label.setText(full_text)
self.disable_marquee()
def check_first_launch(self):
return check_first_launch(self.gui_config_file)
def has_shown_fast_mode_prompt(self):
return has_shown_fast_mode_prompt(self.gui_config_file)
def mark_fast_mode_prompt_shown(self):
mark_fast_mode_prompt_shown(self.gui_config_file)
def set_fast_mode_enabled(self, enabled):
set_fast_mode_enabled(self.gui_config_file, enabled)
def navigate_after_profile_created(self):
if not self.has_shown_fast_mode_prompt():
self.navigator.navigate("fast_mode_prompt")
else:
menu_page = self.navigator.get_cached("menu")
if menu_page is not None and hasattr(menu_page, 'refresh_menu_buttons'):
menu_page.refresh_menu_buttons()
self.navigator.navigate("menu")
def enable_marquee(self, text):
self.marquee_text = text + " "
self.marquee_position = 0
self.marquee_enabled = True
self.marquee_timer.start(500)
def disable_marquee(self):
self.marquee_enabled = False
self.marquee_timer.stop()
def update_marquee(self):
if not self.marquee_enabled:
return
metrics = self.status_label.fontMetrics()
text_width = metrics.horizontalAdvance(self.marquee_text)
self.marquee_position += self.scroll_speed
if self.marquee_position >= text_width:
self.marquee_position = 0
looped_text = self.marquee_text * 2
chars_that_fit = metrics.horizontalAdvance(
looped_text[:self.text_width])
visible_text = looped_text[self.marquee_position:
self.marquee_position + chars_that_fit]
while metrics.horizontalAdvance(visible_text) < self.text_width:
visible_text += self.marquee_text
while metrics.horizontalAdvance(visible_text) > self.text_width:
visible_text = visible_text[:-1]
display_text = 'Status: ' + ' ' * \
(self.text_start_x - metrics.horizontalAdvance('Status: '))
display_text += visible_text
self.status_label.setText(display_text)
def set_scroll_speed(self, speed):
self.scroll_speed = speed
def page_changed(self, index):
if should_be_synchronized():
sync_icon = QtGui.QIcon(os.path.join(
self.btn_path, 'icon_sync_urgent.png'))
else:
sync_icon = QtGui.QIcon(os.path.join(
self.btn_path, 'icon_sync.png'))
self.sync_button.setIcon(sync_icon)
current_page = self.page_stack.widget(index)
if self.page_history:
previous_page = self.page_history[-1]
else:
previous_page = None
current_name = getattr(current_page, 'name', None)
previous_name = getattr(previous_page, 'name', None)
if current_name == 'Menu':
self.update_status(None)
if previous_name in ('Resume', 'Editor', 'Settings', 'FastRegistration', 'FastModePrompt'):
if hasattr(current_page, 'eliminacion'):
current_page.eliminacion()
elif current_name == 'Resume':
self.update_status('Choose a name for your profile')
if hasattr(current_page, 'eliminacion'):
current_page.eliminacion()
elif current_name == 'Id':
pass
elif current_name == 'Location':
self.update_status(None, clear=True)
elif current_name == 'InstallPackage':
self.update_status('Please check package availability.')
else:
self.update_status(None)
if current_name == 'Menu':
self.sync_button.setVisible(True)
self.tor_text.setVisible(True)
self.toggle_button.setGeometry(670, 541, 50, 22)
self.tor_icon.setGeometry(730, 537, 25, 25)
else:
self.sync_button.setVisible(False)
self.tor_text.setVisible(False)
self.toggle_button.setGeometry(540, 541, 50, 22)
self.tor_icon.setGeometry(600, 537, 25, 25)
self.raise_bottom_controls()
if current_page != previous_page:
self.page_history.append(current_page)
if __name__ == "__main__":
app = QApplication(sys.argv)
window = CustomWindow()
sys.exit(app.exec())