ui: app profile desktop shortcuts

This commit is contained in:
beckline
2026-02-15 22:28:56 +03:00
parent 3bb0f11ec5
commit 6ab126251e
2 changed files with 467 additions and 0 deletions

View File

@@ -285,6 +285,27 @@ RU: Восстанавливает маршруты/nft из последнег
row_prof_btn.addStretch(1)
profiles_layout.addLayout(row_prof_btn)
row_prof_shortcuts = QHBoxLayout()
self.btn_app_profile_shortcut_create = QPushButton("Create shortcut")
self.btn_app_profile_shortcut_create.setToolTip(
"EN: Create/overwrite a .desktop shortcut for the selected profile.\n"
"EN: The shortcut will launch the app and apply routing mark automatically.\n"
"RU: Создать/перезаписать .desktop ярлык для выбранного профиля.\n"
"RU: Ярлык запускает приложение и автоматически применяет routing mark."
)
self.btn_app_profile_shortcut_create.clicked.connect(self.on_app_profile_shortcut_create)
row_prof_shortcuts.addWidget(self.btn_app_profile_shortcut_create)
self.btn_app_profile_shortcut_remove = QPushButton("Remove shortcut")
self.btn_app_profile_shortcut_remove.setToolTip(
"EN: Remove the .desktop shortcut for the selected profile.\n"
"RU: Удалить .desktop ярлык для выбранного профиля."
)
self.btn_app_profile_shortcut_remove.clicked.connect(self.on_app_profile_shortcut_remove)
row_prof_shortcuts.addWidget(self.btn_app_profile_shortcut_remove)
row_prof_shortcuts.addStretch(1)
profiles_layout.addLayout(row_prof_shortcuts)
self.lst_app_profiles = QListWidget()
self.lst_app_profiles.setSelectionMode(QAbstractItemView.SingleSelection)
self.lst_app_profiles.setToolTip(
@@ -294,6 +315,9 @@ RU: Восстанавливает маршруты/nft из последнег
self.lst_app_profiles.itemDoubleClicked.connect(
lambda _it: self.on_app_profile_load()
)
self.lst_app_profiles.currentItemChanged.connect(
lambda _cur, _prev: self._update_profile_shortcut_ui()
)
self.lst_app_profiles.setFixedHeight(140)
profiles_layout.addWidget(self.lst_app_profiles)
@@ -301,6 +325,11 @@ RU: Восстанавливает маршруты/nft из последнег
self.lbl_app_profiles.setStyleSheet("color: gray;")
profiles_layout.addWidget(self.lbl_app_profiles)
self.lbl_profile_shortcut = QLabel("Shortcut: —")
self.lbl_profile_shortcut.setWordWrap(True)
self.lbl_profile_shortcut.setStyleSheet("color: gray;")
profiles_layout.addWidget(self.lbl_profile_shortcut)
tab_profiles = QWidget()
tab_profiles_layout = QVBoxLayout(tab_profiles)
tab_profiles_layout.addWidget(profiles_group)
@@ -1138,6 +1167,70 @@ RU: Применяет policy-rules и проверяет health. При оши
return None
return it.data(QtCore.Qt.UserRole)
def _profile_shortcuts_dir(self) -> str:
# ~/.local/share/applications is the standard per-user location.
return os.path.join(os.path.expanduser("~"), ".local", "share", "applications")
def _profile_shortcut_path(self, profile_id: str) -> str:
pid = (profile_id or "").strip()
if not pid:
return ""
safe = re.sub(r"[^A-Za-z0-9._-]+", "-", pid).strip("-")
if not safe:
safe = "profile"
return os.path.join(self._profile_shortcuts_dir(), f"svpn-profile-{safe}.desktop")
def _profile_shortcut_exists(self, profile_id: str) -> bool:
p = self._profile_shortcut_path(profile_id)
return bool(p) and os.path.isfile(p)
def _render_profile_shortcut(self, prof) -> str:
pid = (getattr(prof, "id", "") or "").strip()
name = (getattr(prof, "name", "") or "").strip() or pid or "SVPN profile"
target = (getattr(prof, "target", "") or "").strip().lower()
if target not in ("vpn", "direct"):
target = "vpn"
label_target = "VPN" if target == "vpn" else "Direct"
script = os.path.abspath(os.path.join(os.path.dirname(__file__), "svpn_run_profile.py"))
# Use env python3 so the shortcut works even if python3 is not /usr/bin/python3.
exec_line = f"/usr/bin/env python3 {script} --id {pid}"
# Keep .desktop content ASCII-ish. Values are UTF-8-safe by spec, but avoid surprises.
name_safe = (name or "SVPN profile").replace("\n", " ").replace("\r", " ").strip()
return (
"[Desktop Entry]\n"
"Version=1.0\n"
"Type=Application\n"
f"Name=SVPN: {name_safe} [{label_target}]\n"
f"Comment=Selective VPN: run traffic profile id={pid} target={target}\n"
f"Exec={exec_line}\n"
"Terminal=false\n"
"Categories=Network;\n"
f"X-SVPN-ProfileID={pid}\n"
f"X-SVPN-Target={target}\n"
)
def _update_profile_shortcut_ui(self) -> None:
prof = self._selected_app_profile()
if prof is None:
self.btn_app_profile_shortcut_create.setEnabled(False)
self.btn_app_profile_shortcut_remove.setEnabled(False)
self.lbl_profile_shortcut.setText("Shortcut: —")
return
pid = (getattr(prof, "id", "") or "").strip()
path = self._profile_shortcut_path(pid)
installed = self._profile_shortcut_exists(pid)
self.btn_app_profile_shortcut_create.setEnabled(True)
self.btn_app_profile_shortcut_remove.setEnabled(installed)
state = "installed" if installed else "not installed"
self.lbl_profile_shortcut.setText(f"Shortcut: {state} ({path})")
def refresh_app_profiles(self, quiet: bool = False) -> None:
def work() -> None:
profs = list(self.ctrl.traffic_app_profiles_list() or [])
@@ -1156,12 +1249,16 @@ RU: Применяет policy-rules и проверяет health. При оши
if target in ("vpn", "direct"):
label += f" [{target}]"
it = QListWidgetItem(label)
sc_path = self._profile_shortcut_path(pid)
sc_state = "yes" if (sc_path and os.path.isfile(sc_path)) else "no"
it.setToolTip(
(
f"id: {pid}\n"
f"app_key: {app_key}\n"
f"target: {target}\n"
f"ttl: {ttl_sec}s\n\n"
f"shortcut: {sc_state}\n"
f"shortcut_path: {sc_path}\n\n"
f"{cmd}"
).strip()
)
@@ -1169,6 +1266,7 @@ RU: Применяет policy-rules и проверяет health. При оши
self.lst_app_profiles.addItem(it)
self.lbl_app_profiles.setText(f"Saved profiles: {len(profs)}")
self._update_profile_shortcut_ui()
if quiet:
try:
@@ -1208,6 +1306,18 @@ RU: Применяет policy-rules и проверяет health. При оши
self._append_app_log(f"[profile] saved: id={pid or '-'} target={target} app={app_key or '-'}")
self.refresh_app_profiles(quiet=True)
# If shortcut exists already, rewrite it to reflect updated name/target.
if prof is not None and pid and self._profile_shortcut_exists(pid):
try:
sc_path = self._profile_shortcut_path(pid)
os.makedirs(os.path.dirname(sc_path), exist_ok=True)
with open(sc_path, "w", encoding="utf-8", errors="replace") as f:
f.write(self._render_profile_shortcut(prof))
self._append_app_log(f"[shortcut] updated: {sc_path}")
except Exception as e:
# Non-fatal: profile save is more important than shortcut rewrite.
self._append_app_log(f"[shortcut] update failed: {e}")
# Best-effort select newly saved profile.
if pid:
for i in range(self.lst_app_profiles.count()):
@@ -1221,6 +1331,55 @@ RU: Применяет policy-rules и проверяет health. При оши
self._safe(work, title="Save profile error")
def on_app_profile_shortcut_create(self) -> None:
prof = self._selected_app_profile()
if prof is None:
return
pid = (getattr(prof, "id", "") or "").strip()
if not pid:
return
def work() -> None:
path = self._profile_shortcut_path(pid)
if not path:
raise RuntimeError("cannot derive shortcut path")
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8", errors="replace") as f:
f.write(self._render_profile_shortcut(prof))
self._append_app_log(f"[shortcut] saved: {path} id={pid}")
self._set_action_status(f"Shortcut saved: {os.path.basename(path)}", ok=True)
self._update_profile_shortcut_ui()
self._safe(work, title="Create shortcut error")
def on_app_profile_shortcut_remove(self) -> None:
prof = self._selected_app_profile()
if prof is None:
return
pid = (getattr(prof, "id", "") or "").strip()
if not pid:
return
def work() -> None:
path = self._profile_shortcut_path(pid)
if not path:
raise RuntimeError("cannot derive shortcut path")
if not os.path.exists(path):
self._set_action_status("Shortcut not installed", ok=True)
self._update_profile_shortcut_ui()
return
try:
os.remove(path)
except FileNotFoundError:
pass
self._append_app_log(f"[shortcut] removed: {path} id={pid}")
self._set_action_status("Shortcut removed", ok=True)
self._update_profile_shortcut_ui()
self._safe(work, title="Remove shortcut error")
def on_app_profile_load(self) -> None:
prof = self._selected_app_profile()
if prof is None:
@@ -1290,6 +1449,15 @@ RU: Применяет policy-rules и проверяет health. При оши
self._set_action_status(f"Delete profile failed: {res.message}", ok=False)
raise RuntimeError(res.message or "delete failed")
# Keep a tight coupling: deleting profile removes its shortcut too.
sc_path = self._profile_shortcut_path(pid)
if sc_path and os.path.exists(sc_path):
try:
os.remove(sc_path)
self._append_app_log(f"[shortcut] removed: {sc_path} (profile deleted)")
except Exception as e:
self._append_app_log(f"[shortcut] remove failed: {e}")
self._set_action_status(f"Profile deleted: {pid}", ok=True)
self._append_app_log(f"[profile] deleted: id={pid}")
self.refresh_app_profiles(quiet=True)