ui: app picker + last scope stop/unmark for runtime app routing
This commit is contained in:
@@ -1,11 +1,15 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import configparser
|
||||||
import os
|
import os
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
import shlex
|
import shlex
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
from typing import Callable
|
from dataclasses import dataclass
|
||||||
|
from typing import Callable, Optional
|
||||||
|
|
||||||
from PySide6 import QtCore, QtGui
|
from PySide6 import QtCore, QtGui
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
@@ -33,6 +37,19 @@ from PySide6.QtWidgets import (
|
|||||||
from dashboard_controller import DashboardController
|
from dashboard_controller import DashboardController
|
||||||
|
|
||||||
|
|
||||||
|
_DESKTOP_BOOL_TRUE = {"1", "true", "yes", "y", "on"}
|
||||||
|
_DESKTOP_EXEC_FIELD_RE = re.compile(r"%[A-Za-z]")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DesktopAppEntry:
|
||||||
|
desktop_id: str
|
||||||
|
name: str
|
||||||
|
exec_raw: str
|
||||||
|
path: str
|
||||||
|
source: str # system|flatpak|user
|
||||||
|
|
||||||
|
|
||||||
class TrafficModeDialog(QDialog):
|
class TrafficModeDialog(QDialog):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -52,6 +69,16 @@ class TrafficModeDialog(QDialog):
|
|||||||
|
|
||||||
root = QVBoxLayout(self)
|
root = QVBoxLayout(self)
|
||||||
|
|
||||||
|
# EN: Persist small UI state across dialog sessions.
|
||||||
|
# RU: Сохраняем небольшой UI state между открытиями окна.
|
||||||
|
self._settings = QtCore.QSettings("AdGuardVPN", "SelectiveVPNDashboardQt")
|
||||||
|
self._last_app_unit: str = str(self._settings.value("traffic_app_last_unit", "") or "")
|
||||||
|
self._last_app_target: str = str(self._settings.value("traffic_app_last_target", "") or "")
|
||||||
|
try:
|
||||||
|
self._last_app_cgroup_id: int = int(self._settings.value("traffic_app_last_cgroup_id", 0) or 0)
|
||||||
|
except Exception:
|
||||||
|
self._last_app_cgroup_id = 0
|
||||||
|
|
||||||
hint_group = QGroupBox("Mode behavior")
|
hint_group = QGroupBox("Mode behavior")
|
||||||
hint_layout = QVBoxLayout(hint_group)
|
hint_layout = QVBoxLayout(hint_group)
|
||||||
hint_layout.addWidget(QLabel("Selective: only marked traffic goes via VPN."))
|
hint_layout.addWidget(QLabel("Selective: only marked traffic goes via VPN."))
|
||||||
@@ -190,6 +217,13 @@ RU: Восстанавливает маршруты/nft из последнег
|
|||||||
"RU: Команда запуска. Запускается от текущего пользователя в systemd --user scope."
|
"RU: Команда запуска. Запускается от текущего пользователя в systemd --user scope."
|
||||||
)
|
)
|
||||||
row_cmd.addWidget(self.ed_app_cmd, stretch=1)
|
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) и заполнить команду."
|
||||||
|
)
|
||||||
|
self.btn_app_pick.clicked.connect(self.on_app_pick)
|
||||||
|
row_cmd.addWidget(self.btn_app_pick)
|
||||||
run_layout.addLayout(row_cmd)
|
run_layout.addLayout(row_cmd)
|
||||||
|
|
||||||
row_target = QHBoxLayout()
|
row_target = QHBoxLayout()
|
||||||
@@ -233,20 +267,42 @@ RU: Восстанавливает маршруты/nft из последнег
|
|||||||
self.btn_app_refresh = QPushButton("Refresh counts")
|
self.btn_app_refresh = QPushButton("Refresh counts")
|
||||||
self.btn_app_refresh.clicked.connect(self.refresh_appmarks_counts)
|
self.btn_app_refresh.clicked.connect(self.refresh_appmarks_counts)
|
||||||
row_btn.addWidget(self.btn_app_refresh)
|
row_btn.addWidget(self.btn_app_refresh)
|
||||||
|
row_btn.addStretch(1)
|
||||||
|
run_layout.addLayout(row_btn)
|
||||||
|
|
||||||
|
row_btn2 = QHBoxLayout()
|
||||||
self.btn_app_clear_vpn = QPushButton("Clear VPN marks")
|
self.btn_app_clear_vpn = QPushButton("Clear VPN marks")
|
||||||
self.btn_app_clear_vpn.clicked.connect(lambda: self.on_appmarks_clear("vpn"))
|
self.btn_app_clear_vpn.clicked.connect(lambda: self.on_appmarks_clear("vpn"))
|
||||||
row_btn.addWidget(self.btn_app_clear_vpn)
|
row_btn2.addWidget(self.btn_app_clear_vpn)
|
||||||
self.btn_app_clear_direct = QPushButton("Clear Direct marks")
|
self.btn_app_clear_direct = QPushButton("Clear Direct marks")
|
||||||
self.btn_app_clear_direct.clicked.connect(
|
self.btn_app_clear_direct.clicked.connect(
|
||||||
lambda: self.on_appmarks_clear("direct")
|
lambda: self.on_appmarks_clear("direct")
|
||||||
)
|
)
|
||||||
row_btn.addWidget(self.btn_app_clear_direct)
|
row_btn2.addWidget(self.btn_app_clear_direct)
|
||||||
row_btn.addStretch(1)
|
self.btn_app_stop_last = QPushButton("Stop last scope")
|
||||||
run_layout.addLayout(row_btn)
|
self.btn_app_stop_last.setToolTip(
|
||||||
|
"EN: Stops the last launched systemd --user scope.\n"
|
||||||
|
"RU: Останавливает последний запущенный systemd --user scope."
|
||||||
|
)
|
||||||
|
self.btn_app_stop_last.clicked.connect(self.on_app_stop_last_scope)
|
||||||
|
row_btn2.addWidget(self.btn_app_stop_last)
|
||||||
|
self.btn_app_unmark_last = QPushButton("Unmark last")
|
||||||
|
self.btn_app_unmark_last.setToolTip(
|
||||||
|
"EN: Removes routing mark for the last launched scope (by cgroup id).\n"
|
||||||
|
"RU: Удаляет метку маршрутизации для последнего scope (по cgroup id)."
|
||||||
|
)
|
||||||
|
self.btn_app_unmark_last.clicked.connect(self.on_app_unmark_last)
|
||||||
|
row_btn2.addWidget(self.btn_app_unmark_last)
|
||||||
|
row_btn2.addStretch(1)
|
||||||
|
run_layout.addLayout(row_btn2)
|
||||||
|
|
||||||
self.lbl_app_counts = QLabel("Marks: —")
|
self.lbl_app_counts = QLabel("Marks: —")
|
||||||
self.lbl_app_counts.setStyleSheet("color: gray;")
|
self.lbl_app_counts.setStyleSheet("color: gray;")
|
||||||
run_layout.addWidget(self.lbl_app_counts)
|
run_layout.addWidget(self.lbl_app_counts)
|
||||||
|
self.lbl_app_last = QLabel("Last scope: —")
|
||||||
|
self.lbl_app_last.setStyleSheet("color: gray;")
|
||||||
|
run_layout.addWidget(self.lbl_app_last)
|
||||||
|
self._refresh_last_scope_ui()
|
||||||
|
|
||||||
tab_apps_layout.addWidget(run_group)
|
tab_apps_layout.addWidget(run_group)
|
||||||
|
|
||||||
@@ -749,6 +805,53 @@ RU: Применяет policy-rules и проверяет health. При оши
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.lbl_app_counts.setText(f"Marks: error: {e}")
|
self.lbl_app_counts.setText(f"Marks: error: {e}")
|
||||||
|
|
||||||
|
def _refresh_last_scope_ui(self) -> None:
|
||||||
|
unit = (self._last_app_unit or "").strip()
|
||||||
|
target = (self._last_app_target or "").strip().lower()
|
||||||
|
cg_id = int(self._last_app_cgroup_id or 0)
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
if unit:
|
||||||
|
parts.append(f"unit={unit}")
|
||||||
|
if target in ("vpn", "direct"):
|
||||||
|
parts.append(f"target={target}")
|
||||||
|
if cg_id > 0:
|
||||||
|
parts.append(f"cgroup_id={cg_id}")
|
||||||
|
|
||||||
|
if parts:
|
||||||
|
self.lbl_app_last.setText("Last scope: " + " | ".join(parts))
|
||||||
|
else:
|
||||||
|
self.lbl_app_last.setText("Last scope: —")
|
||||||
|
|
||||||
|
self.btn_app_stop_last.setEnabled(bool(unit))
|
||||||
|
self.btn_app_unmark_last.setEnabled(bool(unit) and target in ("vpn", "direct") and cg_id > 0)
|
||||||
|
|
||||||
|
def _set_last_scope(self, *, unit: str = "", target: str = "", cgroup_id: int = 0) -> None:
|
||||||
|
self._last_app_unit = str(unit or "").strip()
|
||||||
|
self._last_app_target = str(target or "").strip().lower()
|
||||||
|
try:
|
||||||
|
self._last_app_cgroup_id = int(cgroup_id or 0)
|
||||||
|
except Exception:
|
||||||
|
self._last_app_cgroup_id = 0
|
||||||
|
|
||||||
|
self._settings.setValue("traffic_app_last_unit", self._last_app_unit)
|
||||||
|
self._settings.setValue("traffic_app_last_target", self._last_app_target)
|
||||||
|
self._settings.setValue("traffic_app_last_cgroup_id", int(self._last_app_cgroup_id or 0))
|
||||||
|
self._refresh_last_scope_ui()
|
||||||
|
|
||||||
|
def on_app_pick(self) -> None:
|
||||||
|
def work() -> None:
|
||||||
|
dlg = AppPickerDialog(parent=self)
|
||||||
|
if dlg.exec() != QDialog.Accepted:
|
||||||
|
return
|
||||||
|
cmd = (dlg.selected_command() or "").strip()
|
||||||
|
if not cmd:
|
||||||
|
return
|
||||||
|
self.ed_app_cmd.setText(cmd)
|
||||||
|
self._append_app_log(f"[picker] command filled: {cmd}")
|
||||||
|
|
||||||
|
self._safe(work, title="App picker error")
|
||||||
|
|
||||||
def _run_systemd_scope(self, cmdline: str, *, unit: str) -> tuple[str, str]:
|
def _run_systemd_scope(self, cmdline: str, *, unit: str) -> tuple[str, str]:
|
||||||
args = shlex.split(cmdline or "")
|
args = shlex.split(cmdline or "")
|
||||||
if not args:
|
if not args:
|
||||||
@@ -801,6 +904,7 @@ RU: Применяет policy-rules и проверяет health. При оши
|
|||||||
if out:
|
if out:
|
||||||
self._append_app_log(f"[app] systemd-run:\n{out}")
|
self._append_app_log(f"[app] systemd-run:\n{out}")
|
||||||
self._append_app_log(f"[app] ControlGroup: {cg}")
|
self._append_app_log(f"[app] ControlGroup: {cg}")
|
||||||
|
self._set_last_scope(unit=unit, target=target, cgroup_id=0)
|
||||||
|
|
||||||
res = self.ctrl.traffic_appmarks_apply(
|
res = self.ctrl.traffic_appmarks_apply(
|
||||||
op="add",
|
op="add",
|
||||||
@@ -816,6 +920,7 @@ RU: Применяет policy-rules и проверяет health. При оши
|
|||||||
f"App mark added: target={target} cgroup_id={res.cgroup_id}",
|
f"App mark added: target={target} cgroup_id={res.cgroup_id}",
|
||||||
ok=True,
|
ok=True,
|
||||||
)
|
)
|
||||||
|
self._set_last_scope(unit=unit, target=target, cgroup_id=int(res.cgroup_id or 0))
|
||||||
else:
|
else:
|
||||||
self._append_app_log(f"[appmarks] ERROR: {res.message}")
|
self._append_app_log(f"[appmarks] ERROR: {res.message}")
|
||||||
self._set_action_status(
|
self._set_action_status(
|
||||||
@@ -832,6 +937,85 @@ RU: Применяет policy-rules и проверяет health. При оши
|
|||||||
|
|
||||||
self._safe(work, title="Run app error")
|
self._safe(work, title="Run app error")
|
||||||
|
|
||||||
|
def on_app_stop_last_scope(self) -> None:
|
||||||
|
unit = (self._last_app_unit or "").strip()
|
||||||
|
if not unit:
|
||||||
|
return
|
||||||
|
|
||||||
|
def work() -> None:
|
||||||
|
self._append_app_log(f"[app] stop last scope: unit={unit}")
|
||||||
|
p = subprocess.run(
|
||||||
|
["systemctl", "--user", "stop", unit],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
out = ((p.stdout or "") + (p.stderr or "")).strip()
|
||||||
|
if p.returncode == 0:
|
||||||
|
self._append_app_log("[app] stopped OK")
|
||||||
|
if out:
|
||||||
|
self._append_app_log(out)
|
||||||
|
self._set_action_status(f"Stopped scope: {unit}", ok=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
self._append_app_log(f"[app] stop failed: rc={p.returncode}")
|
||||||
|
if out:
|
||||||
|
self._append_app_log(out)
|
||||||
|
# fallback: kill
|
||||||
|
p2 = subprocess.run(
|
||||||
|
["systemctl", "--user", "kill", unit],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
out2 = ((p2.stdout or "") + (p2.stderr or "")).strip()
|
||||||
|
if p2.returncode == 0:
|
||||||
|
self._append_app_log("[app] kill OK (fallback)")
|
||||||
|
if out2:
|
||||||
|
self._append_app_log(out2)
|
||||||
|
self._set_action_status(f"Killed scope: {unit}", ok=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
self._append_app_log(f"[app] kill failed: rc={p2.returncode}")
|
||||||
|
if out2:
|
||||||
|
self._append_app_log(out2)
|
||||||
|
self._set_action_status(f"Stop scope failed: {unit}", ok=False)
|
||||||
|
QMessageBox.critical(self, "Stop scope error", out2 or out or "stop failed")
|
||||||
|
|
||||||
|
self._safe(work, title="Stop scope error")
|
||||||
|
|
||||||
|
def on_app_unmark_last(self) -> None:
|
||||||
|
unit = (self._last_app_unit or "").strip()
|
||||||
|
target = (self._last_app_target or "").strip().lower()
|
||||||
|
cg_id = int(self._last_app_cgroup_id or 0)
|
||||||
|
if not unit or target not in ("vpn", "direct") or cg_id <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
def work() -> None:
|
||||||
|
if QMessageBox.question(
|
||||||
|
self,
|
||||||
|
"Unmark last",
|
||||||
|
f"Remove routing mark for last scope?\n\nunit={unit}\ntarget={target}\ncgroup_id={cg_id}",
|
||||||
|
) != QMessageBox.StandardButton.Yes:
|
||||||
|
return
|
||||||
|
|
||||||
|
res = self.ctrl.traffic_appmarks_apply(
|
||||||
|
op="del",
|
||||||
|
target=target,
|
||||||
|
cgroup=str(cg_id),
|
||||||
|
)
|
||||||
|
if res.ok:
|
||||||
|
self._append_app_log(f"[appmarks] unmarked: target={target} cgroup_id={cg_id}")
|
||||||
|
self._set_action_status(f"Unmarked last: target={target} cgroup_id={cg_id}", ok=True)
|
||||||
|
else:
|
||||||
|
self._append_app_log(f"[appmarks] unmark error: {res.message}")
|
||||||
|
self._set_action_status(f"Unmark last failed: {res.message}", ok=False)
|
||||||
|
QMessageBox.critical(self, "Unmark error", res.message or "unmark failed")
|
||||||
|
|
||||||
|
self.refresh_appmarks_counts()
|
||||||
|
|
||||||
|
self._safe(work, title="Unmark error")
|
||||||
|
|
||||||
def on_appmarks_clear(self, target: str) -> None:
|
def on_appmarks_clear(self, target: str) -> None:
|
||||||
tgt = (target or "").strip().lower()
|
tgt = (target or "").strip().lower()
|
||||||
if tgt not in ("vpn", "direct"):
|
if tgt not in ("vpn", "direct"):
|
||||||
@@ -1450,3 +1634,285 @@ RU: Скрывает элементы, которые уже есть в Force V
|
|||||||
layout.addLayout(row)
|
layout.addLayout(row)
|
||||||
|
|
||||||
self._add_tab("UIDs", "uid", items, extra=extra)
|
self._add_tab("UIDs", "uid", items, extra=extra)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
# App picker (.desktop entries)
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _desktop_bool(v: str) -> bool:
|
||||||
|
return str(v or "").strip().lower() in _DESKTOP_BOOL_TRUE
|
||||||
|
|
||||||
|
|
||||||
|
def _desktop_name_from_section(sec: configparser.SectionProxy) -> str:
|
||||||
|
name = str(sec.get("Name", "") or "").strip()
|
||||||
|
if name:
|
||||||
|
return name
|
||||||
|
|
||||||
|
# Prefer Russian if present, then English, then any localized variant.
|
||||||
|
for key in ("Name[ru]", "Name[en_US]", "Name[en]"):
|
||||||
|
v = str(sec.get(key, "") or "").strip()
|
||||||
|
if v:
|
||||||
|
return v
|
||||||
|
|
||||||
|
for k, v in sec.items():
|
||||||
|
kk = str(k or "")
|
||||||
|
if kk.startswith("Name[") and str(v or "").strip():
|
||||||
|
return str(v).strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_desktop_exec(exec_raw: str, *, name: str = "", desktop_path: str = "") -> str:
|
||||||
|
raw = str(exec_raw or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Desktop spec: "%%" -> literal "%".
|
||||||
|
raw = raw.replace("%%", "%")
|
||||||
|
|
||||||
|
# Best-effort expansion for a couple of common fields.
|
||||||
|
if name:
|
||||||
|
raw = raw.replace("%c", name)
|
||||||
|
if desktop_path:
|
||||||
|
raw = raw.replace("%k", desktop_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
tokens = shlex.split(raw)
|
||||||
|
except Exception:
|
||||||
|
tokens = raw.split()
|
||||||
|
|
||||||
|
out: list[str] = []
|
||||||
|
for t in tokens:
|
||||||
|
tok = str(t or "").strip()
|
||||||
|
if not tok:
|
||||||
|
continue
|
||||||
|
# Drop standalone field codes (%u, %U, %f, ...).
|
||||||
|
if len(tok) == 2 and tok.startswith("%"):
|
||||||
|
continue
|
||||||
|
# Remove field codes inside tokens (e.g. --name=%c).
|
||||||
|
tok = _DESKTOP_EXEC_FIELD_RE.sub("", tok).strip()
|
||||||
|
if tok:
|
||||||
|
out.append(tok)
|
||||||
|
|
||||||
|
if not out:
|
||||||
|
return ""
|
||||||
|
return " ".join(shlex.quote(x) for x in out)
|
||||||
|
|
||||||
|
|
||||||
|
def _desktop_entry_from_file(path: str, *, source: str) -> Optional[DesktopAppEntry]:
|
||||||
|
p = str(path or "").strip()
|
||||||
|
if not p or not p.endswith(".desktop"):
|
||||||
|
return None
|
||||||
|
|
||||||
|
cp = configparser.ConfigParser(interpolation=None, strict=False)
|
||||||
|
cp.optionxform = str # keep case
|
||||||
|
try:
|
||||||
|
cp.read(p, encoding="utf-8")
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
data = pathlib.Path(p).read_bytes()
|
||||||
|
cp.read_string(data.decode("utf-8", errors="replace"))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if "Desktop Entry" not in cp:
|
||||||
|
return None
|
||||||
|
|
||||||
|
sec = cp["Desktop Entry"]
|
||||||
|
if _desktop_bool(sec.get("Hidden", "")) or _desktop_bool(sec.get("NoDisplay", "")):
|
||||||
|
return None
|
||||||
|
|
||||||
|
typ = str(sec.get("Type", "") or "").strip().lower()
|
||||||
|
if typ and typ != "application":
|
||||||
|
return None
|
||||||
|
|
||||||
|
exec_raw = str(sec.get("Exec", "") or "").strip()
|
||||||
|
if not exec_raw:
|
||||||
|
return None
|
||||||
|
|
||||||
|
name = _desktop_name_from_section(sec)
|
||||||
|
desktop_id = pathlib.Path(p).name
|
||||||
|
if not name:
|
||||||
|
name = desktop_id.replace(".desktop", "")
|
||||||
|
|
||||||
|
src = str(source or "").strip().lower() or "system"
|
||||||
|
return DesktopAppEntry(
|
||||||
|
desktop_id=desktop_id,
|
||||||
|
name=name,
|
||||||
|
exec_raw=exec_raw,
|
||||||
|
path=p,
|
||||||
|
source=src,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_desktop_entries() -> list[DesktopAppEntry]:
|
||||||
|
home = os.path.expanduser("~")
|
||||||
|
dirs: list[tuple[str, str]] = [
|
||||||
|
(os.path.join(home, ".local/share/applications"), "user"),
|
||||||
|
("/usr/local/share/applications", "system"),
|
||||||
|
("/usr/share/applications", "system"),
|
||||||
|
(os.path.join(home, ".local/share/flatpak/exports/share/applications"), "flatpak"),
|
||||||
|
("/var/lib/flatpak/exports/share/applications", "flatpak"),
|
||||||
|
]
|
||||||
|
|
||||||
|
seen: set[tuple[str, str]] = set()
|
||||||
|
out: list[DesktopAppEntry] = []
|
||||||
|
for d, src in dirs:
|
||||||
|
if not d or not os.path.isdir(d):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
paths = sorted(pathlib.Path(d).glob("*.desktop"))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
for fp in paths:
|
||||||
|
ent = _desktop_entry_from_file(str(fp), source=src)
|
||||||
|
if ent is None:
|
||||||
|
continue
|
||||||
|
key = (ent.desktop_id, ent.source)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
out.append(ent)
|
||||||
|
|
||||||
|
out.sort(key=lambda e: (e.name.lower(), e.source, e.desktop_id.lower()))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
class AppPickerDialog(QDialog):
|
||||||
|
def __init__(self, *, parent=None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle("Pick app (.desktop)")
|
||||||
|
self.resize(860, 640)
|
||||||
|
|
||||||
|
self._selected_cmd: str = ""
|
||||||
|
self._entries: list[DesktopAppEntry] = _scan_desktop_entries()
|
||||||
|
|
||||||
|
root = QVBoxLayout(self)
|
||||||
|
|
||||||
|
note = QLabel(
|
||||||
|
"EN: Pick an installed GUI app from .desktop entries (system + flatpak). "
|
||||||
|
"The command will be filled without %u/%U/%f placeholders.\n"
|
||||||
|
"RU: Выбери приложение из .desktop (system + flatpak). "
|
||||||
|
"Команда будет заполнена без плейсхолдеров %u/%U/%f."
|
||||||
|
)
|
||||||
|
note.setWordWrap(True)
|
||||||
|
note.setStyleSheet("color: gray;")
|
||||||
|
root.addWidget(note)
|
||||||
|
|
||||||
|
row = QHBoxLayout()
|
||||||
|
row.addWidget(QLabel("Search"))
|
||||||
|
self.ed_search = QLineEdit()
|
||||||
|
self.ed_search.setPlaceholderText("Type to filter (name / id / exec)...")
|
||||||
|
row.addWidget(self.ed_search, stretch=1)
|
||||||
|
self.lbl_count = QLabel("—")
|
||||||
|
self.lbl_count.setStyleSheet("color: gray;")
|
||||||
|
row.addWidget(self.lbl_count)
|
||||||
|
root.addLayout(row)
|
||||||
|
|
||||||
|
self.lst = QListWidget()
|
||||||
|
self.lst.setSelectionMode(QAbstractItemView.SingleSelection)
|
||||||
|
root.addWidget(self.lst, stretch=1)
|
||||||
|
|
||||||
|
self.preview = QPlainTextEdit()
|
||||||
|
self.preview.setReadOnly(True)
|
||||||
|
self.preview.setFixedHeight(180)
|
||||||
|
root.addWidget(self.preview)
|
||||||
|
|
||||||
|
row_btn = QHBoxLayout()
|
||||||
|
self.btn_use = QPushButton("Use selected")
|
||||||
|
self.btn_use.clicked.connect(self.on_use_selected)
|
||||||
|
row_btn.addWidget(self.btn_use)
|
||||||
|
row_btn.addStretch(1)
|
||||||
|
btn_close = QPushButton("Close")
|
||||||
|
btn_close.clicked.connect(self.reject)
|
||||||
|
row_btn.addWidget(btn_close)
|
||||||
|
root.addLayout(row_btn)
|
||||||
|
|
||||||
|
self._populate()
|
||||||
|
self.ed_search.textChanged.connect(lambda _t: self._apply_filter())
|
||||||
|
self.lst.currentItemChanged.connect(lambda _a, _b: self._update_preview())
|
||||||
|
self.lst.itemDoubleClicked.connect(lambda _it: self.on_use_selected())
|
||||||
|
|
||||||
|
QtCore.QTimer.singleShot(0, self._apply_filter)
|
||||||
|
|
||||||
|
def selected_command(self) -> str:
|
||||||
|
return str(self._selected_cmd or "")
|
||||||
|
|
||||||
|
def _populate(self) -> None:
|
||||||
|
self.lst.clear()
|
||||||
|
for ent in self._entries:
|
||||||
|
src = ent.source
|
||||||
|
label = f"{ent.name} [{src}] ({ent.desktop_id})"
|
||||||
|
tip = (
|
||||||
|
f"Name: {ent.name}\n"
|
||||||
|
f"ID: {ent.desktop_id}\n"
|
||||||
|
f"Source: {src}\n"
|
||||||
|
f"Path: {ent.path}\n\n"
|
||||||
|
f"Exec: {ent.exec_raw}"
|
||||||
|
)
|
||||||
|
it = QListWidgetItem(label)
|
||||||
|
it.setToolTip(tip)
|
||||||
|
it.setData(QtCore.Qt.UserRole, ent)
|
||||||
|
# precomputed search string
|
||||||
|
it.setData(
|
||||||
|
QtCore.Qt.UserRole + 1,
|
||||||
|
(label + "\n" + ent.exec_raw + "\n" + ent.path).lower(),
|
||||||
|
)
|
||||||
|
self.lst.addItem(it)
|
||||||
|
|
||||||
|
if self.lst.count() > 0:
|
||||||
|
self.lst.setCurrentRow(0)
|
||||||
|
self._update_preview()
|
||||||
|
|
||||||
|
def _apply_filter(self) -> None:
|
||||||
|
q = (self.ed_search.text() or "").strip().lower()
|
||||||
|
shown = 0
|
||||||
|
for i in range(self.lst.count()):
|
||||||
|
it = self.lst.item(i)
|
||||||
|
if not it:
|
||||||
|
continue
|
||||||
|
hay = str(it.data(QtCore.Qt.UserRole + 1) or "")
|
||||||
|
hide = bool(q) and q not in hay
|
||||||
|
it.setHidden(hide)
|
||||||
|
if not hide:
|
||||||
|
shown += 1
|
||||||
|
self.lbl_count.setText(f"Apps: {shown}/{self.lst.count()}")
|
||||||
|
|
||||||
|
def _current_entry(self) -> Optional[DesktopAppEntry]:
|
||||||
|
it = self.lst.currentItem()
|
||||||
|
if not it:
|
||||||
|
return None
|
||||||
|
ent = it.data(QtCore.Qt.UserRole)
|
||||||
|
if isinstance(ent, DesktopAppEntry):
|
||||||
|
return ent
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _update_preview(self) -> None:
|
||||||
|
ent = self._current_entry()
|
||||||
|
if ent is None:
|
||||||
|
self.preview.setPlainText("—")
|
||||||
|
self.btn_use.setEnabled(False)
|
||||||
|
return
|
||||||
|
cmd = _sanitize_desktop_exec(ent.exec_raw, name=ent.name, desktop_path=ent.path)
|
||||||
|
text = (
|
||||||
|
f"Name: {ent.name}\n"
|
||||||
|
f"ID: {ent.desktop_id}\n"
|
||||||
|
f"Source: {ent.source}\n"
|
||||||
|
f"Path: {ent.path}\n\n"
|
||||||
|
f"Exec (raw):\n{ent.exec_raw}\n\n"
|
||||||
|
f"Command (sanitized):\n{cmd}"
|
||||||
|
)
|
||||||
|
self.preview.setPlainText(text)
|
||||||
|
self.btn_use.setEnabled(bool(cmd.strip()))
|
||||||
|
|
||||||
|
def on_use_selected(self) -> None:
|
||||||
|
ent = self._current_entry()
|
||||||
|
if ent is None:
|
||||||
|
return
|
||||||
|
cmd = _sanitize_desktop_exec(ent.exec_raw, name=ent.name, desktop_path=ent.path).strip()
|
||||||
|
if not cmd:
|
||||||
|
QMessageBox.warning(self, "No command", "Selected app has no usable Exec command.")
|
||||||
|
return
|
||||||
|
self._selected_cmd = cmd
|
||||||
|
self.accept()
|
||||||
|
|||||||
Reference in New Issue
Block a user