diff --git a/gui/resources/images/hysteri_mini_icon.png b/gui/resources/images/hysteri_mini_icon.png new file mode 100755 index 0000000..9e5fd31 Binary files /dev/null and b/gui/resources/images/hysteri_mini_icon.png differ diff --git a/gui/resources/images/hysteria2.png b/gui/resources/images/hysteria2.png old mode 100644 new mode 100755 diff --git a/gui/resources/images/hysteria2_mini.png b/gui/resources/images/hysteria2_mini.png old mode 100644 new mode 100755 index 27ea5e2..6d48503 Binary files a/gui/resources/images/hysteria2_mini.png and b/gui/resources/images/hysteria2_mini.png differ diff --git a/gui/resources/images/hysteria2_nl_li.png b/gui/resources/images/hysteria2_nl_li.png new file mode 100755 index 0000000..c2c88cb Binary files /dev/null and b/gui/resources/images/hysteria2_nl_li.png differ diff --git a/gui/resources/images/hysteria2_us_ny.png b/gui/resources/images/hysteria2_us_ny.png new file mode 100755 index 0000000..ab1ed7a Binary files /dev/null and b/gui/resources/images/hysteria2_us_ny.png differ diff --git a/gui/resources/images/hysteria2_us_wa.png b/gui/resources/images/hysteria2_us_wa.png new file mode 100755 index 0000000..ad604ce Binary files /dev/null and b/gui/resources/images/hysteria2_us_wa.png differ diff --git a/gui/resources/images/hystria2_button.png b/gui/resources/images/hystria2_button.png old mode 100644 new mode 100755 diff --git a/gui/resources/images/icon_mini_is_1.png b/gui/resources/images/icon_mini_is_1.png old mode 100644 new mode 100755 diff --git a/gui/resources/images/icon_mini_is_1.webp b/gui/resources/images/icon_mini_is_1.webp old mode 100644 new mode 100755 diff --git a/gui/resources/images/icon_mini_se_ab.png b/gui/resources/images/icon_mini_se_ab.png old mode 100644 new mode 100755 diff --git a/gui/resources/images/icon_se_ab.png b/gui/resources/images/icon_se_ab.png old mode 100644 new mode 100755 diff --git a/gui/resources/images/vless.png b/gui/resources/images/vless.png old mode 100644 new mode 100755 diff --git a/gui/resources/images/vless_button.png b/gui/resources/images/vless_button.png old mode 100644 new mode 100755 diff --git a/gui/resources/images/vless_icon.png b/gui/resources/images/vless_icon.png new file mode 100755 index 0000000..8cc1ff4 Binary files /dev/null and b/gui/resources/images/vless_icon.png differ diff --git a/gui/resources/images/vless_mini.png b/gui/resources/images/vless_mini.png deleted file mode 100644 index 954868b..0000000 Binary files a/gui/resources/images/vless_mini.png and /dev/null differ diff --git a/gui/resources/images/vless_mini_transparent.png b/gui/resources/images/vless_mini_transparent.png new file mode 100755 index 0000000..e42c9cf Binary files /dev/null and b/gui/resources/images/vless_mini_transparent.png differ diff --git a/gui/resources/images/vless_mini_transparent_white.png b/gui/resources/images/vless_mini_transparent_white.png new file mode 100755 index 0000000..7528c50 Binary files /dev/null and b/gui/resources/images/vless_mini_transparent_white.png differ diff --git a/gui/resources/images/vless_nl_li.png b/gui/resources/images/vless_nl_li.png new file mode 100755 index 0000000..b6c0976 Binary files /dev/null and b/gui/resources/images/vless_nl_li.png differ diff --git a/gui/resources/images/vless_us_ny.png b/gui/resources/images/vless_us_ny.png new file mode 100755 index 0000000..e3e806d Binary files /dev/null and b/gui/resources/images/vless_us_ny.png differ diff --git a/gui/resources/images/vless_us_wa.png b/gui/resources/images/vless_us_wa.png new file mode 100755 index 0000000..0d15484 Binary files /dev/null and b/gui/resources/images/vless_us_wa.png differ diff --git a/gui/resources/images/wireguard_button.png b/gui/resources/images/wireguard_button.png old mode 100644 new mode 100755 diff --git a/gui/resources/images/wireguard_mini.png b/gui/resources/images/wireguard_mini.png old mode 100644 new mode 100755 diff --git a/gui/resources/images/wireguard_mini_transparent.png b/gui/resources/images/wireguard_mini_transparent.png old mode 100644 new mode 100755 diff --git a/gui/v2/actions/editor_data.py b/gui/v2/actions/editor_data.py new file mode 100755 index 0000000..4b07424 --- /dev/null +++ b/gui/v2/actions/editor_data.py @@ -0,0 +1,164 @@ +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 diff --git a/gui/v2/actions/editor_tasks.py b/gui/v2/actions/editor_tasks.py new file mode 100755 index 0000000..070e0a2 --- /dev/null +++ b/gui/v2/actions/editor_tasks.py @@ -0,0 +1,199 @@ +from functools import partial +from typing import Any, Callable, Mapping, Sequence + +from PyQt6.QtCore import Qt + +from core.controllers.ProfileController import ProfileController +from core.controllers.profile_state.update_profile import update_profile +from gui.v2.actions.database_health import GuiStorageDatabaseError +from gui.v2.actions.editor_data import ( + EDITABLE_FIELDS, EditorCallback, EditorResult, EditorState, + copy_editor_state, location_record, prepare_editor_data, render_editor_state, set_editor_value, snapshot_profile, +) +from gui.v2.actions.profile_operations import current_connection, synchronize +from gui.v2.workers.page_data_worker import PageDataWorker + + +_active_workers: set[PageDataWorker] = set() + + +def _release_worker(worker: PageDataWorker) -> None: + """Keep worker references until native completion so page navigation cannot destroy running tasks.""" + _active_workers.discard(worker) + worker.deleteLater() + + +def _report_task_failure(on_result: EditorCallback, request_name: str, message: str) -> None: + """Restore GUI controls even if the worker cannot format an unexpected task failure.""" + on_result(request_name, {"status": "error", "message": message}) + + +def _execute(operation: Callable[[], EditorResult], state: EditorState | None) -> EditorResult: + """Turn background exceptions into explicit outcomes that leave pending changes available.""" + try: + return operation() + except GuiStorageDatabaseError: + message = "Local storage database could not be read. Restart and recover storage.db." + except Exception as error: + message = str(error) or "The editor operation failed." + result: EditorResult = {"status": "error", "message": message} + if state is not None: + result.update(render_editor_state(state, "error")) + return result + + +def _start_task(request_name: str, operation: Callable[[], EditorResult], on_result: EditorCallback, state: EditorState | None = None) -> PageDataWorker: + """Own worker construction and GUI signal delivery so editor handlers only pass task inputs.""" + worker = PageDataWorker(request_name, _execute, operation, state) + worker.data_ready.connect(on_result, Qt.ConnectionType.QueuedConnection) + worker.failed.connect(partial(_report_task_failure, on_result), Qt.ConnectionType.QueuedConnection) + worker.finished.connect(partial(_release_worker, worker)) + _active_workers.add(worker) + worker.start() + return worker + + +def _load_editor(profile_id: int | None, location_choices: list[str], browser_choices: list[str], resolutions: list[str], is_synced: bool, + prepared: Mapping[str, dict[str, str]] | None, previous: EditorState | None) -> EditorResult: + """Load one editing baseline and location metadata while preserving an existing unsaved draft.""" + if profile_id is None: + return {"status": "error", "message": "Select a profile before opening the editor."} + profile_id = int(profile_id) + baseline = previous["baseline"] if previous is not None and previous["changes"] else None + profile = ProfileController.get(profile_id) if baseline is None else None + if baseline is None and profile is None: + return {"status": "error", "message": f"Profile {profile_id} no longer exists."} + if baseline is None: + baseline = snapshot_profile(profile) + location_map = dict(prepared) if prepared is not None else prepare_editor_data() + if not set(location_choices).issubset(location_map): + location_map = prepare_editor_data() + location_metadata = baseline["location_metadata"] + if location_metadata: + location_map[location_metadata["key"]] = location_metadata + state: EditorState = { + "profile_id": profile_id, "baseline": dict(baseline), + "changes": dict(previous["changes"]) if previous is not None else {}, + "locations": location_map, "location_choices": list(dict.fromkeys(location_choices)), + "browser_choices": list(dict.fromkeys(browser_choices)), "resolutions": list(dict.fromkeys(resolutions)), + "is_synced": is_synced, + } + return render_editor_state(state) + + +def load_editor(profile_id: int | None, location_choices: Sequence[str], browser_choices: Sequence[str], resolutions: Sequence[str], is_synced: bool, + prepared: Mapping[str, dict[str, str]] | None, previous: EditorState | None, request_name: str, on_result: EditorCallback) -> PageDataWorker: + """Prepare editor content asynchronously without letting the page coordinate controller lookups.""" + captured = copy_editor_state(previous) if previous is not None else None + operation = partial(_load_editor, profile_id, list(location_choices), list(browser_choices), list(resolutions), is_synced, prepared, captured) + return _start_task(request_name, operation, on_result, captured) + + +def _sync_editor(state: EditorState) -> EditorResult: + """Replace available choices only after synchronization succeeds while retaining the current draft.""" + synced = synchronize(current_connection()) + if synced["status"] == "error": + return {**render_editor_state(state, "error"), "message": synced["message"], "sync_data": synced["sync_data"]} + locations, browsers, _, _, location_models, _ = synced["sync_data"] + records = [location_record(location) for location in location_models] + updated: EditorState = {**state, "location_choices": locations, "browser_choices": browsers, + "locations": {record["key"]: record for record in records if record}, "is_synced": True} + return {**render_editor_state(updated), "sync_data": synced["sync_data"], "message": "Sync complete."} + + +def cycle_editor_value(state: EditorState, key: str, direction: int, request_name: str, on_result: EditorCallback) -> PageDataWorker | None: + """Share previous and next selection logic and own the synchronization needed to populate choices.""" + captured = copy_editor_state(state) + field = next((field for field in render_editor_state(captured)["view"]["fields"] if field["key"] == key), None) + if field is None or not field["enabled"] or not field["visible"] or direction not in (-1, 1): + on_result(request_name, {"status": "error", "message": "This editor field cannot be changed."}) + return None + if field["needs_sync"]: + return _start_task(request_name, partial(_sync_editor, captured), on_result, captured) + choices = field["choices"] + if not choices: + on_result(request_name, {"status": "error", "message": f"No {key} data available. Sync the database and try again."}) + return None + current = field["value"] + index = choices.index(current) if current in choices else (-1 if direction == 1 else 0) + on_result(request_name, set_editor_value(captured, key, choices[(index + direction) % len(choices)])) + return None + + +def _confirmation(state: EditorState, task: str, message: str) -> EditorResult: + """Capture the profile and draft together so confirmations cannot apply subsequently edited values.""" + return {"status": "confirmation", "message": message, + "request": {"task": task, "state": copy_editor_state(state)}} + + +def _apply_warning(state: EditorState) -> str: + """Include every destructive change in one confirmation rather than hiding the browser warning behind location changes.""" + warnings = { + "location": "A new location requires a new subscription code and erases the old one.", + "browser": "Changing the browser would delete all data associated with it.", + } + messages = [message for field, message in warnings.items() if field in state["changes"]] + return "\n\n".join([*messages, "Do you want to proceed?"]) if messages else "" + + +def _save_editor(state: EditorState) -> EditorResult: + """Track each core save so partial failures retain only the changes still needing attention.""" + pending = state["changes"] + unsupported = set(pending).difference(EDITABLE_FIELDS) + if unsupported: + return {**render_editor_state(state, "error"), "message": "These editor changes are not supported; protocol switching is disabled."} + updated = copy_editor_state(state) + saved: list[str] = [] + for key, value in pending.items(): + try: + outcome = update_profile(state["profile_id"], key, value) + message = None if outcome.valid else str(outcome.message or outcome.data or f"Could not save {key}.") + except Exception as error: + message = str(error) or f"Could not save {key}." + if message is not None: + progress = f"Saved {', '.join(saved)}. " if saved else "" + return {**render_editor_state(updated, "error"), "message": f"{progress}Could not save {key}: {message}", + "saved_fields": saved, "refresh_menu": bool(saved)} + updated["baseline"][key] = value + if key == "browser": + updated["baseline"]["browser_supported"] = value in updated["browser_choices"] + updated["changes"].pop(key, None) + saved.append(key) + return {"status": "navigate", "destination": "menu", "state": updated, "refresh_menu": True, + "saved_fields": saved, "message": f"Updated profile {state['profile_id']}"} + + +def apply_editor_changes(state: EditorState, request_name: str, on_result: EditorCallback, confirmed: bool = False) -> PageDataWorker | None: + """Own confirmation and saving as one user task so the GUI never chains individual core updates.""" + captured = copy_editor_state(state) + if not captured["changes"]: + on_result(request_name, {"status": "navigate", "destination": "menu", "state": captured}) + return None + warning = _apply_warning(captured) + if warning and not confirmed: + on_result(request_name, _confirmation(captured, "apply", warning)) + return None + return _start_task(request_name, partial(_save_editor, captured), on_result, captured) + + +def leave_editor(state: EditorState, request_name: str, on_result: EditorCallback, confirmed: bool = False) -> None: + """Discard changes only after the user accepts leaving an editor with an unsaved draft.""" + captured = copy_editor_state(state) + if captured["changes"] and not confirmed: + on_result(request_name, _confirmation(captured, "leave", "You have unsaved changes. Do you want to continue?")) + return + captured["changes"].clear() + on_result(request_name, {"status": "navigate", "destination": "menu", "state": captured}) + + +def continue_editor_task(request: Mapping[str, Any], accepted: bool, request_name: str, on_result: EditorCallback) -> PageDataWorker | None: + """Return popup decisions to the original task without rebuilding its profile or draft from the page.""" + state = request["state"] + if not accepted: + on_result(request_name, render_editor_state(state)) + return None + handlers = { + "apply": partial(apply_editor_changes, state, request_name, on_result, confirmed=True), + "leave": partial(leave_editor, state, request_name, on_result, confirmed=True), + } + return handlers[request["task"]]() diff --git a/gui/v2/actions/menu_profiles.py b/gui/v2/actions/menu_profiles.py index 297287e..231935d 100755 --- a/gui/v2/actions/menu_profiles.py +++ b/gui/v2/actions/menu_profiles.py @@ -124,7 +124,7 @@ def profile_is_session(profile: BaseProfile | None) -> bool: return isinstance(profile, SessionProfile) -def _matched_profile(profile_id: int, profile: BaseProfile) -> tuple[str, dict[str, Any]]: +def summarize_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" @@ -154,7 +154,7 @@ def match_core_profiles(profiles: Mapping[int, BaseProfile], gui_config_file: st """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]) + summarize_profile(profile_id, profiles[profile_id]) for profile_id in profile_ids if profile_id in profiles ) @@ -231,9 +231,7 @@ def _profile_detail_data(profile: BaseProfile | None, summary: Mapping[str, Any] ], "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), + "primary_asset": f"{protocol}_{summary['location']}.png", "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 "", @@ -280,7 +278,7 @@ def get_profile_details( ) -> 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 + current_summary = summarize_profile(profile_id, profile)[1] if profile is not None else summary return _profile_detail_data(profile, current_summary, is_connected) diff --git a/gui/v2/ui/pages/editor_page.py b/gui/v2/ui/pages/editor_page.py index 100d5ae..f721cfe 100755 --- a/gui/v2/ui/pages/editor_page.py +++ b/gui/v2/ui/pages/editor_page.py @@ -1,33 +1,28 @@ import os +from functools import partial from PyQt6.QtWidgets import ( QLabel, QPushButton, QLineEdit, QTextEdit, QFrame, QDialog, - QVBoxLayout, QHBoxLayout, QMessageBox, QGraphicsDropShadowEffect + QVBoxLayout, QHBoxLayout, QMessageBox, QGraphicsDropShadowEffect, ) from PyQt6.QtGui import QPixmap, QIcon, QPainter, QColor, QTransform -from PyQt6.QtCore import Qt, QSize, QTimer, QPointF +from PyQt6.QtCore import Qt, QSize, QTimer, QPointF, QSignalBlocker, pyqtSlot from PyQt6 import QtCore, QtGui -from core.controllers.ProfileController import ProfileController -from core.controllers.LocationController import LocationController -from core.models.Result import Result, ResultError -from core.controllers.profile_state.update_profile import update_profile - +from gui.v2.actions.editor_data import ( + EditorResult, prepare_editor_data, set_editor_resolution, set_editor_value, +) +from gui.v2.actions.editor_tasks import ( + apply_editor_changes, continue_editor_task, cycle_editor_value, leave_editor, load_editor, +) from gui.v2.ui.pages.Page import Page from gui.v2.ui.pages.browser_page import BrowserPage from gui.v2.ui.pages.location_page import LocationPage from gui.v2.ui.pages.screen_page import ScreenPage from gui.v2.ui.popups.confirmation_popup import ConfirmationPopup -from gui.v2.workers.page_data_worker import PageDataWorker -from gui.v2.actions.menu_profiles import match_core_profiles class EditorPage(Page): - SINGBOX_PROTOCOLS = ("hysteria2", "vless") - PROTOCOL_BUTTON_ASSETS = { - "hysteria2": "hystria2", - } - def __init__(self, page_stack, main_window, prepared=None): super().__init__("Editor", page_stack, main_window) self.page_stack = page_stack @@ -73,8 +68,6 @@ class EditorPage(Page): self.name.textChanged.connect( lambda text: self.name_hint.hide() if text else self.name_hint.show()) - self.temp_changes = {} - self.original_values = {} self.res_hint_shown = False self.display.setGeometry(QtCore.QRect(0, 60, 540, 405)) self.display.hide() @@ -89,630 +82,303 @@ class EditorPage(Page): self.brow_disp.hide() self.brow_disp.lower() - self._prefetched_profiles = prepared - self._editor_workers = set() + self._prepared_data = prepared + self.editor_state = None + self._states = {} + self._request_number = 0 + self._request_name = "" + self._worker = None + self._busy = False + self._arrow_controls = [] + self.name.textChanged.connect(self.update_name_value) + @staticmethod def prepare_data(custom_window): - return ProfileController.get_all() + return prepare_editor_data() - def _selected_profiles_str(self): - menu_page = self.find_menu_page() - if menu_page is not None and menu_page.selected_profiles: - return ', '.join( - [f"Profile_{profile['profile_number']}" for profile in menu_page.selected_profiles]) - profile_id = getattr(self.update_status, 'current_profile_id', None) - if profile_id is not None: - return f"Profile_{int(profile_id)}" - return '' - - def update_name_value(self): - original_name = self.data_profile.get('name', '') - new_name = self.name.text() - if original_name != new_name: - self.update_temp_value('name', new_name) - else: - pass - - def go_back(self): - selected_profiles_str = self._selected_profiles_str() - if self.has_unsaved_changes(selected_profiles_str): - self.show_unsaved_changes_popup() - else: - self.custom_window.navigator.navigate("menu") - - def find_menu_page(self): - return self.custom_window.navigator.get_cached("menu") - - def find_resume_page(self): - return self.custom_window.navigator.get_cached("resume") - - def find_protocol_page(self): - return self.custom_window.navigator.get_cached("protocol") + def _new_request(self): + self._request_number += 1 + self._request_name = str(self._request_number) + return self._request_name def showEvent(self, event): super().showEvent(event) self.res_hint_shown = False - if self._selected_profile_in_cache(): - self.extraccion() + profile_id = getattr(self.update_status, "current_profile_id", None) + request_name = self._new_request() + self.editor_state = None + self._clear_editor_widgets() + self.display.hide() + self.garaje.hide() + self.brow_disp.hide() + self._set_editor_busy(True) + prepared = self._prepared_data + self._prepared_data = None + self._worker = load_editor( + profile_id, self.connection_manager.get_location_list(), + self.connection_manager.get_browser_list(), self.connection_manager.available_resolutions, + self.connection_manager.is_synced(), prepared, self._states.get(profile_id), + request_name, self.handle_editor_result, + ) + + def hideEvent(self, event): + self._new_request() + super().hideEvent(event) + + def _clear_editor_widgets(self): + for widget in [*self.labels, *self.buttons]: + widget.deleteLater() + self.labels.clear() + self.buttons.clear() + self._arrow_controls.clear() + + def _set_editor_busy(self, busy): + self._busy = busy + self.name.setEnabled(not busy and self.editor_state is not None) + self.button_apply.setEnabled(not busy and self.editor_state is not None) + self.button_back.setEnabled(not busy) + for button, enabled in self._arrow_controls: + button.setEnabled(not busy and enabled) + + @pyqtSlot(str, object) + def handle_editor_result(self, request_name: str, result: EditorResult): + if request_name != self._request_name: return - worker = PageDataWorker("editor", EditorPage.prepare_data, self.custom_window) - worker.data_ready.connect( - lambda name, profiles: self._on_editor_data_ready(profiles)) - worker.failed.connect( - lambda name, error: self._on_editor_data_failed(error)) - worker.finished.connect( - lambda w=worker: self._cleanup_editor_worker(w)) - self._editor_workers.add(worker) - worker.start() + if "state" in result: + self.editor_state = result["state"] + self._states[self.editor_state["profile_id"]] = self.editor_state + if "sync_data" in result: + self.update_status.update_values(*result["sync_data"]) + if "view" in result and result["status"] != "draft": + self._render_editor(result["view"]) + self._set_editor_busy(result["status"] == "confirmation") + if result.get("refresh_menu"): + menu = self.custom_window.navigator.get_cached("menu") + if menu is not None: + menu.refresh_menu_buttons() + if result.get("message"): + self.update_status.update_status(result["message"]) + handlers = {"confirmation": self._show_confirmation, "navigate": self._navigate_result} + handler = handlers.get(result["status"]) + if handler is not None: + handler(result) - def _on_editor_data_ready(self, profiles): - self._prefetched_profiles = profiles - self.extraccion() + def _navigate_result(self, result): + self.custom_window.navigator.navigate(result["destination"]) - def _on_editor_data_failed(self, error): - self.update_status.update_status(f"Editor data load failed: {error}") - self.extraccion() + def _show_confirmation(self, result): + self.popup = ConfirmationPopup( + self, message=result["message"], action_button_text="Continue", cancel_button_text="Cancel", + ) + self.popup.setWindowModality(Qt.WindowModality.ApplicationModal) + self.popup.finished.connect(partial(self._confirmation_response, result["request"], self._request_name)) + self.popup.show() - def _cleanup_editor_worker(self, worker): - self._editor_workers.discard(worker) - worker.deleteLater() + def _confirmation_response(self, request, request_name, accepted): + if request_name != self._request_name: + return + self._set_editor_busy(True) + self._worker = continue_editor_task(request, accepted, request_name, self.handle_editor_result) - def _selected_profile_in_cache(self): - if not self._prefetched_profiles: - return False - selected = self._selected_profiles_str() - if not selected: - return False - try: - profile_id = int(selected.split('_')[1]) - except (IndexError, ValueError): - return False - return profile_id in self._prefetched_profiles + def update_name_value(self): + if self.editor_state is None: + return + self.handle_editor_result( + self._request_name, set_editor_value(self.editor_state, "name", self.name.text()), + ) - def extraccion(self): - self.data_profile = {} - for label in self.labels: - label.deleteLater() - self.labels = [] - for button in self.buttons: - button.deleteLater() - self.buttons = [] + def go_back(self): + if self.editor_state is None: + self.custom_window.navigator.navigate("menu") + return + self._set_editor_busy(True) + leave_editor(self.editor_state, self._new_request(), self.handle_editor_result) - selected_profiles_str = self._selected_profiles_str() - menu_page = self.find_menu_page() - if menu_page: - if self._prefetched_profiles is not None: - new_profiles = self._prefetched_profiles - else: - new_profiles = ProfileController.get_all() - self.profiles_data = match_core_profiles( - new_profiles, - getattr(self.update_status, "gui_config_file", None), - ) - self.data_profile = self.profiles_data[selected_profiles_str].copy( + def go_selected(self): + if self.editor_state is None: + return + self._set_editor_busy(True) + self._worker = apply_editor_changes(self.editor_state, self._new_request(), self.handle_editor_result) + + def _change_field(self, key, direction): + self._set_editor_busy(True) + self._worker = cycle_editor_value( + self.editor_state, key, direction, self._new_request(), self.handle_editor_result, + ) + + def _render_editor(self, view): + self._clear_editor_widgets() + if self.name.text() != view["name"]: + with QSignalBlocker(self.name): + self.name.setText(view["name"]) + self.name_hint.setVisible(not bool(view["name"])) + self._render_preview(view["preview"]) + if view["preview"]["show_operator"]: + self._render_operator_details(view["preview"]) + for index, field in enumerate(view["fields"]): + self._render_field(index, field) + + def _render_preview(self, preview): + layouts = {"tor": (0, 100, 400, 394), "standard": (0, 60, 540, 405), "vless": (0, 90, 540, 405)} + self.display.setGeometry(*layouts[preview["layout"]]) + pixmap = QPixmap(os.path.join(self.btn_path, preview["asset"])) + if preview["encrypted"]: + pixmap = pixmap.scaled( + self.display.size(), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation, ) + self.display.setPixmap(pixmap) + self.display.setScaledContents(not preview["encrypted"]) + self.display.setVisible(preview["visible"]) + self.brow_disp.setVisible(bool(preview["background"])) + if preview["background"]: + self.brow_disp.setPixmap(QPixmap(os.path.join(self.btn_path, preview["background"]))) + self.garaje.setVisible(bool(preview["garage"])) + if preview["garage"]: + self.garaje.setPixmap(QPixmap(os.path.join(self.btn_path, preview["garage"]))) - profile_id = int(selected_profiles_str.split('_')[1]) - self.data_profile['id'] = profile_id + def _render_operator_details(self, preview): + image = QLabel(self) + image.setGeometry(20, 125, 150, 150) + image.setPixmap(QPixmap(os.path.join(self.btn_path, preview["location_asset"]))) + image.setScaledContents(True) + image.show() + self.labels.append(image) + blocks = [ + ((180, 120, 300, 250), f"Location: {preview['location_text']}\n\nOperator: {preview['operator']}", + 19, QtGui.QTextOption.WrapMode.WrapAtWordBoundaryOrAnywhere), + ((20, 300, 450, 170), f"Nostr: {preview['nostr']}", 21, QtGui.QTextOption.WrapMode.WrapAnywhere), + ] + for geometry, text, size, wrap in blocks: + widget = QTextEdit(self) + widget.setGeometry(*geometry) + widget.setReadOnly(True) + widget.setFrameStyle(QFrame.Shape.NoFrame) + widget.setStyleSheet(f"background: transparent; border: none; color: {preview['text_color']}; font-size: {size}px;") + widget.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + widget.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + widget.setWordWrapMode(wrap) + widget.setText(text) + widget.show() + self.labels.append(widget) - if selected_profiles_str in self.temp_changes: - for key, value in self.temp_changes[selected_profiles_str].items(): - self.data_profile[key] = value + def _field_pixmap(self, field): + renderers = {"image": self._image_field, "browser": self._browser_field, + "location": self._location_field, "resolution": self._resolution_field} + return renderers[field["kind"]](field) - self.name.textChanged.connect(self.update_name_value) - self.verificate(self.data_profile, selected_profiles_str) + def _image_field(self, field): + return QPixmap(os.path.join(self.btn_path, field["asset"])) - def verificate(self, data_profile, selected_profile_str): - protocol = data_profile.get("protocol", "") + def _browser_field(self, field): + if field["blank_browser"]: + return QPixmap() + fallback = os.path.join(self.btn_path, "default_browser_button.png") + if field["unknown_browser"]: + pixmap = QPixmap(fallback) + return pixmap if not pixmap.isNull() else BrowserPage.create_browser_button_image("Browser", fallback, True) + pixmap = BrowserPage.create_browser_button_image(field["browser_text"], self.btn_path) + return pixmap if not pixmap.isNull() else BrowserPage.create_browser_button_image(field["browser_text"], fallback, True) - try: - if protocol == "wireguard": - self.process_and_show_labels(data_profile, { - "protocol": ['wireguard', 'residential', 'hidetor'], - "connection": ['browser-only', 'system-wide'], - "location": self.connection_manager.get_location_list(), - "browser": self.connection_manager.get_browser_list(), - "dimentions": self.connection_manager.get_available_resolutions(data_profile.get('id', '')) - }, selected_profile_str) + def _location_field(self, field): + asset = os.path.join(self.btn_path, field["location_asset"]) + existing = asset if not QPixmap(asset).isNull() else None + return LocationPage.create_location_button_image( + field["value"] if existing else field["location_name"], + os.path.join(self.btn_path, "default_location_button.png"), field["provider"], existing, + ) - elif protocol in self.SINGBOX_PROTOCOLS: - self.process_and_show_labels(data_profile, { - "protocol": [protocol], - "connection": ['system-wide'], - "location": self.connection_manager.get_location_list(), - }, selected_profile_str) + def _resolution_field(self, field): + if field["value"] == "None": + return QPixmap() + return ScreenPage.create_resolution_button_image(self, field["value"]) - elif protocol == "residential" or protocol == "hidetor": - self.process_and_show_labels(data_profile, { - "protocol": ['residential', 'wireguard', 'hidetor'], - "connection": ['tor', 'just proxy'], - "location": self.connection_manager.get_location_list(), - "browser": self.connection_manager.get_browser_list(), - "dimentions": self.connection_manager.get_available_resolutions(data_profile.get('id', '')) - }, selected_profile_str) - - elif protocol == "open": - self.process_and_show_labels(data_profile, { - "protocol": ['open'] - }, selected_profile_str) - - except Exception as e: - print(f'An error occurred in verificate: {e}') - - def process_and_show_labels(self, data_profile, parameters, selected_profile_str): - protocol = data_profile.get('protocol', "") - connection = data_profile.get('connection', "") - location = data_profile.get('location', "") - country_garaje = location - name = data_profile.get('name', "") - self.name.setText(name) - - if protocol == "hidetor": - if connection == "just proxy": - self.display.setPixmap(QPixmap(os.path.join( - self.btn_path, f"icon_{location}.png"))) - else: - self.display.setPixmap(QPixmap(os.path.join( - self.btn_path, f"hdtor_{location}.png"))) - self.display.show() - self.display.setGeometry(0, 100, 400, 394) - - self.display.setScaledContents(True) - - self.garaje.hide() - self.brow_disp.hide() - - if protocol == "wireguard": - self.display.setGeometry(0, 60, 540, 405) - self.display.show() - self.display.setPixmap(QPixmap(os.path.join( - self.btn_path, f"wireguard_{location}.png"))) - self.garaje.hide() - if connection == "browser-only": - is_supported = data_profile.get('browser_supported', False) - image_name = "browser only.png" if is_supported else "unsupported_browser_only.png" - self.brow_disp.setPixmap( - QPixmap(os.path.join(self.btn_path, image_name))) - self.brow_disp.show() - else: - self.brow_disp.hide() - - if protocol in self.SINGBOX_PROTOCOLS: - if protocol == "vless": - self.display.setGeometry(0, 90, 540, 405) - else: - self.display.setGeometry(0, 60, 540, 405) - self.display.show() - self.display.setPixmap(self._encrypted_proxy_display_pixmap( - protocol, location).scaled( - self.display.size(), - Qt.AspectRatioMode.KeepAspectRatio, - Qt.TransformationMode.SmoothTransformation)) - self.garaje.hide() - self.brow_disp.hide() - - if protocol == "residential": - self.display.setGeometry(0, 60, 540, 405) - self.brow_disp.show() - self.garaje.show() - - self.display.hide() - if country_garaje: - country_garaje = 'brazil' - self.garaje.setPixmap(QPixmap(os.path.join( - self.btn_path, f"{country_garaje} garaje.png"))) - - profile_id = data_profile.get('id') - profile_obj = ProfileController.get(profile_id) - - location_info = None - if location: - try: - if '_' in location: - country_code, location_code = location.split('_', 1) - location_info = LocationController.get( - country_code, location_code) - except Exception: - location_info = None - - operator_name = "" - l_name = "" - o_name = "" - n_key = "" - - if location_info: - if hasattr(location_info, 'operator') and location_info.operator: - operator_name = location_info.operator.name - o_name = location_info.operator.name - n_key = location_info.operator.nostr_public_key - - l_name = f"{location_info.country_name}, {location_info.name}" if hasattr( - location_info, 'country_name') else "" - - if operator_name != 'Simplified Privacy' and operator_name != "" and protocol not in ('hidetor', *self.SINGBOX_PROTOCOLS): - text_color = "white" - if profile_obj and profile_obj.is_session_profile(): - text_color = "black" - - l_img = QLabel(self) - l_img.setGeometry(20, 125, 150, 150) - pixmap_loc = QPixmap(os.path.join( - self.btn_path, f"icon_{location}.png")) - l_img.setPixmap(pixmap_loc) - l_img.setScaledContents(True) - l_img.show() - self.labels.append(l_img) - - info_txt = QTextEdit(self) - info_txt.setGeometry(180, 120, 300, 250) - info_txt.setReadOnly(True) - info_txt.setFrameStyle(QFrame.Shape.NoFrame) - info_txt.setStyleSheet( - f"background: transparent; border: none; color: {text_color}; font-size: 19px;") - info_txt.setVerticalScrollBarPolicy( - Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - info_txt.setHorizontalScrollBarPolicy( - Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - info_txt.setWordWrapMode( - QtGui.QTextOption.WrapMode.WrapAtWordBoundaryOrAnywhere) - info_txt.setText( - f"Location: {l_name}\n\nOperator: {o_name}") - info_txt.show() - self.labels.append(info_txt) - - nostr_txt = QTextEdit(self) - nostr_txt.setGeometry(20, 300, 450, 170) - nostr_txt.setReadOnly(True) - nostr_txt.setFrameStyle(QFrame.Shape.NoFrame) - nostr_txt.setStyleSheet( - f"background: transparent; border: none; color: {text_color}; font-size: 21px;") - nostr_txt.setVerticalScrollBarPolicy( - Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - nostr_txt.setHorizontalScrollBarPolicy( - Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - nostr_txt.setWordWrapMode( - QtGui.QTextOption.WrapMode.WrapAnywhere) - nostr_txt.setText(f"Nostr: {n_key}") - nostr_txt.show() - self.labels.append(nostr_txt) - - for i, key in enumerate(parameters.keys()): - if key == 'browser': - browser_value = f"{data_profile.get(key, '')}" - split_value = browser_value.split(':', 1) - browser_type = split_value[0] - browser_version = split_value[1] if len( - split_value) > 1 else '' - browser_value = f"{browser_type} {browser_version}" - if browser_version == '': - browser_version = data_profile.get('browser_version', '') - browser_value = f"{browser_type} {browser_version}" - fallback_path = os.path.join( - self.btn_path, "default_browser_button.png") - normalized_browser = browser_value.strip().lower() - unknown_browser = ( - not normalized_browser - or normalized_browser == "unknown" - or normalized_browser.startswith("unknown browser") - ) - if connection == 'system-wide': - base_image = QPixmap() - elif unknown_browser: - base_image = QPixmap(fallback_path) - if base_image.isNull(): - base_image = BrowserPage.create_browser_button_image( - "Browser", fallback_path, True) - else: - base_image = BrowserPage.create_browser_button_image( - browser_value, self.btn_path) - if base_image.isNull(): - base_image = BrowserPage.create_browser_button_image( - browser_value, fallback_path, True) - elif key == 'location': - current_value = f"{data_profile.get(key, '')}" - image_path = os.path.join( - self.btn_path, f"button_{current_value}.png") - base_image = QPixmap(image_path) - locations = self.connection_manager.get_location_info( - current_value) - provider = locations.operator.name if locations and hasattr( - locations, 'operator') else None - fallback_path = os.path.join( - self.btn_path, "default_location_button.png") - if base_image.isNull(): - if locations and hasattr(locations, 'country_name'): - location_name = locations.country_name - base_image = LocationPage.create_location_button_image( - location_name, fallback_path, provider) - else: - base_image = LocationPage.create_location_button_image( - current_value, fallback_path, provider) - else: - if locations and hasattr(locations, 'country_name'): - base_image = LocationPage.create_location_button_image( - f'{locations.country_code}_{locations.code}', fallback_path, provider, image_path) - else: - base_image = LocationPage.create_location_button_image( - current_value, fallback_path, provider, image_path) - - elif key == 'dimentions': - current_value = f"{data_profile.get(key, '')}" - if current_value != 'None': - base_image = ScreenPage.create_resolution_button_image( - self, current_value) - else: - current_value = data_profile.get(key, '') - if key == 'protocol': - image_path = self._protocol_button_asset(current_value) - else: - image_path = os.path.join( - self.btn_path, f"{current_value}_button.png") - base_image = QPixmap(image_path) - - if key == 'dimentions': - label = QPushButton(self) - label.setGeometry(565, 90 + i * 80, 185, 75) - label.setFlat(True) - label.setStyleSheet("border: none; background: transparent;") - original_icon = QIcon(base_image) - label.setIconSize(QSize(185, 75)) - label.setCursor(QtCore.Qt.CursorShape.PointingHandCursor) - label.clicked.connect(self.show_custom_res_dialog) - - template_path = os.path.join( - self.btn_path, "resolution_template_button.png") - template_pixmap = QPixmap(template_path).scaled( - 187, 75) if os.path.exists(template_path) else None - - if not getattr(self, 'res_hint_shown', False) and connection != 'system-wide': - self.res_hint_shown = True - if template_pixmap: - label.setIcon(QIcon(template_pixmap)) - else: - label.setIcon(QIcon()) - - hint_label = QLabel("Click here to customize", label) - hint_label.setGeometry(15, 0, 155, 75) - hint_label.setWordWrap(True) - hint_label.setAlignment( - QtCore.Qt.AlignmentFlag.AlignCenter) - hint_label.setStyleSheet( - "color: #00ffff; font-weight: bold; font-size: 16px; background: transparent;") - hint_label.setCursor( - QtCore.Qt.CursorShape.PointingHandCursor) - hint_label.setAttribute( - QtCore.Qt.WidgetAttribute.WA_TransparentForMouseEvents) - shadow = QGraphicsDropShadowEffect(hint_label) - shadow.setBlurRadius(20) - shadow.setColor(QColor(0, 255, 255)) - shadow.setOffset(0, 0) - hint_label.setGraphicsEffect(shadow) - - def restore(): - try: - if hint_label and not hint_label.isHidden(): - hint_label.hide() - label.setIcon(original_icon) - except: - pass - QTimer.singleShot(3500, restore) - else: - label.setIcon(original_icon) - else: - label = QLabel(key, self) - label.setGeometry(565, 90 + i * 80, 185, 75) - label.setPixmap(base_image) - label.setScaledContents(True) - - label.show() - self.labels.append(label) - - try: - if key == 'browser': - value_index = [item.replace( - ':', ' ') for item in parameters[key]].index(browser_value) - else: - value_index = parameters[key].index(current_value) - except ValueError: - value_index = 0 - - prev_button = QPushButton(self) - prev_button.setGeometry(535, 90 + i * 80, 30, 75) - prev_button.clicked.connect( - lambda _, k=key, idx=value_index: self.show_previous_value(k, idx, parameters)) - prev_button.show() - icon_path = os.path.join(self.btn_path, f"UP_button.png") - icon = QPixmap(icon_path) - transform = QTransform().rotate(180) - rotated_icon = icon.transformed(transform) - - prev_button.setIcon(QIcon(rotated_icon)) - prev_button.setIconSize(prev_button.size()) - - if (key == 'location' or key == 'browser') and not self.connection_manager.is_synced(): - w = prev_button.width() - h = prev_button.height() - outline_pix = QPixmap(w, h) - outline_pix.fill(Qt.GlobalColor.transparent) - painter = QPainter(outline_pix) - painter.setRenderHint(QPainter.RenderHint.Antialiasing) - pen = QtGui.QPen(QColor(0, 255, 0)) - pen.setWidth(3) - painter.setPen(pen) - painter.setBrush(Qt.BrushStyle.NoBrush) - left_points = [ - QPointF(w*0.65, h*0.15), QPointF(w*0.25, h*0.50), QPointF(w*0.65, h*0.85)] - painter.drawPolygon(QtGui.QPolygonF(left_points)) - painter.end() - prev_button.setIcon(QIcon(outline_pix)) - prev_button.setIconSize(prev_button.size()) - - if (key == 'location' or key == 'browser') and not self.connection_manager.is_synced(): - prev_button.setStyleSheet(""" - QPushButton { - background-color: transparent; - border: none; - } - QPushButton:hover { - background-color: rgba(0, 255, 0, 0.1); - border-radius: 3px; - } - QPushButton:pressed { - background-color: rgba(0, 255, 0, 0.2); - border-radius: 3px; - } - """) - - self.buttons.append(prev_button) - - next_button = QPushButton(self) - next_button.setGeometry(750, 90 + i * 80, 30, 75) - next_button.clicked.connect( - lambda _, k=key, idx=value_index: self.show_next_value(k, idx, parameters)) - next_button.show() - self.buttons.append(next_button) - next_button.setIcon( - QIcon(os.path.join(self.btn_path, f"UP_button.png"))) - next_button.setIconSize(next_button.size()) - if (key == 'location' or key == 'browser') and not self.connection_manager.is_synced(): - w = next_button.width() - h = next_button.height() - outline_pix_r = QPixmap(w, h) - outline_pix_r.fill(Qt.GlobalColor.transparent) - painter_r = QPainter(outline_pix_r) - painter_r.setRenderHint(QPainter.RenderHint.Antialiasing) - pen_r = QtGui.QPen(QColor(0, 255, 0)) - pen_r.setWidth(3) - painter_r.setPen(pen_r) - painter_r.setBrush(Qt.BrushStyle.NoBrush) - right_points = [ - QPointF(w*0.35, h*0.15), QPointF(w*0.75, h*0.50), QPointF(w*0.35, h*0.85)] - painter_r.drawPolygon(QtGui.QPolygonF(right_points)) - painter_r.end() - next_button.setIcon(QIcon(outline_pix_r)) - next_button.setIconSize(next_button.size()) - - if (key == 'location' or key == 'browser') and not self.connection_manager.is_synced(): - next_button.setStyleSheet(""" - QPushButton { - background-color: transparent; - border: none; - } - QPushButton:hover { - background-color: rgba(0, 255, 0, 0.1); - border-radius: 3px; - } - QPushButton:pressed { - background-color: rgba(0, 255, 0, 0.2); - border-radius: 3px; - } - """) - - prev_button.setVisible(True) - next_button.setVisible(True) - if key == 'protocol' or (connection == 'system-wide' and key == 'connection'): - prev_button.setDisabled(True) - next_button.setDisabled(True) - - if connection == 'system-wide' and (key == 'browser' or key == 'dimentions'): - prev_button.setVisible(False) - next_button.setVisible(False) - - def _protocol_button_asset(self, protocol): - asset_name = self.PROTOCOL_BUTTON_ASSETS.get(protocol, protocol) - return os.path.join(self.btn_path, f"{asset_name}_button.png") - - def _encrypted_proxy_display_pixmap(self, protocol, location): - pixmap = QPixmap(os.path.join(self.btn_path, f"{protocol}.png")) - if pixmap.isNull(): - pixmap = QPixmap(os.path.join(self.btn_path, "system_wide_global.png")) - if pixmap.isNull(): - return pixmap - - location_pixmap = QPixmap(os.path.join( - self.btn_path, f"icon_mini_{location}.png")) - if location_pixmap.isNull(): - location_pixmap = QPixmap(os.path.join( - self.btn_path, "default_location_mini.png")) - if location_pixmap.isNull(): - return pixmap - - icon_size = max(42, min(72, int(min(pixmap.width(), pixmap.height()) * 0.22))) - location_pixmap = location_pixmap.scaled( - icon_size, - icon_size, - Qt.AspectRatioMode.KeepAspectRatio, - Qt.TransformationMode.SmoothTransformation) - x = int(pixmap.width() * 0.58) - if protocol == 'hysteria2': - y = int(pixmap.height() * 0.35) + def _render_field(self, index, field): + pixmap = self._field_pixmap(field) + geometry = (565, 90 + index * 80, 185, 75) + if field["kind"] == "resolution": + label = QPushButton(self) + label.setGeometry(*geometry) + label.setFlat(True) + label.setStyleSheet("border: none; background: transparent;") + label.setIcon(QIcon(pixmap)) + label.setIconSize(QSize(185, 75)) + label.setCursor(Qt.CursorShape.PointingHandCursor) + label.clicked.connect(self.show_custom_res_dialog) + self._arrow_controls.append((label, field["visible"])) + if field["visible"]: + self._add_resolution_hint(label, QIcon(pixmap)) else: - y = int(pixmap.height() * 0.30) - x = min(max(0, x), max(0, pixmap.width() - location_pixmap.width())) - y = min(max(0, y), max(0, pixmap.height() - location_pixmap.height())) + label = QLabel(self) + label.setGeometry(*geometry) + label.setPixmap(pixmap) + label.setScaledContents(True) + label.show() + self.labels.append(label) + self._create_arrow(field, -1, (535, 90 + index * 80, 30, 75)) + self._create_arrow(field, 1, (750, 90 + index * 80, 30, 75)) + def _create_arrow(self, field, direction, geometry): + button = QPushButton(self) + button.setGeometry(*geometry) + button.clicked.connect(lambda checked=False: self._change_field(field["key"], direction)) + button.setIcon(QIcon(self._arrow_pixmap(direction, field["needs_sync"]))) + button.setIconSize(button.size()) + button.setVisible(field["visible"]) + self._arrow_controls.append((button, field["enabled"])) + self.buttons.append(button) + if not field["needs_sync"]: + return + button.setStyleSheet(""" + QPushButton { background-color: transparent; border: none; } + QPushButton:hover { background-color: rgba(0, 255, 0, 0.1); border-radius: 3px; } + QPushButton:pressed { background-color: rgba(0, 255, 0, 0.2); border-radius: 3px; } + """) + + def _arrow_pixmap(self, direction, outlined): + if not outlined: + pixmap = QPixmap(os.path.join(self.btn_path, "UP_button.png")) + return pixmap.transformed(QTransform().rotate(180)) if direction == -1 else pixmap + pixmap = QPixmap(30, 75) + pixmap.fill(Qt.GlobalColor.transparent) painter = QPainter(pixmap) - painter.drawPixmap(x, y, location_pixmap) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + pen = QtGui.QPen(QColor(0, 255, 0)) + pen.setWidth(3) + painter.setPen(pen) + painter.setBrush(Qt.BrushStyle.NoBrush) + xs = {-1: (0.65, 0.25, 0.65), 1: (0.35, 0.75, 0.35)}[direction] + points = [QPointF(30 * x, 75 * y) for x, y in zip(xs, (0.15, 0.50, 0.85))] + painter.drawPolygon(QtGui.QPolygonF(points)) painter.end() return pixmap - def on_sync_complete_for_edit_profile(self, available_locations, available_browsers, status, is_tor, locations, all_browsers): - if status: - self.update_status.update_status('Sync complete.') - self.extraccion() - else: - self.update_status.update_status( - 'Sync failed. Please try again later.') - - def show_previous_value(self, key: str, index: int, parameters: dict) -> None: - if key == 'browser' or key == 'location': - if not self.connection_manager.is_synced(): - self.update_status.update_status('Syncing in progress..') - self.update_status.sync() - self.update_status.worker_thread.sync_output.connect( - self.on_sync_complete_for_edit_profile) - return - - values = parameters.get(key, []) - if not values: - self.update_status.update_status( - f"No {key} data available. Sync the database and try again.") + def _add_resolution_hint(self, button, original_icon): + if self.res_hint_shown: return + self.res_hint_shown = True + template = QPixmap(os.path.join(self.btn_path, "resolution_template_button.png")) + button.setIcon(QIcon(template.scaled(187, 75))) + hint = QLabel("Click here to customize", button) + hint.setGeometry(15, 0, 155, 75) + hint.setWordWrap(True) + hint.setAlignment(Qt.AlignmentFlag.AlignCenter) + hint.setStyleSheet("color: #00ffff; font-weight: bold; font-size: 16px; background: transparent;") + hint.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents) + shadow = QGraphicsDropShadowEffect(hint) + shadow.setBlurRadius(20) + shadow.setColor(QColor(0, 255, 255)) + shadow.setOffset(0, 0) + hint.setGraphicsEffect(shadow) + hint.show() + timer = QTimer(button) + timer.setSingleShot(True) + timer.timeout.connect(partial(self._restore_resolution_icon, button, hint, original_icon)) + timer.start(3500) - previous_index = (index - 1) % len(values) - previous_value = values[previous_index] - self.update_temp_value(key, previous_value) - - def show_next_value(self, key: str, index: int, parameters: dict) -> None: - if key == 'browser' or key == 'location': - if not self.connection_manager.is_synced(): - self.update_status.update_status('Syncing in progress..') - self.update_status.sync() - self.update_status.worker_thread.sync_output.connect( - self.on_sync_complete_for_edit_profile) - return - - values = parameters.get(key, []) - if not values: - self.update_status.update_status( - f"No {key} data available. Sync the database and try again.") - return - - next_index = (index + 1) % len(values) - next_value = values[next_index] - - self.update_temp_value(key, next_value) - - def update_temp_value(self, key: str, new_value: str) -> None: - selected_profiles_str = self._selected_profiles_str() - if selected_profiles_str not in self.temp_changes: - self.temp_changes[selected_profiles_str] = {} - self.original_values[selected_profiles_str] = self.data_profile.copy( - ) - - self.temp_changes[selected_profiles_str][key] = new_value - self.extraccion() + def _restore_resolution_icon(self, button, hint, icon): + hint.hide() + button.setIcon(icon) def show_custom_res_dialog(self): dialog = QDialog(self) @@ -774,161 +440,26 @@ class EditorPage(Page): layout.addLayout(button_layout) dialog.setLayout(layout) - def validate_and_accept(): - try: - width = int(width_input.text()) - height = int(height_input.text()) - if width <= 0 or height <= 0: - return - host_w = self.custom_window.host_screen_width - host_h = self.custom_window.host_screen_height - adjusted = False - if width > host_w: - width = host_w - 50 - adjusted = True - if height > host_h: - height = host_h - 50 - adjusted = True - - if adjusted: - QMessageBox.information( - dialog, "Notice", "Adjusted to fit host size") - - self.update_temp_value('dimentions', f"{width}x{height}") - dialog.accept() - except ValueError: - pass - - ok_button.clicked.connect(validate_and_accept) + accept = partial(self._accept_custom_resolution, dialog, width_input, height_input) + ok_button.clicked.connect(accept) cancel_button.clicked.connect(dialog.reject) - - width_input.returnPressed.connect(validate_and_accept) - height_input.returnPressed.connect(validate_and_accept) - + width_input.returnPressed.connect(accept) + height_input.returnPressed.connect(accept) dialog.exec() - def has_unsaved_changes(self, profile_str: str) -> bool: - return profile_str in self.temp_changes and bool(self.temp_changes[profile_str]) - - def show_apply_changes_popup(self, selected_profiles_str: str) -> bool: - if selected_profiles_str not in self.temp_changes: - return False - temp_changes = self.temp_changes[selected_profiles_str] - current_data = self.original_values[selected_profiles_str] - - current_browser = f"{current_data.get('browser', '')} {current_data.get('browser_version', '')}" - - if 'location' in temp_changes and temp_changes['location'] != current_data.get('location'): - message = "A new location would require a new subscription code.\nDo you want to proceed with erasing the old one?" - elif 'browser' in temp_changes and temp_changes['browser'] != current_browser: - message = "Changing the browser would delete all data associated with it. Proceed?" - else: - return False - - self.popup = ConfirmationPopup( - self, - message=message, - action_button_text="Continue", - cancel_button_text="Cancel" + def _accept_custom_resolution(self, dialog, width_input, height_input, checked=False): + if self.editor_state is None: + dialog.reject() + return + result = set_editor_resolution( + self.editor_state, width_input.text(), height_input.text(), + self.custom_window.host_screen_width, self.custom_window.host_screen_height, ) - self.popup.setWindowModality(Qt.WindowModality.ApplicationModal) - self.popup.finished.connect( - lambda result: self.handle_apply_changes_response(result)) - self.popup.show() - return True - - def show_unsaved_changes_popup(self) -> None: - self.popup = ConfirmationPopup( - self, - message="You have unsaved changes. Do you want to continue?", - action_button_text="Continue", - cancel_button_text="Cancel" - ) - self.popup.setWindowModality(Qt.WindowModality.ApplicationModal) - self.popup.finished.connect( - lambda result: self.handle_unsaved_changes_response(result)) - self.popup.show() - - def _refresh_menu_and_navigate(self): - menu_page = self.find_menu_page() - if menu_page is not None and hasattr(menu_page, 'refresh_menu_buttons'): - menu_page.refresh_menu_buttons() - self.custom_window.navigator.navigate("menu") - - def handle_apply_changes_response(self, result: bool) -> None: - if result: - self.commit_changes() - self._refresh_menu_and_navigate() - - def handle_unsaved_changes_response(self, result: bool) -> None: - if result: - self.temp_changes.clear() - self._refresh_menu_and_navigate() - - def go_selected(self) -> None: - selected_profiles_str = self._selected_profiles_str() - - if selected_profiles_str in self.temp_changes and self.temp_changes[selected_profiles_str]: - needs_confirmation = self.show_apply_changes_popup( - selected_profiles_str) - if not needs_confirmation: - self.commit_changes() - self._refresh_menu_and_navigate() - - else: - self.custom_window.navigator.navigate("menu") - - def commit_changes(self) -> None: - selected_profiles_str = self._selected_profiles_str() - if selected_profiles_str in self.temp_changes: - for key, new_value in self.temp_changes[selected_profiles_str].items(): - self.update_core_profiles(key, new_value) - - self.temp_changes.pop(selected_profiles_str, None) - self.original_values.pop(selected_profiles_str, None) - self._prefetched_profiles = None - - def update_core_profiles(self, key, new_value): - profile_id = int(self.update_status.current_profile_id) - - # Sends to core directly, - result = update_profile(profile_id, key, new_value) - - if result.valid: - self.update_status.update_status( - f'Updated profile {profile_id}') - - if key == "resolution": - self.update_res = True - - if result.data == "edit_to_session": - self.edit_to_session() - - else: - self.update_status.update_status(result.message) - - - def edit_to_session(self): - id = int(self.update_status.current_profile_id) - profile = self.data_profile - default_app = 'firefox:123.0' - default_resolution = '1024x760' - location_code = self.connection_manager.get_location_info( - profile.get('location')) - - connection_type = 'tor' - - profile_data = { - 'id': int(id), - 'name': profile.get('name'), - 'country_code': location_code.country_code, - 'code': location_code.code, - 'application': default_app, - 'connection_type': connection_type, - 'resolution': default_resolution, - } - resume_page = self.find_resume_page() - if resume_page: - resume_page.handle_core_action_create_profile( - 'CREATE_SESSION_PROFILE', profile_data, 'session') + if result["status"] == "error": + QMessageBox.warning(dialog, "Invalid Resolution", result["message"]) + return + self.handle_editor_result(self._request_name, result) + if result["adjusted"]: + QMessageBox.information(dialog, "Notice", "Adjusted to fit host size") + dialog.accept() diff --git a/gui/v2/ui/pages/menu_page.py b/gui/v2/ui/pages/menu_page.py index 8341ad2..b017f8c 100755 --- a/gui/v2/ui/pages/menu_page.py +++ b/gui/v2/ui/pages/menu_page.py @@ -419,10 +419,10 @@ class MenuPage(Page): self._detail_image("verified_profile.png", (20, 50, 100, 80)) self.add_selected_profile(details["selection"]) - def _detail_image(self, asset, geometry, lower=False, pixmap=None): + def _detail_image(self, asset, geometry, lower=False): label = QLabel(self) label.setGeometry(*geometry) - label.setPixmap(pixmap if pixmap is not None else QPixmap(os.path.join(self.btn_path, asset))) + label.setPixmap(QPixmap(os.path.join(self.btn_path, asset))) label.setScaledContents(True) label.show() if lower: @@ -434,10 +434,7 @@ class MenuPage(Page): self.additional_labels.append(self.create_verification_widget(details["verification_values"])) def _render_protocol_image(self, details): - pixmap = QPixmap(os.path.join(self.btn_path, details["primary_asset"])) - if details["location_overlay"]: - pixmap = self.build_encrypted_proxy_detail_pixmap(pixmap, details["location"], details["overlay_y"]) - self._detail_image(details["primary_asset"], (0, 80 if details["raised_primary"] else 90, 400, 300), pixmap=pixmap) + self._detail_image(details["primary_asset"], (0, 80 if details["raised_primary"] else 90, 400, 300)) def _render_operator_details(self, details): color = "black" if details["is_session"] else "white" @@ -460,34 +457,6 @@ class MenuPage(Page): widget.show() self.additional_labels.append(widget) - def build_encrypted_proxy_detail_pixmap(self, pixmap, location, overlay_y): - if pixmap.isNull(): - return pixmap - - location_pixmap = QPixmap(os.path.join( - self.btn_path, f"icon_mini_{location}.png")) - if location_pixmap.isNull(): - location_pixmap = QPixmap(os.path.join( - self.btn_path, "default_location_mini.png")) - if location_pixmap.isNull(): - return pixmap - - icon_size = max(42, min(72, int(min(pixmap.width(), pixmap.height()) * 0.22))) - location_pixmap = location_pixmap.scaled( - icon_size, - icon_size, - Qt.AspectRatioMode.KeepAspectRatio, - Qt.TransformationMode.SmoothTransformation) - x = int(pixmap.width() * 0.58) - y = int(pixmap.height() * overlay_y) - x = min(max(0, x), max(0, pixmap.width() - location_pixmap.width())) - y = min(max(0, y), max(0, pixmap.height() - location_pixmap.height())) - - painter = QPainter(pixmap) - painter.drawPixmap(x, y, location_pixmap) - painter.end() - return pixmap - def show_profile_verification(self, profile_id): location_icon_name = find_verification_icon( profile_id,