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