hydraveil-gui/gui/v2/ui/pages/menu_page.py
2026-09-26 16:49:24 -04:00

629 lines
28 KiB
Python
Executable file

import functools
import os
from itertools import chain
from PyQt6 import QtCore, QtGui
from PyQt6.QtCore import Qt, QSize, pyqtSlot
from PyQt6.QtGui import QIcon, QPainter, QPixmap
from PyQt6.QtWidgets import (
QApplication, QButtonGroup, QFrame, QHBoxLayout, QLabel, QLineEdit,
QMessageBox, QPushButton, QScrollArea, QTextEdit, QVBoxLayout, QWidget,
)
from gui.v2.actions.menu_profiles import find_verification_icon, get_profile_details, load_menu_profiles, menu_icons
from gui.v2.actions.menu_tasks import (
continue_menu_task, create_profile, delete_profile, disable_profile,
download_update, edit_profile, enable_profile,
)
from gui.v2.actions.menu_results import MenuResult
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
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.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.reverse_id = None
self._task_busy = False
self.button_states = {}
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)
self._set_verification_icons_enabled()
def _set_verification_icons_enabled(self):
parents = filter(None, (button.parent() for button in getattr(self, 'buttons', [])))
icons = chain.from_iterable(parent.findChildren(QPushButton) for parent in parents)
verification_icons = filter(
lambda icon: icon.geometry().width() == 20 and icon.geometry().height() == 20,
icons,
)
for icon in verification_icons:
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 = download_update(self.handle_task_result)
else:
self.update_status.update_status('Update canceled')
def create_interface_elements(self):
button_definitions = {
"create_profile": (self.create_prof, (410, 440, 110, 60), True, True),
"edit_profile": (self.edit_prof, (540, 440, 110, 60), False, True),
"launch": (self.connect, (0, 440, 185, 63), False, False),
"disconnect": (self.disconnect, (200, 433, 185, 73), True, False),
"disconnect_system_wide": (self.disconnect, (200, 433, 185, 73), True, False),
"just": (self.connect, (200, 433, 185, 73), False, False),
"just_session": (self.connect, (200, 433, 185, 73), False, True),
"settings": (self.settings_gui, (670, 440, 110, 60), True, True),
}
buttons = {}
for icon_name, (function, position, enabled, visible) in button_definitions.items():
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(enabled)
boton.setVisible(visible)
buttons[icon_name] = boton
button_attributes = {
"disconnect_button": "disconnect",
"boton_launch": "launch",
"boton_create": "create_profile",
"boton_edit": "edit_profile",
"boton_just": "just",
"boton_just_session": "just_session",
"connect_button": "just_session",
"disconnect_system_wide_button": "disconnect_system_wide",
"settings_button": "settings",
}
self.__dict__.update({attribute: buttons[name] for attribute, name in button_attributes.items()})
self.refresh_menu_buttons()
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 delete_profile(self):
self._set_task_busy(True)
self.worker = delete_profile(self.reverse_id, self.handle_task_result)
def refresh_profiles_data(self):
self.refresh_menu_buttons()
def refresh_menu_buttons(self, state_change=None):
menu_data = load_menu_profiles(
getattr(self.update_status, 'gui_config_file', None),
self.update_status.update_status, self.reverse_id, state_change,
)
self.profiles_data = menu_data["display_profiles"]
self.profile_info = self.profiles_data
self.number_of_profiles = len(self.profiles_data)
self.connection_manager.set_connected_profiles(menu_data["enabled_kinds"])
icon_paths = {"session_tor": self.connected_tor_profile,
"session": self.connected_profile, "system": self.system_wide_connected_profile}
self.button_states = {profile_id: icon_paths[kind] for profile_id, kind in menu_data["enabled_kinds"].items()}
self.update_status.update_image(appearance_value=menu_data["background"])
for button in getattr(self, 'buttons', []):
button.parent().deleteLater()
if hasattr(self, 'button_group'):
self.button_group.deleteLater()
self.profile_button_map.clear()
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()
if menu_data["selected_details"] is not None:
self._render_profile_details(menu_data["selected_details"])
return
self.clear_profile_details()
def update_scroll_widget_size(self):
if self.number_of_profiles == 0:
self.scroll_widget.setFixedSize(370, 0)
return
rows = (self.number_of_profiles + 1) // 2
height = rows * 120
self.scroll_widget.setFixedSize(370, height)
def clear_profile_details(self):
for label in self.additional_labels:
label.deleteLater()
self.additional_labels.clear()
self.selected_profiles.clear()
self.selected_wireguard.clear()
self.selected_residential.clear()
self.update_status.current_profile_id = None
self.current_profile_location = None
self.reverse_id = None
self.boton_edit.setEnabled(False)
self.boton_launch.setEnabled(False)
self.boton_just.setVisible(False)
self.boton_just_session.setVisible(False)
self.disconnect_button.setVisible(False)
self.disconnect_system_wide_button.setVisible(False)
if not hasattr(self, 'button_group'):
return
self.button_group.setExclusive(False)
for button in self.buttons:
button.setChecked(False)
self.button_group.setExclusive(True)
def create_profile_widget(self, key, value):
profile_id = value['id']
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(str(name or "Unnamed Profile"))
child_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
child_label.setAttribute(
Qt.WidgetAttribute.WA_TransparentForMouseEvents)
child_label.show()
def create_profile_icons(self, parent_label, value):
for index, icon in enumerate(menu_icons(self.btn_path, value)):
child_label = QLabel(parent_label)
child_label.setObjectName("label_profiles")
child_label.setGeometry(3 + index * 60, 5, 50, 50)
child_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
child_label.setStyleSheet("background-color: rgba(128, 128, 128, 0.5); border-radius: 25px;")
pixmap = self._load_profile_icon_pixmap(icon["path"], icon["kind"])
child_label.setPixmap(self._add_browser_warning(pixmap, icon["warning"]))
child_label.show()
def _load_profile_icon_pixmap(self, image_path, label_name):
pixmap = QPixmap(image_path)
fallback_names = {
'location': 'default_location_mini.png',
'browser': 'default_browser_mini.png',
}
fallback_name = fallback_names.get(label_name)
if not pixmap.isNull() or fallback_name is None:
return pixmap
pixmap = QPixmap(os.path.join(self.btn_path, fallback_name))
if not pixmap.isNull():
return pixmap
placeholder = QPixmap(50, 50)
placeholder.fill(QtGui.QColor(200, 200, 200))
return placeholder
def _add_browser_warning(self, pixmap, needs_warning):
if not needs_warning:
return pixmap
painter = QPainter(pixmap)
painter.drawPixmap(pixmap.width() - 22, pixmap.height() - 21,
QPixmap(os.path.join(self.btn_path, 'warning.png')))
painter.end()
return pixmap
def create_verification_widget(self, values):
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)
for label, full_value, value in values:
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)
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)
checkmark_widget.setVisible(bool(value and value != "N/A"))
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, 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):
summary = self.profile_info.get(profile_name)
if summary is None:
self.clear_profile_details()
return
details = get_profile_details(summary["id"], summary,
self.connection_manager.is_profile_connected(summary["id"]),
self.update_status.update_status)
self._render_profile_details(details)
def _render_profile_details(self, details):
self.reverse_id = details["id"]
self.update_status.current_profile_id = self.reverse_id
self.current_profile_location = details["location"]
target_button = self.profile_button_map.get(self.reverse_id)
if target_button is not None:
target_button.setChecked(True)
self.connection_manager.set_profile_button_objects(self.reverse_id, target_button)
self.boton_edit.setEnabled(not details["is_connected"] and not self._task_busy)
self.boton_launch.setEnabled(not self._task_busy)
self.boton_just.setEnabled(not self._task_busy)
self.boton_just_session.setEnabled(not self._task_busy)
self._set_verification_icons_enabled()
self.update_button_visibility(details["is_connected"], details["is_session"])
self.update_status_message(details)
for label in self.additional_labels:
label.deleteLater()
self.additional_labels.clear()
if details["background_asset"]:
self._detail_image(details["background_asset"], (0, 60, 410, 354), lower=True)
renderers = {"verification": self._render_verification,
"operator_details": self._render_operator_details,
"protocol_image": self._render_protocol_image}
renderers[details["display_mode"]](details)
if details["show_verified_icon"]:
self._detail_image("verified_profile.png", (20, 50, 100, 80))
self.add_selected_profile(details["selection"])
def _detail_image(self, asset, geometry, lower=False, pixmap=None):
label = QLabel(self)
label.setGeometry(*geometry)
label.setPixmap(pixmap if pixmap is not None else QPixmap(os.path.join(self.btn_path, asset)))
label.setScaledContents(True)
label.show()
if lower:
label.lower()
self.additional_labels.append(label)
return label
def _render_verification(self, details):
self.additional_labels.append(self.create_verification_widget(details["verification_values"]))
def _render_protocol_image(self, details):
pixmap = QPixmap(os.path.join(self.btn_path, details["primary_asset"]))
if details["location_overlay"]:
pixmap = self.build_encrypted_proxy_detail_pixmap(pixmap, details["location"], details["overlay_y"])
self._detail_image(details["primary_asset"], (0, 80 if details["raised_primary"] else 90, 400, 300), pixmap=pixmap)
def _render_operator_details(self, details):
color = "black" if details["is_session"] else "white"
self._detail_image(f"icon_{details['location']}.png", (20, 125, 100, 100))
text_blocks = [
((130, 110, 260, 250), f"Location: {details['location_text']}\n\nOperator: {details['operator_name']}",
QtGui.QTextOption.WrapMode.WrapAtWordBoundaryOrAnywhere),
((20, 260, 370, 80), f"Nostr: {details['nostr_public_key']}", QtGui.QTextOption.WrapMode.WrapAnywhere),
]
for geometry, text, wrapping in text_blocks:
widget = QTextEdit(self)
widget.setGeometry(*geometry)
widget.setReadOnly(True)
widget.setFrameStyle(QFrame.Shape.NoFrame)
widget.setStyleSheet(f"background: transparent; border: none; color: {color}; font-size: 17px;")
widget.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
widget.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
widget.setWordWrapMode(wrapping)
widget.setText(text)
widget.show()
self.additional_labels.append(widget)
def build_encrypted_proxy_detail_pixmap(self, pixmap, location, overlay_y):
if pixmap.isNull():
return pixmap
location_pixmap = QPixmap(os.path.join(
self.btn_path, f"icon_mini_{location}.png"))
if location_pixmap.isNull():
location_pixmap = QPixmap(os.path.join(
self.btn_path, "default_location_mini.png"))
if location_pixmap.isNull():
return pixmap
icon_size = max(42, min(72, int(min(pixmap.width(), pixmap.height()) * 0.22)))
location_pixmap = location_pixmap.scaled(
icon_size,
icon_size,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation)
x = int(pixmap.width() * 0.58)
y = int(pixmap.height() * overlay_y)
x = min(max(0, x), max(0, pixmap.width() - location_pixmap.width()))
y = min(max(0, y), max(0, pixmap.height() - location_pixmap.height()))
painter = QPainter(pixmap)
painter.drawPixmap(x, y, location_pixmap)
painter.end()
return pixmap
def show_profile_verification(self, profile_id):
location_icon_name = find_verification_icon(
profile_id,
self.btn_path,
self.connection_manager.get_location_info,
self.update_status.update_status,
)
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 _set_task_busy(self, busy):
self._task_busy = busy
for button in (self.boton_just, self.boton_just_session, self.disconnect_button,
self.disconnect_system_wide_button, self.boton_create, self.boton_edit):
button.setEnabled(not busy)
self.scroll_area.setEnabled(not busy)
self.boton_just.setEnabled(not busy and self.reverse_id is not None)
self.boton_just_session.setEnabled(not busy and self.reverse_id is not None)
self.boton_edit.setEnabled(not busy and self.reverse_id is not None
and not self.connection_manager.is_profile_connected(self.reverse_id))
def connect(self):
self._set_task_busy(True)
self.update_status.update_status("Attempting to connect...")
self.worker = enable_profile({"id": self.reverse_id}, self.handle_task_result)
def disconnect(self):
self._set_task_busy(True)
self.update_status.update_status("Disconnecting...")
self.worker = disable_profile(self.reverse_id, self.connection_manager.get_connected_profiles(),
self.handle_task_result)
def create_prof(self):
self._set_task_busy(True)
self.worker = create_profile(getattr(self.update_status, "gui_config_file", None),
self.connection_manager.is_synced(), self.handle_task_result)
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, state):
if not state["profile_loaded"]:
self.update_status.update_status("Profile data could not be loaded. Sync or restart and try again.")
return
status_handlers = {
True: self.update_status.enable_marquee,
False: self.update_status.update_status,
}
status_handlers[state["use_marquee"]](state["message"])
def edit_prof(self):
self._set_task_busy(True)
self.worker = edit_profile(getattr(self.update_status, "gui_config_file", None),
self.connection_manager.is_synced(), self.handle_task_result)
def settings_gui(self):
self.custom_window.navigator.navigate("settings")
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')
@pyqtSlot(object)
def handle_task_result(self, result: MenuResult):
if "sync_data" in result:
self.update_status.update_values(*result["sync_data"])
if result.get("clear_selection") and result.get("profile_id") == self.reverse_id:
self.clear_profile_details()
if result.get("refresh"):
self.refresh_menu_buttons(result)
self._set_task_busy(result.get("busy", False))
if result.get("message"):
handlers = {True: self.update_status.enable_marquee, False: self.update_status.update_status}
handlers[bool(result.get("is_enabled"))](result["message"])
handlers = {"confirmation": self._show_confirmation, "verification": self._show_verification,
"navigate": self._navigate_result, "install": self._show_install_result,
"ticket_data_loss": self._show_ticket_loss_result}
handler = handlers.get(result["status"])
if handler is not None:
handler(result)
def _show_confirmation(self, result):
popup = self._make_confirmation_popup(result["message"], result["button_text"])
popup.finished.connect(functools.partial(self._confirmation_response, result["request"]))
self.popup = popup
popup.show()
def _confirmation_response(self, request, accepted):
self._set_task_busy(accepted)
self.worker = continue_menu_task(request, "continue" if accepted else "abort", self.handle_task_result)
def _show_verification(self, result):
popup = EndpointVerificationPopup(self, message=result["message"])
popup.finished.connect(functools.partial(self._verification_response, result["request"]))
self.popup = popup
popup.show()
def _verification_response(self, request, accepted, choice):
self._set_task_busy(accepted)
self.worker = continue_menu_task(request, choice if accepted else "abort", self.handle_task_result)
def _navigate_result(self, result):
if "profile_id" in result:
self.update_status.current_profile_id = result["profile_id"]
destination = result["destination"]
page = self.custom_window.navigator.get_cached(destination)
if destination == "ticket_or_billing_choice" and page is not None:
page.populate()
self.custom_window.navigator.navigate(destination)
def _show_install_result(self, result):
self.DisplayInstallScreen(result["package_name"])
def _show_ticket_loss_result(self, result):
self.show_ticket_data_loss_popup(result["ticket_number"], result["billing_code"])