update: updated menu page

This commit is contained in:
JOhn 2026-09-25 09:11:27 -04:00
parent 83fd1e08ec
commit 80407f636d
16 changed files with 1136 additions and 1539 deletions

View file

@ -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

358
gui/v2/actions/menu_profiles.py Executable file
View file

@ -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

26
gui/v2/actions/menu_results.py Executable file
View file

@ -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)}

80
gui/v2/actions/menu_tasks.py Executable file
View file

@ -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)

View file

@ -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}

View file

@ -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()
}

View file

@ -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

View file

@ -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(
)

View file

@ -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(

File diff suppressed because it is too large Load diff

View file

@ -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):

View file

@ -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,
)

View file

@ -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()

View file

@ -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):

View file

@ -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))

View file

@ -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()