forked from Support/sp-hydra-veil-gui
324 lines
17 KiB
Python
Executable file
324 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."""
|
|
|
|
print(f"request is entering: {request}")
|
|
print(f"request type is {type(request)}")
|
|
|
|
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."""
|
|
|
|
print(f"request enters _enable. {request}")
|
|
|
|
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
|
|
|
|
# Payment not finished:
|
|
try:
|
|
if profile.subscription.billing_pending:
|
|
print("This is resuming a pre-done payment")
|
|
return {"status": "navigate", "destination": "payment_details"}
|
|
except Exception as e:
|
|
print(f"Skipping billing resume check")
|
|
|
|
print(f"checking preparation")
|
|
|
|
# preparation = _prepare_subscription(profile, request)
|
|
# if preparation is not None:
|
|
# return preparation
|
|
|
|
print("entering observers")
|
|
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))
|
|
|
|
print("doing the enable")
|
|
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)
|
|
print("returning success..")
|
|
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."""
|
|
|
|
print(f"request is in execute_menu_task as {request}")
|
|
|
|
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}
|