hydraveil-gui/gui/v2/actions/profile_operations.py
2026-09-25 09:12:02 -04:00

298 lines
17 KiB
Python
Executable file

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}