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}