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): print(f"hit connect to enable profile. self.reverse_id is {self.reverse_id}") 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"])