Files
elmprodvpn/selective-vpn-gui/main_window/singbox/editor_mixin.py

634 lines
29 KiB
Python

from __future__ import annotations
import json
from typing import Any
from main_window.constants import SINGBOX_EDITOR_PROTOCOL_IDS, SINGBOX_PROTOCOL_SEED_SPEC
class SingBoxEditorMixin:
def _selected_singbox_profile_id(self) -> str:
selected = self._selected_transport_client()
if selected is not None:
selected_cid = str(getattr(selected, "id", "") or "").strip()
if (
selected_cid
and self._singbox_editor_profile_id
and selected_cid == str(self._singbox_editor_profile_client_id or "").strip()
):
return str(self._singbox_editor_profile_id).strip()
if selected_cid:
# Desktop SingBox tab keeps one deterministic profile per engine card.
return selected_cid
return self._selected_transport_engine_id()
def _set_singbox_editor_enabled(self, enabled: bool) -> None:
widgets = [
self.ent_singbox_proto_name,
self.chk_singbox_proto_enabled,
self.cmb_singbox_proto_protocol,
self.ent_singbox_vless_server,
self.spn_singbox_vless_port,
self.ent_singbox_vless_uuid,
self.ent_singbox_proto_password,
self.cmb_singbox_vless_flow,
self.cmb_singbox_vless_packet_encoding,
self.cmb_singbox_ss_method,
self.ent_singbox_ss_plugin,
self.spn_singbox_hy2_up_mbps,
self.spn_singbox_hy2_down_mbps,
self.ent_singbox_hy2_obfs,
self.ent_singbox_hy2_obfs_password,
self.cmb_singbox_tuic_congestion,
self.cmb_singbox_tuic_udp_mode,
self.chk_singbox_tuic_zero_rtt,
self.ent_singbox_wg_private_key,
self.ent_singbox_wg_peer_public_key,
self.ent_singbox_wg_psk,
self.ent_singbox_wg_local_address,
self.ent_singbox_wg_reserved,
self.spn_singbox_wg_mtu,
self.btn_singbox_wg_paste_private,
self.btn_singbox_wg_copy_private,
self.btn_singbox_wg_paste_peer,
self.btn_singbox_wg_copy_peer,
self.btn_singbox_wg_paste_psk,
self.btn_singbox_wg_copy_psk,
self.cmb_singbox_vless_transport,
self.ent_singbox_vless_path,
self.ent_singbox_vless_grpc_service,
self.cmb_singbox_vless_security,
self.ent_singbox_vless_sni,
self.ent_singbox_tls_alpn,
self.cmb_singbox_vless_utls_fp,
self.ent_singbox_vless_reality_pk,
self.ent_singbox_vless_reality_sid,
self.chk_singbox_vless_insecure,
self.chk_singbox_vless_sniff,
]
for w in widgets:
w.setEnabled(bool(enabled))
def _clear_singbox_editor(self) -> None:
self._singbox_editor_loading = True
try:
self._singbox_editor_profile_id = ""
self._singbox_editor_profile_client_id = ""
self._singbox_editor_protocol = "vless"
self._singbox_editor_source_raw = {}
self.ent_singbox_proto_name.setText("")
self.chk_singbox_proto_enabled.setChecked(True)
self.cmb_singbox_proto_protocol.setCurrentIndex(0)
self.ent_singbox_vless_server.setText("")
self.spn_singbox_vless_port.setValue(443)
self.ent_singbox_vless_uuid.setText("")
self.ent_singbox_proto_password.setText("")
self.cmb_singbox_vless_flow.setCurrentIndex(0)
self.cmb_singbox_vless_flow.setEditText("")
self.cmb_singbox_vless_packet_encoding.setCurrentIndex(0)
self.cmb_singbox_ss_method.setCurrentIndex(0)
self.ent_singbox_ss_plugin.setText("")
self.spn_singbox_hy2_up_mbps.setValue(0)
self.spn_singbox_hy2_down_mbps.setValue(0)
self.ent_singbox_hy2_obfs.setText("")
self.ent_singbox_hy2_obfs_password.setText("")
self.cmb_singbox_tuic_congestion.setCurrentIndex(0)
self.cmb_singbox_tuic_udp_mode.setCurrentIndex(0)
self.chk_singbox_tuic_zero_rtt.setChecked(False)
self.ent_singbox_wg_private_key.setText("")
self.ent_singbox_wg_peer_public_key.setText("")
self.ent_singbox_wg_psk.setText("")
self.ent_singbox_wg_local_address.setText("")
self.ent_singbox_wg_reserved.setText("")
self.spn_singbox_wg_mtu.setValue(0)
self.cmb_singbox_vless_transport.setCurrentIndex(0)
self.ent_singbox_vless_path.setText("")
self.ent_singbox_vless_grpc_service.setText("")
self.cmb_singbox_vless_security.setCurrentIndex(0)
self.ent_singbox_vless_sni.setText("")
self.ent_singbox_tls_alpn.setText("")
self.cmb_singbox_vless_utls_fp.setCurrentIndex(0)
self.ent_singbox_vless_reality_pk.setText("")
self.ent_singbox_vless_reality_sid.setText("")
self.chk_singbox_vless_insecure.setChecked(False)
self.chk_singbox_vless_sniff.setChecked(True)
finally:
self._singbox_editor_loading = False
self.on_singbox_vless_editor_changed()
def _load_singbox_editor_for_selected(self, *, silent: bool = True) -> None:
client = self._selected_transport_client()
if client is None:
self._clear_singbox_editor()
self._set_singbox_editor_enabled(False)
return
try:
cid = str(getattr(client, "id", "") or "").strip()
profile = self.ctrl.singbox_profile_get_for_client(
client,
profile_id=self._selected_singbox_profile_id(),
)
self._apply_singbox_editor_profile(profile, fallback_name=str(getattr(client, "name", "") or "").strip())
self._singbox_editor_profile_client_id = cid
self._set_singbox_editor_enabled(True)
except Exception as e:
if not silent:
raise
self._append_transport_log(f"[profile] editor load failed: {e}")
self._clear_singbox_editor()
self._set_singbox_editor_enabled(False)
def _find_editor_proxy_outbound(self, outbounds: list[Any]) -> dict[str, Any]:
proxy = {}
for row in outbounds:
if not isinstance(row, dict):
continue
t = str(row.get("type") or "").strip().lower()
tag = str(row.get("tag") or "").strip().lower()
if self._is_supported_editor_protocol(t):
proxy = row
break
if tag == "proxy":
proxy = row
return dict(proxy) if isinstance(proxy, dict) else {}
def _find_editor_sniff_inbound(self, inbounds: list[Any]) -> dict[str, Any]:
inbound = {}
for row in inbounds:
if not isinstance(row, dict):
continue
tag = str(row.get("tag") or "").strip().lower()
t = str(row.get("type") or "").strip().lower()
if tag == "socks-in" or t == "socks":
inbound = row
break
return dict(inbound) if isinstance(inbound, dict) else {}
def _apply_singbox_editor_profile(self, profile, *, fallback_name: str = "") -> None:
raw = getattr(profile, "raw_config", {}) or {}
if not isinstance(raw, dict):
raw = {}
protocol = str(getattr(profile, "protocol", "") or "").strip().lower() or "vless"
outbounds = raw.get("outbounds") or []
if not isinstance(outbounds, list):
outbounds = []
inbounds = raw.get("inbounds") or []
if not isinstance(inbounds, list):
inbounds = []
proxy = self._find_editor_proxy_outbound(outbounds)
inbound = self._find_editor_sniff_inbound(inbounds)
proxy_type = str(proxy.get("type") or "").strip().lower()
if self._is_supported_editor_protocol(proxy_type):
protocol = proxy_type
tls = proxy.get("tls") if isinstance(proxy.get("tls"), dict) else {}
reality = tls.get("reality") if isinstance(tls.get("reality"), dict) else {}
utls = tls.get("utls") if isinstance(tls.get("utls"), dict) else {}
transport = proxy.get("transport") if isinstance(proxy.get("transport"), dict) else {}
security = "none"
if bool(tls.get("enabled", False)):
security = "tls"
if bool(reality.get("enabled", False)):
security = "reality"
transport_type = str(transport.get("type") or "").strip().lower() or "tcp"
path = str(transport.get("path") or "").strip()
grpc_service = str(transport.get("service_name") or "").strip()
alpn_vals = tls.get("alpn") or []
if not isinstance(alpn_vals, list):
alpn_vals = []
alpn_text = ",".join([str(x).strip() for x in alpn_vals if str(x).strip()])
self._singbox_editor_loading = True
try:
self._singbox_editor_profile_id = str(getattr(profile, "id", "") or "").strip()
self._singbox_editor_protocol = protocol
self._singbox_editor_source_raw = json.loads(json.dumps(raw))
self.ent_singbox_proto_name.setText(
str(getattr(profile, "name", "") or "").strip() or fallback_name or self._singbox_editor_profile_id
)
self.chk_singbox_proto_enabled.setChecked(bool(getattr(profile, "enabled", True)))
pidx = self.cmb_singbox_proto_protocol.findData(protocol)
self.cmb_singbox_proto_protocol.setCurrentIndex(pidx if pidx >= 0 else 0)
self.ent_singbox_vless_server.setText(str(proxy.get("server") or "").strip())
try:
self.spn_singbox_vless_port.setValue(int(proxy.get("server_port") or 443))
except Exception:
self.spn_singbox_vless_port.setValue(443)
self.ent_singbox_vless_uuid.setText(str(proxy.get("uuid") or "").strip())
self.ent_singbox_proto_password.setText(str(proxy.get("password") or "").strip())
flow_value = str(proxy.get("flow") or "").strip()
idx = self.cmb_singbox_vless_flow.findData(flow_value)
if idx >= 0:
self.cmb_singbox_vless_flow.setCurrentIndex(idx)
else:
self.cmb_singbox_vless_flow.setEditText(flow_value)
pe = str(proxy.get("packet_encoding") or "").strip().lower()
if pe in ("none", "off", "false"):
pe = ""
idx = self.cmb_singbox_vless_packet_encoding.findData(pe)
self.cmb_singbox_vless_packet_encoding.setCurrentIndex(idx if idx >= 0 else 0)
ss_method = str(proxy.get("method") or "").strip().lower()
idx = self.cmb_singbox_ss_method.findData(ss_method)
if idx >= 0:
self.cmb_singbox_ss_method.setCurrentIndex(idx)
else:
self.cmb_singbox_ss_method.setEditText(ss_method)
self.ent_singbox_ss_plugin.setText(str(proxy.get("plugin") or "").strip())
try:
self.spn_singbox_hy2_up_mbps.setValue(int(proxy.get("up_mbps") or 0))
except Exception:
self.spn_singbox_hy2_up_mbps.setValue(0)
try:
self.spn_singbox_hy2_down_mbps.setValue(int(proxy.get("down_mbps") or 0))
except Exception:
self.spn_singbox_hy2_down_mbps.setValue(0)
obfs = proxy.get("obfs") if isinstance(proxy.get("obfs"), dict) else {}
self.ent_singbox_hy2_obfs.setText(str(obfs.get("type") or "").strip())
self.ent_singbox_hy2_obfs_password.setText(str(obfs.get("password") or "").strip())
cc = str(proxy.get("congestion_control") or "").strip()
idx = self.cmb_singbox_tuic_congestion.findData(cc)
if idx >= 0:
self.cmb_singbox_tuic_congestion.setCurrentIndex(idx)
else:
self.cmb_singbox_tuic_congestion.setCurrentIndex(0)
udp_mode = str(proxy.get("udp_relay_mode") or "").strip()
idx = self.cmb_singbox_tuic_udp_mode.findData(udp_mode)
self.cmb_singbox_tuic_udp_mode.setCurrentIndex(idx if idx >= 0 else 0)
self.chk_singbox_tuic_zero_rtt.setChecked(bool(proxy.get("zero_rtt_handshake", False)))
self.ent_singbox_wg_private_key.setText(str(proxy.get("private_key") or "").strip())
self.ent_singbox_wg_peer_public_key.setText(str(proxy.get("peer_public_key") or "").strip())
self.ent_singbox_wg_psk.setText(str(proxy.get("pre_shared_key") or "").strip())
local_addr = proxy.get("local_address") or []
if not isinstance(local_addr, list):
if str(local_addr or "").strip():
local_addr = [str(local_addr).strip()]
else:
local_addr = []
self.ent_singbox_wg_local_address.setText(
",".join([str(x).strip() for x in local_addr if str(x).strip()])
)
reserved = proxy.get("reserved") or []
if not isinstance(reserved, list):
if str(reserved or "").strip():
reserved = [str(reserved).strip()]
else:
reserved = []
self.ent_singbox_wg_reserved.setText(
",".join([str(x).strip() for x in reserved if str(x).strip()])
)
try:
self.spn_singbox_wg_mtu.setValue(int(proxy.get("mtu") or 0))
except Exception:
self.spn_singbox_wg_mtu.setValue(0)
idx = self.cmb_singbox_vless_transport.findData(transport_type)
self.cmb_singbox_vless_transport.setCurrentIndex(idx if idx >= 0 else 0)
self.ent_singbox_vless_path.setText(path)
self.ent_singbox_vless_grpc_service.setText(grpc_service)
idx = self.cmb_singbox_vless_security.findData(security)
self.cmb_singbox_vless_security.setCurrentIndex(idx if idx >= 0 else 0)
self.ent_singbox_vless_sni.setText(str(tls.get("server_name") or "").strip())
self.ent_singbox_tls_alpn.setText(alpn_text)
idx = self.cmb_singbox_vless_utls_fp.findData(str(utls.get("fingerprint") or "").strip())
self.cmb_singbox_vless_utls_fp.setCurrentIndex(idx if idx >= 0 else 0)
self.ent_singbox_vless_reality_pk.setText(str(reality.get("public_key") or "").strip())
self.ent_singbox_vless_reality_sid.setText(str(reality.get("short_id") or "").strip())
self.chk_singbox_vless_insecure.setChecked(bool(tls.get("insecure", False)))
self.chk_singbox_vless_sniff.setChecked(bool(inbound.get("sniff", True)))
finally:
self._singbox_editor_loading = False
self.on_singbox_vless_editor_changed()
def _validate_singbox_editor_form(self) -> None:
protocol = self._current_editor_protocol()
addr = self.ent_singbox_vless_server.text().strip()
if not addr:
raise RuntimeError("Address is required")
security = str(self.cmb_singbox_vless_security.currentData() or "none").strip().lower()
transport = str(self.cmb_singbox_vless_transport.currentData() or "tcp").strip().lower()
if protocol == "vless":
if not self.ent_singbox_vless_uuid.text().strip():
raise RuntimeError("UUID is required for VLESS")
if security == "reality" and not self.ent_singbox_vless_reality_pk.text().strip():
raise RuntimeError("Reality public key is required for Reality security mode")
elif protocol == "trojan":
if not self.ent_singbox_proto_password.text().strip():
raise RuntimeError("Password is required for Trojan")
if security == "reality":
raise RuntimeError("Reality security is not supported for Trojan in this editor")
elif protocol == "shadowsocks":
method = str(self.cmb_singbox_ss_method.currentData() or "").strip()
if not method:
method = self.cmb_singbox_ss_method.currentText().strip()
if not method:
raise RuntimeError("SS method is required for Shadowsocks")
if not self.ent_singbox_proto_password.text().strip():
raise RuntimeError("Password is required for Shadowsocks")
elif protocol == "hysteria2":
if not self.ent_singbox_proto_password.text().strip():
raise RuntimeError("Password is required for Hysteria2")
elif protocol == "tuic":
if not self.ent_singbox_vless_uuid.text().strip():
raise RuntimeError("UUID is required for TUIC")
if not self.ent_singbox_proto_password.text().strip():
raise RuntimeError("Password is required for TUIC")
elif protocol == "wireguard":
if not self.ent_singbox_wg_private_key.text().strip():
raise RuntimeError("WireGuard private key is required")
if not self.ent_singbox_wg_peer_public_key.text().strip():
raise RuntimeError("WireGuard peer public key is required")
local_addr = [str(x).strip() for x in self.ent_singbox_wg_local_address.text().split(",") if str(x).strip()]
if not local_addr:
raise RuntimeError("WireGuard local address is required (CIDR list)")
self._parse_wg_reserved_values(
[str(x).strip() for x in self.ent_singbox_wg_reserved.text().split(",") if str(x).strip()],
strict=True,
)
if protocol in ("vless", "trojan"):
if transport == "grpc" and not self.ent_singbox_vless_grpc_service.text().strip():
raise RuntimeError("gRPC service is required for gRPC transport")
if transport in ("ws", "http", "httpupgrade") and not self.ent_singbox_vless_path.text().strip():
raise RuntimeError("Transport path is required for selected transport")
def _build_singbox_editor_raw_config(self) -> dict[str, Any]:
base = self._singbox_editor_source_raw
if not isinstance(base, dict):
base = {}
raw: dict[str, Any] = json.loads(json.dumps(base))
protocol = self._current_editor_protocol()
outbounds = raw.get("outbounds") or []
if not isinstance(outbounds, list):
outbounds = []
proxy_idx = -1
for i, row in enumerate(outbounds):
if not isinstance(row, dict):
continue
t = str(row.get("type") or "").strip().lower()
tag = str(row.get("tag") or "").strip().lower()
if self._is_supported_editor_protocol(t) or tag == "proxy":
proxy_idx = i
break
proxy: dict[str, Any]
if proxy_idx >= 0:
proxy = dict(outbounds[proxy_idx]) if isinstance(outbounds[proxy_idx], dict) else {}
else:
proxy = {}
proxy["type"] = protocol
proxy["tag"] = str(proxy.get("tag") or "proxy")
proxy["server"] = self.ent_singbox_vless_server.text().strip()
proxy["server_port"] = int(self.spn_singbox_vless_port.value())
# clear protocol-specific keys before repopulating
for key in (
"uuid",
"password",
"method",
"plugin",
"flow",
"packet_encoding",
"up_mbps",
"down_mbps",
"obfs",
"congestion_control",
"udp_relay_mode",
"zero_rtt_handshake",
"private_key",
"peer_public_key",
"pre_shared_key",
"local_address",
"reserved",
"mtu",
):
proxy.pop(key, None)
if protocol == "vless":
proxy["uuid"] = self.ent_singbox_vless_uuid.text().strip()
flow = str(self.cmb_singbox_vless_flow.currentData() or "").strip()
if not flow:
flow = self.cmb_singbox_vless_flow.currentText().strip()
if flow:
proxy["flow"] = flow
packet_encoding = str(self.cmb_singbox_vless_packet_encoding.currentData() or "").strip().lower()
if packet_encoding and packet_encoding != "none":
proxy["packet_encoding"] = packet_encoding
elif protocol == "trojan":
proxy["password"] = self.ent_singbox_proto_password.text().strip()
elif protocol == "shadowsocks":
method = str(self.cmb_singbox_ss_method.currentData() or "").strip()
if not method:
method = self.cmb_singbox_ss_method.currentText().strip()
proxy["method"] = method
proxy["password"] = self.ent_singbox_proto_password.text().strip()
plugin = self.ent_singbox_ss_plugin.text().strip()
if plugin:
proxy["plugin"] = plugin
elif protocol == "hysteria2":
proxy["password"] = self.ent_singbox_proto_password.text().strip()
up = int(self.spn_singbox_hy2_up_mbps.value())
down = int(self.spn_singbox_hy2_down_mbps.value())
if up > 0:
proxy["up_mbps"] = up
if down > 0:
proxy["down_mbps"] = down
obfs_type = self.ent_singbox_hy2_obfs.text().strip()
if obfs_type:
obfs: dict[str, Any] = {"type": obfs_type}
obfs_password = self.ent_singbox_hy2_obfs_password.text().strip()
if obfs_password:
obfs["password"] = obfs_password
proxy["obfs"] = obfs
elif protocol == "tuic":
proxy["uuid"] = self.ent_singbox_vless_uuid.text().strip()
proxy["password"] = self.ent_singbox_proto_password.text().strip()
cc = str(self.cmb_singbox_tuic_congestion.currentData() or "").strip()
if not cc:
cc = self.cmb_singbox_tuic_congestion.currentText().strip()
if cc:
proxy["congestion_control"] = cc
udp_mode = str(self.cmb_singbox_tuic_udp_mode.currentData() or "").strip()
if udp_mode:
proxy["udp_relay_mode"] = udp_mode
if self.chk_singbox_tuic_zero_rtt.isChecked():
proxy["zero_rtt_handshake"] = True
elif protocol == "wireguard":
proxy["private_key"] = self.ent_singbox_wg_private_key.text().strip()
proxy["peer_public_key"] = self.ent_singbox_wg_peer_public_key.text().strip()
psk = self.ent_singbox_wg_psk.text().strip()
if psk:
proxy["pre_shared_key"] = psk
local_addr = [str(x).strip() for x in self.ent_singbox_wg_local_address.text().split(",") if str(x).strip()]
if local_addr:
proxy["local_address"] = local_addr
reserved_vals = self._parse_wg_reserved_values(
[str(x).strip() for x in self.ent_singbox_wg_reserved.text().split(",") if str(x).strip()],
strict=True,
)
if reserved_vals:
proxy["reserved"] = reserved_vals
mtu = int(self.spn_singbox_wg_mtu.value())
if mtu > 0:
proxy["mtu"] = mtu
transport_type = str(self.cmb_singbox_vless_transport.currentData() or "tcp").strip().lower()
if protocol in ("vless", "trojan"):
self._apply_proxy_transport(
proxy,
transport=transport_type,
path=self.ent_singbox_vless_path.text().strip(),
grpc_service=self.ent_singbox_vless_grpc_service.text().strip(),
)
else:
proxy.pop("transport", None)
security = str(self.cmb_singbox_vless_security.currentData() or "none").strip().lower()
if protocol == "vless":
pass
elif protocol == "trojan":
if security == "reality":
security = "tls"
elif protocol in ("hysteria2", "tuic"):
security = "tls"
else:
security = "none"
alpn = []
for p in self.ent_singbox_tls_alpn.text().split(","):
v = str(p or "").strip()
if v:
alpn.append(v)
self._apply_proxy_tls(
proxy,
security=security,
sni=self.ent_singbox_vless_sni.text().strip(),
utls_fp=str(self.cmb_singbox_vless_utls_fp.currentData() or "").strip(),
tls_insecure=bool(self.chk_singbox_vless_insecure.isChecked()),
reality_public_key=self.ent_singbox_vless_reality_pk.text().strip(),
reality_short_id=self.ent_singbox_vless_reality_sid.text().strip(),
alpn=alpn,
)
if proxy_idx >= 0:
outbounds[proxy_idx] = proxy
else:
outbounds.insert(0, proxy)
has_direct = any(
isinstance(row, dict)
and str(row.get("type") or "").strip().lower() == "direct"
and str(row.get("tag") or "").strip().lower() == "direct"
for row in outbounds
)
if not has_direct:
outbounds.append({"type": "direct", "tag": "direct"})
raw["outbounds"] = outbounds
inbounds = raw.get("inbounds") or []
if not isinstance(inbounds, list):
inbounds = []
inbound_idx = -1
for i, row in enumerate(inbounds):
if not isinstance(row, dict):
continue
tag = str(row.get("tag") or "").strip().lower()
t = str(row.get("type") or "").strip().lower()
if tag == "socks-in" or t == "socks":
inbound_idx = i
break
inbound = (
dict(inbounds[inbound_idx]) if inbound_idx >= 0 and isinstance(inbounds[inbound_idx], dict) else {}
)
inbound["type"] = str(inbound.get("type") or "socks")
inbound["tag"] = str(inbound.get("tag") or "socks-in")
inbound["listen"] = str(inbound.get("listen") or "127.0.0.1")
inbound["listen_port"] = int(inbound.get("listen_port") or 10808)
sniff = bool(self.chk_singbox_vless_sniff.isChecked())
inbound["sniff"] = sniff
inbound["sniff_override_destination"] = sniff
if inbound_idx >= 0:
inbounds[inbound_idx] = inbound
else:
inbounds.insert(0, inbound)
raw["inbounds"] = inbounds
route = raw.get("route") if isinstance(raw.get("route"), dict) else {}
route["final"] = str(route.get("final") or "direct")
rules = route.get("rules") or []
if not isinstance(rules, list):
rules = []
has_proxy_rule = False
for row in rules:
if not isinstance(row, dict):
continue
outbound = str(row.get("outbound") or "").strip().lower()
inbound_list = row.get("inbound") or []
if not isinstance(inbound_list, list):
inbound_list = []
inbound_norm = [str(x).strip().lower() for x in inbound_list if str(x).strip()]
if outbound == "proxy" and "socks-in" in inbound_norm:
has_proxy_rule = True
break
if not has_proxy_rule:
rules.insert(0, {"inbound": ["socks-in"], "outbound": "proxy"})
route["rules"] = rules
raw["route"] = route
return raw
def _save_singbox_editor_draft(self, client, *, profile_id: str = ""):
protocol = self._current_editor_protocol()
self._validate_singbox_editor_form()
raw_cfg = self._build_singbox_editor_raw_config()
name = self.ent_singbox_proto_name.text().strip()
enabled = bool(self.chk_singbox_proto_enabled.isChecked())
res = self.ctrl.singbox_profile_save_raw_for_client(
client,
profile_id=profile_id,
name=name,
enabled=enabled,
protocol=protocol,
raw_config=raw_cfg,
)
profile = self.ctrl.singbox_profile_get_for_client(client, profile_id=profile_id)
self._singbox_editor_profile_id = str(getattr(profile, "id", "") or "").strip()
self._singbox_editor_profile_client_id = str(getattr(client, "id", "") or "").strip()
self._singbox_editor_protocol = str(getattr(profile, "protocol", "") or protocol).strip().lower() or protocol
self._singbox_editor_source_raw = json.loads(json.dumps(getattr(profile, "raw_config", {}) or {}))
return res
def _sync_selected_singbox_profile_link(self, *, silent: bool = True) -> None:
client = self._selected_transport_client()
if client is None:
return
try:
preferred_pid = str(getattr(client, "id", "") or "").strip()
res = self.ctrl.singbox_profile_ensure_linked(
client,
preferred_profile_id=preferred_pid,
)
except Exception as e:
if not silent:
raise
self._append_transport_log(f"[profile] auto-link skipped: {e}")
return
line = (res.pretty_text or "").strip()
if not line:
return
# Keep noisy "already linked" messages out of normal flow.
if "already linked" in line.lower() and silent:
return
self._append_transport_log(f"[profile] {line}")
self.ctrl.log_gui(f"[singbox-profile] {line}")