ui: snap app picker + runtime scopes list/cleanup

This commit is contained in:
beckline
2026-02-15 01:57:28 +03:00
parent f6a7cfa85a
commit f74b1cf9a9
5 changed files with 312 additions and 4 deletions

View File

@@ -50,6 +50,14 @@ class DesktopAppEntry:
source: str # system|flatpak|user
@dataclass(frozen=True)
class RuntimeScopeInfo:
unit: str
target: str # vpn|direct|?
cgroup_path: str
cgroup_id: int
class TrafficModeDialog(QDialog):
def __init__(
self,
@@ -219,8 +227,8 @@ RU: Восстанавливает маршруты/nft из последнег
row_cmd.addWidget(self.ed_app_cmd, stretch=1)
self.btn_app_pick = QPushButton("Pick app...")
self.btn_app_pick.setToolTip(
"EN: Pick an installed app from .desktop entries (system + flatpak) and fill the command.\n"
"RU: Выбрать установленное приложение из .desktop (system + flatpak) и заполнить команду."
"EN: Pick an installed app from .desktop entries (system + flatpak + snap) and fill the command.\n"
"RU: Выбрать установленное приложение из .desktop (system + flatpak + snap) и заполнить команду."
)
self.btn_app_pick.clicked.connect(self.on_app_pick)
row_cmd.addWidget(self.btn_app_pick)
@@ -306,6 +314,53 @@ RU: Восстанавливает маршруты/nft из последнег
tab_apps_layout.addWidget(run_group)
scopes_group = QGroupBox("Active svpn scopes (systemd --user)")
scopes_layout = QVBoxLayout(scopes_group)
scopes_row = QHBoxLayout()
self.btn_scopes_refresh = QPushButton("Refresh scopes")
self.btn_scopes_refresh.setToolTip(
"EN: Refresh list of running svpn-* scopes.\n"
"RU: Обновить список запущенных svpn-* scope."
)
self.btn_scopes_refresh.clicked.connect(self.refresh_running_scopes)
scopes_row.addWidget(self.btn_scopes_refresh)
self.btn_scopes_stop_selected = QPushButton("Stop selected")
self.btn_scopes_stop_selected.setToolTip(
"EN: Unmarks + stops selected scopes.\n"
"RU: Удаляет метки + останавливает выбранные scope."
)
self.btn_scopes_stop_selected.clicked.connect(self.on_scopes_stop_selected)
scopes_row.addWidget(self.btn_scopes_stop_selected)
self.btn_scopes_cleanup = QPushButton("Cleanup all svpn scopes")
self.btn_scopes_cleanup.setToolTip(
"EN: Unmarks + stops ALL running svpn-* scopes.\n"
"RU: Удаляет метки + останавливает ВСЕ запущенные svpn-* scope."
)
self.btn_scopes_cleanup.clicked.connect(self.on_scopes_cleanup_all)
scopes_row.addWidget(self.btn_scopes_cleanup)
scopes_row.addStretch(1)
scopes_layout.addLayout(scopes_row)
self.lst_scopes = QListWidget()
self.lst_scopes.setSelectionMode(QAbstractItemView.ExtendedSelection)
self.lst_scopes.setToolTip(
"EN: Running svpn scopes. Double click to copy unit name.\n"
"RU: Запущенные svpn scope. Двойной клик копирует имя unit."
)
self.lst_scopes.itemDoubleClicked.connect(lambda it: self._copy_scope_unit(it))
self.lst_scopes.setFixedHeight(140)
scopes_layout.addWidget(self.lst_scopes)
self.lbl_scopes = QLabel("Running scopes: —")
self.lbl_scopes.setStyleSheet("color: gray;")
scopes_layout.addWidget(self.lbl_scopes)
tab_apps_layout.addWidget(scopes_group)
self.txt_app = QPlainTextEdit()
self.txt_app.setReadOnly(True)
tab_apps_layout.addWidget(self.txt_app, stretch=1)
@@ -411,6 +466,7 @@ RU: Применяет policy-rules и проверяет health. При оши
QtCore.QTimer.singleShot(0, self.refresh_state)
QtCore.QTimer.singleShot(0, self.refresh_appmarks_counts)
QtCore.QTimer.singleShot(0, self.refresh_running_scopes)
def _is_operation_error(self, message: str) -> bool:
low = (message or "").strip().lower()
@@ -1047,6 +1103,257 @@ RU: Применяет policy-rules и проверяет health. При оши
self._safe(work, title="Clear app marks error")
# -----------------------------------------------------------------
# Scopes list + cleanup (runtime)
# -----------------------------------------------------------------
def _scope_target_from_unit(self, unit: str) -> str:
u = (unit or "").strip()
if not u.startswith("svpn-"):
return ""
rest = u[len("svpn-") :]
if rest.startswith("vpn-"):
return "vpn"
if rest.startswith("direct-"):
return "direct"
return ""
def _systemctl_user(self, args: list[str]) -> tuple[int, str]:
p = subprocess.run(
["systemctl", "--user"] + list(args or []),
capture_output=True,
text=True,
check=False,
)
out = ((p.stdout or "") + (p.stderr or "")).strip()
return int(p.returncode or 0), out
def _list_running_svpn_scopes(self) -> list[str]:
code, out = self._systemctl_user(
[
"list-units",
"--type=scope",
"--state=running",
"--no-legend",
"--no-pager",
"--plain",
]
)
if code != 0:
raise RuntimeError(out or f"systemctl list-units failed: rc={code}")
units: list[str] = []
for raw in (out or "").splitlines():
line = raw.strip()
if not line:
continue
fields = line.split()
if not fields:
continue
unit = fields[0].strip()
if unit.startswith("svpn-") and unit.endswith(".scope"):
units.append(unit)
units.sort()
return units
def _control_group_for_unit(self, unit: str) -> str:
code, out = self._systemctl_user(["show", "-p", "ControlGroup", "--value", unit])
if code != 0:
raise RuntimeError(out or f"systemctl show failed: rc={code}")
return (out or "").strip()
def _cgroup_inode_id(self, cgroup_path: str) -> int:
cg = (cgroup_path or "").strip()
if not cg:
return 0
rel = cg.lstrip("/")
if not rel:
return 0
full = os.path.join("/sys/fs/cgroup", rel)
try:
st = os.stat(full)
return int(getattr(st, "st_ino", 0) or 0)
except Exception:
return 0
def refresh_running_scopes(self) -> None:
def work() -> None:
units = self._list_running_svpn_scopes()
self.lst_scopes.clear()
for unit in units:
target = self._scope_target_from_unit(unit)
cg = ""
cg_id = 0
try:
cg = self._control_group_for_unit(unit)
cg_id = self._cgroup_inode_id(cg)
except Exception:
pass
label = unit
if target in ("vpn", "direct"):
label += f" [{target.upper()}]"
it = QListWidgetItem(label)
info = RuntimeScopeInfo(
unit=unit,
target=target or "?",
cgroup_path=cg,
cgroup_id=int(cg_id or 0),
)
it.setData(QtCore.Qt.UserRole, info)
it.setToolTip(
f"Unit: {unit}\n"
f"Target: {target or '-'}\n"
f"ControlGroup: {cg or '-'}\n"
f"cgroup_id: {cg_id or '-'}\n\n"
"EN: Stop selected will unmark by cgroup_id (if known) and stop the scope.\n"
"RU: Stop selected удалит метку по cgroup_id (если известен) и остановит scope."
)
self.lst_scopes.addItem(it)
self.lbl_scopes.setText(f"Running scopes: {len(units)}")
has_any = self.lst_scopes.count() > 0
self.btn_scopes_stop_selected.setEnabled(has_any)
self.btn_scopes_cleanup.setEnabled(has_any)
self._safe(work, title="Scopes refresh error")
def _copy_scope_unit(self, it: QListWidgetItem) -> None:
unit = ""
info = it.data(QtCore.Qt.UserRole) if it else None
if isinstance(info, RuntimeScopeInfo):
unit = info.unit
else:
unit = (it.text() or "").split()[0].strip() if it else ""
if not unit:
return
try:
QtGui.QGuiApplication.clipboard().setText(unit)
except Exception:
pass
self._append_app_log(f"[scope] copied unit: {unit}")
self._set_action_status(f"Copied unit: {unit}", ok=True)
def _stop_scope_unit(self, unit: str) -> None:
u = (unit or "").strip()
if not u:
return
code, out = self._systemctl_user(["stop", u])
if code == 0:
if out:
self._append_app_log(out)
return
code2, out2 = self._systemctl_user(["kill", u])
if code2 != 0:
raise RuntimeError(out2 or out or f"stop/kill failed: {u}")
def _unmark_scope(self, info: RuntimeScopeInfo) -> None:
target = (info.target or "").strip().lower()
cg_id = int(info.cgroup_id or 0)
if target not in ("vpn", "direct") or cg_id <= 0:
return
res = self.ctrl.traffic_appmarks_apply(
op="del",
target=target,
cgroup=str(cg_id),
)
if not res.ok:
raise RuntimeError(res.message or "unmark failed")
def _selected_scope_infos(self) -> list[RuntimeScopeInfo]:
infos: list[RuntimeScopeInfo] = []
for it in self.lst_scopes.selectedItems() or []:
info = it.data(QtCore.Qt.UserRole) if it else None
if isinstance(info, RuntimeScopeInfo):
infos.append(info)
return infos
def on_scopes_stop_selected(self) -> None:
def work() -> None:
infos = self._selected_scope_infos()
if not infos:
QMessageBox.information(self, "No selection", "Select one or more scopes first.")
return
if QMessageBox.question(
self,
"Stop selected",
f"Unmark + stop {len(infos)} selected scope(s)?",
) != QMessageBox.StandardButton.Yes:
return
for info in infos:
self._append_app_log(
f"[scope] stop: unit={info.unit} target={info.target} cgroup_id={info.cgroup_id}"
)
try:
self._unmark_scope(info)
self._append_app_log("[scope] unmark OK")
except Exception as e:
self._append_app_log(f"[scope] unmark WARN: {e}")
self._stop_scope_unit(info.unit)
self._append_app_log("[scope] stop OK")
self.refresh_running_scopes()
self.refresh_appmarks_counts()
self._set_action_status(f"Stopped scopes: {len(infos)}", ok=True)
self._safe(work, title="Stop selected scopes error")
def on_scopes_cleanup_all(self) -> None:
def work() -> None:
units = self._list_running_svpn_scopes()
if not units:
self._set_action_status("No running svpn scopes", ok=True)
self.refresh_running_scopes()
return
if QMessageBox.question(
self,
"Cleanup all",
f"Unmark + stop ALL running svpn scopes ({len(units)})?",
) != QMessageBox.StandardButton.Yes:
return
stopped = 0
for unit in units:
target = self._scope_target_from_unit(unit)
cg = ""
cg_id = 0
try:
cg = self._control_group_for_unit(unit)
cg_id = self._cgroup_inode_id(cg)
except Exception:
pass
info = RuntimeScopeInfo(
unit=unit,
target=target or "?",
cgroup_path=cg,
cgroup_id=int(cg_id or 0),
)
self._append_app_log(
f"[scope] cleanup: unit={info.unit} target={info.target} cgroup_id={info.cgroup_id}"
)
try:
self._unmark_scope(info)
self._append_app_log("[scope] unmark OK")
except Exception as e:
self._append_app_log(f"[scope] unmark WARN: {e}")
try:
self._stop_scope_unit(info.unit)
stopped += 1
except Exception as e:
self._append_app_log(f"[scope] stop ERROR: {e}")
self.refresh_running_scopes()
self.refresh_appmarks_counts()
self._set_action_status(f"Cleanup done: stopped={stopped}/{len(units)}", ok=True)
self._safe(work, title="Cleanup scopes error")
def on_rollback(self) -> None:
def work() -> None:
res = self.ctrl.routes_clear()
@@ -1754,6 +2061,7 @@ def _scan_desktop_entries() -> list[DesktopAppEntry]:
("/usr/share/applications", "system"),
(os.path.join(home, ".local/share/flatpak/exports/share/applications"), "flatpak"),
("/var/lib/flatpak/exports/share/applications", "flatpak"),
("/var/lib/snapd/desktop/applications", "snap"),
]
seen: set[tuple[str, str]] = set()
@@ -1791,9 +2099,9 @@ class AppPickerDialog(QDialog):
root = QVBoxLayout(self)
note = QLabel(
"EN: Pick an installed GUI app from .desktop entries (system + flatpak). "
"EN: Pick an installed GUI app from .desktop entries (system + flatpak + snap). "
"The command will be filled without %u/%U/%f placeholders.\n"
"RU: Выбери приложение из .desktop (system + flatpak). "
"RU: Выбери приложение из .desktop (system + flatpak + snap). "
"Команда будет заполнена без плейсхолдеров %u/%U/%f."
)
note.setWordWrap(True)