From 80407f636d3649faf4e5a9b9d3dc319aedb72437 Mon Sep 17 00:00:00 2001 From: JOhn Date: Fri, 25 Sep 2026 09:11:27 -0400 Subject: [PATCH] update: updated menu page --- gui/v2/actions/flags.py | 10 +- gui/v2/actions/menu_profiles.py | 358 +++++ gui/v2/actions/menu_results.py | 26 + gui/v2/actions/menu_tasks.py | 80 + gui/v2/actions/profile_operations.py | 298 ++++ gui/v2/actions/settings_data.py | 23 +- gui/v2/infrastructure/connection_manager.py | 4 + gui/v2/ui/pages/editor_page.py | 7 +- gui/v2/ui/pages/id_page.py | 5 +- gui/v2/ui/pages/menu_page.py | 1341 ++++------------- .../ui/pages/ticket_or_billing_choice_page.py | 8 +- gui/v2/ui/pages/ticket_prep_page.py | 5 +- gui/v2/ui/popups/confirmation_popup.py | 8 + .../ui/popups/endpoint_verification_popup.py | 6 +- gui/v2/workers/worker.py | 303 +--- gui/v2/workers/worker_thread.py | 193 +-- 16 files changed, 1136 insertions(+), 1539 deletions(-) create mode 100755 gui/v2/actions/menu_profiles.py create mode 100755 gui/v2/actions/menu_results.py create mode 100755 gui/v2/actions/menu_tasks.py create mode 100755 gui/v2/actions/profile_operations.py diff --git a/gui/v2/actions/flags.py b/gui/v2/actions/flags.py index 317728e..3d8ad75 100755 --- a/gui/v2/actions/flags.py +++ b/gui/v2/actions/flags.py @@ -35,14 +35,20 @@ def mark_fast_mode_prompt_shown(gui_config_file): save_gui_config(gui_config_file, config) -def is_fast_registration_enabled(gui_config_file): +def is_fast_registration_enabled(gui_config_file: str | None) -> bool: + """Read fast-registration state here so pages do not inspect configuration sections themselves.""" + if not gui_config_file: + return False config = load_gui_config(gui_config_file) if not config: return False return config.get("registrations", {}).get("fast_registration_enabled", False) -def is_auto_sync_enabled(gui_config_file): +def is_auto_sync_enabled(gui_config_file: str | None) -> bool: + """Read auto-sync policy here so edit actions can orchestrate synchronization without window state.""" + if not gui_config_file: + return False config = load_gui_config(gui_config_file) if not config: return False diff --git a/gui/v2/actions/menu_profiles.py b/gui/v2/actions/menu_profiles.py new file mode 100755 index 0000000..297287e --- /dev/null +++ b/gui/v2/actions/menu_profiles.py @@ -0,0 +1,358 @@ +import os +from datetime import datetime, timezone +from typing import Any, Callable, Iterable, Mapping + +from core.controllers.ConfigurationController import ConfigurationController +from core.controllers.ProfileController import ProfileController +from core.errors.logger import logger +from core.models.BaseProfile import BaseProfile +from core.models.session.SessionProfile import SessionProfile + +from gui.v2.actions.database_health import GuiStorageDatabaseError +from gui.v2.actions.profile_order import normalize_profile_order +from gui.v2.actions.profile_status import is_profile_enabled_for_gui +from gui.v2.actions.settings_data import build_verification_view, truncate_key + + +StatusCallback = Callable[[str], None] +LocationLookup = Callable[[str], Any] + +ENCRYPTED_PROTOCOLS = ("wireguard", "hysteria2", "vless") + + +def _report_load_error(error: Exception, storage_message: str, fallback_message: str, on_error: StatusCallback) -> None: + """Report database failures consistently so every menu operation presents the same recovery advice.""" + logger.error(f"[MENU] {error}") + message = storage_message if isinstance(error, GuiStorageDatabaseError) else fallback_message + on_error(message) + + +def load_profile(profile_id: int, on_error: StatusCallback) -> BaseProfile | None: + """Load one profile here so UI methods do not duplicate controller error handling.""" + try: + return ProfileController.get(int(profile_id)) + except Exception as error: + _report_load_error( + error, + "Local storage database could not be read. Restart and recover storage.db.", + "Could not load profile data. Sync or restart and try again.", + on_error, + ) + return None + + +def load_profiles(on_error: StatusCallback) -> dict[int, BaseProfile]: + """Load all profiles here so list rendering is not responsible for controller failures.""" + try: + return ProfileController.get_all() + except Exception as error: + _report_load_error( + error, + "Local storage database could not be read. Restart and recover storage.db.", + "Could not load profiles. Sync or restart and try again.", + on_error, + ) + return {} + + +def connection_code(profile: BaseProfile | None) -> str: + """Normalize missing connection data once so menu rules do not repeatedly guard model attributes.""" + connection = getattr(profile, "connection", None) + return getattr(connection, "code", None) or "unknown" + + +def location_key(profile: BaseProfile | None) -> str: + """Convert both legacy dictionaries and current location models into the asset key expected by the menu.""" + location = getattr(profile, "location", None) + if isinstance(location, dict): + return f'{location.get("country_code") or "na"}_{location.get("code") or "na"}' + + country_code = getattr(location, "country_code", None) + code = getattr(location, "code", None) + return f"{country_code}_{code}" if country_code and code else "na_na" + + +def browser_info(profile: BaseProfile | None) -> tuple[str, str, bool]: + """Keep browser compatibility interpretation outside Qt because it depends only on core profile data.""" + if not isinstance(profile, SessionProfile): + return "unknown browser", "", False + + application_version = getattr(profile, "application_version", None) + if isinstance(application_version, dict): + return ( + application_version.get("application_code") or "unknown browser", + application_version.get("version_number") or "", + False, + ) + + return ( + getattr(application_version, "application_code", None) or "unknown browser", + getattr(application_version, "version_number", None) or "", + bool(getattr(application_version, "supported", False)), + ) + + +def profile_incomplete_reason(profile: BaseProfile | None) -> str | None: + """Centralize profile readiness rules so the menu and enable worker cannot disagree.""" + if profile is None: + return "Profile data could not be loaded. Sync or restart and try again." + if connection_code(profile) == "unknown": + return "Profile connection data is incomplete. Sync the database and try again." + + location = getattr(profile, "location", None) + if location is None or isinstance(location, dict): + return "Profile location data is incomplete. Sync the database and try again." + if not isinstance(profile, SessionProfile): + return None + + application_version = getattr(profile, "application_version", None) + if isinstance(application_version, dict) or application_version is None: + return "Profile browser data is incomplete. Sync the database and try again." + if not getattr(application_version, "application_code", None) or not getattr(application_version, "version_number", None): + return "Profile browser data is incomplete. Sync the database and try again." + return None + + +def location_operator(profile: BaseProfile | None) -> Any | None: + """Hide legacy location shapes so rendering only receives a usable operator object.""" + location = getattr(profile, "location", None) + return None if isinstance(location, dict) or location is None else getattr(location, "operator", None) + + +def profile_is_session(profile: BaseProfile | None) -> bool: + """Expose profile type as a plain decision so pages do not inspect core model classes.""" + return isinstance(profile, SessionProfile) + + +def _matched_profile(profile_id: int, profile: BaseProfile) -> tuple[str, dict[str, Any]]: + """Build one normalized menu record so ordering does not contain profile-shape branches.""" + protocol = connection_code(profile) + display_protocol = protocol if protocol in ENCRYPTED_PROTOCOLS else "hidetor" + connection_type = "browser-only" if profile_is_session(profile) else "system-wide" + connection_labels = { + "tor": "tor", + "wireguard": connection_type, + "hysteria2": connection_type, + "vless": connection_type, + } + browser, browser_version, browser_supported = browser_info(profile) + return f"Profile_{profile_id}", { + "id": profile_id, + "location": location_key(profile), + "protocol": display_protocol, + "connection": connection_labels.get(protocol, "just proxy"), + "browser": browser, + "browser_version": browser_version, + "browser_supported": browser_supported, + "dimentions": getattr(profile, "resolution", "None"), + "name": getattr(profile, "name", None) or f"Profile {profile_id}", + "incomplete_reason": profile_incomplete_reason(profile), + } + + +def match_core_profiles(profiles: Mapping[int, BaseProfile], gui_config_file: str | None) -> dict[str, dict[str, Any]]: + """Translate core profiles into stable menu data before any Qt widgets are created.""" + profile_ids = normalize_profile_order(gui_config_file, profiles.keys()) + matched_profiles = ( + _matched_profile(profile_id, profiles[profile_id]) + for profile_id in profile_ids + if profile_id in profiles + ) + return dict(matched_profiles) + + +def _enabled_profile_kind(profile: BaseProfile, enabled: bool | None = None) -> str | None: + """Collapse enabled-state and profile-type checks so menu loading stays declarative.""" + try: + is_enabled = is_profile_enabled_for_gui(profile) if enabled is None else enabled + except Exception: + return None + if not is_enabled: + return None + if not profile_is_session(profile): + return "system" + return "session_tor" if connection_code(profile) == "tor" else "session" + + +def load_menu_profiles( + gui_config_file: str | None, on_error: StatusCallback, + selected_id: int | None = None, state_change: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + """Provide one menu-loading action so the page does not separately fetch, order, and classify profiles.""" + profiles = load_profiles(on_error) + state_change = state_change or {} + changed_id = state_change.get("profile_id") + enabled_kinds = { + profile_id: kind + for profile_id, profile in profiles.items() + if (kind := _enabled_profile_kind( + profile, state_change.get("is_enabled") if profile_id == changed_id else None)) is not None + } + + summaries = match_core_profiles(profiles, gui_config_file) + selected = profiles.get(selected_id) + details = None if selected is None else _profile_detail_data( + selected, summaries[f"Profile_{selected_id}"], selected_id in enabled_kinds) + return { + "display_profiles": summaries, + "selected_details": details, + "background": "background_connected" if "system" in enabled_kinds.values() else "original", + "enabled_kinds": enabled_kinds, + } + + +def _profile_detail_data(profile: BaseProfile | None, summary: Mapping[str, Any], is_connected: bool) -> dict[str, Any]: + """Resolve profile display choices once so Qt code only renders the selected presentation.""" + protocol = str(summary.get("protocol", "")).lower() + operator = location_operator(profile) + operator_name = getattr(operator, "name", "") if operator else "" + connection = connection_code(profile) + show_verification = protocol == "wireguard" and is_connected and connection == "wireguard" + show_operator_details = operator_name != "Simplified Privacy" and protocol in ENCRYPTED_PROTOCOLS + detail_modes = { + (True, True): "verification", + (True, False): "verification", + (False, True): "operator_details", + (False, False): "protocol_image", + } + display_mode = detail_modes[(show_verification, show_operator_details)] + profile_location = getattr(profile, "location", None) + profile_location = None if isinstance(profile_location, dict) else profile_location + + return { + **summary, + "profile_loaded": profile is not None, + "message": None if profile is None else profile_status_message(profile, True, not is_connected), + "use_marquee": is_connected, + "selection": {"profile_number": str(summary["id"]), "name": summary["name"], "protocol": protocol}, + "verification_values": [ + (label, value, value if label == "Operator Name" else truncate_key(value)) + for label, value in verification_values(profile) + ], + "location_text": f"{profile_location.country_name}, {profile_location.name}" if profile_location else "", + "background_asset": _detail_background(summary, display_mode, profile_is_session(profile)), + "primary_asset": f"{protocol}.png" if protocol in ("hysteria2", "vless") else f"{protocol}_{summary['location']}.png", + "location_overlay": protocol in ("hysteria2", "vless"), + "overlay_y": {"hysteria2": 0.35}.get(protocol, 0.30), + "raised_primary": protocol == "hidetor" or (protocol == "wireguard" and summary["connection"] == "browser-only"), + "operator_name": operator_name, + "nostr_public_key": getattr(operator, "nostr_public_key", "") if operator else "", + "hydraveil_public_key": getattr(operator, "public_key", "") if operator else "", + "is_session": profile_is_session(profile), + "is_connected": is_connected, + "display_mode": display_mode, + "show_verified_icon": ( + protocol == "wireguard" + and is_connected + and connection == "wireguard" + and ConfigurationController.get_endpoint_verification_enabled() + ), + } + + +def _detail_background(summary: Mapping[str, Any], mode: str, is_session: bool) -> str | None: + """Select background assets using profile policy before Qt starts drawing.""" + if mode == "verification": + return None + protocol = summary["protocol"] + connection = summary["connection"] + if protocol == "hidetor": + prefix = "icon" if connection == "just proxy" else "hdtor" + return f"{prefix}_{summary['location']}.png" + if protocol == "wireguard" and connection == "browser-only": + return "browser only.png" if summary["browser_supported"] else "unsupported_browser_only.png" + return "browser only.png" if mode == "operator_details" and is_session else None + + +def menu_icons(btn_path: str, summary: Mapping[str, Any]) -> list[dict[str, Any]]: + """Prepare icon paths and compatibility warnings without exposing protocol rules to Qt.""" + connection = summary["connection"] + warning = connection not in ("tor", "just proxy") and not summary["browser_supported"] and summary["browser"] != "unknown browser" + return [{"kind": kind, "path": menu_icon_path(btn_path, kind, summary, connection), + "warning": kind == "browser" and warning} for kind in ("protocol", "location", "browser")] + + +def get_profile_details( + profile_id: int, + summary: Mapping[str, Any], + is_connected: bool, + on_error: StatusCallback, +) -> dict[str, Any]: + """Load and prepare profile details as one task so the page never coordinates core model helpers.""" + profile = load_profile(profile_id, on_error) + current_summary = _matched_profile(profile_id, profile)[1] if profile is not None else summary + return _profile_detail_data(profile, current_summary, is_connected) + + +def verification_values(profile: BaseProfile | None) -> list[tuple[str, str]]: + """Prepare full verification values once so copy controls and displayed text cannot diverge.""" + verification = build_verification_view(profile) + values = { + "Operator Name": verification["operator_name"], + "Nostr Key": verification["nostr_public_key"], + "HydraVeil Key": verification["hydraveil_public_key"], + } + return [(label, value or "N/A") for label, value in values.items()] + + +def menu_icon_path(btn_path: str, label_name: str, value: Mapping[str, Any], connection_type: str) -> str: + """Choose menu asset paths outside Qt so protocol mapping remains declarative and reusable.""" + protocol = str(value.get("protocol", "")) + protocol_icons = { + "hysteria2": "hysteria2_mini.png", + "vless": "vless_mini.png", + "tor": "toricon_mini.png", + "just proxy": "just proxy_mini.png", + } + if label_name == "protocol": + filename = protocol_icons.get(protocol) or protocol_icons.get(connection_type) or "wireguard_mini.png" + return os.path.join(btn_path, filename) + if label_name == "browser" and connection_type == "system-wide": + return os.path.join(btn_path, "wireguard_system_wide.png") + if label_name == "browser": + return os.path.join(btn_path, f'{value.get(label_name, "unknown browser")} latest_mini.png') + return os.path.join(btn_path, f'icon_mini_{value.get(label_name, "na_na")}.png') + + +def profile_status_message(profile: BaseProfile, is_enabled: bool, idle: bool = False) -> str: + """Generate status text outside Qt so workers and pages report identical subscription state.""" + subscription = getattr(profile, "subscription", None) + profile_date = getattr(subscription, "expires_at", None) + if profile_date is None: + return "Offline. No subscription found." + + expiration_date = profile_date.replace(tzinfo=timezone.utc) + time_left = expiration_date - datetime.now(timezone.utc) + days_left = time_left.days + hours_left, _ = divmod(time_left.seconds, 3600) + if expiration_date < datetime.now(timezone.utc): + return "Offline. Subscription has expired." + if idle: + return f"Offline. Expires in {days_left} days." + if not is_enabled: + return f"Profile {int(profile.id)} disabled" + + formatted_expiration = expiration_date.strftime("%Y-%m-%d %H:%M:%S") + return f"Profile {int(profile.id)} enabled. Expires on {formatted_expiration}. Time left: {days_left} days, {hours_left} hours." + +def find_verification_icon(profile_id: int, btn_path: str, location_lookup: LocationLookup, on_error: StatusCallback) -> str | None: + """Find the matching verification asset before navigation so the page does not scan files or compare locations.""" + profile = load_profile(profile_id, on_error) + profile_location = getattr(profile, "location", None) + if profile is None or profile_location is None or isinstance(profile_location, dict): + return None + + target = location_lookup(f"{profile_location.country_code}_{profile_location.code}") + if target is None: + return None + + icon_files: Iterable[str] = os.listdir(btn_path) + icon_names = [name.removeprefix("button_").removesuffix(".png") for name in icon_files if name.startswith("button_") and name.endswith(".png")] + matches = [ + name + for name in icon_names + if (candidate := location_lookup(name)) + and candidate.country_code == target.country_code + and candidate.code == target.code + ] + return matches[0] if matches else None diff --git a/gui/v2/actions/menu_results.py b/gui/v2/actions/menu_results.py new file mode 100755 index 0000000..a47f78f --- /dev/null +++ b/gui/v2/actions/menu_results.py @@ -0,0 +1,26 @@ +from typing import Any, Callable, TypedDict + + +class MenuResult(TypedDict, total=False): + status: str + message: str + request: dict[str, Any] + destination: str + button_text: str + profile_id: int + refresh: bool + clear_selection: bool + is_enabled: bool + busy: bool + package_name: str + ticket_number: str + billing_code: str + sync_data: tuple[list, list, bool, bool, list, list] + + +MenuCallback = Callable[[MenuResult], None] + + +def failure(message: str, request: dict[str, Any]) -> MenuResult: + """Keep failures explicit so the GUI never infers success from status text.""" + return {"status": "error", "message": message, "request": dict(request)} diff --git a/gui/v2/actions/menu_tasks.py b/gui/v2/actions/menu_tasks.py new file mode 100755 index 0000000..886fb1a --- /dev/null +++ b/gui/v2/actions/menu_tasks.py @@ -0,0 +1,80 @@ +from functools import partial +from typing import Any, Iterable, Mapping + +from PyQt6.QtCore import Qt + +from gui.v2.actions.menu_results import MenuCallback +from gui.v2.actions.profile_operations import execute_menu_task +from gui.v2.infrastructure.screen_size import get_max_screensize +from gui.v2.workers.worker import Worker + + +_active_workers: set[Worker] = set() + + +def _release_worker(worker: Worker) -> None: + """Retain workers until their native thread finishes, including long-running browser sessions.""" + _active_workers.discard(worker) + worker.deleteLater() + + +def _start_task(request: Mapping[str, Any], on_result: MenuCallback) -> Worker: + """Own worker creation and queued GUI delivery so pages only supply task inputs.""" + operation = partial(execute_menu_task, dict(request), max_resolution=get_max_screensize()) + worker = Worker(operation) + worker.output.connect(on_result, Qt.ConnectionType.QueuedConnection) + worker.finished.connect(partial(_release_worker, worker)) + _active_workers.add(worker) + worker.start() + return worker + + +def enable_profile(profile_data: Mapping[str, Any], on_result: MenuCallback, allow_uninstalled_tor: bool = False) -> Worker: + """Start the complete activation workflow with billing and retry data kept in one request.""" + return _start_task({**profile_data, "task": "enable", "allow_uninstalled_tor": allow_uninstalled_tor}, on_result) + + +def disable_profile(profile_id: int, connected_profile_ids: Iterable[int], on_result: MenuCallback) -> Worker: + """Let the disable task decide whether dependent sessions require confirmation.""" + return _start_task({"task": "disable", "id": profile_id, "connected_ids": list(connected_profile_ids)}, on_result) + + +def delete_profile(profile_id: int, on_result: MenuCallback) -> Worker: + """Capture the deletion target before showing a confirmation.""" + return _start_task({"task": "delete", "id": profile_id}, on_result) + + +def create_profile(gui_config_file: str | None, is_synced: bool, on_result: MenuCallback) -> Worker: + """Let the create task select fast registration and perform any required synchronization.""" + return _start_task({"task": "create", "gui_config_file": gui_config_file, "is_synced": is_synced}, on_result) + + +def edit_profile(gui_config_file: str | None, is_synced: bool, on_result: MenuCallback) -> Worker: + """Let the edit task finish required synchronization before requesting navigation.""" + return _start_task({"task": "edit", "gui_config_file": gui_config_file, "is_synced": is_synced}, on_result) + + +def download_update(on_result: MenuCallback) -> Worker: + """Keep update startup in the action layer rather than the confirmation dialog.""" + return _start_task({"task": "update"}, on_result) + + +def continue_menu_task(request: Mapping[str, Any], choice: str, on_result: MenuCallback) -> Worker | None: + """Resume the captured task without losing accepted exceptions or spending a ticket again.""" + if choice == "abort": + on_result({"status": "cancelled", "message": "Operation cancelled"}) + return None + updates = { + ("tor_download", "continue"): {"allow_uninstalled_tor": True}, + ("billing_tor", "continue"): {"allow_clearweb": True}, + ("force", "continue"): {"ignore_profile_state_conflict": True}, + ("endpoint", "continue"): {"ignore_endpoint_verification": True}, + ("endpoint", "sync"): {"sync_before_enable": True}, + ("disable_all", "continue"): {"confirmed": True}, + ("delete", "continue"): {"confirmed": True}, + } + flags = updates.get((request.get("stage"), choice)) + if flags is None: + on_result({"status": "error", "message": "Unknown confirmation response"}) + return None + return _start_task({**request, **flags}, on_result) diff --git a/gui/v2/actions/profile_operations.py b/gui/v2/actions/profile_operations.py new file mode 100755 index 0000000..5376497 --- /dev/null +++ b/gui/v2/actions/profile_operations.py @@ -0,0 +1,298 @@ +import time +from datetime import datetime, timezone +from functools import partial +from typing import Any + +from core.controllers.ApplicationVersionController import ApplicationVersionController +from core.controllers.ClientController import ClientController +from core.controllers.ConfigurationController import ConfigurationController +from core.controllers.LocationController import LocationController +from core.controllers.ProfileController import ProfileController +from core.controllers.SubscriptionController import SubscriptionController +from core.controllers.SyncController import new_sync +from core.controllers.tickets.UseTicketController import do_we_use_a_random_ticket, get_unused_tickets, use_ticket +from core.Errors import ( + CommandNotFoundError, EndpointVerificationError, FileIntegrityError, + InvalidSubscriptionError, MissingSubscriptionError, ProfileActivationError, + ProfileModificationError, ProfileStateConflictError, UnsupportedApplicationVersionError, +) +from core.models.BaseProfile import BaseProfile +from core.models.Result import Result +from core.models.session.SessionProfile import SessionProfile +from core.models.system.SystemProfile import SystemProfile +from core.observers.ProfileObserver import ProfileObserver +from gui.v2.actions.database_health import GuiStorageDatabaseError +from gui.v2.actions.disable_profiles import disable_profile_via_controller +from gui.v2.actions.flags import is_auto_sync_enabled, is_fast_registration_enabled +from gui.v2.actions.locations import location_candidates +from gui.v2.actions.menu_profiles import profile_incomplete_reason, profile_status_message +from gui.v2.actions.menu_results import MenuCallback, MenuResult, failure +from gui.v2.infrastructure.setup_observers import ( + application_version_observer, client_observer, connection_observer, + profile_observer, ticket_observer, +) + + +def current_connection() -> str: + """Read connection policy in the action layer instead of asking a window to interpret it.""" + return ConfigurationController.get_connection() or "system" + + +def _require_profile(profile_id: int) -> BaseProfile: + """Reject missing profiles before a task can report a misleading success.""" + profile = ProfileController.get(int(profile_id)) + if profile is None: + raise ValueError(f"No profile found with ID: {profile_id}") + return profile + + +def _confirmation(request: dict[str, Any], stage: str, message: str, button_text: str = "Proceed") -> MenuResult: + """Capture the original request so a later selection change cannot redirect confirmation.""" + return {"status": "confirmation", "message": message, "button_text": button_text, "busy": True, + "request": {**request, "stage": stage}} + + +def _billing_entry(profile: BaseProfile, request: dict[str, Any], message: str) -> MenuResult: + """Choose billing navigation without allowing a billing helper to take over activation.""" + if profile.connection.code == "tor" and current_connection() != "tor" and not request.get("allow_clearweb"): + return _confirmation(request, "billing_tor", "The Tor profile needs a subscription; switch to Tor for billing or proceed over clearweb.") + try: + unused = get_unused_tickets(ticket_observer) + setting, _ = do_we_use_a_random_ticket(ticket_observer) + except Exception: + unused, setting = {}, None + has_tickets = isinstance(unused, dict) and unused.get("valid") and bool(unused.get("data")) + destination = "ticket_or_billing_choice" if has_tickets and setting is None else "id" + return {"status": "navigate", "destination": destination, "profile_id": request["id"], + "message": message, "request": dict(request)} + + +def _valid_subscription(profile: BaseProfile) -> bool: + """Avoid spending another ticket when an attached subscription is still valid.""" + expires = getattr(getattr(profile, "subscription", None), "expires_at", None) + if expires is None: + return False + expires = expires.replace(tzinfo=timezone.utc) if expires.tzinfo is None else expires + return expires > datetime.now(timezone.utc) + + +def _ticket_at_location(profile: BaseProfile, ticket: int | str, location: int) -> tuple[str | None, str, bool]: + """Preserve returned billing codes even when ticket validation reports a recoverable failure.""" + outcome = use_ticket(which_ticket=ticket, which_location=location, + ticket_observer=ticket_observer, connection_observer=connection_observer, profile=profile) + if not isinstance(outcome, Result): + return None, "Unexpected ticket result", False + if outcome.data: + return str(outcome.data), "", False + message = outcome.message or getattr(outcome.error_type, "value", "") or "Ticket use failed" + return None, message, "invalid_location" in message + + +def _consume_ticket(profile: BaseProfile, request: dict[str, Any], ticket: int | str) -> MenuResult | None: + """Keep the consumed ticket and billing code together so prompt retries cannot spend it twice.""" + message = "Could not determine profile location for ticket use." + for location in location_candidates(profile, request.get("ticket_location")): + code, message, retry = _ticket_at_location(profile, ticket, location) + if code: + request.update(billing_code=code, consumed_ticket=str(ticket)) + return None + if not retry: + break + return _billing_entry(profile, request, f"Ticket use failed: {message}") + + +def _prepare_subscription(profile: BaseProfile, request: dict[str, Any]) -> MenuResult | None: + """Prepare billing once before activation while retaining recovery data across prompts.""" + if request.get("subscription_attached"): + return None + ticket = request.get("use_ticket") + error = None + automatic = not _valid_subscription(profile) and profile.connection.code == "wireguard" and not getattr(profile, "assassin", False) + if not request.get("billing_code") and ticket is None and automatic: + ticket, error = do_we_use_a_random_ticket(ticket_observer) + if error: + return _billing_entry(profile, request, f"Ticket use failed: {error}") + result = None + if not request.get("billing_code") and ticket not in (None, "error"): + result = _consume_ticket(profile, request, ticket) + if result is not None: + return result + if not request.get("billing_code"): + return None + try: + subscription = SubscriptionController.get(request["billing_code"], connection_observer=connection_observer) + if subscription is not None: + ProfileController.attach_subscription(profile, subscription) + request["subscription_attached"] = True + return None + except Exception as error: + return _subscription_failure(profile, request, str(error)) + return _subscription_failure(profile, request, "The billing code is invalid.") + + +def _subscription_failure(profile: BaseProfile, request: dict[str, Any], message: str) -> MenuResult: + """Expose the ticket-derived billing code for recovery instead of silently losing it on failure.""" + if request.get("consumed_ticket"): + return {"status": "ticket_data_loss", "ticket_number": request["consumed_ticket"], + "billing_code": request["billing_code"], "request": dict(request)} + return _billing_entry(profile, request, message) + + +def _profile_event(emit: MenuCallback, enabled: bool, event: Any) -> None: + """Deliver live browser-session state without leaving permanent callbacks on the global observer.""" + profile_observer.notify("enabled" if enabled else "disabled", event.subject, event.meta or {}) + emit({"status": "state", "is_enabled": enabled, "profile_id": event.subject.id, + "message": profile_status_message(event.subject, enabled), "refresh": True}) + + +def _enable_error(error: Exception, profile: BaseProfile, request: dict[str, Any]) -> MenuResult: + """Translate core errors into UI instructions while retaining the complete activation request.""" + if isinstance(error, EndpointVerificationError): + return {"status": "verification", "busy": True, "message": "Operator verification failed. Do you want to continue anyway?", + "request": {**request, "stage": "endpoint"}} + if isinstance(error, ProfileStateConflictError): + return _confirmation(request, "force", "The profile is already enabled. Do you want to force enable it anyway?") + if isinstance(error, (InvalidSubscriptionError, MissingSubscriptionError)): + return _billing_entry(profile, request, f"Subscription missing or invalid for profile {request['id']}") + if isinstance(error, CommandNotFoundError): + return {"status": "install", "package_name": str(error.subject), "request": dict(request)} + messages = { + ProfileActivationError: "The profile could not be enabled", + UnsupportedApplicationVersionError: "The application version in question is not supported", + FileIntegrityError: "Application version file integrity could not be verified.", + ProfileModificationError: "WireGuard configuration could not be attached.", + } + return {**failure(messages.get(type(error), str(error) or "An unknown error occurred"), request), "refresh": True} + + +def _enable(request: dict[str, Any], emit: MenuCallback, max_resolution: str | None) -> MenuResult: + """Own readiness, billing, activation and recovery as one connect task.""" + synced = synchronize(current_connection()) if request.pop("sync_before_enable", False) else None + if synced is not None: + emit({**synced, "busy": True}) + if synced is not None and synced["status"] == "error": + return failure("Sync failed; the profile was not retried.", request) + profile = _require_profile(request["id"]) + incomplete = profile_incomplete_reason(profile) + if incomplete: + return failure(incomplete, request) + if request.get("stage") == "billing_tor": + return _billing_entry(profile, request, "Enter a billing code or use a ticket.") + needs_download = profile.connection.code == "tor" and not getattr(getattr(profile, "application_version", None), "installed", False) + if needs_download and current_connection() != "tor" and not request.get("allow_uninstalled_tor"): + return _confirmation(request, "tor_download", "The Tor browser is not installed; switch to Tor to download it or proceed over clearweb.") + request["allow_uninstalled_tor"] = True + preparation = _prepare_subscription(profile, request) + if preparation is not None: + return preparation + ignored = {"ignore_endpoint_verification": EndpointVerificationError, + "ignore_profile_state_conflict": ProfileStateConflictError} + observer = ProfileObserver() + observer.subscribe("enabled", partial(_profile_event, emit, True)) + observer.subscribe("disabled", partial(_profile_event, emit, False)) + try: + ProfileController.enable(profile, ignore=tuple(error for flag, error in ignored.items() if request.get(flag)), + profile_observer=observer, application_version_observer=application_version_observer, + connection_observer=connection_observer, ticket_observer=ticket_observer, + max_resolution=max_resolution) + except Exception as error: + return _enable_error(error, profile, request) + return {"status": "success", "request": dict(request), "refresh": True} + + +def synchronize(connection: str) -> MenuResult: + """Report sync success explicitly so failed synchronization never triggers a retry or navigation.""" + try: + ConfigurationController.set_connection(connection) + outcome = new_sync(client_observer=client_observer, connection_observer=connection_observer) + if not outcome.valid: + raise ValueError(outcome.message or "Connection error or invalid sync URL") + locations = LocationController.get_all() + browsers = ApplicationVersionController.get_all() + return {"status": "sync", "sync_data": ( + [f"{item.country_code}_{item.code}" for item in locations], + [f"{item.application_code}:{item.version_number}" for item in browsers if item.supported], + True, connection == "tor", locations, browsers)} + except Exception as error: + return {"status": "error", "message": f"Sync failed: {error}", "sync_data": ([], [], False, False, [], [])} + + +def _disable(request: dict[str, Any]) -> MenuResult: + """Disable dependent browser sessions before their system connection.""" + profile = _require_profile(request["id"]) + connected = request.get("connected_ids", []) + needs_all = isinstance(profile, SystemProfile) and len(connected) > 1 + if needs_all and not request.get("confirmed"): + return _confirmation(request, "disable_all", "All connected session profiles will be disconnected. Do you want to proceed?", "Disable") + profiles = [_require_profile(profile_id) for profile_id in connected] if needs_all else [profile] + return _disable_profiles(profiles) + + +def _disable_profiles(profiles: list[BaseProfile]) -> MenuResult: + """Share ordered teardown with legacy worker callers without duplicating core operations.""" + sessions = [item for item in profiles if isinstance(item, SessionProfile)] + systems = [item for item in profiles if isinstance(item, SystemProfile)] + if len(sessions) + len(systems) != len(profiles): + return failure("Unsupported profile type", {}) + for item in sessions: + disable_profile_via_controller(item) + if sessions and systems: + time.sleep(1) + for item in systems: + disable_profile_via_controller(item) + return {"status": "success", "message": "Profiles disabled", "refresh": True} + + +def _delete(request: dict[str, Any]) -> MenuResult: + """Delete only the confirmed profile and report success separately from display text.""" + if not request.get("confirmed"): + return _confirmation(request, "delete", "Are you sure you want to delete this profile?", "Delete") + profile = _require_profile(request["id"]) + ProfileController.destroy(profile, profile_observer, ticket_observer, connection_observer) + return {"status": "success", "message": f"Profile {profile.id} deleted", "profile_id": profile.id, + "refresh": True, "clear_selection": True} + + +def _navigate(request: dict[str, Any], emit: MenuCallback) -> MenuResult: + """Keep fast-registration and edit synchronization policy out of page signal handlers.""" + fast = is_fast_registration_enabled(request.get("gui_config_file")) + auto = is_auto_sync_enabled(request.get("gui_config_file")) + create = request["task"] == "create" + synced = request.get("is_synced", False) + needs_sync = not synced and ((create and fast) or (not create and auto)) + result = synchronize(current_connection()) if needs_sync else None + if result is not None: + emit({**result, "busy": True}) + synced = result["status"] != "error" + if result is not None and not synced: + return failure("Sync failed. Please try again later.", request) + destinations = {(True, True): "fast_registration", (True, False): "fast_registration", + (False, True): "protocol", (False, False): "sync_screen"} + destination = destinations[(fast, synced)] if create else "editor" + return {"status": "navigate", "destination": destination} + + +def _update() -> MenuResult: + """Delegate the update to core while keeping worker startup outside the prompt.""" + ClientController.update(client_observer=client_observer) + return {"status": "success", "message": "Restart client to apply update."} + + +def execute_menu_task(request: dict[str, Any], emit: MenuCallback, max_resolution: str | None) -> MenuResult: + """Dispatch background operations independently of Qt and normalize their failure results.""" + handlers = {"enable": partial(_enable, request, emit, max_resolution), "disable": partial(_disable, request), + "disable_all": lambda: _disable_profiles([_require_profile(profile_id) for profile_id in request["connected_ids"]]), + "delete": partial(_delete, request), "create": partial(_navigate, request, emit), + "edit": partial(_navigate, request, emit), "update": _update} + try: + return handlers[request["task"]]() + except GuiStorageDatabaseError: + message = "Local storage database could not be read. Restart and recover storage.db." + if request["task"] == "delete": + message = f"Could not delete profile {request['id']}: {message}" + return failure(message, request) + except Exception as error: + message = str(error) or "The operation failed." + if request["task"] == "delete": + message = f"Could not delete profile {request['id']}: {message}" + return {**failure(message, request), "refresh": True} diff --git a/gui/v2/actions/settings_data.py b/gui/v2/actions/settings_data.py index aa4e355..2a82972 100755 --- a/gui/v2/actions/settings_data.py +++ b/gui/v2/actions/settings_data.py @@ -1,5 +1,6 @@ import json import os +from typing import Any from core.Constants import Constants from core.controllers.ConfigurationController import ConfigurationController @@ -112,7 +113,8 @@ def extract_endpoint_ip(profile): return None -def truncate_key(text, max_length=50): +def truncate_key(text: str, max_length: int = 50) -> str: + """Shorten long keys only for display so copy actions can retain the complete value.""" if not text or text == "N/A" or len(text) <= max_length: return text start_len = max_length // 2 - 2 @@ -120,11 +122,10 @@ def truncate_key(text, max_length=50): return text[:start_len] + "....." + text[-end_len:] -def build_verification_view(profile): - operator = None +def build_verification_view(profile: Any | None) -> dict[str, str]: + """Normalize operator verification fields so every screen displays the same key values.""" location = getattr(profile, 'location', None) if profile else None - if location and not isinstance(location, dict) and getattr(location, 'operator', None): - operator = location.operator + operator = None if isinstance(location, dict) or location is None else getattr(location, 'operator', None) if not operator: return { "operator_name": "N/A", @@ -132,11 +133,15 @@ def build_verification_view(profile): "hydraveil_public_key": "N/A", "nostr_attestation_event_reference": "N/A", } + field_attributes = { + "operator_name": "name", + "nostr_public_key": "nostr_public_key", + "hydraveil_public_key": "public_key", + "nostr_attestation_event_reference": "nostr_attestation_event_reference", + } return { - "operator_name": operator.name or "N/A", - "nostr_public_key": operator.nostr_public_key or "N/A", - "hydraveil_public_key": operator.public_key or "N/A", - "nostr_attestation_event_reference": operator.nostr_attestation_event_reference or "N/A", + field: getattr(operator, attribute, None) or "N/A" + for field, attribute in field_attributes.items() } diff --git a/gui/v2/infrastructure/connection_manager.py b/gui/v2/infrastructure/connection_manager.py index 484f41d..179df5d 100755 --- a/gui/v2/infrastructure/connection_manager.py +++ b/gui/v2/infrastructure/connection_manager.py @@ -42,6 +42,10 @@ class ConnectionManager: def get_connected_profiles(self): return list(self._connected_profiles) + def set_connected_profiles(self, profile_ids): + """Replace the cached connection set atomically so pages never mutate private manager state.""" + self._connected_profiles = set(profile_ids) + def set_synced(self, status): self._is_synced = status diff --git a/gui/v2/ui/pages/editor_page.py b/gui/v2/ui/pages/editor_page.py index e7cacd0..100d5ae 100755 --- a/gui/v2/ui/pages/editor_page.py +++ b/gui/v2/ui/pages/editor_page.py @@ -19,6 +19,7 @@ from gui.v2.ui.pages.location_page import LocationPage from gui.v2.ui.pages.screen_page import ScreenPage from gui.v2.ui.popups.confirmation_popup import ConfirmationPopup from gui.v2.workers.page_data_worker import PageDataWorker +from gui.v2.actions.menu_profiles import match_core_profiles class EditorPage(Page): @@ -185,8 +186,10 @@ class EditorPage(Page): new_profiles = self._prefetched_profiles else: new_profiles = ProfileController.get_all() - self.profiles_data = menu_page.match_core_profiles( - profiles_dict=new_profiles) + self.profiles_data = match_core_profiles( + new_profiles, + getattr(self.update_status, "gui_config_file", None), + ) self.data_profile = self.profiles_data[selected_profiles_str].copy( ) diff --git a/gui/v2/ui/pages/id_page.py b/gui/v2/ui/pages/id_page.py index 7f3eaf3..80d638c 100755 --- a/gui/v2/ui/pages/id_page.py +++ b/gui/v2/ui/pages/id_page.py @@ -13,6 +13,7 @@ from core.services.prepare_tickets.ticket_tracker import delete_ticket_data from gui.v2.ui.pages.Page import Page from gui.v2.ui.popups.message_box import style_message_box, mark_confirm_button +from gui.v2.actions.menu_tasks import enable_profile as enable_profile_action HOW_MANY_PROFILES_DEFAULT = 6 @@ -49,7 +50,9 @@ class IdPage(Page): } menu_page = self.find_menu_page() if menu_page: - menu_page.enabling_profile(profile_data) + self.profile_enable_worker = enable_profile_action( + profile_data, menu_page.handle_task_result, allow_uninstalled_tor=True, + ) else: self.update_status.update_status( diff --git a/gui/v2/ui/pages/menu_page.py b/gui/v2/ui/pages/menu_page.py index a3a614e..291d419 100755 --- a/gui/v2/ui/pages/menu_page.py +++ b/gui/v2/ui/pages/menu_page.py @@ -1,37 +1,28 @@ import functools import os -import threading +from itertools import chain from PyQt6 import QtCore, QtGui -from PyQt6.QtCore import Qt, QSize +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 core.controllers.ConfigurationController import ConfigurationController -from core.controllers.ProfileController import ProfileController -from core.errors.logger import logger -from core.controllers.tickets.UseTicketController import ( - do_we_use_a_random_ticket, - get_unused_tickets, +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 as create_profile_action, + delete_profile as delete_profile_action, disable_profile as disable_profile_action, + download_update, edit_profile as edit_profile_action, enable_profile as enable_profile_action, ) -from core.models.session.SessionProfile import SessionProfile -from core.models.system.SystemProfile import SystemProfile - -from gui.v2.infrastructure.setup_observers import ticket_observer -from gui.v2.actions.database_health import GuiStorageDatabaseError -from gui.v2.actions.profile_order import normalize_profile_order -from gui.v2.actions.profile_status import is_profile_enabled_for_gui +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 -from gui.v2.workers.worker import Worker -from gui.v2.workers.worker_thread import WorkerThread class MenuPage(Page): @@ -39,9 +30,6 @@ class MenuPage(Page): super().__init__("Menu", page_stack, main_window, parent) self.main_window = main_window self.connection_manager = main_window.connection_manager - self.is_tor_mode = main_window.is_tor_mode - self.is_animating = main_window.is_animating - self.animation_step = main_window.animation_step self.btn_path = main_window.btn_path self.page_stack = page_stack self.profile_info = {} @@ -72,9 +60,9 @@ class MenuPage(Page): self.connected_tor_profile = os.path.join( self.btn_path, "button_session_tor.png") self.worker = None - self.IsSystem: int = 0 + self.reverse_id = None + self._task_busy = False self.button_states = {} - self.is_system_connected = False self.profile_button_map = {} self.font_style = f"font-family: '{main_window.open_sans_family}';" if main_window.open_sans_family else "" @@ -82,37 +70,40 @@ class MenuPage(Page): def showEvent(self, event): super().showEvent(event) - if hasattr(self, 'buttons'): - for button in self.buttons: - parent = button.parent() - if parent: - verification_icons = parent.findChildren(QPushButton) - for icon in verification_icons: - if icon.geometry().width() == 20 and icon.geometry().height() == 20: - icon.setEnabled( - self.connection_manager.is_synced()) + 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 = WorkerThread('DOWNLOAD_UPDATE') - self.worker.start() + self.worker = download_update(self.handle_task_result) else: self.update_status.update_status('Update canceled') def create_interface_elements(self): - for icon_name, function, position in [ - ("create_profile", self.create_prof, (410, 440, 110, 60)), - ("edit_profile", self.edit_prof, (540, 440, 110, 60)), - ("launch", self.launch, (0, 440, 185, 63)), - ("disconnect", self.disconnect, (200, 433, 185, 73)), - ("disconnect_system_wide", self.disconnect, (200, 433, 185, 73)), - ("just", self.connect, (200, 433, 185, 73)), - ("just_session", self.connect, (200, 433, 185, 73)), - ("settings", self.settings_gui, (670, 440, 110, 60)), - ]: + 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") @@ -120,35 +111,24 @@ class MenuPage(Page): boton.setIcon(icon) boton.setIconSize(boton.size()) boton.clicked.connect(functools.partial(function)) - boton.setEnabled(False) - if icon_name == "disconnect": - boton.setEnabled(True) - boton.setVisible(False) - self.disconnect_button = boton - if icon_name == "launch": - self.boton_launch = boton - self.boton_launch.setVisible(False) - if icon_name == "create_profile": - self.boton_create = boton - self.boton_create.setEnabled(True) - if icon_name == "edit_profile": - self.boton_edit = boton - if icon_name == "just": - boton.setVisible(False) - self.boton_just = boton - self.connect_button = boton - if icon_name == "just_session": - self.boton_just_session = boton - self.connect_button = boton - if icon_name == "disconnect_system_wide": - boton.setEnabled(True) - boton.setVisible(False) - self.disconnect_system_wide_button = boton - if icon_name == "settings": - self.settings_button = boton - self.settings_button.setEnabled(True) + boton.setEnabled(enabled) + boton.setVisible(visible) + buttons[icon_name] = boton - self.create() + 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( @@ -156,257 +136,40 @@ class MenuPage(Page): popup.setWindowModality(Qt.WindowModality.ApplicationModal) return popup - def _show_disconnect_confirmation(self, connected_profiles, button_text='Disable', message=None): - self.popup = self._make_confirmation_popup(message, button_text) - - def on_result(result): - if result: - self.update_status.update_status('Disabling profiles...') - self.worker_thread = WorkerThread( - 'DISABLE_ALL_PROFILES', profile_data=connected_profiles) - self.worker_thread.text_output.connect( - lambda text: self.update_status.update_status(text)) - self.worker_thread.start() - - self.popup.finished.connect(on_result) - self.popup.show() - def delete_profile(self): - self.popup = ConfirmationPopup(self, message="Are you sure you want to\ndelete this profile?", - action_button_text="Delete", cancel_button_text="Cancel") - self.popup.setWindowModality(Qt.WindowModality.ApplicationModal) - self.popup.finished.connect( - lambda result: self.on_popup_finished(result)) - self.popup.show() - - def on_popup_finished(self, result): - if result: - profile_id = self.reverse_id - self.update_status.update_status(f'Deleting profile...') - self.worker_thread = WorkerThread( - 'DESTROY_PROFILE', profile_data={'id': profile_id}) - self.worker_thread.text_output.connect(self.delete_status_update) - self.worker_thread.start() - else: - pass - - def delete_status_update(self, text): - self.update_status.update_status(text) - if not text.startswith('Could not delete'): - self.clear_profile_details() - self.eliminacion() - - def show_disconnect_button(self, toggle, profile_type, target_button): - try: - if target_button.isChecked(): - if profile_type == 1: - self.disconnect_button.setVisible(toggle) - self.boton_just_session.setVisible(not toggle) - else: - self.disconnect_system_wide_button.setVisible(toggle) - self.boton_just.setVisible(not toggle) - except RuntimeError: - pass - - def _safe_profiles(self): - try: - return ProfileController.get_all() - except GuiStorageDatabaseError as error: - logger.error(f"[MENU] Storage database error while loading profiles: {error}") - self.update_status.update_status("Local storage database could not be read. Restart and recover storage.db.") - except Exception as error: - logger.error(f"[MENU] Error while loading profiles: {error}") - self.update_status.update_status("Could not load profiles. Sync or restart and try again.") - return {} - - def _safe_profile(self, profile_id): - try: - return ProfileController.get(int(profile_id)) - except GuiStorageDatabaseError as error: - logger.error(f"[MENU] Storage database error while loading profile {profile_id}: {error}") - self.update_status.update_status("Local storage database could not be read. Restart and recover storage.db.") - except Exception as error: - logger.error(f"[MENU] Error while loading profile {profile_id}: {error}") - self.update_status.update_status("Could not load profile data. Sync or restart and try again.") - return None - - def _connection_code(self, profile): - connection = getattr(profile, 'connection', None) - return getattr(connection, 'code', None) or 'unknown' - - def _location_key(self, profile): - location = getattr(profile, 'location', None) - if isinstance(location, dict): - country_code = location.get("country_code") or "na" - code = location.get("code") or "na" - return f'{country_code}_{code}' - - country_code = getattr(location, 'country_code', None) - code = getattr(location, 'code', None) - if country_code and code: - return f'{country_code}_{code}' - - return 'na_na' - - def _browser_info(self, profile): - if not isinstance(profile, SessionProfile): - return "unknown browser", "", False - - application_version = getattr(profile, 'application_version', None) - if isinstance(application_version, dict): - browser = application_version.get('application_code') or "unknown browser" - version = application_version.get('version_number') or "" - return browser, version, False - - browser = getattr(application_version, 'application_code', None) or "unknown browser" - version = getattr(application_version, 'version_number', None) or "" - supported = bool(getattr(application_version, 'supported', False)) - return browser, version, supported - - def _profile_incomplete_reason(self, profile): - if profile is None: - return "Profile data could not be loaded. Sync or restart and try again." - - connection = getattr(profile, 'connection', None) - if not getattr(connection, 'code', None): - return "Profile connection data is incomplete. Sync the database and try again." - - location = getattr(profile, 'location', None) - if location is None or isinstance(location, dict): - return "Profile location data is incomplete. Sync the database and try again." - - if isinstance(profile, SessionProfile): - application_version = getattr(profile, 'application_version', None) - if isinstance(application_version, dict) or application_version is None: - return "Profile browser data is incomplete. Sync the database and try again." - if not getattr(application_version, 'application_code', None) or not getattr(application_version, 'version_number', None): - return "Profile browser data is incomplete. Sync the database and try again." - - return None - - def _location_operator(self, profile): - location = getattr(profile, 'location', None) - if isinstance(location, dict) or location is None: - return None - return getattr(location, 'operator', None) - - def match_core_profiles(self, profiles_dict): - new_dict = {} - - profile_ids = normalize_profile_order( - getattr(self.update_status, 'gui_config_file', None), - profiles_dict.keys()) - - for idx in profile_ids: - profile = profiles_dict.get(idx) - if profile is None: - continue - - new_profile = {} - protocol = self._connection_code(profile) - location = self._location_key(profile) - - new_profile['location'] = location - - 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 in ('wireguard', 'hysteria2', 'vless'): - new_profile['connection'] = connection_type - elif protocol == 'tor': - new_profile['connection'] = protocol - else: - new_profile['connection'] = 'just proxy' - - browser, browser_version, browser_supported = self._browser_info(profile) - new_profile['browser'] = browser - new_profile['browser_version'] = browser_version - new_profile['browser_supported'] = browser_supported - - resolution = profile.resolution if hasattr( - profile, 'resolution') else 'None' - new_profile['dimentions'] = resolution - - new_profile['name'] = getattr(profile, 'name', None) or f'Profile {idx}' - new_profile['incomplete_reason'] = self._profile_incomplete_reason(profile) - - - new_dict[f'Profile_{idx}'] = new_profile - - return new_dict - - def create(self): - self.boton_just.setEnabled(False) - self.boton_just_session.setEnabled(False) - self.boton_edit.setEnabled(False) - if hasattr(self, 'verification_button'): - self.verification_button.setEnabled(False) - self.profiles_data = self.match_core_profiles( - self._safe_profiles()) - - self.number_of_profiles = len(self.profiles_data) - self.profile_info = dict(self.profiles_data) - - 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() + self._set_task_busy(True) + self.worker = delete_profile_action(self.reverse_id, self.handle_task_result) def refresh_profiles_data(self): - self.profiles_data = self.match_core_profiles( - self._safe_profiles()) + 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.profile_info = dict(self.profiles_data) - for profile_name, profile_value in self.profiles_data.items(): - self.create_profile_widget(profile_name, profile_value) - self.update_scroll_widget_size() - - def refresh_menu_buttons(self): - profiles = self._safe_profiles() - self.button_states.clear() - self.connection_manager._connected_profiles.clear() - self.IsSystem = 0 - for profile_id, profile in profiles.items(): - try: - is_enabled = is_profile_enabled_for_gui(profile) - except Exception: - is_enabled = False - if is_enabled: - self.connection_manager.add_connected_profile(profile_id) - if isinstance(profile, SessionProfile): - if profile.connection and profile.connection.code == 'tor': - self.button_states[profile_id] = self.connected_tor_profile - else: - self.button_states[profile_id] = self.connected_profile - elif isinstance(profile, SystemProfile): - self.button_states[profile_id] = self.system_wide_connected_profile - self.IsSystem += 1 - - if self.IsSystem > 0: - self.update_status.update_image( - appearance_value='background_connected') - else: - self.update_status.update_image() - - for widget in self.scroll_widget.findChildren(QLabel): - widget.setParent(None) - - for widget in self.scroll_widget.findChildren(QPushButton): - widget.setParent(None) - + 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 = [] - - self.create() + 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: @@ -433,14 +196,15 @@ class MenuPage(Page): self.boton_just_session.setVisible(False) self.disconnect_button.setVisible(False) self.disconnect_system_wide_button.setVisible(False) - if hasattr(self, 'button_group'): - self.button_group.setExclusive(False) - for button in getattr(self, 'buttons', []): - button.setChecked(False) - self.button_group.setExclusive(True) + 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 = int(key.split('_')[1]) + profile_id = value['id'] parent_label = self.create_parent_label(profile_id) button = self.create_profile_button(parent_label, profile_id, key) @@ -501,109 +265,44 @@ class MenuPage(Page): child_label.show() def create_profile_icons(self, parent_label, value): - connection_type = value.get('connection', '') - if connection_type in ['tor', 'just proxy']: - self.create_tor_proxy_icons(parent_label, value, connection_type) - else: - self.create_other_icons(parent_label, value, connection_type) - - def create_tor_proxy_icons(self, parent_label, value, connection_type): - for j, label_name in enumerate(['protocol', 'location', 'browser']): + for index, icon in enumerate(menu_icons(self.btn_path, value)): child_label = QLabel(parent_label) - child_label.setObjectName(f"label_{j}") - child_label.setGeometry(3 + j * 60, 5, 50, 50) child_label.setObjectName("label_profiles") - child_label.setAttribute( - Qt.WidgetAttribute.WA_TransparentForMouseEvents) - child_label.setStyleSheet( - "background-color: rgba(128, 128, 128, 0.5); border-radius: 25px;") - image_path = self.get_icon_path(label_name, value, connection_type) - pixmap = QPixmap(image_path) - if pixmap.isNull() and label_name == 'location': - fallback_path = os.path.join( - self.btn_path, "default_location_mini.png") - pixmap = QPixmap(fallback_path) - if pixmap.isNull(): - pixmap = QPixmap(50, 50) - pixmap.fill(QtGui.QColor(200, 200, 200)) - if pixmap.isNull() and label_name == 'browser': - fallback_path = os.path.join( - self.btn_path, "default_browser_mini.png") - pixmap = QPixmap(fallback_path) - if pixmap.isNull(): - pixmap = QPixmap(50, 50) - pixmap.fill(QtGui.QColor(200, 200, 200)) - child_label.setPixmap(pixmap) + 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 create_other_icons(self, parent_label, value, connection_type): - for j, label_name in enumerate(['protocol', 'location', 'browser']): - child_label = QLabel(parent_label) - child_label.setObjectName(f"label_{j}") - child_label.setGeometry(3 + j * 60, 5, 50, 50) - child_label.setObjectName("label_profiles") - child_label.setAttribute( - Qt.WidgetAttribute.WA_TransparentForMouseEvents) - child_label.setStyleSheet( - "background-color: rgba(128, 128, 128, 0.5); border-radius: 25px;") + 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 - image_path = self.get_icon_path(label_name, value, connection_type) - pixmap = QPixmap(image_path) - if pixmap.isNull() and label_name == 'location': - fallback_path = os.path.join( - self.btn_path, "default_location_mini.png") - pixmap = QPixmap(fallback_path) - if pixmap.isNull(): - pixmap = QPixmap(50, 50) - pixmap.fill(QtGui.QColor(200, 200, 200)) - if pixmap.isNull() and label_name == 'browser': - fallback_path = os.path.join( - self.btn_path, "default_browser_mini.png") - pixmap = QPixmap(fallback_path) - if pixmap.isNull(): - pixmap = QPixmap(50, 50) - pixmap.fill(QtGui.QColor(200, 200, 200)) + pixmap = QPixmap(os.path.join(self.btn_path, fallback_name)) + if not pixmap.isNull(): + return pixmap - if label_name == 'browser' and not value.get('browser_supported', True) and value.get('browser') != 'unknown browser': - warning_pixmap = QPixmap( - os.path.join(self.btn_path, 'warning.png')) - painter = QPainter(pixmap) - painter.drawPixmap(pixmap.width() - 22, - pixmap.height() - 21, warning_pixmap) - painter.end() + placeholder = QPixmap(50, 50) + placeholder.fill(QtGui.QColor(200, 200, 200)) + return placeholder - child_label.setPixmap(pixmap) - child_label.show() + 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 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': - return os.path.join(self.btn_path, "just proxy_mini.png") - - 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, "wireguard_system_wide.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") - - return os.path.join(self.btn_path, f"icon_mini_{value[label_name]}.png") - - def truncate_key(self, text, max_length=50): - if not text or text == "N/A" or len(text) <= max_length: - return text - start_len = max_length // 2 - 2 - end_len = max_length // 2 - 2 - return text[:start_len] + "....." + text[-end_len:] - - def create_verification_widget(self, profile_obj): + def create_verification_widget(self, values): verification_widget = QWidget(self) verification_widget.setGeometry(0, 90, 400, 300) verification_widget.setStyleSheet("background: transparent;") @@ -612,15 +311,7 @@ class MenuPage(Page): verification_layout.setContentsMargins(20, 20, 20, 20) verification_layout.setSpacing(15) - operator = self._location_operator(profile_obj) - - info_items = [ - ("Operator Name", "operator_name"), - ("Nostr Key", "nostr_public_key"), - ("HydraVeil Key", "hydraveil_public_key"), - ] - - for label, key in info_items: + for label, full_value, value in values: container = QWidget() container.setStyleSheet("background: transparent;") container_layout = QHBoxLayout(container) @@ -636,22 +327,6 @@ class MenuPage(Page): value_widget = QLineEdit() value_widget.setReadOnly(True) - if operator: - if key == "operator_name": - value = operator.name or "N/A" - elif key == "nostr_public_key": - value = operator.nostr_public_key or "N/A" - value = self.truncate_key( - value) if value != "N/A" else value - elif key == "hydraveil_public_key": - value = operator.public_key or "N/A" - value = self.truncate_key( - value) if value != "N/A" else value - else: - value = "N/A" - else: - value = "N/A" - value_widget.setText(value) value_widget.setCursorPosition(0) value_widget.setStyleSheet(f""" @@ -672,10 +347,7 @@ class MenuPage(Page): f"color: #4CAF50; font-size: 20px; font-weight: bold; {self.font_style}") checkmark_widget.setFixedSize(20, 20) checkmark_widget.setAlignment(Qt.AlignmentFlag.AlignCenter) - if value and value != "N/A": - checkmark_widget.show() - else: - checkmark_widget.hide() + checkmark_widget.setVisible(bool(value and value != "N/A")) container_layout.addWidget(checkmark_widget) copy_button = QPushButton() @@ -694,18 +366,6 @@ class MenuPage(Page): }} """) - if operator: - if key == "operator_name": - full_value = operator.name or "N/A" - elif key == "nostr_public_key": - full_value = operator.nostr_public_key or "N/A" - elif key == "hydraveil_public_key": - full_value = operator.public_key or "N/A" - else: - full_value = "N/A" - else: - full_value = "N/A" - copy_button.clicked.connect( lambda checked=False, val=full_value: self.copy_verification_value(val)) container_layout.addWidget(copy_button) @@ -723,248 +383,85 @@ class MenuPage(Page): "Verification value copied to clipboard") def print_profile_details(self, profile_name): - self.reverse_id = int(profile_name.split('_')[1]) + 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: - self.connection_manager.set_profile_button_objects( - self.reverse_id, target_button) - else: - print(f"No button found for profile {self.reverse_id}") - is_connected = self.connection_manager.is_profile_connected( - int(self.reverse_id)) - if is_connected: - self.boton_edit.setEnabled(False) - else: - self.boton_edit.setEnabled(True) - - profile = self.profile_info.get(profile_name) - self.boton_launch.setEnabled(True) - self.boton_just.setEnabled(True) - self.boton_just_session.setEnabled(True) - self.boton_create.setEnabled(True) - if hasattr(self, 'buttons'): - for button in self.buttons: - parent = button.parent() - if parent: - verification_icons = parent.findChildren(QPushButton) - for icon in verification_icons: - if icon.geometry().width() == 20 and icon.geometry().height() == 20: - icon.setEnabled( - self.connection_manager.is_synced()) - + 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"]) - self.change_connect_button() + 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 - if profile: - self.update_status.current_profile_id = self.reverse_id - profile_number = profile_name.split("_")[1] - name = profile.get("name", "") - protocol = profile.get("protocol", "") - location = profile.get("location", "") - connection = profile.get("connection", "") - country_garaje = profile.get("country_garaje", "") + def _render_verification(self, details): + self.additional_labels.append(self.create_verification_widget(details["verification_values"])) - profile_obj = self._safe_profile(self.reverse_id) - is_profile_enabled = self.connection_manager.is_profile_connected( - self.reverse_id) - show_verification_widget = False - label_principal = None - label_tor = None - text_color = "white" - profile_location = getattr(profile_obj, 'location', None) - if isinstance(profile_location, dict): - profile_location = None - profile_operator = self._location_operator(profile_obj) - operator_name = getattr(profile_operator, 'name', '') if profile_operator else "" - if protocol.lower() == "wireguard" and is_profile_enabled and profile_obj and profile_obj.connection and profile_obj.connection.code == 'wireguard': - verification_widget = self.create_verification_widget( - profile_obj) - self.additional_labels.append(verification_widget) - show_verification_widget = True - label_principal = None + 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) - elif operator_name != 'Simplified Privacy' and protocol.lower() in ("wireguard", "hysteria2", "vless"): - if isinstance(profile_obj, SessionProfile): - text_color = "black" - label_background = QLabel(self) - label_background.setGeometry(0, 60, 410, 354) - pixmap_bg = QPixmap(os.path.join( - self.btn_path, "browser only.png")) - label_background.setPixmap(pixmap_bg) - label_background.setScaledContents(True) - label_background.show() - label_background.lower() - self.additional_labels.append(label_background) + 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) - l_img = QLabel(self) - l_img.setGeometry(20, 125, 100, 100) - pixmap_loc = QPixmap(os.path.join( - self.btn_path, f"icon_{location}.png")) - l_img.setPixmap(pixmap_loc) - l_img.setScaledContents(True) - l_img.show() - self.additional_labels.append(l_img) - l_name = f"{profile_location.country_name}, {profile_location.name}" if profile_location else "" - o_name = getattr(profile_operator, 'name', '') if profile_operator else "" - n_key = getattr(profile_operator, 'nostr_public_key', '') if profile_operator else "" - - info_txt = QTextEdit(self) - info_txt.setGeometry(130, 110, 260, 250) - info_txt.setReadOnly(True) - info_txt.setFrameStyle(QFrame.Shape.NoFrame) - info_txt.setStyleSheet( - f"background: transparent; border: none; color: {text_color}; font-size: 17px;") - info_txt.setVerticalScrollBarPolicy( - Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - info_txt.setHorizontalScrollBarPolicy( - Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - info_txt.setWordWrapMode( - QtGui.QTextOption.WrapMode.WrapAtWordBoundaryOrAnywhere) - info_txt.setText( - f"Location: {l_name}\n\nOperator: {o_name}") - info_txt.show() - self.additional_labels.append(info_txt) - - nostr_txt = QTextEdit(self) - nostr_txt.setGeometry(20, 260, 370, 80) - nostr_txt.setReadOnly(True) - nostr_txt.setFrameStyle(QFrame.Shape.NoFrame) - nostr_txt.setStyleSheet( - f"background: transparent; border: none; color: {text_color}; font-size: 17px;") - nostr_txt.setVerticalScrollBarPolicy( - Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - nostr_txt.setHorizontalScrollBarPolicy( - Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - nostr_txt.setWordWrapMode( - QtGui.QTextOption.WrapMode.WrapAnywhere) - nostr_txt.setText(f"Nostr: {n_key}") - nostr_txt.show() - self.additional_labels.append(nostr_txt) - - elif protocol.lower() in ["wireguard", "open", "residential", "hidetor", "hysteria2", "vless"]: - label_principal = QLabel(self) - label_principal.setGeometry(0, 90, 400, 300) - if protocol.lower() in ["hysteria2", "vless"]: - pixmap = self.build_encrypted_proxy_detail_pixmap( - protocol.lower(), location) - else: - pixmap = QPixmap(os.path.join( - self.btn_path, f"{protocol}_{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", "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': - verified_icon = QLabel(self) - verified_icon.setGeometry(20, 50, 100, 80) - verified_pixmap = QPixmap(os.path.join( - self.btn_path, "verified_profile.png")) - verified_icon.setPixmap(verified_pixmap) - verified_icon.setScaledContents(True) - verified_icon.show() - self.additional_labels.append(verified_icon) - - if protocol.lower() == "hidetor" and not show_verification_widget: - - if label_principal: - label_principal.setGeometry(0, 80, 400, 300) - label_background = QLabel(self) - label_background.setGeometry(0, 60, 410, 354) - if connection == 'just proxy': - image_path = os.path.join( - self.btn_path, f"icon_{location}.png") - else: - image_path = os.path.join( - self.btn_path, f"hdtor_{location}.png") - pixmap = QPixmap(image_path) - label_background.setPixmap(pixmap) - label_background.show() - label_background.setScaledContents(True) - label_background.lower() - self.additional_labels.append(label_background) - - if protocol.lower() == "wireguard" and connection.lower() == "browser-only" and not show_verification_widget: - - if label_principal: - label_principal.setGeometry(0, 80, 400, 300) - - label_background = QLabel(self) - label_background.setGeometry(0, 60, 410, 354) - is_supported = profile.get('browser_supported', False) - image_name = "browser only.png" if is_supported else "unsupported_browser_only.png" - pixmap = QPixmap(os.path.join(self.btn_path, image_name)) - label_background.setPixmap(pixmap) - label_background.show() - label_background.setScaledContents(True) - label_background.lower() - self.additional_labels.append(label_background) - - if protocol.lower() == "residential" and connection.lower() == "tor": - - label_background = QLabel(self) - label_background.setGeometry(0, 60, 410, 354) - pixmap = QPixmap(os.path.join( - self.btn_path, "browser_tor.png")) - label_background.setPixmap(pixmap) - label_background.show() - label_background.setScaledContents(True) - self.additional_labels.append(label_background) - - label_garaje = QLabel(self) - label_garaje.setGeometry(5, 110, 400, 300) - pixmap = QPixmap(os.path.join( - self.btn_path, f"{country_garaje} garaje.png")) - label_garaje.setPixmap(pixmap) - label_garaje.show() - label_garaje.setScaledContents(True) - self.additional_labels.append(label_garaje) - - label_tor = QLabel(self) - label_tor.setGeometry(330, 300, 75, 113) - - pixmap = QPixmap(os.path.join(self.btn_path, "tor 86x130.png")) - if label_tor: - label_tor.setPixmap(pixmap) - label_tor.show() - label_tor.setScaledContents(True) - self.additional_labels.append(label_tor) - if protocol.lower() == "residential" and connection.lower() == "just proxy": - - label_background = QLabel(self) - label_background.setGeometry(0, 60, 410, 354) - pixmap = QPixmap(os.path.join( - self.btn_path, "browser_just_proxy.png")) - label_background.setPixmap(pixmap) - label_background.show() - label_background.setScaledContents(True) - self.additional_labels.append(label_background) - - label_garaje = QLabel(self) - label_garaje.setGeometry(5, 110, 400, 300) - pixmap = QPixmap(os.path.join( - self.btn_path, f"{country_garaje} garaje.png")) - label_garaje.setPixmap(pixmap) - label_garaje.show() - label_garaje.setScaledContents(True) - self.additional_labels.append(label_garaje) - - editar_profile = {"profile_number": profile_number, - "name": name, "protocol": protocol} - self.add_selected_profile(editar_profile) - - self.current_profile_location = location - - def build_encrypted_proxy_detail_pixmap(self, protocol, location): - pixmap = QPixmap(os.path.join(self.btn_path, f"{protocol}.png")) + def build_encrypted_proxy_detail_pixmap(self, pixmap, location, overlay_y): if pixmap.isNull(): return pixmap @@ -983,10 +480,7 @@ class MenuPage(Page): Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) x = int(pixmap.width() * 0.58) - if protocol == 'hysteria2': - y = int(pixmap.height() * 0.35) - else: - y = int(pixmap.height() * 0.30) + 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())) @@ -996,27 +490,12 @@ class MenuPage(Page): return pixmap def show_profile_verification(self, profile_id): - profile = self._safe_profile(profile_id) - profile_location = getattr(profile, 'location', None) - if not profile or not profile_location or isinstance(profile_location, dict): - return - - location_key = f'{profile_location.country_code}_{profile_location.code}' - location_info = self.connection_manager.get_location_info(location_key) - if not location_info: - return - - location_icon_name = None - btn_path = self.btn_path - - for icon_file in os.listdir(btn_path): - if icon_file.startswith('button_') and icon_file.endswith('.png'): - icon_name = icon_file.replace( - 'button_', '').replace('.png', '') - test_loc = self.connection_manager.get_location_info(icon_name) - if test_loc and test_loc.country_code == location_info.country_code and test_loc.code == location_info.code: - location_icon_name = icon_name - break + 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( @@ -1025,152 +504,32 @@ class MenuPage(Page): self.page_stack.setCurrentIndex( self.page_stack.indexOf(verification_page)) - def launch(self): - pass + 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.boton_just.setEnabled(False) - self.boton_just_session.setEnabled(False) - - profile = self._safe_profile(int(self.reverse_id)) - incomplete_reason = self._profile_incomplete_reason(profile) - if incomplete_reason: - self.update_status.update_status(incomplete_reason) - self.boton_just.setEnabled(True) - self.boton_just_session.setEnabled(True) - return - - try: - is_tor = self.update_status.get_current_connection() == 'tor' - except Exception: - is_tor = False - - connection = getattr(profile, 'connection', None) - application_version = getattr(profile, 'application_version', None) - if getattr(connection, 'code', None) == 'tor' and not getattr(application_version, 'installed', False) and not is_tor: - message = f'You are using a Tor profile, but the associated browser is not installed. If you want the browser to be downloaded with Tor, you must switch to a Tor connection. Otherwise, proceed with a clearweb connection.' - - else: - self.get_billing_code_by_id() - return - - self.popup = self._make_confirmation_popup(message, button_text='Proceed') - self.popup.finished.connect( - lambda result: self._handle_popup_result(result)) - self.popup.show() - - def _handle_popup_result(self, result, change_screen=False): - if result: - if change_screen: - self._route_to_billing_entry() - else: - self.get_billing_code_by_id() - else: - self.popup.close() - - def _route_to_billing_entry(self): - try: - unused = get_unused_tickets(ticket_observer) - random_setting, _ = do_we_use_a_random_ticket(ticket_observer) - except Exception: - unused = {'valid': False} - random_setting = None - - has_tickets = isinstance(unused, dict) and unused.get('valid') and len(unused.get('data', [])) > 0 - random_off = random_setting is None - - if has_tickets and random_off: - chooser = self.custom_window.navigator.get_cached("ticket_or_billing_choice") - if chooser: - chooser.populate() - self.page_stack.setCurrentWidget(chooser) - return - - id_page = self.custom_window.navigator.get_cached("id") - if id_page: - self.page_stack.setCurrentWidget(id_page) - else: - self.custom_window.navigator.navigate("id") + self._set_task_busy(True) + self.update_status.update_status("Attempting to connect...") + self.worker = enable_profile_action({"id": self.reverse_id}, self.handle_task_result) def disconnect(self): - self.disconnect_button.setEnabled(False) - self.disconnect_system_wide_button.setEnabled(False) - self.update_status.update_status('Disconnecting...') - profile_data = { - 'id': int(self.reverse_id) - } - profile = self._safe_profile(int(self.reverse_id)) - if profile is None: - self.update_status.update_status("Could not load profile data. Sync or restart and try again.") - self.disconnect_button.setEnabled(True) - self.disconnect_system_wide_button.setEnabled(True) - return - is_session_profile = isinstance(profile, SessionProfile) - if not is_session_profile: - connected_profiles = self.connection_manager.get_connected_profiles() - if len(connected_profiles) > 1: - message = f'Profile{"" if len(connected_profiles) == 1 else "s"} {", ".join(str(profile_id) for profile_id in connected_profiles)} are still connected.\nAll connected session profiles will be disconnected. \nDo you want to proceed?' - self._show_disconnect_confirmation( - connected_profiles, button_text='Disable', message=message) - self.disconnect_button.setEnabled(True) - self.disconnect_system_wide_button.setEnabled(True) - return - self.worker_thread = WorkerThread( - 'DISABLE_PROFILE', profile_data=profile_data) - self.worker_thread.text_output.connect( - lambda text: self.update_status.update_status(str(text))) - self.worker_thread.finished.connect(self.on_disconnect_done) - self.worker_thread.start() - - def on_disconnect_done(self): - self.disconnect_button.setEnabled(True) - self.disconnect_system_wide_button.setEnabled(True) - selected_profile_id = self.reverse_id - self.refresh_menu_buttons() - if selected_profile_id is not None and self._safe_profile(selected_profile_id): - self.print_profile_details(f"Profile_{selected_profile_id}") - - def _config_flag(self, section, key, default=False): - try: - config = self.update_status._load_gui_config() - if config and section in config: - return config[section].get(key, default) - except Exception: - pass - return default + self._set_task_busy(True) + self.update_status.update_status("Disconnecting...") + self.worker = disable_profile_action(self.reverse_id, self.connection_manager.get_connected_profiles(), + self.handle_task_result) def create_prof(self): - fast_mode = self._config_flag("registrations", "fast_registration_enabled") - if fast_mode: - if not self.connection_manager.is_synced(): - self.update_status.update_status('Syncing in progress..') - self.update_status.sync() - self.update_status.worker_thread.sync_output.connect( - self.on_sync_complete_for_fast_registration) - else: - self.custom_window.navigator.navigate("fast_registration") - return - - if not self.connection_manager.is_synced(): - self.custom_window.navigator.navigate("sync_screen") - else: - self.custom_window.navigator.navigate("protocol") - - def on_sync_complete_for_fast_registration(self, available_locations, available_browsers, status, is_tor, locations, all_browsers): - if status: - self.custom_window.navigator.navigate("fast_registration") - else: - self.update_status.update_status( - 'Sync failed. Please try again later.') - - def change_connect_button(self): - profile = self._safe_profile(int(self.reverse_id)) - is_connected = self.connection_manager.is_profile_connected( - int(self.reverse_id)) - is_session_profile = isinstance(profile, SessionProfile) - - self.update_button_visibility(is_connected, is_session_profile) - self.update_status_message(profile, is_connected) + self._set_task_busy(True) + self.worker = create_profile_action(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( @@ -1180,86 +539,25 @@ class MenuPage(Page): self.disconnect_system_wide_button.setVisible( is_connected and not is_session_profile) - def update_status_message(self, profile, is_connected): - if profile is None: + 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 - if is_connected: - message = Worker.generate_profile_message(profile, is_enabled=True) - self.update_status.enable_marquee(message) - else: - message = Worker.generate_profile_message( - profile, is_enabled=True, idle=True) - self.update_status.update_status(message) + 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): - auto_sync = self._config_flag("registrations", "auto_sync_enabled") - if auto_sync and not self.connection_manager.is_synced(): - self.update_status.update_status('Syncing in progress..') - self.update_status.sync() - self.update_status.worker_thread.sync_output.connect( - self.on_sync_complete_for_edit_profile) - else: - self.custom_window.navigator.navigate("editor") - - def on_sync_complete_for_edit_profile(self, available_locations, available_browsers, status, is_tor, locations, all_browsers): - if status: - self.custom_window.navigator.navigate("editor") - else: - self.update_status.update_status( - 'Sync failed. Please try again later.') + self._set_task_busy(True) + self.worker = edit_profile_action(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 eliminacion(self): - current_state = { - 'is_tor_mode': self.is_tor_mode, - 'is_animating': self.is_animating, - 'animation_step': self.animation_step - } - - self.main_window.toggle_bg.setParent(None) - self.main_window.toggle_handle.setParent(None) - self.main_window.on_label.setParent(None) - self.main_window.off_label.setParent(None) - - self.refresh_menu_buttons() - - self.main_window.toggle_bg.setParent(self.main_window.toggle_button) - self.main_window.toggle_handle.setParent( - self.main_window.toggle_button) - self.main_window.on_label.setParent(self.main_window.toggle_button) - self.main_window.off_label.setParent(self.main_window.toggle_button) - - self.main_window.toggle_bg.show() - self.main_window.toggle_handle.show() - self.main_window.on_label.show() - self.main_window.off_label.show() - - self.is_tor_mode = current_state['is_tor_mode'] - self.is_animating = current_state['is_animating'] - self.animation_step = current_state['animation_step'] - - def get_billing_code_by_id(self, force: bool = False): - profile_id = self.update_status.current_profile_id - profile_data = { - 'id': profile_id, - 'force': force - } - self.update_status.update_status('Attempting to connect...') - - self.enabling_profile(profile_data) - - def enabling_profile(self, profile_data): - self.worker = Worker(profile_data) - self.worker.update_signal.connect(self.update_gui_main_thread) - self.worker.change_page.connect(self.change_app_page) - self.worker.ticket_data_loss.connect(self.show_ticket_data_loss_popup) - - thread = threading.Thread(target=self.worker.run) - thread.start() def show_ticket_data_loss_popup(self, ticket_number, billing_code): self.custom_window.navigator.navigate("menu") @@ -1277,157 +575,56 @@ class MenuPage(Page): if install_page is not None: install_page.configure(package_name=package_name, distro='debian') - def handle_enable_force_result(self, result): - if result: - profile_data = {'id': self.reverse_id, - 'ignore_profile_state_conflict': True} - if hasattr(self, '_pending_endpoint_verification_ignore') and self._pending_endpoint_verification_ignore: - profile_data['ignore_endpoint_verification'] = True - self.enabling_profile(profile_data) - else: - pass - if hasattr(self, '_pending_profile_state_conflict_ignore'): - delattr(self, '_pending_profile_state_conflict_ignore') - if hasattr(self, '_pending_endpoint_verification_ignore'): - delattr(self, '_pending_endpoint_verification_ignore') - self.popup.close() + @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 handle_endpoint_verification_result(self, result, action, profile_id): - if action == "continue": - profile_data = {'id': profile_id, - 'ignore_endpoint_verification': True} - if hasattr(self, '_pending_profile_state_conflict_ignore') and self._pending_profile_state_conflict_ignore: - profile_data['ignore_profile_state_conflict'] = True - self.enabling_profile(profile_data) - elif action == "sync": - self.update_status.update_status("Syncing...") - self.worker_thread = WorkerThread('SYNC') - self.worker_thread.finished.connect( - lambda: self.handle_sync_after_verification(profile_id)) - self.worker_thread.sync_output.connect( - self.update_status.update_values) - self.worker_thread.sync_error.connect( - self.update_status.update_status) - self.worker_thread.start() - else: - self.update_status.update_status("Profile enable aborted") - if hasattr(self, 'popup'): - self.popup.close() + 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 handle_sync_after_verification(self, profile_id): - profile_data = {'id': profile_id} - if hasattr(self, '_pending_profile_state_conflict_ignore') and self._pending_profile_state_conflict_ignore: - profile_data['ignore_profile_state_conflict'] = True - self.enabling_profile(profile_data) + 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 update_gui_main_thread(self, text, is_enabled, profile_id, profile_type, profile_connection): - if text == "ENDPOINT_VERIFICATION_ERROR": - message = "Operator verification failed. Do you want to continue anyway?" - self.popup = EndpointVerificationPopup(self, message=message) - self.popup.finished.connect( - lambda result, action: self.handle_endpoint_verification_result(result, action, profile_id)) - self.popup.show() - return + 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() - if text == "PROFILE_STATE_CONFLICT_ERROR": - message = f'The profile is already enabled. Do you want to force enable it anyway?' - self._pending_profile_state_conflict_ignore = True - self.popup = self._make_confirmation_popup(message, button_text='Proceed') - self.popup.finished.connect( - lambda result: self.handle_enable_force_result(result)) - self.popup.show() - return + 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) - if profile_id is not None and profile_id < 0: - self.DisplayInstallScreen(text) - return - if is_enabled: - self.update_status.enable_marquee(str(text)) - else: - self.update_status.update_status(str(text)) - if profile_id is not None: - self.on_finished_update_gui( - is_enabled, profile_id, profile_type, profile_connection) - self.boton_just.setEnabled(True) - self.boton_just_session.setEnabled(True) + 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 change_app_page(self, text, Is_changed): - self.update_status.update_status(str(text)) - if Is_changed: - try: - current_connection = self.update_status.get_current_connection() - except Exception: - current_connection = None - profile = self._safe_profile(int(self.reverse_id)) - incomplete_reason = self._profile_incomplete_reason(profile) - if incomplete_reason: - self.update_status.update_status(incomplete_reason) - elif current_connection != 'tor' and getattr(getattr(profile, 'connection', None), 'code', None) == 'tor': - message = f'You are using a Tor profile, but the profile subscription is missing or expired. If you want the billing to be done thourgh Tor, you must switch to a Tor connection. Otherwise, proceed with a clearweb connection.' - self.popup = self._make_confirmation_popup(message, button_text='Proceed') - self.popup.finished.connect( - lambda result: self._handle_popup_result(result, True)) - self.popup.show() - else: - self._route_to_billing_entry() + def _show_install_result(self, result): + self.DisplayInstallScreen(result["package_name"]) - self.boton_just.setEnabled(True) - self.boton_just_session.setEnabled(True) - - def on_finished_update_gui(self, is_enabled, profile_id, profile_type, profile_connection): - try: - if is_enabled: - self.connection_manager.add_connected_profile(profile_id) - target_button: QPushButton = self.connection_manager.get_profile_button_objects( - profile_id) - - if profile_type == 1: - if target_button: - icon_path = self.connected_tor_profile if profile_connection == 'tor' else self.connected_profile - try: - target_button.setIcon(QIcon(icon_path)) - except RuntimeError: - pass - self.button_states[profile_id] = icon_path - else: - if target_button: - try: - target_button.setIcon( - QIcon(self.system_wide_connected_profile)) - except RuntimeError: - pass - self.update_status.update_image( - appearance_value='background_connected') - self.button_states[profile_id] = self.system_wide_connected_profile - self.IsSystem += 1 - - if target_button: - self.show_disconnect_button( - True, profile_type, target_button) - self.boton_edit.setEnabled(False) - - if profile_id == self.reverse_id: - profile_obj = self._safe_profile(profile_id) - if profile_obj and profile_obj.connection and profile_obj.connection.code == 'wireguard' and ConfigurationController.get_endpoint_verification_enabled(): - self.print_profile_details(f"Profile_{profile_id}") - else: - self.connection_manager.remove_connected_profile(profile_id) - if profile_type == 1: - if profile_id in self.button_states: - del self.button_states[profile_id] - else: - self.IsSystem -= 1 - if profile_id in self.button_states: - del self.button_states[profile_id] - if self.IsSystem == 0: - self.update_status.update_image() - - self.boton_edit.setEnabled(True) - - if profile_id == self.reverse_id: - self.print_profile_details(f"Profile_{profile_id}") - - self.refresh_menu_buttons() - except IndexError: - self.update_status.update_status( - 'An error occurred while updating profile state.') + def _show_ticket_loss_result(self, result): + self.show_ticket_data_loss_popup(result["ticket_number"], result["billing_code"]) diff --git a/gui/v2/ui/pages/ticket_or_billing_choice_page.py b/gui/v2/ui/pages/ticket_or_billing_choice_page.py index d32a648..9e5d582 100755 --- a/gui/v2/ui/pages/ticket_or_billing_choice_page.py +++ b/gui/v2/ui/pages/ticket_or_billing_choice_page.py @@ -5,6 +5,7 @@ from core.controllers.ProfileController import ProfileController from core.controllers.tickets.UseTicketController import get_unused_tickets from gui.v2.actions.locations import location_candidates +from gui.v2.actions.menu_tasks import enable_profile as enable_profile_action from gui.v2.infrastructure.setup_observers import ticket_observer from gui.v2.ui.pages.Page import Page @@ -103,13 +104,14 @@ class TicketOrBillingChoicePage(Page): profile_data = { 'id': int(profile_id), 'use_ticket': which_ticket, - 'ticket_location': candidates[0], - 'profile': profile + 'ticket_location': candidates[0] } menu_page = self.custom_window.navigator.get_cached("menu") if menu_page: self.update_status.update_status(f"Using ticket #{which_ticket}...") - menu_page.enabling_profile(profile_data) + self.profile_enable_worker = enable_profile_action( + profile_data, menu_page.handle_task_result, allow_uninstalled_tor=True, + ) self.custom_window.navigator.navigate("menu") def on_use_billing(self): diff --git a/gui/v2/ui/pages/ticket_prep_page.py b/gui/v2/ui/pages/ticket_prep_page.py index 91bb30d..eeff919 100755 --- a/gui/v2/ui/pages/ticket_prep_page.py +++ b/gui/v2/ui/pages/ticket_prep_page.py @@ -2,6 +2,7 @@ from PyQt6.QtWidgets import QLabel, QPushButton from PyQt6 import QtCore from gui.v2.infrastructure.setup_observers import ticket_observer +from gui.v2.actions.menu_tasks import enable_profile as enable_profile_action from gui.v2.ui.pages.Page import Page from gui.v2.ui.widgets.terminal_widget import TerminalWidget from gui.v2.workers.ticketing_worker_thread import TicketingWorkerThread @@ -114,4 +115,6 @@ class TicketPrepPage(Page): self.update_status.update_status("Tickets ready. Random ticket use is ON.") return self.update_status.update_status("Applying random ticket to profile...") - menu_page.enabling_profile({'id': int(profile_id)}) + self.profile_enable_worker = enable_profile_action( + {'id': int(profile_id)}, menu_page.handle_task_result, allow_uninstalled_tor=True, + ) diff --git a/gui/v2/ui/popups/confirmation_popup.py b/gui/v2/ui/popups/confirmation_popup.py index 6ac12f5..ca22aeb 100755 --- a/gui/v2/ui/popups/confirmation_popup.py +++ b/gui/v2/ui/popups/confirmation_popup.py @@ -18,6 +18,7 @@ class ConfirmationPopup(QWidget): def __init__(self, parent=None, message="", action_button_text="", cancel_button_text="Cancel"): super().__init__(parent) self.parent_window = parent + self._resolved = False self.message = message self.action_button_text = action_button_text self.cancel_button_text = cancel_button_text @@ -79,9 +80,16 @@ class ConfirmationPopup(QWidget): button_layout.addWidget(action_button) def perform_action(self): + self._resolved = True self.finished.emit(True) self.close() + def closeEvent(self, event): + if not self._resolved: + self._resolved = True + self.finished.emit(False) + event.accept() + def mousePressEvent(self, event): self.oldPos = event.globalPosition().toPoint() diff --git a/gui/v2/ui/popups/endpoint_verification_popup.py b/gui/v2/ui/popups/endpoint_verification_popup.py index 613f184..fb726d0 100755 --- a/gui/v2/ui/popups/endpoint_verification_popup.py +++ b/gui/v2/ui/popups/endpoint_verification_popup.py @@ -17,6 +17,7 @@ class EndpointVerificationPopup(QWidget): def __init__(self, parent=None, message=""): super().__init__(parent) self.parent_window = parent + self._resolved = False self.message = message self.initUI() @@ -106,11 +107,14 @@ class EndpointVerificationPopup(QWidget): button_layout.addWidget(continue_button) def close_with_action(self, action): + self._resolved = True self.finished.emit(True, action) self.close() def closeEvent(self, event): - self.finished.emit(False, "abort") + if not self._resolved: + self._resolved = True + self.finished.emit(False, "abort") event.accept() def mousePressEvent(self, event): diff --git a/gui/v2/workers/worker.py b/gui/v2/workers/worker.py index 118efce..b8e8d46 100755 --- a/gui/v2/workers/worker.py +++ b/gui/v2/workers/worker.py @@ -1,303 +1,16 @@ -from datetime import datetime, timezone +from typing import Callable -from PyQt6.QtCore import QObject, pyqtSignal +from PyQt6.QtCore import QThread, pyqtSignal -from core.controllers.ProfileController import ProfileController -from core.controllers.SubscriptionController import SubscriptionController -from core.controllers.tickets.UseTicketController import ( - use_ticket, - do_we_use_a_random_ticket, -) -from core.models.session.SessionProfile import SessionProfile -from core.models.system.SystemProfile import SystemProfile -from core.models.Result import Result, ResultError -from core.errors.exceptions import SudoScript, MissingPreReqs, FirewallError -from core.Errors import ( - CommandNotFoundError, - EndpointVerificationError, - FileIntegrityError, - InvalidSubscriptionError, - MissingSubscriptionError, - ProfileActivationError, - ProfileModificationError, - ProfileStateConflictError, - UnsupportedApplicationVersionError, -) - -from gui.v2.actions.locations import location_candidates -from gui.v2.actions.database_health import GuiStorageDatabaseError -from gui.v2.infrastructure.screen_size import get_max_screensize -from gui.v2.infrastructure.setup_observers import ( - application_version_observer, - connection_observer, - profile_observer, - ticket_observer, -) +from gui.v2.actions.menu_results import MenuCallback, MenuResult -class Worker(QObject): - update_signal = pyqtSignal(str, bool, int, int, str) - change_page = pyqtSignal(str, bool) - ticket_data_loss = pyqtSignal(str, str) +class Worker(QThread): + output = pyqtSignal(object) - def __init__(self, profile_data): - self.profile_data = profile_data + def __init__(self, operation: Callable[[MenuCallback], MenuResult]): super().__init__() - profile_observer.subscribe( - 'disabled', lambda event: self.handle_profile_status(event.subject, False)) - profile_observer.subscribe( - 'enabled', lambda event: self.handle_profile_status(event.subject, True)) - self.profile_type = None - self._ticket_error_emitted = False - self._consumed_ticket = None + self.operation = operation def run(self): - try: - self.profile = ProfileController.get(int(self.profile_data['id'])) - except GuiStorageDatabaseError: - self.update_signal.emit( - "Local storage database could not be read. Restart and recover storage.db.", False, None, None, None) - return - except Exception: - self.update_signal.emit( - "Could not load profile data. Sync or restart and try again.", False, None, None, None) - return - - incomplete_reason = self._profile_incomplete_reason() - if incomplete_reason: - self.update_signal.emit(incomplete_reason, False, None, None, None) - return - - if 'use_ticket' in self.profile_data: - ticket_billing_code = self._consume_ticket( - self.profile_data['use_ticket'], - self.profile_data.get('ticket_location')) - if ticket_billing_code is None: - return - self.profile_data['billing_code'] = ticket_billing_code - elif 'billing_code' not in self.profile_data and self.profile is not None: - if not self._profile_has_valid_subscription(): - ticket_billing_code = self._maybe_auto_use_ticket() - if ticket_billing_code is None and self._ticket_error_emitted: - return - if ticket_billing_code: - self.profile_data['billing_code'] = ticket_billing_code - - if 'billing_code' in self.profile_data: - subscription = SubscriptionController.get( - self.profile_data['billing_code'], connection_observer=connection_observer) - if subscription is not None: - ProfileController.attach_subscription( - self.profile, subscription) - else: - if self._consumed_ticket is not None: - self._ticket_error_emitted = True - self.ticket_data_loss.emit( - self._consumed_ticket, - str(self.profile_data.get('billing_code', '')), - ) - return - self.change_page.emit('The billing code is invalid.', True) - return - if self.profile: - try: - ignore_exceptions = [] - if self.profile_data.get('ignore_endpoint_verification', False): - ignore_exceptions.append(EndpointVerificationError) - if self.profile_data.get('ignore_profile_state_conflict', False): - ignore_exceptions.append(ProfileStateConflictError) - ignore_tuple = tuple(ignore_exceptions) - max_resolution = get_max_screensize() - ProfileController.enable(self.profile, ignore=ignore_tuple, profile_observer=profile_observer, - application_version_observer=application_version_observer, - connection_observer=connection_observer, ticket_observer=ticket_observer, max_resolution=max_resolution) - except EndpointVerificationError: - self.update_signal.emit( - "ENDPOINT_VERIFICATION_ERROR", False, self.profile_data['id'], None, None) - except (InvalidSubscriptionError, MissingSubscriptionError) as e: - self.change_page.emit( - f"Subscription missing or invalid for profile {self.profile_data['id']}", True) - except ProfileActivationError: - self.update_signal.emit( - "The profile could not be enabled", False, None, None, None) - except UnsupportedApplicationVersionError: - self.update_signal.emit( - "The application version in question is not supported", False, None, None, None) - except FileIntegrityError: - self.update_signal.emit( - "Application version file integrity could not be verified.", False, None, None, None) - except ProfileModificationError: - self.update_signal.emit( - "WireGuard configuration could not be attached.", False, None, None, None) - except ProfileStateConflictError: - self.update_signal.emit( - "PROFILE_STATE_CONFLICT_ERROR", False, self.profile_data['id'], None, None) - except CommandNotFoundError as e: - self.update_signal.emit(str(e.subject), False, -1, None, None) - except SudoScript as e: - self.update_signal.emit(str(e), False, None, None, None) - except MissingPreReqs as e: - self.update_signal.emit(str(e), False, None, None, None) - except FirewallError as e: - self.update_signal.emit(str(e), False, None, None, None) - except Exception as e: - print(e) - self.update_signal.emit( - "An unknown error occurred", False, None, None, None) - - else: - self.update_signal.emit( - f"No profile found with ID: {self.profile_data['id']}", False, None, None, None) - - def _profile_incomplete_reason(self): - if self.profile is None: - return None - - connection = getattr(self.profile, 'connection', None) - if not getattr(connection, 'code', None): - return "Profile connection data is incomplete. Sync the database and try again." - - location = getattr(self.profile, 'location', None) - if location is None or isinstance(location, dict): - return "Profile location data is incomplete. Sync the database and try again." - - if isinstance(self.profile, SessionProfile): - application_version = getattr(self.profile, 'application_version', None) - if isinstance(application_version, dict) or application_version is None: - return "Profile browser data is incomplete. Sync the database and try again." - if not getattr(application_version, 'application_code', None) or not getattr(application_version, 'version_number', None): - return "Profile browser data is incomplete. Sync the database and try again." - - return None - - def _location_candidates(self, preferred=None): - return location_candidates(self.profile, preferred) - - def _profile_has_valid_subscription(self): - try: - sub = getattr(self.profile, 'subscription', None) - if not sub: - return False - expires_at = getattr(sub, 'expires_at', None) - if not expires_at: - return False - if expires_at.tzinfo is None: - expires_at = expires_at.replace(tzinfo=timezone.utc) - return expires_at > datetime.now(timezone.utc) - except Exception: - return False - - def _maybe_auto_use_ticket(self): - profile_connection_type = self.profile.connection.code - - if profile_connection_type != "wireguard": - error_msg = "Tickets are not yet supported for Tor. We are very sorry, but we're not able to develop all features for all products." - print(error_msg) - return None - - if self.profile.assassin: - print("skipping random ticket use for the assassin, as it already has it baked in.") - return None - try: - print("using a random ticket from GUI...") - which_ticket, error_msg = do_we_use_a_random_ticket(ticket_observer) - except Exception: - return None - if error_msg: - self._ticket_error_emitted = True - self.change_page.emit(f'Ticket use failed: {error_msg}', True) - return None - if which_ticket is None or which_ticket == 'error': - return None - return self._consume_ticket(which_ticket, None) - - def _consume_ticket(self, which_ticket, which_location): - candidates = self._location_candidates(preferred=which_location) - if not candidates: - self._ticket_error_emitted = True - self.change_page.emit('Could not determine profile location for ticket use.', True) - return None - - last_msg = None - for cand in candidates: - try: - outcome = use_ticket( - which_ticket=which_ticket, - which_location=cand, - ticket_observer=ticket_observer, - connection_observer=connection_observer, - profile=self.profile - ) - except Exception as e: - last_msg = str(e) - continue - if not isinstance(outcome, Result): - last_msg = f'Unexpected ticket result type: {type(outcome).__name__}' - self._ticket_error_emitted = True - self.change_page.emit(f'Ticket use failed: {last_msg}', True) - return None - - billing_code = outcome.data - if outcome.valid and billing_code: - self._consumed_ticket = str(which_ticket) - return billing_code - if billing_code: - self._consumed_ticket = str(which_ticket) - return billing_code - - msg = outcome.message - if not msg and outcome.error_type: - msg = outcome.error_type.value - msg = msg or 'Ticket use failed.' - last_msg = msg - if 'invalid_location' not in msg: - self._ticket_error_emitted = True - self.change_page.emit(f'Ticket use failed: {msg}', True) - return None - - self._ticket_error_emitted = True - self.change_page.emit(f'Ticket use failed: {last_msg or "no valid location"}', True) - return None - - def handle_profile_status(self, profile, is_enabled): - profile_id = profile.id - profile_connection = str(profile.connection.code) - message = self.generate_profile_message(profile, is_enabled) - - if isinstance(profile, SessionProfile): - self.profile_type = 1 - elif isinstance(profile, SystemProfile): - self.profile_type = 2 - else: - self.profile_type = None - - self.update_signal.emit( - message, is_enabled, profile_id, self.profile_type, profile_connection) - - @staticmethod - def generate_profile_message(profile, is_enabled, idle=False): - - profile_id = profile.id - if not profile.subscription or not profile.subscription.expires_at: - return f"Offline. No subscription found." - - profile_date = profile.subscription.expires_at - status = 'enabled' if is_enabled else 'disabled' - - expiration_date = profile_date.replace(tzinfo=timezone.utc) - - time_left = expiration_date - datetime.now(timezone.utc) - days_left = time_left.days - hours_left, remainder = divmod(time_left.seconds, 3600) - - formatted_expiration = expiration_date.strftime("%Y-%m-%d %H:%M:%S") - - if expiration_date < datetime.now(timezone.utc): - return "Offline. Subscription has expired." - if idle: - return f"Offline. Expires in {days_left} days." - - if is_enabled: - return f"Profile {int(profile_id)} {status}. Expires on {formatted_expiration}. Time left: {days_left} days, {hours_left} hours." - else: - return f"Profile {int(profile_id)} {status}" + self.output.emit(self.operation(self.output.emit)) diff --git a/gui/v2/workers/worker_thread.py b/gui/v2/workers/worker_thread.py index cd211fe..dfba15c 100755 --- a/gui/v2/workers/worker_thread.py +++ b/gui/v2/workers/worker_thread.py @@ -1,14 +1,11 @@ import shlex import subprocess -# import inspect -import time from PyQt6.QtCore import QThread, pyqtSignal from core.controllers.ApplicationVersionController import ApplicationVersionController from core.controllers.ClientController import ClientController -from core.controllers.ConfigurationController import ConfigurationController, ConnectionChoice -from core.Constants import Constants +from core.controllers.ConfigurationController import ConfigurationController from core.controllers.SyncController import new_sync from core.controllers.InvoiceController import InvoiceController @@ -21,14 +18,11 @@ from core.models.session.SessionProfile import SessionProfile from core.models.system.SystemConnection import SystemConnection 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.models.Result import ResultError from core.services.helpers.install_dependencies import setup_singbox_binary as install_singbox_binary -from gui.v2.actions.disable_profiles import ( - filter_profiles_by_type, - disable_profile_via_controller -) +from gui.v2.actions.profile_operations import execute_menu_task, synchronize +from gui.v2.actions.menu_results import MenuResult from gui.v2.infrastructure.setup_observers import ( application_version_observer, @@ -61,45 +55,26 @@ class WorkerThread(QThread): self.is_running = True self.is_disabling = False - # def _disable_profile(self, profile): - # kwargs = { - # 'profile_observer': profile_observer, - # 'ticket_observer': ticket_observer, - # 'connection_observer': connection_observer, - # } - # supported = inspect.signature(ProfileController.disable).parameters - # ProfileController.disable( - # profile, - # **{key: value for key, value in kwargs.items() if key in supported} - # ) - def run(self): - if self.action == 'LIST_PROFILES': - self.list_profiles() - elif self.action == 'CREATE_SESSION_PROFILE': - self.create_profile(self.profile_type) - elif self.action == 'CREATE_SYSTEM_PROFILE': - self.create_profile(self.profile_type) - elif self.action == 'GET_SUBSCRIPTION': - self.get_subscription() - elif self.action == 'DISABLE_PROFILE': - self.disable_profile() - elif self.action == 'SYNC' or self.action == 'SYNC_TOR': - self.sync() - elif self.action == 'DESTROY_PROFILE': - self.destroy_profile() - elif self.action == 'DISABLE_ALL_PROFILES': - 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': - self.download_update() - elif self.action == 'CHECK_INVOICE_STATUS': - self.check_invoice_status() + action_handlers = { + 'LIST_PROFILES': self.list_profiles, + 'CREATE_SESSION_PROFILE': lambda: self.create_profile(self.profile_type), + 'CREATE_SYSTEM_PROFILE': lambda: self.create_profile(self.profile_type), + 'GET_SUBSCRIPTION': self.get_subscription, + 'DISABLE_PROFILE': self.disable_profile, + 'SYNC': self.sync, + 'SYNC_TOR': self.sync, + 'DESTROY_PROFILE': self.destroy_profile, + 'DISABLE_ALL_PROFILES': self.disable_all_profiles, + 'INSTALL_PACKAGE': self.install_package, + 'SETUP_SINGBOX_BINARY': self.setup_singbox_binary, + 'CHECK_FOR_UPDATE': self.check_for_update, + 'DOWNLOAD_UPDATE': self.download_update, + 'CHECK_INVOICE_STATUS': self.check_invoice_status, + } + handler = action_handlers.get(self.action) + if handler is not None: + handler() def check_invoice_status(self): try: @@ -117,12 +92,7 @@ class WorkerThread(QThread): self.invoice_finished.emit(False) def download_update(self): - self.text_output.emit("Starting update process...") - ClientController.update(client_observer=client_observer) - client_observer.subscribe('update_progressing', lambda event: self.text_output.emit( - f"Downloading: {event.meta.get('progress'):.1f}%")) - client_observer.subscribe( - 'updated', lambda event: self.text_output.emit("Update process completed")) + self._menu_operation({"task": "update"}) def check_for_update(self): self.text_output.emit("Checking for updates...") @@ -184,60 +154,10 @@ class WorkerThread(QThread): self.finished.emit(False) def disable_all_profiles(self): - """ - Purpose: - Loop through all profiles in the class data, - Classify them, and disable them. - Why: - Session profiles must be disabled first before System profiles. - Called by: - Same Class run() - """ - session_profiles, system_profiles = filter_profiles_by_type(self.profile_data) - try: - # SESSION - for profile in session_profiles: - disable_profile_via_controller(profile) - print("finished with session profiles. now moving onto session profiles") - - if session_profiles and system_profiles: - time.sleep(1) - - # SYSTEM - for profile in system_profiles: - disable_profile_via_controller(profile) - self.text_output.emit("All profiles were successfully disabled") - except SudoScript as e: - self.text_output.emit(str(e)) - except FirewallError as e: - self.text_output.emit(str(e)) - except MissingPreReqs as e: - self.text_output.emit(str(e)) - except Exception as e: - print(f"Error: {str(e)}") - self.text_output.emit("An error occurred when disabling profile") - finally: - self.finished.emit(True) + self._menu_operation({"task": "disable_all", "connected_ids": self.profile_data}) def destroy_profile(self): - profile_id = int(self.profile_data['id']) - profile = ProfileController.get(profile_id) - - if profile is not None: - try: - ProfileController.destroy(profile, profile_observer, ticket_observer, connection_observer) - except Exception as e: - error_name = type(e).__name__ - error_text = str(e) or 'Unknown deletion error' - self.text_output.emit( - f'Could not delete profile {profile_id}: {error_name}: {error_text}') - self.finished.emit(False) - return - self.text_output.emit(f'Profile {profile_id} deleted') - self.finished.emit(True) - else: - self.text_output.emit(f'Profile {profile_id} does not exist') - self.finished.emit(False) + self._menu_operation({"task": "delete", "id": self.profile_data["id"], "confirmed": True}) def list_profiles(self): try: @@ -312,55 +232,22 @@ class WorkerThread(QThread): f"{profile_type.capitalize()} Profile created with ID: {profile.id}") def disable_profile(self): - try: - profile = ProfileController.get(int(self.profile_data['id'])) - if profile: - disable_profile_via_controller(profile) - else: - self.text_output.emit( - f"No profile found with ID: {self.profile_data['id']}") - except SudoScript as e: - self.text_output.emit(str(e)) - except FirewallError as e: - self.text_output.emit(str(e)) - except MissingPreReqs as e: - self.text_output.emit(str(e)) - except Exception as e: - self.text_output.emit("An error occurred when disabling profile") - finally: - self.finished.emit(True) + self._menu_operation({"task": "disable", "id": self.profile_data["id"]}) + + def _menu_operation(self, request): + result = execute_menu_task(request, self._emit_menu_result, None) + self._emit_menu_result(result) + self.finished.emit(result["status"] == "success") + + def _emit_menu_result(self, result: MenuResult): + self.text_output.emit(result.get("message", "")) def sync(self): - try: - if self.action == 'SYNC_TOR': - ConfigurationController.set_connection('tor') - else: - ConfigurationController.set_connection('system') - print(f"[SYNC] Syncing from URL: {Constants.SP_API_BASE_URL}/cachedsync") - sync_result = new_sync( - client_observer=client_observer, - connection_observer=connection_observer) - if not sync_result.valid: - error_message = sync_result.message or "Connection error or invalid sync URL" - self.sync_output.emit([], [], False, False, [], []) - self.sync_error.emit(f"Sync failed: {error_message}") - return - - locations = LocationController.get_all() - - - browser = ApplicationVersionController.get_all() - # print('the browser is: ', browser) - all_browser_versions = [ - f"{browser.application_code}:{browser.version_number}" for browser in browser if browser.supported] - all_location_codes = [ - f"{location.country_code}_{location.code}" for location in locations] - self.sync_output.emit( - all_location_codes, all_browser_versions, True, False, locations, browser) - except Exception as e: - print(f'the error is: {e}') - self.sync_output.emit([], [], False, False, [], []) - self.sync_error.emit(f"Sync failed: {e}") + connection = {"SYNC_TOR": "tor", "SYNC": "system"}[self.action] + result = synchronize(connection) + self.sync_output.emit(*result["sync_data"]) + if result["status"] == "error": + self.sync_error.emit(result["message"]) def get_connection(self): connection = ConfigurationController.get_connection()