diff --git a/gui/v2/ui/pages/location_page.py b/gui/v2/ui/pages/location_page.py index 6a99ec0..c70999b 100755 --- a/gui/v2/ui/pages/location_page.py +++ b/gui/v2/ui/pages/location_page.py @@ -66,6 +66,7 @@ class LocationPage(Page): button.setChecked(False) if hasattr(self, 'verification_button'): self.verification_button.setEnabled(False) + self.refresh_protocol_location_visibility() def create_interface_elements(self, available_locations): @@ -79,7 +80,7 @@ class LocationPage(Page): boton.setCheckable(True) locations = self.connection_manager.get_location_info(icon_name) - if locations and not (hasattr(locations, 'is_wireguard_capable') and locations.is_wireguard_capable): + if locations and not self._location_supports_selected_protocol(locations): boton.setVisible(False) icon_path = os.path.join(self.btn_path, f"button_{icon_name}.png") boton.setIcon(QIcon(icon_path)) @@ -121,6 +122,30 @@ class LocationPage(Page): boton.clicked.connect( lambda checked, loc=icon_name: self.show_location(loc)) + def refresh_protocol_location_visibility(self): + for button in self.buttons: + location_key = getattr(button, 'location_icon_name', None) + locations = self.connection_manager.get_location_info(location_key) + button.setVisible(not locations or self._location_supports_selected_protocol(locations)) + + def _current_protocol(self): + profile_data = self.update_status.read_data() + return profile_data.get("protocol", "wireguard") + + def _is_enabled_capability(self, value): + return value in (True, 1, "1", "true", "True") + + def _location_supports_selected_protocol(self, locations): + capability_by_protocol = { + "wireguard": "is_wireguard_capable", + "hysteria2": "is_hysteria2_capable", + "vless": "is_vless_capable", + } + capability_name = capability_by_protocol.get(self._current_protocol()) + if capability_name is None: + return True + return self._is_enabled_capability(getattr(locations, capability_name, False)) + def update_swarp_json(self, get_connection=False): profile_data = self.update_status.read_data() self.connection_type = profile_data.get("connection", "") diff --git a/gui/v2/ui/pages/menu_page.py b/gui/v2/ui/pages/menu_page.py index 16ae7ad..a203147 100755 --- a/gui/v2/ui/pages/menu_page.py +++ b/gui/v2/ui/pages/menu_page.py @@ -307,15 +307,15 @@ class MenuPage(Page): new_profile['location'] = location - if protocol == 'wireguard': - new_profile['protocol'] = 'wireguard' + if protocol in ('wireguard', 'hysteria2', 'vless'): + new_profile['protocol'] = protocol else: new_profile['protocol'] = 'hidetor' connection_type = 'browser-only' if isinstance( profile, SessionProfile) else 'system-wide' - if protocol == 'wireguard': + if protocol in ('wireguard', 'hysteria2', 'vless'): new_profile['connection'] = connection_type elif protocol == 'tor': new_profile['connection'] = protocol @@ -575,7 +575,10 @@ class MenuPage(Page): child_label.show() def get_icon_path(self, label_name, value, connection_type): + protocol = value.get('protocol', '') if label_name == 'protocol': + if protocol in ('hysteria2', 'vless'): + return os.path.join(self.btn_path, f"{protocol}_mini.png") if connection_type == 'tor': return os.path.join(self.btn_path, "toricon_mini.png") elif connection_type == 'just proxy': @@ -584,6 +587,8 @@ class MenuPage(Page): return os.path.join(self.btn_path, "wireguard_mini.png") elif label_name == 'browser': if connection_type == 'system-wide': + if protocol in ('hysteria2', 'vless'): + return os.path.join(self.btn_path, "system_wide_global.png") return os.path.join(self.btn_path, "wireguard_system_wide.png") else: return os.path.join(self.btn_path, f"{value[label_name]} latest_mini.png") @@ -841,17 +846,20 @@ class MenuPage(Page): nostr_txt.show() self.additional_labels.append(nostr_txt) - elif protocol.lower() in ["wireguard", "open", "residential", "hidetor"]: + elif protocol.lower() in ["wireguard", "open", "residential", "hidetor", "hysteria2", "vless"]: label_principal = QLabel(self) label_principal.setGeometry(0, 90, 400, 300) pixmap = QPixmap(os.path.join( self.btn_path, f"{protocol}_{location}.png")) + if pixmap.isNull() and protocol.lower() in ["hysteria2", "vless"]: + pixmap = QPixmap(os.path.join( + self.btn_path, f"icon_{location}.png")) label_principal.setPixmap(pixmap) label_principal.setScaledContents(True) label_principal.show() self.additional_labels.append(label_principal) - if protocol.lower() in ["wireguard", "open", "residential", "hidetor"]: + if protocol.lower() in ["wireguard", "open", "residential", "hidetor", "hysteria2", "vless"]: if protocol.lower() == "wireguard" and ConfigurationController.get_endpoint_verification_enabled(): if is_profile_enabled and profile_obj and profile_obj.connection and profile_obj.connection.code == 'wireguard': diff --git a/gui/v2/ui/pages/networking_setup_page.py b/gui/v2/ui/pages/networking_setup_page.py index 2827e58..44ab90a 100755 --- a/gui/v2/ui/pages/networking_setup_page.py +++ b/gui/v2/ui/pages/networking_setup_page.py @@ -4,13 +4,14 @@ from PyQt6.QtWidgets import QLabel, QPushButton, QWidget from PyQt6.QtGui import QPixmap from PyQt6.QtCore import Qt, QTimer -from core.services.helpers.setup_sudo_scripts import auto_install_sudo_script, test_if_in_sudo_folder +from core.services.helpers.setup_sudo_scripts import auto_install_sudo_script, test_if_in_sudo_folder, is_singbox_wrapper_ready from core.services.helpers.manage_assets import sudo_assets_folder_setup from core.models.Result import Result, ResultError import sys from gui.v2.ui.pages.Page import Page +from gui.v2.workers.worker_thread import WorkerThread class NetworkingSetupPage(Page): @@ -19,6 +20,10 @@ class NetworkingSetupPage(Page): self.btn_path = main_window.btn_path self.update_status = main_window self.manual_scripts_ready = False + self.singbox_completion = None + self.singbox_setup_started = False + self.singbox_worker = None + self.latest_singbox_message = "" self.setStyleSheet("font-family: Arial;") self.button_next.clicked.disconnect(self.gestionar_next) self.button_next.setVisible(True) @@ -29,29 +34,32 @@ class NetworkingSetupPage(Page): super().showEvent(event) self.button_next.setVisible(True) self.button_next.raise_() + if self._is_singbox_mode(): + self.button_next.setVisible(False) + QTimer.singleShot(0, self.prepare_singbox_prereqs) def _setup_ui(self): header_icon = self._icon_label("networking_shield.png", 48, self) header_icon.setGeometry(74, 66, 48, 48) - title = QLabel("Firewall & Managed-DNS Setup", self) - title.setGeometry(138, 68, 590, 44) - title.setStyleSheet("font-family: Arial; font-size: 26px; font-weight: bold; color: cyan;") + self.title_label = QLabel("Firewall & Managed-DNS Setup", self) + self.title_label.setGeometry(138, 68, 590, 44) + self.title_label.setStyleSheet("font-family: Arial; font-size: 26px; font-weight: bold; color: cyan;") - subtitle = QLabel( + self.subtitle_label = QLabel( "Optional privileged networking helpers", self) - subtitle.setGeometry(140, 108, 560, 24) - subtitle.setStyleSheet("font-family: Arial; font-size: 14px; color: #d8ffff;") + self.subtitle_label.setGeometry(140, 108, 560, 24) + self.subtitle_label.setStyleSheet("font-family: Arial; font-size: 14px; color: #d8ffff;") - description = QLabel( + self.description_label = QLabel( "These firewall and managed-DNS helpers are separate bash scripts. " "They stay outside the AppImage Python runtime, and if installed they are placed " "in sudo-protected folders owned by root.", self) - description.setGeometry(80, 152, 640, 72) - description.setWordWrap(True) - description.setStyleSheet("font-family: Arial; font-size: 15px; color: cyan;") + self.description_label.setGeometry(80, 152, 640, 72) + self.description_label.setWordWrap(True) + self.description_label.setStyleSheet("font-family: Arial; font-size: 15px; color: cyan;") manual_panel = self._option_panel(74, 248, "Option 1", "Manual review") manual_text = QLabel( @@ -152,6 +160,90 @@ class NetworkingSetupPage(Page): def _result_message(self, result: Result, fallback): return getattr(result, "message", fallback) + def _is_singbox_mode(self): + return self.singbox_completion is not None + + def configure_for_singbox_profile(self, completion_callback): + self.singbox_completion = completion_callback + self.singbox_setup_started = False + self.singbox_worker = None + self.latest_singbox_message = "" + self.manual_scripts_ready = False + self.title_label.setText("Singbox Prerequisites") + self.subtitle_label.setText("Required for Hysteria2 and VLESS systemwide profiles") + self.description_label.setText( + "Hysteria2 and VLESS need the firewall, managed-DNS, Singbox wrapper, " + "and Singbox binary before the profile can be created.") + self.manual_button.setText("Get scripts") + self.manual_button.setDisabled(False) + self.auto_button.setDisabled(False) + self.button_next.setVisible(False) + self._set_status("Checking Singbox prerequisites...") + return self + + def _sudo_scripts_ready_for_singbox(self): + if not is_singbox_wrapper_ready(): + return False + confirmed = test_if_in_sudo_folder() + return confirmed.valid + + def prepare_singbox_prereqs(self): + if not self._is_singbox_mode(): + return + if not self._sudo_scripts_ready_for_singbox(): + self.manual_button.setDisabled(False) + self.auto_button.setDisabled(False) + self.button_next.setVisible(False) + self._set_status("Firewall, managed-DNS, and the Singbox wrapper are required. Choose manual or automated setup.", "#d8ffff") + return + self.start_singbox_binary_setup() + + def start_singbox_binary_setup(self): + if self.singbox_setup_started: + return + self.singbox_setup_started = True + self.latest_singbox_message = "" + self.manual_button.setDisabled(True) + self.auto_button.setDisabled(True) + self.button_next.setVisible(False) + self._set_status("Sudo wrapper scripts are ready. Setting up Singbox binary...") + self.singbox_worker = WorkerThread('SETUP_SINGBOX_BINARY') + self.singbox_worker.text_output.connect(self._set_singbox_status) + self.singbox_worker.finished.connect(self.on_singbox_setup_finished) + self.singbox_worker.start() + + def _set_singbox_status(self, message): + self.latest_singbox_message = message + self._set_status(message) + + def on_singbox_setup_finished(self, success): + if success: + self._set_status("Singbox setup complete. Creating profile...", "#2ecc71") + QTimer.singleShot(600, self.finish_singbox_prereq_flow) + return + self.singbox_setup_started = False + self.manual_button.setDisabled(False) + self.auto_button.setDisabled(False) + self.button_next.setVisible(True) + self._set_status(self.latest_singbox_message or "Singbox setup failed.", "#ff6b6b") + + def finish_singbox_prereq_flow(self): + completion_callback = self.singbox_completion + self.singbox_completion = None + self.singbox_setup_started = False + self.latest_singbox_message = "" + self.title_label.setText("Firewall & Managed-DNS Setup") + self.subtitle_label.setText("Optional privileged networking helpers") + self.description_label.setText( + "These firewall and managed-DNS helpers are separate bash scripts. " + "They stay outside the AppImage Python runtime, and if installed they are placed " + "in sudo-protected folders owned by root.") + self.manual_button.setText("Get scripts") + self.manual_button.setDisabled(False) + self.auto_button.setDisabled(False) + if completion_callback is not None: + completion_callback() + def handle_manual_setup(self): if self.manual_scripts_ready: self.confirm_sudo_scripts() @@ -169,6 +261,10 @@ class NetworkingSetupPage(Page): def confirm_sudo_scripts(self): confirmed = test_if_in_sudo_folder() if confirmed.valid: + if self._is_singbox_mode(): + self._set_status("Confirmed it worked. Continuing to Singbox setup.", "#2ecc71") + QTimer.singleShot(600, self.start_singbox_binary_setup) + return self._set_status("Confirmed it worked. Continuing to prerequisite installation.", "#2ecc71") QTimer.singleShot(600, self.go_to_install) else: @@ -180,6 +276,10 @@ class NetworkingSetupPage(Page): if results.valid: confirmed = test_if_in_sudo_folder() if confirmed.valid: + if self._is_singbox_mode(): + self._set_status("The setup script ran successfully. Continuing to Singbox setup.", "#2ecc71") + QTimer.singleShot(600, self.start_singbox_binary_setup) + return self._set_status("The setup script ran successfully. Continuing to prerequisite installation.", "#2ecc71") QTimer.singleShot(600, self.go_to_install) else: @@ -190,6 +290,9 @@ class NetworkingSetupPage(Page): f"Setup script did NOT work because {results.message}", "#ff6b6b") def go_to_install(self): + if self._is_singbox_mode(): + self.prepare_singbox_prereqs() + return 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: diff --git a/gui/v2/ui/pages/protocol_page.py b/gui/v2/ui/pages/protocol_page.py index a4d5b22..264bd84 100755 --- a/gui/v2/ui/pages/protocol_page.py +++ b/gui/v2/ui/pages/protocol_page.py @@ -9,6 +9,11 @@ from gui.v2.ui.pages.Page import Page class ProtocolPage(Page): + SINGBOX_PROTOCOLS = ("hysteria2", "vless") + PROTOCOL_BUTTON_ASSETS = { + "hysteria2": "hystria2", + } + def __init__(self, page_stack, main_window=None, parent=None): super().__init__("Protocol", page_stack, main_window, parent) self.main_window = main_window @@ -36,9 +41,11 @@ class ProtocolPage(Page): self.buttons = [] self.selected_page_name = None for j, (object_type, icon_name, page_name, geometry) in enumerate([ - (QPushButton, "wireguard", "wireguard", (585, 90, 185, 75)), - (QPushButton, "residential", "residential", (585, 90+30+75, 185, 75)), - (QPushButton, "hidetor", "hidetor", (585, 90+30+75+30+75, 185, 75)) + (QPushButton, "wireguard", "wireguard", (585, 80, 185, 75)), + (QPushButton, "hysteria2", "location", (585, 160, 185, 75)), + (QPushButton, "vless", "location", (585, 240, 185, 75)), + (QPushButton, "residential", "residential", (585, 320, 185, 75)), + (QPushButton, "hidetor", "hidetor", (585, 400, 185, 75)) ]): boton = object_type(self) boton.setGeometry(*geometry) @@ -46,28 +53,40 @@ class ProtocolPage(Page): boton.setCheckable(True) boton.setDisabled(True) boton.setIcon( - QIcon(os.path.join(self.btn_path, f"{icon_name}_button.png"))) + QIcon(self._button_asset(icon_name))) self.buttons.append(boton) self.buttonGroup.addButton(boton, j) boton.clicked.connect( lambda _, name=page_name, protocol=icon_name: self.show_protocol(name, protocol)) + def _button_asset(self, protocol): + asset_name = self.PROTOCOL_BUTTON_ASSETS.get(protocol, protocol) + return os.path.join(self.btn_path, f"{asset_name}_button.png") + + def _display_asset(self, protocol): + full_size_path = os.path.join(self.btn_path, f"{protocol}.png") + if os.path.exists(full_size_path): + return full_size_path + return self._button_asset(protocol) + def enable_protocol_buttons(self): for button in self.buttons: button.setDisabled(False) def update_swarp_json(self): - self.update_status.write_data( - {"protocol": self.selected_protocol_icon}) + data = {"protocol": self.selected_protocol_icon} + if self.selected_protocol_icon in self.SINGBOX_PROTOCOLS: + data["connection"] = "system-wide" + self.update_status.write_data(data) def show_protocol(self, page_name, protocol): self.update_status.clear_data() - self.display.setPixmap(QPixmap(os.path.join(self.btn_path, f"{protocol}.png")).scaled( + self.display.setPixmap(QPixmap(self._display_asset(protocol)).scaled( self.display.size(), Qt.AspectRatioMode.KeepAspectRatio)) self.selected_protocol_icon = protocol self.selected_page_name = page_name - if protocol in ["wireguard", "hidetor"]: + if protocol in ["wireguard", "hidetor", *self.SINGBOX_PROTOCOLS]: self.button_go.setVisible(True) self.coming_soon_label.setVisible(False) else: diff --git a/gui/v2/ui/pages/resume_page.py b/gui/v2/ui/pages/resume_page.py index 8bce4ed..283d435 100755 --- a/gui/v2/ui/pages/resume_page.py +++ b/gui/v2/ui/pages/resume_page.py @@ -16,6 +16,11 @@ from gui.v2.ui.pages.screen_page import ScreenPage class ResumePage(Page): + SINGBOX_PROTOCOLS = ("hysteria2", "vless") + PROTOCOL_BUTTON_ASSETS = { + "hysteria2": "hystria2", + } + def __init__(self, page_stack, main_window=None, parent=None): super().__init__("Resume", page_stack, main_window, parent) self.update_status = main_window @@ -200,8 +205,11 @@ class ResumePage(Page): parent_label.show() self.labels_creados.append(parent_label) else: - icon_path = os.path.join( - self.btn_path, f"{text}_button.png") + if item == 'protocol': + icon_path = self._protocol_button_asset(text) + else: + icon_path = os.path.join( + self.btn_path, f"{text}_button.png") geometry = (585, initial_y + i * label_height, 185, 75) parent_label = QLabel(self) parent_label.setGeometry(*geometry) @@ -278,8 +286,8 @@ class ResumePage(Page): elif connection_exists: if profile_1.get("connection", "") == "system-wide": - image_path = os.path.join( - self.btn_path, f"wireguard_{profile_1.get('location', '')}.png") + image_path = self._system_profile_image( + profile_1.get('protocol', 'wireguard'), profile_1.get('location', '')) main_label = QLabel(self) main_label.setGeometry(10, 130, 500, 375) main_label.setPixmap(QPixmap(image_path)) @@ -336,6 +344,21 @@ class ResumePage(Page): if hasattr(self, 'arrow_label'): self.arrow_label.raise_() + def _protocol_button_asset(self, protocol): + asset_name = self.PROTOCOL_BUTTON_ASSETS.get(protocol, protocol) + return os.path.join(self.btn_path, f"{asset_name}_button.png") + + def _system_profile_image(self, protocol, location): + candidates = [ + os.path.join(self.btn_path, f"{protocol}_{location}.png"), + os.path.join(self.btn_path, f"icon_{location}.png"), + os.path.join(self.btn_path, "system_wide_global.png"), + ] + for candidate in candidates: + if os.path.exists(candidate): + return candidate + return candidates[-1] + def toggle_button_visibility(self): self.button_go.setVisible(bool(self.line_edit.text())) @@ -344,10 +367,6 @@ class ResumePage(Page): def copy_profile(self): profile_name = self.line_edit.text() - menu_page = self.find_menu_page() - if menu_page: - number_of_profiles = menu_page.number_of_profiles - profile_data = self.update_status.read_data() required_fields = [profile_data.get("protocol"), profile_name] @@ -361,13 +380,29 @@ class ResumePage(Page): profiles = ProfileController.get_all() profile_id = self.get_next_available_id(profiles) new_profile = profile_data + existing_profile_ids = tuple(profiles.keys()) + if new_profile.get('protocol') in self.SINGBOX_PROTOCOLS: + self.show_singbox_prereq_setup(new_profile, profile_id, existing_profile_ids) + return + + self.finish_profile_creation(new_profile, profile_id, existing_profile_ids) + + def show_singbox_prereq_setup(self, profile, profile_id, existing_profile_ids): + setup_page = self.custom_window.navigator.navigate("networking_setup") + if setup_page is None: + self.update_status.update_status("Singbox prerequisite page is unavailable.") + return + setup_page.configure_for_singbox_profile( + lambda: self.finish_profile_creation(profile, profile_id, existing_profile_ids)) + + def finish_profile_creation(self, new_profile, profile_id, existing_profile_ids): self.create_core_profiles(new_profile, profile_id) if ProfileController.get(profile_id) is not None: append_profile_to_visual_order( getattr(self.update_status, 'gui_config_file', None), profile_id, - profiles.keys()) + existing_profile_ids) main = self.update_status if hasattr(main, 'navigate_after_profile_created'): @@ -376,7 +411,6 @@ class ResumePage(Page): self.custom_window.navigator.navigate("menu") self.update_status.clear_data() - self.line_edit.clear() self.display.clear() self.button_go.setVisible(False) @@ -408,8 +442,8 @@ class ResumePage(Page): parts = profile.get('location').split('_') country_code = parts[0] location_code = parts[1] - if profile.get('protocol') == 'wireguard': - connection_type = 'wireguard' + if profile.get('protocol') in ('wireguard', 'hysteria2', 'vless'): + connection_type = profile.get('protocol') elif profile.get('protocol') == 'hidetor' or profile.get('protocol') == 'residential': if profile.get('connection') == 'tor': connection_type = 'tor' diff --git a/gui/v2/workers/worker_thread.py b/gui/v2/workers/worker_thread.py index 6078f10..2d408ca 100755 --- a/gui/v2/workers/worker_thread.py +++ b/gui/v2/workers/worker_thread.py @@ -20,8 +20,10 @@ from core.models.BaseProfile import ProfileType from core.models.system.SystemProfile import SystemProfile from core.errors.exceptions import SudoScript, MissingPreReqs, FirewallError from core.models.Result import Result, ResultError +from core.services.helpers.install_dependencies import setup_singbox_binary as install_singbox_binary from gui.v2.infrastructure.setup_observers import ( + application_version_observer, client_observer, connection_observer, invoice_observer, @@ -69,6 +71,8 @@ class WorkerThread(QThread): self.disable_all_profiles() elif self.action == 'INSTALL_PACKAGE': self.install_package() + elif self.action == 'SETUP_SINGBOX_BINARY': + self.setup_singbox_binary() elif self.action == 'CHECK_FOR_UPDATE': self.check_for_update() elif self.action == 'DOWNLOAD_UPDATE': @@ -124,6 +128,40 @@ class WorkerThread(QThread): f"An error occurred when installing {self.package_name}: {e}") self.finished.emit(False) + def setup_singbox_binary(self): + connection_error = "Connection problems downloading Singbox or related data. Please disable Tor or try again with a better connection." + try: + setup_result = install_singbox_binary(application_version_observer, connection_observer) + except ConnectionError as e: + self.text_output.emit(f"{connection_error}: {str(e)}") + self.finished.emit(False) + return + except ValueError as e: + self.text_output.emit(f"Your configuration files may be corrupted, or a server-side error gave bad data: {str(e)}") + self.finished.emit(False) + return + except Exception as e: + self.text_output.emit(f"Unknown error: {str(e)}") + self.finished.emit(False) + return + + if setup_result.valid: + self.text_output.emit("Setup done. You're all set to proceed with Singbox.") + self.finished.emit(True) + return + + messages = { + ResultError.NEED_SYNC: "You must sync to find out which Singbox version is supported.", + ResultError.FILE_SYSTEM: "Please check the configuration file, disk space, permissions, and filesystem health.", + ResultError.CONNECTION: connection_error, + ResultError.INVALID_INPUT: "This is a rare bug. Check the error logs, then run the program again from the terminal with DEBUG=true.", + ResultError.PERMISSION: "Singbox requires sudo for setup. After that, the wrapper allows it to run without sudo on an ongoing basis.", + ResultError.MISSING_FILE: "Singbox download or file setup did not complete. Please try again.", + ResultError.UNKNOWN: f"Unknown error: {setup_result.message}", + } + self.text_output.emit(messages.get(setup_result.error_type, setup_result.message or "Unknown Singbox setup error")) + self.finished.emit(False) + def disable_all_profiles(self): try: for profile_id in self.profile_data: