from core.models.BaseProfile import ProfileType from core.models.Result import Result, ResultError 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_coordinator import connect_profile 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.styles.css.menu_css import CREATE_BUTTON_STYLE 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.infrastructure.setup_observers import ( application_version_observer, connection_observer, profile_observer ) 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(CREATE_BUTTON_STYLE) 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): print(f"details entering _render_profile_details is {details}") self.reverse_id = details["id"] # THIS IS UPDATING THE PROFILE 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 post_connect(self, result: Result): """Recieve the callback after a connection attempt.""" # -------------------- # BOTTOM LEFT STATUS BAR # -------------------- if result.message: if len(result.message) > 20: self.update_status.enable_marquee self.update_status.update_status(result.message) # -------------------- # CONNECTION WORKED # -------------------- if result.valid: # BACKGROUND CONNECTION IMAGE: self.update_status.update_image(appearance_value="background_connected") # BUTTON: profile_type = result.data if profile_type == ProfileType.SYSTEM: self.disconnect_system_wide_button.setVisible(True) elif profile_type == ProfileType.SESSION: self.disconnect_button.setVisible(True) else: print("This is an error where the GUI recieved an invalid profile type, but we are enabling the systemwide by default.") self.disconnect_system_wide_button.setVisible(True) return # -------------------- # FAILURE EVALUATION # -------------------- if result.error_type == ResultError.NAVIGATE_TO: print(f"We are auto-navigating to page {result.navigate_to}") self.custom_window.navigator.navigate(result.navigate_to) elif result.error_type == ResultError.SUBSCRIPTION: print("We are navigating to the subscription page") self.update_status.update_status("Select Subscription Choice") self.custom_window.navigator.navigate("id") else: error_type = result.error_type.value print(f"We do not currently have a pre-made solution for the unknown situation of {error_type}") self.update_status.update_status(error_type) def connect(self): """Enable the profile id tied to reverse_id""" print("Starting to connect..") # Step 1. Disable all buttons to prevent repeat pushing. self.boton_just.setVisible(False) self.boton_launch.setEnabled(False) self.boton_just_session.setEnabled(False) # Step 2. Connect with post_connect setup to recieve the result. immediate_error = connect_profile(profile_id=self.reverse_id, ui_caller=self.post_connect) # Step 3. Evaluate immediate errors (if it never even gets to the callback) if not immediate_error.valid: self.update_status.update_status(immediate_error.message) return # Temporary until moved to menu coordinator: # print("connecting...") # 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): print(f"result in handle_task_result is {result}") 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"])