forked from Support/sp-hydra-veil-gui
update: updated menu page
This commit is contained in:
parent
83fd1e08ec
commit
80407f636d
16 changed files with 1136 additions and 1539 deletions
|
|
@ -35,14 +35,20 @@ def mark_fast_mode_prompt_shown(gui_config_file):
|
||||||
save_gui_config(gui_config_file, config)
|
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)
|
config = load_gui_config(gui_config_file)
|
||||||
if not config:
|
if not config:
|
||||||
return False
|
return False
|
||||||
return config.get("registrations", {}).get("fast_registration_enabled", 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)
|
config = load_gui_config(gui_config_file)
|
||||||
if not config:
|
if not config:
|
||||||
return False
|
return False
|
||||||
|
|
|
||||||
358
gui/v2/actions/menu_profiles.py
Executable file
358
gui/v2/actions/menu_profiles.py
Executable 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
26
gui/v2/actions/menu_results.py
Executable 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
80
gui/v2/actions/menu_tasks.py
Executable 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)
|
||||||
298
gui/v2/actions/profile_operations.py
Executable file
298
gui/v2/actions/profile_operations.py
Executable 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}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from core.Constants import Constants
|
from core.Constants import Constants
|
||||||
from core.controllers.ConfigurationController import ConfigurationController
|
from core.controllers.ConfigurationController import ConfigurationController
|
||||||
|
|
@ -112,7 +113,8 @@ def extract_endpoint_ip(profile):
|
||||||
return None
|
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:
|
if not text or text == "N/A" or len(text) <= max_length:
|
||||||
return text
|
return text
|
||||||
start_len = max_length // 2 - 2
|
start_len = max_length // 2 - 2
|
||||||
|
|
@ -120,11 +122,10 @@ def truncate_key(text, max_length=50):
|
||||||
return text[:start_len] + "....." + text[-end_len:]
|
return text[:start_len] + "....." + text[-end_len:]
|
||||||
|
|
||||||
|
|
||||||
def build_verification_view(profile):
|
def build_verification_view(profile: Any | None) -> dict[str, str]:
|
||||||
operator = None
|
"""Normalize operator verification fields so every screen displays the same key values."""
|
||||||
location = getattr(profile, 'location', None) if profile else None
|
location = getattr(profile, 'location', None) if profile else None
|
||||||
if location and not isinstance(location, dict) and getattr(location, 'operator', None):
|
operator = None if isinstance(location, dict) or location is None else getattr(location, 'operator', None)
|
||||||
operator = location.operator
|
|
||||||
if not operator:
|
if not operator:
|
||||||
return {
|
return {
|
||||||
"operator_name": "N/A",
|
"operator_name": "N/A",
|
||||||
|
|
@ -132,11 +133,15 @@ def build_verification_view(profile):
|
||||||
"hydraveil_public_key": "N/A",
|
"hydraveil_public_key": "N/A",
|
||||||
"nostr_attestation_event_reference": "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 {
|
return {
|
||||||
"operator_name": operator.name or "N/A",
|
field: getattr(operator, attribute, None) or "N/A"
|
||||||
"nostr_public_key": operator.nostr_public_key or "N/A",
|
for field, attribute in field_attributes.items()
|
||||||
"hydraveil_public_key": operator.public_key or "N/A",
|
|
||||||
"nostr_attestation_event_reference": operator.nostr_attestation_event_reference or "N/A",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,10 @@ class ConnectionManager:
|
||||||
def get_connected_profiles(self):
|
def get_connected_profiles(self):
|
||||||
return list(self._connected_profiles)
|
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):
|
def set_synced(self, status):
|
||||||
self._is_synced = status
|
self._is_synced = status
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.pages.screen_page import ScreenPage
|
||||||
from gui.v2.ui.popups.confirmation_popup import ConfirmationPopup
|
from gui.v2.ui.popups.confirmation_popup import ConfirmationPopup
|
||||||
from gui.v2.workers.page_data_worker import PageDataWorker
|
from gui.v2.workers.page_data_worker import PageDataWorker
|
||||||
|
from gui.v2.actions.menu_profiles import match_core_profiles
|
||||||
|
|
||||||
|
|
||||||
class EditorPage(Page):
|
class EditorPage(Page):
|
||||||
|
|
@ -185,8 +186,10 @@ class EditorPage(Page):
|
||||||
new_profiles = self._prefetched_profiles
|
new_profiles = self._prefetched_profiles
|
||||||
else:
|
else:
|
||||||
new_profiles = ProfileController.get_all()
|
new_profiles = ProfileController.get_all()
|
||||||
self.profiles_data = menu_page.match_core_profiles(
|
self.profiles_data = match_core_profiles(
|
||||||
profiles_dict=new_profiles)
|
new_profiles,
|
||||||
|
getattr(self.update_status, "gui_config_file", None),
|
||||||
|
)
|
||||||
self.data_profile = self.profiles_data[selected_profiles_str].copy(
|
self.data_profile = self.profiles_data[selected_profiles_str].copy(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.pages.Page import Page
|
||||||
from gui.v2.ui.popups.message_box import style_message_box, mark_confirm_button
|
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
|
HOW_MANY_PROFILES_DEFAULT = 6
|
||||||
|
|
@ -49,7 +50,9 @@ class IdPage(Page):
|
||||||
}
|
}
|
||||||
menu_page = self.find_menu_page()
|
menu_page = self.find_menu_page()
|
||||||
if 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:
|
else:
|
||||||
self.update_status.update_status(
|
self.update_status.update_status(
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -5,6 +5,7 @@ from core.controllers.ProfileController import ProfileController
|
||||||
from core.controllers.tickets.UseTicketController import get_unused_tickets
|
from core.controllers.tickets.UseTicketController import get_unused_tickets
|
||||||
|
|
||||||
from gui.v2.actions.locations import location_candidates
|
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.infrastructure.setup_observers import ticket_observer
|
||||||
from gui.v2.ui.pages.Page import Page
|
from gui.v2.ui.pages.Page import Page
|
||||||
|
|
||||||
|
|
@ -103,13 +104,14 @@ class TicketOrBillingChoicePage(Page):
|
||||||
profile_data = {
|
profile_data = {
|
||||||
'id': int(profile_id),
|
'id': int(profile_id),
|
||||||
'use_ticket': which_ticket,
|
'use_ticket': which_ticket,
|
||||||
'ticket_location': candidates[0],
|
'ticket_location': candidates[0]
|
||||||
'profile': profile
|
|
||||||
}
|
}
|
||||||
menu_page = self.custom_window.navigator.get_cached("menu")
|
menu_page = self.custom_window.navigator.get_cached("menu")
|
||||||
if menu_page:
|
if menu_page:
|
||||||
self.update_status.update_status(f"Using ticket #{which_ticket}...")
|
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")
|
self.custom_window.navigator.navigate("menu")
|
||||||
|
|
||||||
def on_use_billing(self):
|
def on_use_billing(self):
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ from PyQt6.QtWidgets import QLabel, QPushButton
|
||||||
from PyQt6 import QtCore
|
from PyQt6 import QtCore
|
||||||
|
|
||||||
from gui.v2.infrastructure.setup_observers import ticket_observer
|
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.pages.Page import Page
|
||||||
from gui.v2.ui.widgets.terminal_widget import TerminalWidget
|
from gui.v2.ui.widgets.terminal_widget import TerminalWidget
|
||||||
from gui.v2.workers.ticketing_worker_thread import TicketingWorkerThread
|
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.")
|
self.update_status.update_status("Tickets ready. Random ticket use is ON.")
|
||||||
return
|
return
|
||||||
self.update_status.update_status("Applying random ticket to profile...")
|
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,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ class ConfirmationPopup(QWidget):
|
||||||
def __init__(self, parent=None, message="", action_button_text="", cancel_button_text="Cancel"):
|
def __init__(self, parent=None, message="", action_button_text="", cancel_button_text="Cancel"):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.parent_window = parent
|
self.parent_window = parent
|
||||||
|
self._resolved = False
|
||||||
self.message = message
|
self.message = message
|
||||||
self.action_button_text = action_button_text
|
self.action_button_text = action_button_text
|
||||||
self.cancel_button_text = cancel_button_text
|
self.cancel_button_text = cancel_button_text
|
||||||
|
|
@ -79,9 +80,16 @@ class ConfirmationPopup(QWidget):
|
||||||
button_layout.addWidget(action_button)
|
button_layout.addWidget(action_button)
|
||||||
|
|
||||||
def perform_action(self):
|
def perform_action(self):
|
||||||
|
self._resolved = True
|
||||||
self.finished.emit(True)
|
self.finished.emit(True)
|
||||||
self.close()
|
self.close()
|
||||||
|
|
||||||
|
def closeEvent(self, event):
|
||||||
|
if not self._resolved:
|
||||||
|
self._resolved = True
|
||||||
|
self.finished.emit(False)
|
||||||
|
event.accept()
|
||||||
|
|
||||||
def mousePressEvent(self, event):
|
def mousePressEvent(self, event):
|
||||||
self.oldPos = event.globalPosition().toPoint()
|
self.oldPos = event.globalPosition().toPoint()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ class EndpointVerificationPopup(QWidget):
|
||||||
def __init__(self, parent=None, message=""):
|
def __init__(self, parent=None, message=""):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.parent_window = parent
|
self.parent_window = parent
|
||||||
|
self._resolved = False
|
||||||
self.message = message
|
self.message = message
|
||||||
self.initUI()
|
self.initUI()
|
||||||
|
|
||||||
|
|
@ -106,11 +107,14 @@ class EndpointVerificationPopup(QWidget):
|
||||||
button_layout.addWidget(continue_button)
|
button_layout.addWidget(continue_button)
|
||||||
|
|
||||||
def close_with_action(self, action):
|
def close_with_action(self, action):
|
||||||
|
self._resolved = True
|
||||||
self.finished.emit(True, action)
|
self.finished.emit(True, action)
|
||||||
self.close()
|
self.close()
|
||||||
|
|
||||||
def closeEvent(self, event):
|
def closeEvent(self, event):
|
||||||
self.finished.emit(False, "abort")
|
if not self._resolved:
|
||||||
|
self._resolved = True
|
||||||
|
self.finished.emit(False, "abort")
|
||||||
event.accept()
|
event.accept()
|
||||||
|
|
||||||
def mousePressEvent(self, event):
|
def mousePressEvent(self, event):
|
||||||
|
|
|
||||||
|
|
@ -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 gui.v2.actions.menu_results import MenuCallback, MenuResult
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Worker(QObject):
|
class Worker(QThread):
|
||||||
update_signal = pyqtSignal(str, bool, int, int, str)
|
output = pyqtSignal(object)
|
||||||
change_page = pyqtSignal(str, bool)
|
|
||||||
ticket_data_loss = pyqtSignal(str, str)
|
|
||||||
|
|
||||||
def __init__(self, profile_data):
|
def __init__(self, operation: Callable[[MenuCallback], MenuResult]):
|
||||||
self.profile_data = profile_data
|
|
||||||
super().__init__()
|
super().__init__()
|
||||||
profile_observer.subscribe(
|
self.operation = operation
|
||||||
'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
|
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
try:
|
self.output.emit(self.operation(self.output.emit))
|
||||||
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}"
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,11 @@
|
||||||
import shlex
|
import shlex
|
||||||
import subprocess
|
import subprocess
|
||||||
# import inspect
|
|
||||||
import time
|
|
||||||
|
|
||||||
from PyQt6.QtCore import QThread, pyqtSignal
|
from PyQt6.QtCore import QThread, pyqtSignal
|
||||||
|
|
||||||
from core.controllers.ApplicationVersionController import ApplicationVersionController
|
from core.controllers.ApplicationVersionController import ApplicationVersionController
|
||||||
from core.controllers.ClientController import ClientController
|
from core.controllers.ClientController import ClientController
|
||||||
from core.controllers.ConfigurationController import ConfigurationController, ConnectionChoice
|
from core.controllers.ConfigurationController import ConfigurationController
|
||||||
from core.Constants import Constants
|
|
||||||
|
|
||||||
from core.controllers.SyncController import new_sync
|
from core.controllers.SyncController import new_sync
|
||||||
from core.controllers.InvoiceController import InvoiceController
|
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.system.SystemConnection import SystemConnection
|
||||||
from core.models.BaseProfile import ProfileType
|
from core.models.BaseProfile import ProfileType
|
||||||
from core.models.system.SystemProfile import SystemProfile
|
from core.models.system.SystemProfile import SystemProfile
|
||||||
from core.errors.exceptions import SudoScript, MissingPreReqs, FirewallError
|
from core.models.Result import ResultError
|
||||||
from core.models.Result import Result, ResultError
|
|
||||||
from core.services.helpers.install_dependencies import setup_singbox_binary as install_singbox_binary
|
from core.services.helpers.install_dependencies import setup_singbox_binary as install_singbox_binary
|
||||||
|
|
||||||
from gui.v2.actions.disable_profiles import (
|
from gui.v2.actions.profile_operations import execute_menu_task, synchronize
|
||||||
filter_profiles_by_type,
|
from gui.v2.actions.menu_results import MenuResult
|
||||||
disable_profile_via_controller
|
|
||||||
)
|
|
||||||
|
|
||||||
from gui.v2.infrastructure.setup_observers import (
|
from gui.v2.infrastructure.setup_observers import (
|
||||||
application_version_observer,
|
application_version_observer,
|
||||||
|
|
@ -61,45 +55,26 @@ class WorkerThread(QThread):
|
||||||
self.is_running = True
|
self.is_running = True
|
||||||
self.is_disabling = False
|
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):
|
def run(self):
|
||||||
if self.action == 'LIST_PROFILES':
|
action_handlers = {
|
||||||
self.list_profiles()
|
'LIST_PROFILES': self.list_profiles,
|
||||||
elif self.action == 'CREATE_SESSION_PROFILE':
|
'CREATE_SESSION_PROFILE': lambda: self.create_profile(self.profile_type),
|
||||||
self.create_profile(self.profile_type)
|
'CREATE_SYSTEM_PROFILE': lambda: self.create_profile(self.profile_type),
|
||||||
elif self.action == 'CREATE_SYSTEM_PROFILE':
|
'GET_SUBSCRIPTION': self.get_subscription,
|
||||||
self.create_profile(self.profile_type)
|
'DISABLE_PROFILE': self.disable_profile,
|
||||||
elif self.action == 'GET_SUBSCRIPTION':
|
'SYNC': self.sync,
|
||||||
self.get_subscription()
|
'SYNC_TOR': self.sync,
|
||||||
elif self.action == 'DISABLE_PROFILE':
|
'DESTROY_PROFILE': self.destroy_profile,
|
||||||
self.disable_profile()
|
'DISABLE_ALL_PROFILES': self.disable_all_profiles,
|
||||||
elif self.action == 'SYNC' or self.action == 'SYNC_TOR':
|
'INSTALL_PACKAGE': self.install_package,
|
||||||
self.sync()
|
'SETUP_SINGBOX_BINARY': self.setup_singbox_binary,
|
||||||
elif self.action == 'DESTROY_PROFILE':
|
'CHECK_FOR_UPDATE': self.check_for_update,
|
||||||
self.destroy_profile()
|
'DOWNLOAD_UPDATE': self.download_update,
|
||||||
elif self.action == 'DISABLE_ALL_PROFILES':
|
'CHECK_INVOICE_STATUS': self.check_invoice_status,
|
||||||
self.disable_all_profiles()
|
}
|
||||||
elif self.action == 'INSTALL_PACKAGE':
|
handler = action_handlers.get(self.action)
|
||||||
self.install_package()
|
if handler is not None:
|
||||||
elif self.action == 'SETUP_SINGBOX_BINARY':
|
handler()
|
||||||
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()
|
|
||||||
|
|
||||||
def check_invoice_status(self):
|
def check_invoice_status(self):
|
||||||
try:
|
try:
|
||||||
|
|
@ -117,12 +92,7 @@ class WorkerThread(QThread):
|
||||||
self.invoice_finished.emit(False)
|
self.invoice_finished.emit(False)
|
||||||
|
|
||||||
def download_update(self):
|
def download_update(self):
|
||||||
self.text_output.emit("Starting update process...")
|
self._menu_operation({"task": "update"})
|
||||||
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"))
|
|
||||||
|
|
||||||
def check_for_update(self):
|
def check_for_update(self):
|
||||||
self.text_output.emit("Checking for updates...")
|
self.text_output.emit("Checking for updates...")
|
||||||
|
|
@ -184,60 +154,10 @@ class WorkerThread(QThread):
|
||||||
self.finished.emit(False)
|
self.finished.emit(False)
|
||||||
|
|
||||||
def disable_all_profiles(self):
|
def disable_all_profiles(self):
|
||||||
"""
|
self._menu_operation({"task": "disable_all", "connected_ids": self.profile_data})
|
||||||
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)
|
|
||||||
|
|
||||||
def destroy_profile(self):
|
def destroy_profile(self):
|
||||||
profile_id = int(self.profile_data['id'])
|
self._menu_operation({"task": "delete", "id": self.profile_data["id"], "confirmed": True})
|
||||||
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)
|
|
||||||
|
|
||||||
def list_profiles(self):
|
def list_profiles(self):
|
||||||
try:
|
try:
|
||||||
|
|
@ -312,55 +232,22 @@ class WorkerThread(QThread):
|
||||||
f"{profile_type.capitalize()} Profile created with ID: {profile.id}")
|
f"{profile_type.capitalize()} Profile created with ID: {profile.id}")
|
||||||
|
|
||||||
def disable_profile(self):
|
def disable_profile(self):
|
||||||
try:
|
self._menu_operation({"task": "disable", "id": self.profile_data["id"]})
|
||||||
profile = ProfileController.get(int(self.profile_data['id']))
|
|
||||||
if profile:
|
def _menu_operation(self, request):
|
||||||
disable_profile_via_controller(profile)
|
result = execute_menu_task(request, self._emit_menu_result, None)
|
||||||
else:
|
self._emit_menu_result(result)
|
||||||
self.text_output.emit(
|
self.finished.emit(result["status"] == "success")
|
||||||
f"No profile found with ID: {self.profile_data['id']}")
|
|
||||||
except SudoScript as e:
|
def _emit_menu_result(self, result: MenuResult):
|
||||||
self.text_output.emit(str(e))
|
self.text_output.emit(result.get("message", ""))
|
||||||
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)
|
|
||||||
|
|
||||||
def sync(self):
|
def sync(self):
|
||||||
try:
|
connection = {"SYNC_TOR": "tor", "SYNC": "system"}[self.action]
|
||||||
if self.action == 'SYNC_TOR':
|
result = synchronize(connection)
|
||||||
ConfigurationController.set_connection('tor')
|
self.sync_output.emit(*result["sync_data"])
|
||||||
else:
|
if result["status"] == "error":
|
||||||
ConfigurationController.set_connection('system')
|
self.sync_error.emit(result["message"])
|
||||||
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}")
|
|
||||||
|
|
||||||
def get_connection(self):
|
def get_connection(self):
|
||||||
connection = ConfigurationController.get_connection()
|
connection = ConfigurationController.get_connection()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue