Refactor profile editor and update protocol location images
BIN
gui/resources/images/hysteri_mini_icon.png
Executable file
|
After Width: | Height: | Size: 37 KiB |
0
gui/resources/images/hysteria2.png
Normal file → Executable file
|
Before Width: | Height: | Size: 258 KiB After Width: | Height: | Size: 258 KiB |
BIN
gui/resources/images/hysteria2_mini.png
Normal file → Executable file
|
Before Width: | Height: | Size: 3 KiB After Width: | Height: | Size: 4.4 KiB |
BIN
gui/resources/images/hysteria2_nl_li.png
Executable file
|
After Width: | Height: | Size: 259 KiB |
BIN
gui/resources/images/hysteria2_us_ny.png
Executable file
|
After Width: | Height: | Size: 258 KiB |
BIN
gui/resources/images/hysteria2_us_wa.png
Executable file
|
After Width: | Height: | Size: 259 KiB |
0
gui/resources/images/hystria2_button.png
Normal file → Executable file
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.3 KiB |
0
gui/resources/images/icon_mini_is_1.png
Normal file → Executable file
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 9.3 KiB |
0
gui/resources/images/icon_mini_is_1.webp
Normal file → Executable file
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 8.1 KiB |
0
gui/resources/images/icon_mini_se_ab.png
Normal file → Executable file
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
0
gui/resources/images/icon_se_ab.png
Normal file → Executable file
|
Before Width: | Height: | Size: 277 KiB After Width: | Height: | Size: 277 KiB |
0
gui/resources/images/vless.png
Normal file → Executable file
|
Before Width: | Height: | Size: 215 KiB After Width: | Height: | Size: 215 KiB |
0
gui/resources/images/vless_button.png
Normal file → Executable file
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 6.2 KiB |
BIN
gui/resources/images/vless_icon.png
Executable file
|
After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
BIN
gui/resources/images/vless_mini_transparent.png
Executable file
|
After Width: | Height: | Size: 58 KiB |
BIN
gui/resources/images/vless_mini_transparent_white.png
Executable file
|
After Width: | Height: | Size: 58 KiB |
BIN
gui/resources/images/vless_nl_li.png
Executable file
|
After Width: | Height: | Size: 220 KiB |
BIN
gui/resources/images/vless_us_ny.png
Executable file
|
After Width: | Height: | Size: 219 KiB |
BIN
gui/resources/images/vless_us_wa.png
Executable file
|
After Width: | Height: | Size: 220 KiB |
0
gui/resources/images/wireguard_button.png
Normal file → Executable file
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.7 KiB |
0
gui/resources/images/wireguard_mini.png
Normal file → Executable file
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 4.5 KiB |
0
gui/resources/images/wireguard_mini_transparent.png
Normal file → Executable file
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
164
gui/v2/actions/editor_data.py
Executable file
|
|
@ -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
|
||||||
199
gui/v2/actions/editor_tasks.py
Executable file
|
|
@ -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"]]()
|
||||||
|
|
@ -124,7 +124,7 @@ def profile_is_session(profile: BaseProfile | None) -> bool:
|
||||||
return isinstance(profile, SessionProfile)
|
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."""
|
"""Build one normalized menu record so ordering does not contain profile-shape branches."""
|
||||||
protocol = connection_code(profile)
|
protocol = connection_code(profile)
|
||||||
display_protocol = protocol if protocol in ENCRYPTED_PROTOCOLS else "hidetor"
|
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."""
|
"""Translate core profiles into stable menu data before any Qt widgets are created."""
|
||||||
profile_ids = normalize_profile_order(gui_config_file, profiles.keys())
|
profile_ids = normalize_profile_order(gui_config_file, profiles.keys())
|
||||||
matched_profiles = (
|
matched_profiles = (
|
||||||
_matched_profile(profile_id, profiles[profile_id])
|
summarize_profile(profile_id, profiles[profile_id])
|
||||||
for profile_id in profile_ids
|
for profile_id in profile_ids
|
||||||
if profile_id in profiles
|
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 "",
|
"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)),
|
"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",
|
"primary_asset": 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"),
|
"raised_primary": protocol == "hidetor" or (protocol == "wireguard" and summary["connection"] == "browser-only"),
|
||||||
"operator_name": operator_name,
|
"operator_name": operator_name,
|
||||||
"nostr_public_key": getattr(operator, "nostr_public_key", "") if operator else "",
|
"nostr_public_key": getattr(operator, "nostr_public_key", "") if operator else "",
|
||||||
|
|
@ -280,7 +278,7 @@ def get_profile_details(
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Load and prepare profile details as one task so the page never coordinates core model helpers."""
|
"""Load and prepare profile details as one task so the page never coordinates core model helpers."""
|
||||||
profile = load_profile(profile_id, on_error)
|
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)
|
return _profile_detail_data(profile, current_summary, is_connected)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -419,10 +419,10 @@ class MenuPage(Page):
|
||||||
self._detail_image("verified_profile.png", (20, 50, 100, 80))
|
self._detail_image("verified_profile.png", (20, 50, 100, 80))
|
||||||
self.add_selected_profile(details["selection"])
|
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 = QLabel(self)
|
||||||
label.setGeometry(*geometry)
|
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.setScaledContents(True)
|
||||||
label.show()
|
label.show()
|
||||||
if lower:
|
if lower:
|
||||||
|
|
@ -434,10 +434,7 @@ class MenuPage(Page):
|
||||||
self.additional_labels.append(self.create_verification_widget(details["verification_values"]))
|
self.additional_labels.append(self.create_verification_widget(details["verification_values"]))
|
||||||
|
|
||||||
def _render_protocol_image(self, details):
|
def _render_protocol_image(self, details):
|
||||||
pixmap = QPixmap(os.path.join(self.btn_path, details["primary_asset"]))
|
self._detail_image(details["primary_asset"], (0, 80 if details["raised_primary"] else 90, 400, 300))
|
||||||
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)
|
|
||||||
|
|
||||||
def _render_operator_details(self, details):
|
def _render_operator_details(self, details):
|
||||||
color = "black" if details["is_session"] else "white"
|
color = "black" if details["is_session"] else "white"
|
||||||
|
|
@ -460,34 +457,6 @@ class MenuPage(Page):
|
||||||
widget.show()
|
widget.show()
|
||||||
self.additional_labels.append(widget)
|
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):
|
def show_profile_verification(self, profile_id):
|
||||||
location_icon_name = find_verification_icon(
|
location_icon_name = find_verification_icon(
|
||||||
profile_id,
|
profile_id,
|
||||||
|
|
|
||||||