ui: app profile desktop shortcuts
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user