forked from Support/sp-hydra-veil-gui
164 lines
8.8 KiB
Python
Executable file
164 lines
8.8 KiB
Python
Executable file
from typing import Any, Callable, Mapping, TypedDict
|
|
|
|
from core.controllers.LocationController import LocationController
|
|
from core.models.BaseProfile import BaseProfile
|
|
from gui.v2.actions.menu_profiles import profile_is_session, summarize_profile
|
|
|
|
|
|
class EditorState(TypedDict):
|
|
profile_id: int
|
|
baseline: dict[str, Any]
|
|
changes: dict[str, str]
|
|
locations: dict[str, dict[str, str]]
|
|
location_choices: list[str]
|
|
browser_choices: list[str]
|
|
resolutions: list[str]
|
|
is_synced: bool
|
|
|
|
|
|
class EditorResult(TypedDict, total=False):
|
|
status: str
|
|
message: str
|
|
state: EditorState
|
|
view: dict[str, Any]
|
|
request: dict[str, Any]
|
|
destination: str
|
|
refresh_menu: bool
|
|
adjusted: bool
|
|
saved_fields: list[str]
|
|
sync_data: tuple[list, list, bool, bool, list, list]
|
|
|
|
|
|
EditorCallback = Callable[[str, EditorResult], None]
|
|
SINGBOX_PROTOCOLS = ("hysteria2", "vless")
|
|
EDITABLE_FIELDS = ("name", "location", "browser", "dimentions", "connection")
|
|
|
|
|
|
def location_record(location: Any) -> dict[str, str]:
|
|
"""Flatten location and operator models before they reach Qt rendering code."""
|
|
if location is None or isinstance(location, dict):
|
|
return {}
|
|
operator = getattr(location, "operator", None)
|
|
return {
|
|
"key": f"{location.country_code}_{location.code}",
|
|
"country": getattr(location, "country_name", "") or "",
|
|
"label": f"{getattr(location, 'country_name', '')}, {getattr(location, 'name', '')}",
|
|
"operator": getattr(operator, "name", "") or "",
|
|
"nostr": getattr(operator, "nostr_public_key", "") or "",
|
|
}
|
|
|
|
|
|
def snapshot_profile(profile: BaseProfile) -> dict[str, Any]:
|
|
"""Reuse menu normalization while keeping the editor baseline independent of mutable core models."""
|
|
summary = summarize_profile(int(profile.id), profile)[1]
|
|
browser = summary["browser"]
|
|
version = summary["browser_version"]
|
|
return {**summary, "browser": f"{browser}:{version}" if version else browser,
|
|
"is_session": profile_is_session(profile),
|
|
"location_metadata": location_record(getattr(profile, "location", None))}
|
|
|
|
|
|
def prepare_editor_data() -> dict[str, dict[str, str]]:
|
|
"""Preload shared location metadata while leaving the editing baseline to a fresh selected-profile read."""
|
|
records = [location_record(location) for location in LocationController.get_all()]
|
|
return {record["key"]: record for record in records if record}
|
|
|
|
|
|
def copy_editor_state(state: EditorState) -> EditorState:
|
|
"""Capture task inputs so asynchronous operations and confirmations cannot modify the live draft."""
|
|
return {**state, "baseline": dict(state["baseline"]), "changes": dict(state["changes"]),
|
|
"locations": dict(state["locations"]), "location_choices": list(state["location_choices"]),
|
|
"browser_choices": list(state["browser_choices"]), "resolutions": list(state["resolutions"])}
|
|
|
|
|
|
def editor_parameters(data: Mapping[str, Any], state: EditorState) -> dict[str, list[str]]:
|
|
"""Choose supported editor fields with protocol tables instead of nested rendering branches."""
|
|
shared = {"location": state["location_choices"], "browser": state["browser_choices"],
|
|
"dimentions": list(dict.fromkeys([*state["resolutions"], str(data.get("dimentions", "None"))]))}
|
|
proxy = {"protocol": ["residential", "wireguard", "hidetor"], "connection": ["tor", "just proxy"], **shared}
|
|
protocol = data["protocol"]
|
|
rules = {
|
|
"wireguard": {"protocol": ["wireguard", "residential", "hidetor"], "connection": ["browser-only", "system-wide"], **shared},
|
|
"hidetor": proxy, "residential": proxy, "open": {"protocol": ["open"]},
|
|
"hysteria2": {"protocol": ["hysteria2"], "connection": ["system-wide"], "location": state["location_choices"]},
|
|
"vless": {"protocol": ["vless"], "connection": ["system-wide"], "location": state["location_choices"]},
|
|
}
|
|
return rules.get(protocol, {})
|
|
|
|
|
|
def _field_spec(key: str, data: Mapping[str, Any], state: EditorState, choices: list[str]) -> dict[str, Any]:
|
|
"""Prepare field content and control permissions before Qt creates the widgets."""
|
|
value = str(data.get(key, ""))
|
|
kind = {"browser": "browser", "location": "location", "dimentions": "resolution"}.get(key, "image")
|
|
location = state["locations"].get(value, {})
|
|
protocol_asset = {"hysteria2": "hystria2"}.get(value, value)
|
|
hidden = data["connection"] == "system-wide" and key in ("browser", "dimentions")
|
|
locked = key == "protocol" or (key == "connection" and (data["connection"] == "system-wide" or data["protocol"] in SINGBOX_PROTOCOLS))
|
|
browser = value.replace(":", " ", 1)
|
|
return {"key": key, "kind": kind, "value": value, "visible": not hidden,
|
|
"enabled": not locked, "needs_sync": key in ("browser", "location") and not state["is_synced"],
|
|
"asset": f"{protocol_asset if key == 'protocol' else value}_button.png",
|
|
"browser_text": browser, "unknown_browser": not browser or browser.lower().startswith("unknown"),
|
|
"blank_browser": key == "browser" and hidden,
|
|
"location_asset": f"button_{value}.png", "location_name": location.get("country") or value,
|
|
"provider": location.get("operator", ""), "choices": list(choices)}
|
|
|
|
|
|
def _preview(data: Mapping[str, Any], state: EditorState) -> dict[str, Any]:
|
|
"""Select preview assets and operator information without leaving protocol policy in Qt methods."""
|
|
protocol, connection, location = data["protocol"], data["connection"], data["location"]
|
|
location_data = state["locations"].get(location, {})
|
|
tor_prefix = "icon" if connection == "just proxy" else "hdtor"
|
|
primary = {"hidetor": f"{tor_prefix}_{location}.png"}.get(protocol, f"{protocol}_{location}.png")
|
|
operator = location_data.get("operator", "")
|
|
show_operator = bool(operator) and operator != "Simplified Privacy" and protocol not in ("hidetor", *SINGBOX_PROTOCOLS)
|
|
background = None
|
|
if protocol == "wireguard" and connection == "browser-only":
|
|
background = "browser only.png" if data["browser_supported"] else "unsupported_browser_only.png"
|
|
if protocol == "residential":
|
|
background = "browser only.png"
|
|
return {"asset": primary, "layout": {"hidetor": "tor", "vless": "vless"}.get(protocol, "standard"),
|
|
"encrypted": protocol in SINGBOX_PROTOCOLS,
|
|
"visible": protocol != "residential", "background": background,
|
|
"garage": "brazil garaje.png" if protocol == "residential" and location else None,
|
|
"operator": operator, "location_text": location_data.get("label", ""),
|
|
"nostr": location_data.get("nostr", ""), "show_operator": show_operator,
|
|
"location_asset": f"icon_{location}.png", "text_color": "black" if data["is_session"] else "white"}
|
|
|
|
|
|
def render_editor_state(state: EditorState, status: str = "render") -> EditorResult:
|
|
"""Rebuild presentation from the baseline and draft without fetching profile data again."""
|
|
data = {**state["baseline"], **state["changes"]}
|
|
if "browser" in state["changes"]:
|
|
data["browser_supported"] = data["browser"] in state["browser_choices"]
|
|
parameters = editor_parameters(data, state)
|
|
return {"status": status, "state": state, "view": {
|
|
"name": data["name"], "preview": _preview(data, state),
|
|
"fields": [_field_spec(key, data, state, choices) for key, choices in parameters.items()],
|
|
}}
|
|
|
|
|
|
def set_editor_value(state: EditorState, key: str, value: str) -> EditorResult:
|
|
"""Remove reverted values from the draft so unsaved-change warnings reflect actual differences."""
|
|
if key not in EDITABLE_FIELDS:
|
|
return {"status": "error", "message": f"Editing {key} is not supported.", "state": state}
|
|
updated = copy_editor_state(state)
|
|
updated["changes"].pop(key, None)
|
|
if value != str(updated["baseline"].get(key, "")):
|
|
updated["changes"][key] = value
|
|
return render_editor_state(updated, "draft" if key == "name" else "render")
|
|
|
|
|
|
def set_editor_resolution(state: EditorState, width_text: str, height_text: str, host_width: int, host_height: int) -> EditorResult:
|
|
"""Validate and fit custom dimensions before the dialog records a pending resolution."""
|
|
try:
|
|
width, height = int(width_text), int(height_text)
|
|
except ValueError:
|
|
return {"status": "error", "message": "Enter whole numbers for width and height."}
|
|
if min(width, height, host_width, host_height) <= 0:
|
|
return {"status": "error", "message": "Width, height and host dimensions must be positive."}
|
|
fitted_width = max(1, host_width - 50) if width > host_width else width
|
|
fitted_height = max(1, host_height - 50) if height > host_height else height
|
|
result = set_editor_value(state, "dimentions", f"{fitted_width}x{fitted_height}")
|
|
result["adjusted"] = (width, height) != (fitted_width, fitted_height)
|
|
return result
|