diff --git a/selective-vpn-gui/traffic_mode_dialog.py b/selective-vpn-gui/traffic_mode_dialog.py index 912be74..6e79f20 100644 --- a/selective-vpn-gui/traffic_mode_dialog.py +++ b/selective-vpn-gui/traffic_mode_dialog.py @@ -267,6 +267,14 @@ RU: Восстанавливает маршруты/nft из последнег self.btn_app_profile_load.clicked.connect(self.on_app_profile_load) row_prof_btn.addWidget(self.btn_app_profile_load) + self.btn_app_profile_run = QPushButton("Run profile") + self.btn_app_profile_run.setToolTip( + "EN: Launch selected profile via systemd-run --user and apply routing mark.\n" + "RU: Запустить выбранный профиль через systemd-run --user и применить метку маршрутизации." + ) + self.btn_app_profile_run.clicked.connect(self.on_app_profile_run) + row_prof_btn.addWidget(self.btn_app_profile_run) + self.btn_app_profile_delete = QPushButton("Delete profile") self.btn_app_profile_delete.setToolTip( "EN: Delete selected saved profile.\n" @@ -1025,6 +1033,105 @@ RU: Применяет policy-rules и проверяет health. При оши # Fallback: first token return (cmd.split() or [""])[0].strip() + def _launch_and_mark( + self, + *, + cmdline: str, + target: str, + ttl_sec: int, + app_key: str = "", + ) -> None: + cmdline = (cmdline or "").strip() + if not cmdline: + raise ValueError("empty command") + tgt = (target or "").strip().lower() + if tgt not in ("vpn", "direct"): + raise ValueError("invalid target") + ttl = int(ttl_sec or 0) + if ttl <= 0: + ttl = int(self.spn_app_ttl.value()) * 3600 + key = (app_key or "").strip() or self._infer_app_key_from_cmdline(cmdline) + + # EN: If we already have a running unit for the same app_key+target, refresh mark instead of spawning. + # RU: Если уже есть запущенный unit для того же app_key+target — обновляем метку, не плодим инстансы. + try: + items = list(self.ctrl.traffic_appmarks_items() or []) + except Exception: + items = [] + for it in items: + if (getattr(it, "target", "") or "").strip().lower() != tgt: + continue + if (getattr(it, "app_key", "") or "").strip() != key: + continue + unit = (getattr(it, "unit", "") or "").strip() + if not unit: + continue + code, out = self._systemctl_user(["is-active", unit]) + if code == 0 and (out or "").strip().lower() == "active": + cg = self._effective_cgroup_for_unit_retry(unit, timeout_sec=3.0) + self._append_app_log( + f"[profile] already running: app={key} target={tgt} unit={unit} (refreshing mark)" + ) + res = self.ctrl.traffic_appmarks_apply( + op="add", + target=tgt, + cgroup=cg, + unit=unit, + command=cmdline, + app_key=key, + timeout_sec=ttl, + ) + if not res.ok: + raise RuntimeError(res.message or "appmark refresh failed") + self._set_action_status( + f"App mark refreshed: target={tgt} cgroup_id={res.cgroup_id}", + ok=True, + ) + self._set_last_scope( + unit=unit, + target=tgt, + app_key=key, + cmdline=cmdline, + cgroup_id=int(res.cgroup_id or 0), + ) + self.refresh_appmarks_items(quiet=True) + self.refresh_appmarks_counts() + self.refresh_running_scopes(quiet=True) + return + + unit = f"svpn-{tgt}-{int(time.time())}.service" + self._append_app_log(f"[profile] launching: app={key or '-'} target={tgt} ttl={ttl}s unit={unit}") + cg, out = self._run_systemd_unit(cmdline, unit=unit) + if out: + self._append_app_log(f"[profile] systemd-run:\n{out}") + self._append_app_log(f"[profile] ControlGroup: {cg}") + self._set_last_scope(unit=unit, target=tgt, app_key=key, cmdline=cmdline, cgroup_id=0) + + res = self.ctrl.traffic_appmarks_apply( + op="add", + target=tgt, + cgroup=cg, + unit=unit, + command=cmdline, + app_key=key, + timeout_sec=ttl, + ) + if not res.ok: + raise RuntimeError(res.message or "appmark apply failed") + + self._append_app_log(f"[appmarks] OK: {res.message} cgroup_id={res.cgroup_id} timeout={res.timeout_sec}s") + self._set_action_status(f"App mark added: target={tgt} cgroup_id={res.cgroup_id}", ok=True) + self._set_last_scope( + unit=unit, + target=tgt, + app_key=key, + cmdline=cmdline, + cgroup_id=int(res.cgroup_id or 0), + ) + self.refresh_appmarks_items(quiet=True) + self.refresh_appmarks_counts() + self.refresh_running_scopes(quiet=True) + def _selected_app_profile(self): it = self.lst_app_profiles.currentItem() if not it: @@ -1142,6 +1249,25 @@ RU: Применяет policy-rules и проверяет health. При оши self._safe(work, title="Load profile error") + def on_app_profile_run(self) -> None: + prof = self._selected_app_profile() + if prof is None: + return + + def work() -> None: + cmd = (getattr(prof, "command", "") or "").strip() + target = (getattr(prof, "target", "") or "").strip().lower() + ttl = int(getattr(prof, "ttl_sec", 0) or 0) + app_key = (getattr(prof, "app_key", "") or "").strip() + if not cmd: + raise RuntimeError("profile has empty command") + if target not in ("vpn", "direct"): + target = "vpn" + + self._launch_and_mark(cmdline=cmd, target=target, ttl_sec=ttl, app_key=app_key) + + self._safe(work, title="Run profile error") + def on_app_profile_delete(self) -> None: prof = self._selected_app_profile() if prof is None: