diff --git a/selective-vpn-api/app/config.go b/selective-vpn-api/app/config.go index 1872787..b2aff41 100644 --- a/selective-vpn-api/app/config.go +++ b/selective-vpn-api/app/config.go @@ -12,11 +12,12 @@ import "embed" // --------------------------------------------------------------------- const ( - stateDir = "/var/lib/selective-vpn" - statusFilePath = stateDir + "/status.json" - dnsModePath = stateDir + "/dns-mode.json" - trafficModePath = stateDir + "/traffic-mode.json" - trafficAppMarksPath = stateDir + "/traffic-appmarks.json" + stateDir = "/var/lib/selective-vpn" + statusFilePath = stateDir + "/status.json" + dnsModePath = stateDir + "/dns-mode.json" + trafficModePath = stateDir + "/traffic-mode.json" + trafficAppMarksPath = stateDir + "/traffic-appmarks.json" + trafficAppProfilesPath = stateDir + "/traffic-app-profiles.json" traceLogPath = stateDir + "/trace.log" smartdnsLogPath = stateDir + "/smartdns.log" diff --git a/selective-vpn-api/app/server.go b/selective-vpn-api/app/server.go index 68dff4c..b9835e4 100644 --- a/selective-vpn-api/app/server.go +++ b/selective-vpn-api/app/server.go @@ -148,6 +148,8 @@ func Run() { mux.HandleFunc("/api/v1/traffic/candidates", handleTrafficCandidates) // per-app runtime marks (systemd scope / cgroup -> fwmark) mux.HandleFunc("/api/v1/traffic/appmarks", handleTrafficAppMarks) + // persistent app profiles (saved launch configs) + mux.HandleFunc("/api/v1/traffic/app-profiles", handleTrafficAppProfiles) // trace: хвост + JSON + append для GUI mux.HandleFunc("/api/v1/trace", handleTraceTailPlain) diff --git a/selective-vpn-api/app/traffic_app_profiles.go b/selective-vpn-api/app/traffic_app_profiles.go new file mode 100644 index 0000000..7ae847f --- /dev/null +++ b/selective-vpn-api/app/traffic_app_profiles.go @@ -0,0 +1,306 @@ +package app + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" +) + +// --------------------------------------------------------------------- +// traffic app profiles (persistent app configs) +// --------------------------------------------------------------------- +// +// EN: App profiles are persistent configs that describe *what* to launch and +// EN: how to route it. They are separate from runtime marks, because runtime +// EN: marks are tied to a конкретный systemd unit/cgroup. +// RU: App profiles - это постоянные конфиги, которые описывают *что* запускать +// RU: и как маршрутизировать. Они отдельно от runtime marks, потому что marks +// RU: привязаны к конкретному systemd unit/cgroup. + +const ( + trafficAppProfilesDefaultTTLSec = 24 * 60 * 60 +) + +var trafficAppProfilesMu sync.Mutex + +type trafficAppProfilesState struct { + Version int `json:"version"` + UpdatedAt string `json:"updated_at"` + Profiles []TrafficAppProfile `json:"profiles,omitempty"` +} + +func handleTrafficAppProfiles(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + profiles := listTrafficAppProfiles() + if profiles == nil { + profiles = []TrafficAppProfile{} + } + writeJSON(w, http.StatusOK, TrafficAppProfilesResponse{Profiles: profiles, Message: "ok"}) + case http.MethodPost: + var body TrafficAppProfileUpsertRequest + if r.Body != nil { + defer r.Body.Close() + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil && err != io.EOF { + http.Error(w, "bad json", http.StatusBadRequest) + return + } + } + prof, err := upsertTrafficAppProfile(body) + if err != nil { + writeJSON(w, http.StatusOK, TrafficAppProfilesResponse{Profiles: nil, Message: err.Error()}) + return + } + events.push("traffic_profiles_changed", map[string]any{"id": prof.ID, "target": prof.Target}) + writeJSON(w, http.StatusOK, TrafficAppProfilesResponse{Profiles: []TrafficAppProfile{prof}, Message: "saved"}) + case http.MethodDelete: + id := strings.TrimSpace(r.URL.Query().Get("id")) + if id == "" { + http.Error(w, "missing id", http.StatusBadRequest) + return + } + ok, msg := deleteTrafficAppProfile(id) + events.push("traffic_profiles_changed", map[string]any{"id": id, "deleted": ok}) + writeJSON(w, http.StatusOK, map[string]any{"ok": ok, "message": msg}) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func listTrafficAppProfiles() []TrafficAppProfile { + trafficAppProfilesMu.Lock() + defer trafficAppProfilesMu.Unlock() + + st := loadTrafficAppProfilesState() + out := append([]TrafficAppProfile(nil), st.Profiles...) + sort.Slice(out, func(i, j int) bool { + // Newest first. + return out[i].UpdatedAt > out[j].UpdatedAt + }) + return out +} + +func upsertTrafficAppProfile(req TrafficAppProfileUpsertRequest) (TrafficAppProfile, error) { + trafficAppProfilesMu.Lock() + defer trafficAppProfilesMu.Unlock() + + st := loadTrafficAppProfilesState() + + target := strings.ToLower(strings.TrimSpace(req.Target)) + if target == "" { + target = "vpn" + } + if target != "vpn" && target != "direct" { + return TrafficAppProfile{}, fmt.Errorf("target must be vpn|direct") + } + + cmd := strings.TrimSpace(req.Command) + if cmd == "" { + return TrafficAppProfile{}, fmt.Errorf("missing command") + } + + appKey := strings.TrimSpace(req.AppKey) + if appKey == "" { + fields := strings.Fields(cmd) + if len(fields) > 0 { + appKey = strings.TrimSpace(fields[0]) + } + } + if appKey == "" { + return TrafficAppProfile{}, fmt.Errorf("cannot infer app_key") + } + + id := strings.TrimSpace(req.ID) + if id == "" { + // If profile for same app_key+target exists, update it. + for _, p := range st.Profiles { + if strings.TrimSpace(p.AppKey) == appKey && strings.ToLower(strings.TrimSpace(p.Target)) == target { + id = strings.TrimSpace(p.ID) + break + } + } + } + if id == "" { + id = deriveTrafficAppProfileID(appKey, target, st.Profiles) + } + if id == "" { + return TrafficAppProfile{}, fmt.Errorf("cannot derive profile id") + } + + name := strings.TrimSpace(req.Name) + if name == "" { + name = filepath.Base(appKey) + if name == "" || name == "/" || name == "." { + name = id + } + } + + ttl := req.TTLSec + if ttl <= 0 { + ttl = trafficAppProfilesDefaultTTLSec + } + + vpnProfile := strings.TrimSpace(req.VPNProfile) + + now := time.Now().UTC().Format(time.RFC3339) + prof := TrafficAppProfile{ + ID: id, + Name: name, + AppKey: appKey, + Command: cmd, + Target: target, + TTLSec: ttl, + VPNProfile: vpnProfile, + UpdatedAt: now, + } + + // Upsert. + updated := false + for i := range st.Profiles { + if strings.TrimSpace(st.Profiles[i].ID) != id { + continue + } + // Keep created_at stable. + prof.CreatedAt = strings.TrimSpace(st.Profiles[i].CreatedAt) + if prof.CreatedAt == "" { + prof.CreatedAt = now + } + st.Profiles[i] = prof + updated = true + break + } + if !updated { + prof.CreatedAt = now + st.Profiles = append(st.Profiles, prof) + } + + if err := saveTrafficAppProfilesState(st); err != nil { + return TrafficAppProfile{}, err + } + return prof, nil +} + +func deleteTrafficAppProfile(id string) (bool, string) { + trafficAppProfilesMu.Lock() + defer trafficAppProfilesMu.Unlock() + + id = strings.TrimSpace(id) + if id == "" { + return false, "empty id" + } + + st := loadTrafficAppProfilesState() + kept := st.Profiles[:0] + found := false + for _, p := range st.Profiles { + if strings.TrimSpace(p.ID) == id { + found = true + continue + } + kept = append(kept, p) + } + st.Profiles = kept + + if !found { + return true, "not found" + } + if err := saveTrafficAppProfilesState(st); err != nil { + return false, err.Error() + } + return true, "deleted" +} + +func deriveTrafficAppProfileID(appKey string, target string, existing []TrafficAppProfile) string { + base := filepath.Base(strings.TrimSpace(appKey)) + if base == "" || base == "/" || base == "." { + base = "app" + } + base = sanitizeID(base) + if base == "" { + base = "app" + } + + idBase := base + "-" + strings.ToLower(strings.TrimSpace(target)) + id := idBase + + used := map[string]struct{}{} + for _, p := range existing { + used[strings.TrimSpace(p.ID)] = struct{}{} + } + if _, ok := used[id]; !ok { + return id + } + for i := 2; i < 1000; i++ { + cand := fmt.Sprintf("%s-%d", idBase, i) + if _, ok := used[cand]; !ok { + return cand + } + } + return "" +} + +func sanitizeID(s string) string { + in := strings.ToLower(strings.TrimSpace(s)) + var b strings.Builder + b.Grow(len(in)) + lastDash := false + for i := 0; i < len(in); i++ { + ch := in[i] + isAZ := ch >= 'a' && ch <= 'z' + is09 := ch >= '0' && ch <= '9' + if isAZ || is09 { + b.WriteByte(ch) + lastDash = false + continue + } + if !lastDash { + b.WriteByte('-') + lastDash = true + } + } + out := strings.Trim(b.String(), "-") + return out +} + +func loadTrafficAppProfilesState() trafficAppProfilesState { + st := trafficAppProfilesState{Version: 1} + data, err := os.ReadFile(trafficAppProfilesPath) + if err != nil { + return st + } + if err := json.Unmarshal(data, &st); err != nil { + return trafficAppProfilesState{Version: 1} + } + if st.Version == 0 { + st.Version = 1 + } + if st.Profiles == nil { + st.Profiles = nil + } + return st +} + +func saveTrafficAppProfilesState(st trafficAppProfilesState) error { + st.Version = 1 + st.UpdatedAt = time.Now().UTC().Format(time.RFC3339) + + data, err := json.MarshalIndent(st, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(trafficAppProfilesPath), 0o755); err != nil { + return err + } + tmp := trafficAppProfilesPath + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return err + } + return os.Rename(tmp, trafficAppProfilesPath) +} diff --git a/selective-vpn-api/app/types.go b/selective-vpn-api/app/types.go index d7fce74..10a01a3 100644 --- a/selective-vpn-api/app/types.go +++ b/selective-vpn-api/app/types.go @@ -227,6 +227,39 @@ type TrafficAppMarksStatusResponse struct { Message string `json:"message,omitempty"` } +// --------------------------------------------------------------------- +// traffic app profiles (persistent app launcher configs) +// --------------------------------------------------------------------- + +// EN: Persistent per-app launcher profile (separate from runtime marks). +// RU: Постоянный профиль запуска приложения (отдельно от runtime marks). +type TrafficAppProfile struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + AppKey string `json:"app_key,omitempty"` + Command string `json:"command,omitempty"` + Target string `json:"target,omitempty"` // vpn|direct + TTLSec int `json:"ttl_sec,omitempty"` + VPNProfile string `json:"vpn_profile,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +type TrafficAppProfilesResponse struct { + Profiles []TrafficAppProfile `json:"profiles"` + Message string `json:"message,omitempty"` +} + +type TrafficAppProfileUpsertRequest struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + AppKey string `json:"app_key,omitempty"` + Command string `json:"command,omitempty"` + Target string `json:"target,omitempty"` // vpn|direct + TTLSec int `json:"ttl_sec,omitempty"` + VPNProfile string `json:"vpn_profile,omitempty"` +} + type SystemdState struct { State string `json:"state"` } diff --git a/selective-vpn-gui/api_client.py b/selective-vpn-gui/api_client.py index a32a4d9..f8cb09a 100644 --- a/selective-vpn-gui/api_client.py +++ b/selective-vpn-gui/api_client.py @@ -138,6 +138,26 @@ class TrafficAppMarksResult: timeout_sec: int = 0 +@dataclass(frozen=True) +class TrafficAppProfile: + id: str + name: str + app_key: str + command: str + target: str # vpn|direct + ttl_sec: int + vpn_profile: str + created_at: str + updated_at: str + + +@dataclass(frozen=True) +class TrafficAppProfileSaveResult: + ok: bool + message: str + profile: Optional[TrafficAppProfile] = None + + @dataclass(frozen=True) class TrafficCandidateSubnet: @@ -860,6 +880,114 @@ class ApiClient: timeout_sec=int(data.get("timeout_sec", 0) or 0), ) + def traffic_app_profiles_list(self) -> List[TrafficAppProfile]: + data = cast( + Dict[str, Any], + self._json(self._request("GET", "/api/v1/traffic/app-profiles")) or {}, + ) + raw = data.get("profiles") or [] + if not isinstance(raw, list): + raw = [] + + out: List[TrafficAppProfile] = [] + for it in raw: + if not isinstance(it, dict): + continue + pid = str(it.get("id") or "").strip() + if not pid: + continue + out.append( + TrafficAppProfile( + id=pid, + name=str(it.get("name") or "").strip(), + app_key=str(it.get("app_key") or "").strip(), + command=str(it.get("command") or "").strip(), + target=str(it.get("target") or "").strip().lower(), + ttl_sec=int(it.get("ttl_sec", 0) or 0), + vpn_profile=str(it.get("vpn_profile") or "").strip(), + created_at=str(it.get("created_at") or "").strip(), + updated_at=str(it.get("updated_at") or "").strip(), + ) + ) + return out + + def traffic_app_profile_upsert( + self, + *, + id: str = "", + name: str = "", + app_key: str = "", + command: str, + target: str, + ttl_sec: int = 0, + vpn_profile: str = "", + ) -> TrafficAppProfileSaveResult: + payload: Dict[str, Any] = { + "command": str(command or "").strip(), + "target": str(target or "").strip().lower(), + } + if id: + payload["id"] = str(id).strip() + if name: + payload["name"] = str(name).strip() + if app_key: + payload["app_key"] = str(app_key).strip() + if int(ttl_sec or 0) > 0: + payload["ttl_sec"] = int(ttl_sec) + if vpn_profile: + payload["vpn_profile"] = str(vpn_profile).strip() + + data = cast( + Dict[str, Any], + self._json( + self._request("POST", "/api/v1/traffic/app-profiles", json_body=payload) + ) + or {}, + ) + msg = str(data.get("message") or "") + raw = data.get("profiles") or [] + if not isinstance(raw, list): + raw = [] + prof: Optional[TrafficAppProfile] = None + if raw and isinstance(raw[0], dict): + it = cast(Dict[str, Any], raw[0]) + pid = str(it.get("id") or "").strip() + if pid: + prof = TrafficAppProfile( + id=pid, + name=str(it.get("name") or "").strip(), + app_key=str(it.get("app_key") or "").strip(), + command=str(it.get("command") or "").strip(), + target=str(it.get("target") or "").strip().lower(), + ttl_sec=int(it.get("ttl_sec", 0) or 0), + vpn_profile=str(it.get("vpn_profile") or "").strip(), + created_at=str(it.get("created_at") or "").strip(), + updated_at=str(it.get("updated_at") or "").strip(), + ) + + ok = bool(prof) and (msg.strip().lower() in ("saved", "ok")) + if not msg and ok: + msg = "saved" + return TrafficAppProfileSaveResult(ok=ok, message=msg, profile=prof) + + def traffic_app_profile_delete(self, id: str) -> CmdResult: + pid = str(id or "").strip() + if not pid: + raise ValueError("missing id") + data = cast( + Dict[str, Any], + self._json( + self._request("DELETE", "/api/v1/traffic/app-profiles", params={"id": pid}) + ) + or {}, + ) + return CmdResult( + ok=bool(data.get("ok", False)), + message=str(data.get("message") or ""), + exit_code=None, + stdout="", + stderr="", + ) # DNS / SmartDNS def dns_upstreams_get(self) -> DnsUpstreams: diff --git a/selective-vpn-gui/dashboard_controller.py b/selective-vpn-gui/dashboard_controller.py index 220d4e1..081c1a8 100644 --- a/selective-vpn-gui/dashboard_controller.py +++ b/selective-vpn-gui/dashboard_controller.py @@ -34,6 +34,8 @@ from api_client import ( TrafficCandidates, TrafficAppMarksResult, TrafficAppMarksStatus, + TrafficAppProfile, + TrafficAppProfileSaveResult, TrafficInterfaces, TrafficModeStatus, TraceDump, @@ -200,6 +202,9 @@ class DashboardController: return ["routes"] if k == "traffic_mode_changed": return ["routes", "status"] + if k == "traffic_profiles_changed": + # Used by Traffic mode dialog (Apps/runtime) for persistent app profiles. + return ["routes"] return [] # -------- helpers -------- @@ -731,6 +736,33 @@ class DashboardController: timeout_sec=timeout_sec, ) + def traffic_app_profiles_list(self) -> List[TrafficAppProfile]: + return self.client.traffic_app_profiles_list() + + def traffic_app_profile_upsert( + self, + *, + id: str = "", + name: str = "", + app_key: str = "", + command: str, + target: str, + ttl_sec: int = 0, + vpn_profile: str = "", + ) -> TrafficAppProfileSaveResult: + return self.client.traffic_app_profile_upsert( + id=id, + name=name, + app_key=app_key, + command=command, + target=target, + ttl_sec=ttl_sec, + vpn_profile=vpn_profile, + ) + + def traffic_app_profile_delete(self, id: str) -> CmdResult: + return self.client.traffic_app_profile_delete(id) + def routes_nft_progress_from_event(self, ev: Event) -> RoutesNftProgressView: """ Превращает Event(kind='routes_nft_progress') в удобную модель diff --git a/selective-vpn-gui/traffic_mode_dialog.py b/selective-vpn-gui/traffic_mode_dialog.py index afcb337..83194dc 100644 --- a/selective-vpn-gui/traffic_mode_dialog.py +++ b/selective-vpn-gui/traffic_mode_dialog.py @@ -213,6 +213,83 @@ RU: Восстанавливает маршруты/nft из последнег apps_hint.setStyleSheet("color: gray;") tab_apps_layout.addWidget(apps_hint) + profiles_group = QGroupBox("Added apps (profiles)") + profiles_layout = QVBoxLayout(profiles_group) + + profiles_hint = QLabel( + "Persistent launch configs (saved):\n" + "- These describe what to run and how to route it.\n" + "- Separate from runtime marks/units (which are tied to a specific cgroup)." + ) + profiles_hint.setWordWrap(True) + profiles_hint.setStyleSheet("color: gray;") + profiles_layout.addWidget(profiles_hint) + + row_prof_name = QHBoxLayout() + row_prof_name.addWidget(QLabel("Name")) + self.ed_app_profile_name = QLineEdit() + self.ed_app_profile_name.setPlaceholderText( + "optional (default: basename of app)" + ) + self.ed_app_profile_name.setToolTip( + "EN: Optional profile name (for display). Leave empty to auto-name.\n" + "RU: Необязательное имя профиля (для отображения). Можно оставить пустым." + ) + row_prof_name.addWidget(self.ed_app_profile_name, stretch=1) + self.btn_app_profiles_refresh = QPushButton("Refresh profiles") + self.btn_app_profiles_refresh.setToolTip( + "EN: Reload saved app profiles from backend.\n" + "RU: Обновить список сохранённых профилей из backend." + ) + self.btn_app_profiles_refresh.clicked.connect(self.refresh_app_profiles) + row_prof_name.addWidget(self.btn_app_profiles_refresh) + profiles_layout.addLayout(row_prof_name) + + row_prof_btn = QHBoxLayout() + self.btn_app_profile_save = QPushButton("Save / update profile") + self.btn_app_profile_save.setToolTip( + "EN: Save current command/route/TTL as a persistent profile (upsert).\n" + "RU: Сохранить текущую команду/маршрут/TTL как постоянный профиль (upsert)." + ) + self.btn_app_profile_save.clicked.connect(self.on_app_profile_save) + row_prof_btn.addWidget(self.btn_app_profile_save) + + self.btn_app_profile_load = QPushButton("Load to form") + self.btn_app_profile_load.setToolTip( + "EN: Load selected profile into the form (command/route/TTL).\n" + "RU: Загрузить выбранный профиль в форму (команда/маршрут/TTL)." + ) + 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_delete = QPushButton("Delete profile") + self.btn_app_profile_delete.setToolTip( + "EN: Delete selected saved profile.\n" + "RU: Удалить выбранный сохранённый профиль." + ) + self.btn_app_profile_delete.clicked.connect(self.on_app_profile_delete) + row_prof_btn.addWidget(self.btn_app_profile_delete) + row_prof_btn.addStretch(1) + profiles_layout.addLayout(row_prof_btn) + + self.lst_app_profiles = QListWidget() + self.lst_app_profiles.setSelectionMode(QAbstractItemView.SingleSelection) + self.lst_app_profiles.setToolTip( + "EN: Saved app profiles. Double click loads into the form.\n" + "RU: Сохранённые профили приложений. Двойной клик загружает в форму." + ) + self.lst_app_profiles.itemDoubleClicked.connect( + lambda _it: self.on_app_profile_load() + ) + self.lst_app_profiles.setFixedHeight(140) + profiles_layout.addWidget(self.lst_app_profiles) + + self.lbl_app_profiles = QLabel("Saved profiles: —") + self.lbl_app_profiles.setStyleSheet("color: gray;") + profiles_layout.addWidget(self.lbl_app_profiles) + + tab_apps_layout.addWidget(profiles_group) + run_group = QGroupBox("Run app (systemd unit) + apply mark") run_layout = QVBoxLayout(run_group) @@ -467,6 +544,7 @@ RU: Применяет policy-rules и проверяет health. При оши root.addLayout(row_bottom) QtCore.QTimer.singleShot(0, self.refresh_state) + QtCore.QTimer.singleShot(0, self.refresh_app_profiles) QtCore.QTimer.singleShot(0, self.refresh_appmarks_counts) QtCore.QTimer.singleShot(0, self.refresh_running_scopes) @@ -871,6 +949,164 @@ RU: Применяет policy-rules и проверяет health. При оши pass self._emit_log(line) + def _infer_app_key_from_cmdline(self, cmdline: str) -> str: + cmd = (cmdline or "").strip() + if not cmd: + return "" + try: + args = shlex.split(cmd) + if args: + return str(args[0] or "").strip() + except Exception: + pass + # Fallback: first token + return (cmd.split() or [""])[0].strip() + + def _selected_app_profile(self): + it = self.lst_app_profiles.currentItem() + if not it: + return None + return it.data(QtCore.Qt.UserRole) + + def refresh_app_profiles(self, quiet: bool = False) -> None: + def work() -> None: + profs = list(self.ctrl.traffic_app_profiles_list() or []) + self.lst_app_profiles.clear() + + for p in profs: + # p is a UI-friendly dataclass from ApiClient. + name = (getattr(p, "name", "") or "").strip() + pid = (getattr(p, "id", "") or "").strip() + target = (getattr(p, "target", "") or "").strip().lower() + app_key = (getattr(p, "app_key", "") or "").strip() + cmd = (getattr(p, "command", "") or "").strip() + ttl_sec = int(getattr(p, "ttl_sec", 0) or 0) + + label = name or pid or "(unnamed)" + if target in ("vpn", "direct"): + label += f" [{target}]" + it = QListWidgetItem(label) + it.setToolTip( + ( + f"id: {pid}\n" + f"app_key: {app_key}\n" + f"target: {target}\n" + f"ttl: {ttl_sec}s\n\n" + f"{cmd}" + ).strip() + ) + it.setData(QtCore.Qt.UserRole, p) + self.lst_app_profiles.addItem(it) + + self.lbl_app_profiles.setText(f"Saved profiles: {len(profs)}") + + if quiet: + try: + work() + except Exception as e: + self.lbl_app_profiles.setText(f"Saved profiles: error: {e}") + return + + self._safe(work, title="Refresh profiles error") + + def on_app_profile_save(self) -> None: + def work() -> None: + cmdline = (self.ed_app_cmd.text() or "").strip() + if not cmdline: + QMessageBox.warning(self, "Missing command", "Please enter a command first.") + return + + target = "vpn" if self.rad_app_vpn.isChecked() else "direct" + ttl_sec = int(self.spn_app_ttl.value()) * 3600 + name = (self.ed_app_profile_name.text() or "").strip() + app_key = self._infer_app_key_from_cmdline(cmdline) + + res = self.ctrl.traffic_app_profile_upsert( + name=name, + app_key=app_key, + command=cmdline, + target=target, + ttl_sec=ttl_sec, + ) + if not res.ok: + self._set_action_status(f"Save profile failed: {res.message}", ok=False) + raise RuntimeError(res.message or "save failed") + + prof = getattr(res, "profile", None) + pid = (getattr(prof, "id", "") or "").strip() if prof is not None else "" + self._set_action_status(f"Profile saved: {pid or '(ok)'}", ok=True) + self._append_app_log(f"[profile] saved: id={pid or '-'} target={target} app={app_key or '-'}") + self.refresh_app_profiles(quiet=True) + + # Best-effort select newly saved profile. + if pid: + for i in range(self.lst_app_profiles.count()): + it = self.lst_app_profiles.item(i) + if not it: + continue + p = it.data(QtCore.Qt.UserRole) + if (getattr(p, "id", "") or "").strip() == pid: + self.lst_app_profiles.setCurrentRow(i) + break + + self._safe(work, title="Save profile error") + + def on_app_profile_load(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_sec = int(getattr(prof, "ttl_sec", 0) or 0) + name = (getattr(prof, "name", "") or "").strip() + + if cmd: + self.ed_app_cmd.setText(cmd) + if target == "direct": + self.rad_app_direct.setChecked(True) + else: + self.rad_app_vpn.setChecked(True) + + if ttl_sec > 0: + # UI uses hours; round up. + hours = max(1, (ttl_sec + 3599) // 3600) + self.spn_app_ttl.setValue(int(hours)) + + self.ed_app_profile_name.setText(name) + self._set_action_status("Profile loaded into form", ok=True) + + self._safe(work, title="Load profile error") + + def on_app_profile_delete(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: + if QMessageBox.question( + self, + "Delete profile", + f"Delete saved profile?\n\nid={pid}", + ) != QMessageBox.StandardButton.Yes: + return + + res = self.ctrl.traffic_app_profile_delete(pid) + if not res.ok: + self._set_action_status(f"Delete profile failed: {res.message}", ok=False) + raise RuntimeError(res.message or "delete failed") + + 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) + + self._safe(work, title="Delete profile error") + def refresh_appmarks_counts(self) -> None: try: st = self.ctrl.traffic_appmarks_status()