feat: add ProcessDock process and port manager
This commit is contained in:
3
processdock/__init__.py
Normal file
3
processdock/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""ProcessDock - a local process and port manager."""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
187
processdock/models.py
Normal file
187
processdock/models.py
Normal file
@@ -0,0 +1,187 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import QAbstractTableModel, QModelIndex, QRect, QSortFilterProxyModel, Qt, Signal
|
||||
from PySide6.QtGui import QColor, QFont, QMouseEvent, QPainter
|
||||
from PySide6.QtWidgets import QStyledItemDelegate, QStyleOptionViewItem
|
||||
|
||||
from processdock.services import matches_process
|
||||
|
||||
|
||||
PROCESS_ROLE = Qt.ItemDataRole.UserRole + 1
|
||||
SORT_ROLE = Qt.ItemDataRole.UserRole + 2
|
||||
|
||||
|
||||
class ProcessTableModel(QAbstractTableModel):
|
||||
columns = ("PID", "进程名称", "监听端口", "CPU", "内存", "用户", "状态", "操作")
|
||||
|
||||
def __init__(self, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self._processes: list[dict[str, Any]] = []
|
||||
|
||||
def set_processes(self, processes: list[dict[str, Any]]) -> None:
|
||||
self.beginResetModel()
|
||||
self._processes = list(processes)
|
||||
self.endResetModel()
|
||||
|
||||
@property
|
||||
def processes(self) -> list[dict[str, Any]]:
|
||||
return self._processes
|
||||
|
||||
def rowCount(self, parent=QModelIndex()) -> int: # noqa: N802
|
||||
return 0 if parent.isValid() else len(self._processes)
|
||||
|
||||
def columnCount(self, parent=QModelIndex()) -> int: # noqa: N802
|
||||
return 0 if parent.isValid() else len(self.columns)
|
||||
|
||||
def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole): # noqa: N802
|
||||
if role == Qt.ItemDataRole.DisplayRole and orientation == Qt.Orientation.Horizontal:
|
||||
return self.columns[section]
|
||||
return None
|
||||
|
||||
def data(self, index: QModelIndex, role=Qt.ItemDataRole.DisplayRole):
|
||||
if not index.isValid() or not 0 <= index.row() < len(self._processes):
|
||||
return None
|
||||
process = self._processes[index.row()]
|
||||
column = index.column()
|
||||
|
||||
if role == PROCESS_ROLE:
|
||||
return process
|
||||
if role == SORT_ROLE:
|
||||
values = (
|
||||
process["pid"],
|
||||
process["name"].casefold(),
|
||||
process["ports"][0] if process["ports"] else -1,
|
||||
process["cpu_percent"],
|
||||
process["memory_bytes"],
|
||||
process["username"].casefold(),
|
||||
process["status_label"],
|
||||
process["pid"],
|
||||
)
|
||||
return values[column]
|
||||
if role == Qt.ItemDataRole.DisplayRole:
|
||||
ports = ", ".join(str(port) for port in process["ports"]) or "—"
|
||||
values = (
|
||||
str(process["pid"]),
|
||||
process["name"],
|
||||
ports,
|
||||
f'{process["cpu_percent"]:.1f}%',
|
||||
process["memory_text"],
|
||||
process["username"],
|
||||
process["status_label"],
|
||||
"当前应用" if process["is_current"] else "结束",
|
||||
)
|
||||
return values[column]
|
||||
if role == Qt.ItemDataRole.TextAlignmentRole:
|
||||
if column in (0, 2, 3, 4, 6, 7):
|
||||
return Qt.AlignmentFlag.AlignCenter
|
||||
return Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft
|
||||
if role == Qt.ItemDataRole.ForegroundRole:
|
||||
if column == 6:
|
||||
if process["status"] == "running":
|
||||
return QColor("#129B72")
|
||||
if process["status"] in {"stopped", "suspended"}:
|
||||
return QColor("#D88918")
|
||||
if column == 2 and process["ports"]:
|
||||
return QColor("#5B5BD6")
|
||||
if column == 7:
|
||||
return QColor("#A73A48" if process["can_terminate"] else "#9AA3B2")
|
||||
if role == Qt.ItemDataRole.FontRole:
|
||||
font = QFont()
|
||||
if column in (0, 2, 3, 4):
|
||||
font.setFamily("Cascadia Mono")
|
||||
font.setPointSize(9)
|
||||
if column == 1:
|
||||
font.setWeight(QFont.Weight.DemiBold)
|
||||
return font
|
||||
if role == Qt.ItemDataRole.ToolTipRole:
|
||||
if column == 2 and process["port_details"]:
|
||||
return "\n".join(
|
||||
f'{item["protocol"]} {item["address"]}:{item["port"]}'
|
||||
for item in process["port_details"]
|
||||
)
|
||||
if column == 1:
|
||||
return process["exe"] or process["name"]
|
||||
if column == 7 and process["is_current"]:
|
||||
return "ProcessDock 不允许结束自身"
|
||||
return None
|
||||
|
||||
|
||||
class ProcessFilterProxyModel(QSortFilterProxyModel):
|
||||
def __init__(self, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self._query = ""
|
||||
self.setDynamicSortFilter(True)
|
||||
self.setSortRole(SORT_ROLE)
|
||||
|
||||
def set_query(self, query: str) -> None:
|
||||
self.beginFilterChange()
|
||||
self._query = query
|
||||
self.endFilterChange(QSortFilterProxyModel.Direction.Rows)
|
||||
|
||||
def filterAcceptsRow(self, source_row: int, source_parent: QModelIndex) -> bool: # noqa: N802
|
||||
model = self.sourceModel()
|
||||
index = model.index(source_row, 0, source_parent)
|
||||
process = model.data(index, PROCESS_ROLE)
|
||||
return bool(process and matches_process(process, self._query))
|
||||
|
||||
def lessThan(self, left: QModelIndex, right: QModelIndex) -> bool: # noqa: N802
|
||||
left_value = self.sourceModel().data(left, SORT_ROLE)
|
||||
right_value = self.sourceModel().data(right, SORT_ROLE)
|
||||
return left_value < right_value
|
||||
|
||||
|
||||
class ActionButtonDelegate(QStyledItemDelegate):
|
||||
terminate_requested = Signal(object)
|
||||
|
||||
def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex) -> None:
|
||||
process = index.data(PROCESS_ROLE)
|
||||
if not process:
|
||||
return super().paint(painter, option, index)
|
||||
|
||||
painter.save()
|
||||
selected = bool(option.state & option.state.State_Selected)
|
||||
if selected:
|
||||
painter.fillRect(option.rect, QColor("#EEF0FF"))
|
||||
|
||||
rect = self._button_rect(option.rect)
|
||||
enabled = bool(process["can_terminate"])
|
||||
fill = QColor("#FFF0F1") if enabled else QColor("#F2F4F7")
|
||||
text_color = QColor("#B53C4A") if enabled else QColor("#9AA3B2")
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.setBrush(fill)
|
||||
painter.drawRoundedRect(rect, 7, 7)
|
||||
painter.setPen(text_color)
|
||||
font = painter.font()
|
||||
font.setPointSize(9)
|
||||
font.setWeight(QFont.Weight.DemiBold)
|
||||
painter.setFont(font)
|
||||
text = "结束" if enabled else "当前应用"
|
||||
painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, text)
|
||||
painter.restore()
|
||||
|
||||
def editorEvent(self, event, model, option, index) -> bool: # noqa: N802
|
||||
process = index.data(PROCESS_ROLE)
|
||||
if (
|
||||
process
|
||||
and process["can_terminate"]
|
||||
and isinstance(event, QMouseEvent)
|
||||
and event.type() == QMouseEvent.Type.MouseButtonRelease
|
||||
and event.button() == Qt.MouseButton.LeftButton
|
||||
and self._button_rect(option.rect).contains(event.position().toPoint())
|
||||
):
|
||||
self.terminate_requested.emit(process)
|
||||
return True
|
||||
return super().editorEvent(event, model, option, index)
|
||||
|
||||
@staticmethod
|
||||
def _button_rect(cell: QRect) -> QRect:
|
||||
width, height = 58, 30
|
||||
return QRect(
|
||||
cell.center().x() - width // 2,
|
||||
cell.center().y() - height // 2,
|
||||
width,
|
||||
height,
|
||||
)
|
||||
499
processdock/services.py
Normal file
499
processdock/services.py
Normal file
@@ -0,0 +1,499 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import json
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import psutil
|
||||
|
||||
|
||||
STATUS_LABELS = {
|
||||
# psutil exposes different status constants on each operating system, while
|
||||
# the returned values are stable strings. Literal keys keep imports portable.
|
||||
"running": "运行中",
|
||||
"sleeping": "休眠",
|
||||
"disk-sleep": "等待磁盘",
|
||||
"stopped": "已停止",
|
||||
"tracing-stop": "调试暂停",
|
||||
"zombie": "僵尸进程",
|
||||
"dead": "已结束",
|
||||
"wake-kill": "唤醒终止",
|
||||
"waking": "唤醒中",
|
||||
"parked": "已驻留",
|
||||
"idle": "空闲",
|
||||
"locked": "已锁定",
|
||||
"waiting": "等待中",
|
||||
"suspended": "已挂起",
|
||||
}
|
||||
|
||||
|
||||
class ProcessDockError(RuntimeError):
|
||||
"""Base error with a user-facing message."""
|
||||
|
||||
|
||||
class ProcessGoneError(ProcessDockError):
|
||||
pass
|
||||
|
||||
|
||||
class ProcessAccessError(ProcessDockError):
|
||||
pass
|
||||
|
||||
|
||||
class ProtectedProcessError(ProcessDockError):
|
||||
pass
|
||||
|
||||
|
||||
class ProcessChangedError(ProcessDockError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ActionResult:
|
||||
success: bool
|
||||
message: str
|
||||
still_running: bool = False
|
||||
|
||||
|
||||
def format_command(arguments: list[str] | None) -> str:
|
||||
if not arguments:
|
||||
return ""
|
||||
if os.name == "nt":
|
||||
return subprocess.list2cmdline(arguments)
|
||||
return " ".join(_shell_quote(argument) for argument in arguments)
|
||||
|
||||
|
||||
def _shell_quote(value: str) -> str:
|
||||
if not value:
|
||||
return "''"
|
||||
if all(character.isalnum() or character in "@%_-+=:,./" for character in value):
|
||||
return value
|
||||
return "'" + value.replace("'", "'\"'\"'") + "'"
|
||||
|
||||
|
||||
def humanize_bytes(size: int | float) -> str:
|
||||
value = float(max(size, 0))
|
||||
for unit in ("B", "KB", "MB", "GB", "TB"):
|
||||
if value < 1024 or unit == "TB":
|
||||
if unit == "B":
|
||||
return f"{value:.0f} {unit}"
|
||||
return f"{value:.1f} {unit}"
|
||||
value /= 1024
|
||||
return f"{value:.1f} TB"
|
||||
|
||||
|
||||
def matches_process(process: dict[str, Any], query: str) -> bool:
|
||||
normalized = query.strip().casefold()
|
||||
if not normalized:
|
||||
return True
|
||||
values = [
|
||||
str(process.get("pid", "")),
|
||||
str(process.get("name", "")),
|
||||
str(process.get("username", "")),
|
||||
str(process.get("exe", "")),
|
||||
str(process.get("command", "")),
|
||||
",".join(str(port) for port in process.get("ports", [])),
|
||||
]
|
||||
return any(normalized in value.casefold() for value in values)
|
||||
|
||||
|
||||
class ProcessService:
|
||||
"""Reads process/port data and performs guarded local process actions."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.current_pid = os.getpid()
|
||||
self._cpu_count = max(psutil.cpu_count(logical=True) or 1, 1)
|
||||
self._cpu_samples: dict[int, tuple[float, float, float]] = {}
|
||||
self._last_cpu: dict[int, float] = {}
|
||||
self._lock = threading.RLock()
|
||||
|
||||
def snapshot(self) -> dict[str, Any]:
|
||||
port_map, port_warning = self._collect_ports()
|
||||
warnings = [port_warning] if port_warning else []
|
||||
if os.name == "nt":
|
||||
try:
|
||||
processes = self._collect_windows_processes(port_map)
|
||||
except (OSError, subprocess.SubprocessError, json.JSONDecodeError) as error:
|
||||
warnings.append(f"快速采集不可用,已切换兼容模式:{error}")
|
||||
processes = self._collect_psutil_processes(port_map)
|
||||
else:
|
||||
processes = self._collect_psutil_processes(port_map)
|
||||
|
||||
processes.sort(key=lambda item: (item["name"].casefold(), item["pid"]))
|
||||
unique_ports = {
|
||||
(detail["protocol"], detail["address"], detail["port"])
|
||||
for process in processes
|
||||
for detail in process["port_details"]
|
||||
}
|
||||
memory = psutil.virtual_memory()
|
||||
summary = {
|
||||
"process_count": len(processes),
|
||||
"port_count": len(unique_ports),
|
||||
"cpu_percent": round(float(psutil.cpu_percent(interval=None)), 1),
|
||||
"memory_percent": round(float(memory.percent), 1),
|
||||
"memory_used": humanize_bytes(memory.used),
|
||||
"memory_total": humanize_bytes(memory.total),
|
||||
}
|
||||
return {
|
||||
"processes": processes,
|
||||
"summary": summary,
|
||||
"warnings": warnings,
|
||||
"refreshed_at": datetime.now().strftime("%H:%M:%S"),
|
||||
}
|
||||
|
||||
def _collect_psutil_processes(
|
||||
self, port_map: dict[int, list[dict[str, Any]]]
|
||||
) -> list[dict[str, Any]]:
|
||||
now = time.monotonic()
|
||||
processes: list[dict[str, Any]] = []
|
||||
live_pids: set[int] = set()
|
||||
for process in psutil.process_iter():
|
||||
try:
|
||||
row = self._read_process(process, port_map.get(process.pid, []), now)
|
||||
except (psutil.NoSuchProcess, psutil.ZombieProcess):
|
||||
continue
|
||||
except Exception:
|
||||
# A single unusual system process should never break the full refresh.
|
||||
continue
|
||||
processes.append(row)
|
||||
live_pids.add(row["pid"])
|
||||
|
||||
with self._lock:
|
||||
self._cpu_samples = {
|
||||
pid: sample for pid, sample in self._cpu_samples.items() if pid in live_pids
|
||||
}
|
||||
self._last_cpu = {
|
||||
pid: value for pid, value in self._last_cpu.items() if pid in live_pids
|
||||
}
|
||||
return processes
|
||||
|
||||
def _collect_windows_processes(
|
||||
self, port_map: dict[int, list[dict[str, Any]]]
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Use Windows CIM's bulk providers to avoid hundreds of slow per-PID calls."""
|
||||
script = r"""
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
$processes = Get-CimInstance Win32_Process -ErrorAction Stop | ForEach-Object {
|
||||
$created = 0
|
||||
if ($_.CreationDate) {
|
||||
$created = [DateTimeOffset]$_.CreationDate
|
||||
$created = $created.ToUnixTimeMilliseconds() / 1000
|
||||
}
|
||||
[PSCustomObject]@{
|
||||
pid = [int]$_.ProcessId
|
||||
ppid = [int]$_.ParentProcessId
|
||||
name = [string]$_.Name
|
||||
exe = [string]$_.ExecutablePath
|
||||
command = [string]$_.CommandLine
|
||||
created = [double]$created
|
||||
}
|
||||
}
|
||||
$performance = Get-CimInstance Win32_PerfFormattedData_PerfProc_Process -ErrorAction Stop |
|
||||
Where-Object { $_.Name -ne '_Total' } |
|
||||
ForEach-Object {
|
||||
[PSCustomObject]@{
|
||||
pid = [int]$_.IDProcess
|
||||
cpu = [double]$_.PercentProcessorTime
|
||||
memory = [int64]$_.WorkingSetPrivate
|
||||
}
|
||||
}
|
||||
[PSCustomObject]@{ processes = @($processes); performance = @($performance) } |
|
||||
ConvertTo-Json -Depth 4 -Compress
|
||||
"""
|
||||
completed = subprocess.run(
|
||||
["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=8,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
detail = (completed.stderr or completed.stdout).strip()
|
||||
raise OSError(detail or "PowerShell CIM 查询失败")
|
||||
payload = json.loads(completed.stdout.lstrip("\ufeff"))
|
||||
static_by_pid = {
|
||||
int(item["pid"]): item
|
||||
for item in self._ensure_list(payload.get("processes"))
|
||||
if item.get("pid") is not None
|
||||
}
|
||||
performance_by_pid = {
|
||||
int(item["pid"]): item
|
||||
for item in self._ensure_list(payload.get("performance"))
|
||||
if item.get("pid") is not None
|
||||
}
|
||||
total_memory = max(int(psutil.virtual_memory().total), 1)
|
||||
processes: list[dict[str, Any]] = []
|
||||
|
||||
for pid, values in static_by_pid.items():
|
||||
if pid == 0:
|
||||
continue
|
||||
performance = performance_by_pid.get(pid, {})
|
||||
memory_bytes = max(int(performance.get("memory") or 0), 0)
|
||||
raw_cpu = max(float(performance.get("cpu") or 0.0), 0.0)
|
||||
cpu_percent = round(min(raw_cpu / self._cpu_count, 100.0), 1)
|
||||
try:
|
||||
username = psutil.Process(pid).username() or "—"
|
||||
except (psutil.Error, OSError):
|
||||
username = "—"
|
||||
port_details = port_map.get(pid, [])
|
||||
ports = sorted({detail["port"] for detail in port_details})
|
||||
executable = values.get("exe") or ""
|
||||
command = values.get("command") or ""
|
||||
create_time = float(values.get("created") or 0.0)
|
||||
processes.append(
|
||||
{
|
||||
"pid": pid,
|
||||
"name": values.get("name") or f"PID {pid}",
|
||||
"ports": ports,
|
||||
"port_details": port_details,
|
||||
"cpu_percent": cpu_percent,
|
||||
"memory_bytes": memory_bytes,
|
||||
"memory_text": humanize_bytes(memory_bytes),
|
||||
"memory_percent": round(memory_bytes / total_memory * 100, 1),
|
||||
"username": username,
|
||||
"status": "running",
|
||||
"status_label": "运行中",
|
||||
"exe": executable,
|
||||
"command": command,
|
||||
"command_parts": [],
|
||||
"create_time": create_time,
|
||||
"create_time_text": self._format_timestamp(create_time),
|
||||
"ppid": int(values.get("ppid") or 0),
|
||||
"access_limited": not executable and not command and username == "—",
|
||||
"is_current": pid == self.current_pid,
|
||||
"can_terminate": pid != self.current_pid,
|
||||
"can_open_location": bool(executable),
|
||||
}
|
||||
)
|
||||
return processes
|
||||
|
||||
@staticmethod
|
||||
def _ensure_list(value: Any) -> list[Any]:
|
||||
if value is None:
|
||||
return []
|
||||
return value if isinstance(value, list) else [value]
|
||||
|
||||
def _collect_ports(self) -> tuple[dict[int, list[dict[str, Any]]], str | None]:
|
||||
ports: dict[int, set[tuple[str, str, int]]] = defaultdict(set)
|
||||
try:
|
||||
connections = psutil.net_connections(kind="inet")
|
||||
except psutil.AccessDenied:
|
||||
return {}, "权限不足:无法读取完整端口列表,建议以管理员身份运行。"
|
||||
except OSError as error:
|
||||
return {}, f"端口信息暂时不可用:{error}"
|
||||
|
||||
for connection in connections:
|
||||
if connection.pid is None or not connection.laddr:
|
||||
continue
|
||||
protocol = "TCP" if connection.type == socket.SOCK_STREAM else "UDP"
|
||||
if protocol == "TCP" and connection.status != psutil.CONN_LISTEN:
|
||||
continue
|
||||
try:
|
||||
address = connection.laddr.ip
|
||||
port = int(connection.laddr.port)
|
||||
except AttributeError:
|
||||
address, port = connection.laddr[:2]
|
||||
port = int(port)
|
||||
if port > 0:
|
||||
ports[connection.pid].add((protocol, str(address), port))
|
||||
|
||||
result = {
|
||||
pid: [
|
||||
{"protocol": protocol, "address": address, "port": port}
|
||||
for protocol, address, port in sorted(
|
||||
items, key=lambda item: (item[2], item[0], item[1])
|
||||
)
|
||||
]
|
||||
for pid, items in ports.items()
|
||||
}
|
||||
return result, None
|
||||
|
||||
def _read_process(
|
||||
self,
|
||||
process: psutil.Process,
|
||||
port_details: list[dict[str, Any]],
|
||||
sample_time: float,
|
||||
) -> dict[str, Any]:
|
||||
values = process.as_dict(
|
||||
attrs=[
|
||||
"pid",
|
||||
"name",
|
||||
"username",
|
||||
"status",
|
||||
"exe",
|
||||
"cmdline",
|
||||
"create_time",
|
||||
"ppid",
|
||||
"memory_info",
|
||||
"memory_percent",
|
||||
"cpu_times",
|
||||
],
|
||||
ad_value=None,
|
||||
)
|
||||
pid = int(values["pid"])
|
||||
create_time = float(values.get("create_time") or 0.0)
|
||||
cpu_percent = self._calculate_cpu(
|
||||
pid, values.get("cpu_times"), create_time, sample_time
|
||||
)
|
||||
memory_info = values.get("memory_info")
|
||||
memory_bytes = int(getattr(memory_info, "rss", 0) or 0)
|
||||
command_parts = values.get("cmdline") or []
|
||||
status = str(values.get("status") or "unknown")
|
||||
ports = sorted({detail["port"] for detail in port_details})
|
||||
exe = values.get("exe") or ""
|
||||
username = values.get("username") or "—"
|
||||
limited = not exe and not command_parts and username == "—"
|
||||
|
||||
return {
|
||||
"pid": pid,
|
||||
"name": values.get("name") or f"PID {pid}",
|
||||
"ports": ports,
|
||||
"port_details": port_details,
|
||||
"cpu_percent": cpu_percent,
|
||||
"memory_bytes": memory_bytes,
|
||||
"memory_text": humanize_bytes(memory_bytes),
|
||||
"memory_percent": round(float(values.get("memory_percent") or 0.0), 1),
|
||||
"username": username,
|
||||
"status": status,
|
||||
"status_label": STATUS_LABELS.get(status, "未知"),
|
||||
"exe": exe,
|
||||
"command": format_command(command_parts),
|
||||
"command_parts": command_parts,
|
||||
"create_time": create_time,
|
||||
"create_time_text": self._format_timestamp(create_time),
|
||||
"ppid": int(values.get("ppid") or 0),
|
||||
"access_limited": limited,
|
||||
"is_current": pid == self.current_pid,
|
||||
"can_terminate": pid != self.current_pid,
|
||||
"can_open_location": bool(exe),
|
||||
}
|
||||
|
||||
def _calculate_cpu(
|
||||
self,
|
||||
pid: int,
|
||||
cpu_times: Any,
|
||||
create_time: float,
|
||||
sample_time: float,
|
||||
) -> float:
|
||||
if cpu_times is None:
|
||||
return 0.0
|
||||
total = float(getattr(cpu_times, "user", 0.0)) + float(
|
||||
getattr(cpu_times, "system", 0.0)
|
||||
)
|
||||
with self._lock:
|
||||
previous = self._cpu_samples.get(pid)
|
||||
result = self._last_cpu.get(pid, 0.0)
|
||||
if previous is not None:
|
||||
previous_time, previous_total, previous_created = previous
|
||||
elapsed = sample_time - previous_time
|
||||
if elapsed >= 0.2 and abs(previous_created - create_time) < 0.01:
|
||||
used = max(total - previous_total, 0.0)
|
||||
result = min(max(used / elapsed / self._cpu_count * 100, 0.0), 100.0)
|
||||
result = round(result, 1)
|
||||
self._cpu_samples[pid] = (sample_time, total, create_time)
|
||||
self._last_cpu[pid] = result
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _format_timestamp(timestamp: float) -> str:
|
||||
if timestamp <= 0:
|
||||
return "—"
|
||||
try:
|
||||
return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S")
|
||||
except (OSError, OverflowError, ValueError):
|
||||
return "—"
|
||||
|
||||
def terminate(
|
||||
self, pid: int, *, force: bool, expected_create_time: float | None = None
|
||||
) -> ActionResult:
|
||||
if pid == self.current_pid:
|
||||
raise ProtectedProcessError("ProcessDock 正在使用该进程,不能结束自身。")
|
||||
try:
|
||||
process = psutil.Process(pid)
|
||||
actual_create_time = process.create_time()
|
||||
if (
|
||||
expected_create_time is not None
|
||||
and expected_create_time > 0
|
||||
and abs(actual_create_time - expected_create_time) >= 0.01
|
||||
):
|
||||
raise ProcessChangedError("PID 已被新的进程复用,请刷新后重试。")
|
||||
name = process.name()
|
||||
|
||||
if force:
|
||||
process.kill()
|
||||
elif os.name == "nt":
|
||||
completed = subprocess.run(
|
||||
["taskkill", "/PID", str(pid)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
)
|
||||
# Console/background processes commonly reject taskkill without /F.
|
||||
# Treat that as "still running" below so the GUI can offer force,
|
||||
# rather than presenting a misleading hard failure.
|
||||
else:
|
||||
process.terminate()
|
||||
|
||||
try:
|
||||
process.wait(timeout=3)
|
||||
except psutil.TimeoutExpired:
|
||||
return ActionResult(
|
||||
False,
|
||||
f"{name} 尚未退出,可以尝试强制结束。",
|
||||
still_running=True,
|
||||
)
|
||||
action = "强制结束" if force else "正常结束"
|
||||
return ActionResult(True, f"已{action} {name}(PID {pid})。")
|
||||
except ProtectedProcessError:
|
||||
raise
|
||||
except ProcessChangedError:
|
||||
raise
|
||||
except psutil.NoSuchProcess as error:
|
||||
raise ProcessGoneError("进程已经结束。") from error
|
||||
except psutil.AccessDenied as error:
|
||||
raise ProcessAccessError("权限不足,请尝试以管理员身份运行 ProcessDock。") from error
|
||||
except subprocess.TimeoutExpired as error:
|
||||
raise ProcessDockError("系统结束命令超时,请稍后重试。") from error
|
||||
|
||||
def open_file_location(
|
||||
self, pid: int, *, expected_create_time: float | None = None
|
||||
) -> ActionResult:
|
||||
try:
|
||||
process = psutil.Process(pid)
|
||||
actual_create_time = process.create_time()
|
||||
if (
|
||||
expected_create_time is not None
|
||||
and expected_create_time > 0
|
||||
and abs(actual_create_time - expected_create_time) >= 0.01
|
||||
):
|
||||
raise ProcessChangedError("PID 已被新的进程复用,请刷新后重试。")
|
||||
executable = process.exe()
|
||||
if not executable:
|
||||
raise ProcessDockError("该进程没有可访问的程序路径。")
|
||||
path = Path(executable)
|
||||
if sys.platform == "win32":
|
||||
subprocess.Popen(["explorer.exe", f"/select,{path}"])
|
||||
elif sys.platform == "darwin":
|
||||
subprocess.Popen(["open", "-R", str(path)])
|
||||
else:
|
||||
subprocess.Popen(["xdg-open", str(path.parent)])
|
||||
return ActionResult(True, "已打开程序所在位置。")
|
||||
except ProcessChangedError:
|
||||
raise
|
||||
except psutil.NoSuchProcess as error:
|
||||
raise ProcessGoneError("进程已经结束。") from error
|
||||
except psutil.AccessDenied as error:
|
||||
raise ProcessAccessError("权限不足,无法读取程序路径。") from error
|
||||
except OSError as error:
|
||||
raise ProcessDockError(f"无法打开文件位置:{error}") from error
|
||||
198
processdock/styles.py
Normal file
198
processdock/styles.py
Normal file
@@ -0,0 +1,198 @@
|
||||
APP_STYLE = r"""
|
||||
* {
|
||||
font-family: "Microsoft YaHei UI";
|
||||
color: #202534;
|
||||
}
|
||||
QMainWindow, QWidget#AppRoot {
|
||||
background: #F4F6FA;
|
||||
}
|
||||
QFrame#HeaderCard, QFrame#TableCard, QFrame#DetailCard, QFrame#SummaryCard {
|
||||
background: #FFFFFF;
|
||||
border: 1px solid #E6E9F0;
|
||||
border-radius: 14px;
|
||||
}
|
||||
QLabel#BrandMark {
|
||||
color: white;
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #6868E8, stop:1 #4C4CC1);
|
||||
border-radius: 11px;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
QLabel#BrandTitle {
|
||||
color: #171B2A;
|
||||
font-size: 19px;
|
||||
font-weight: 700;
|
||||
}
|
||||
QLabel#BrandSubtitle, QLabel#MutedLabel, QLabel#CardSubtitle, QLabel#DetailKey {
|
||||
color: #80899A;
|
||||
}
|
||||
QLabel#BrandSubtitle { font-size: 10px; }
|
||||
QLineEdit#SearchBox {
|
||||
min-height: 38px;
|
||||
min-width: 260px;
|
||||
padding: 0 14px;
|
||||
background: #F7F8FB;
|
||||
border: 1px solid #DFE3EB;
|
||||
border-radius: 9px;
|
||||
selection-background-color: #6464D9;
|
||||
}
|
||||
QLineEdit#SearchBox:focus {
|
||||
background: #FFFFFF;
|
||||
border: 1px solid #6262D6;
|
||||
}
|
||||
QPushButton {
|
||||
min-height: 36px;
|
||||
padding: 0 16px;
|
||||
border-radius: 9px;
|
||||
font-weight: 600;
|
||||
}
|
||||
QPushButton#PrimaryButton {
|
||||
color: white;
|
||||
background: #5B5BD6;
|
||||
border: 1px solid #5B5BD6;
|
||||
}
|
||||
QPushButton#PrimaryButton:hover { background: #4F4FC5; }
|
||||
QPushButton#SecondaryButton {
|
||||
background: #FFFFFF;
|
||||
border: 1px solid #DDE1EA;
|
||||
}
|
||||
QPushButton#SecondaryButton:hover { background: #F6F7FB; border-color: #C9CEDA; }
|
||||
QPushButton#DangerButton {
|
||||
color: white;
|
||||
background: #C64B59;
|
||||
border: 1px solid #C64B59;
|
||||
}
|
||||
QPushButton#DangerButton:hover { background: #B33D4B; }
|
||||
QPushButton#SoftDangerButton {
|
||||
color: #B53C4A;
|
||||
background: #FFF1F2;
|
||||
border: 1px solid #F3CDD2;
|
||||
}
|
||||
QPushButton#SoftDangerButton:hover { background: #FDE5E8; }
|
||||
QPushButton:disabled {
|
||||
color: #A6ADBA;
|
||||
background: #F2F4F7;
|
||||
border-color: #E4E7EC;
|
||||
}
|
||||
QCheckBox { spacing: 8px; color: #596273; }
|
||||
QLabel#CardIcon {
|
||||
min-width: 38px; max-width: 38px;
|
||||
min-height: 38px; max-height: 38px;
|
||||
color: #5858CD;
|
||||
background: #EEEEFF;
|
||||
border-radius: 10px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
QLabel#CardValue {
|
||||
color: #1C2130;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
}
|
||||
QLabel#CardTitle { color: #687183; font-size: 11px; }
|
||||
QLabel#WarningBanner {
|
||||
color: #8B5A10;
|
||||
background: #FFF7E5;
|
||||
border: 1px solid #F2D79A;
|
||||
border-radius: 9px;
|
||||
padding: 9px 12px;
|
||||
}
|
||||
QLabel#SectionTitle {
|
||||
color: #202534;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
QTableView {
|
||||
background: #FFFFFF;
|
||||
alternate-background-color: #FBFCFE;
|
||||
border: none;
|
||||
gridline-color: transparent;
|
||||
selection-background-color: #EEF0FF;
|
||||
selection-color: #202534;
|
||||
outline: none;
|
||||
}
|
||||
QTableView::item {
|
||||
border-bottom: 1px solid #EEF0F4;
|
||||
padding: 0 10px;
|
||||
}
|
||||
QTableView::item:hover { background: #F7F8FF; }
|
||||
QHeaderView::section {
|
||||
color: #70798A;
|
||||
background: #F7F8FB;
|
||||
border: none;
|
||||
border-bottom: 1px solid #E8EAF0;
|
||||
padding: 9px 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
QScrollBar:vertical { width: 9px; background: transparent; margin: 3px; }
|
||||
QScrollBar::handle:vertical { min-height: 30px; background: #CFD4DE; border-radius: 4px; }
|
||||
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }
|
||||
QScrollBar:horizontal { height: 9px; background: transparent; margin: 3px; }
|
||||
QScrollBar::handle:horizontal { min-width: 30px; background: #CFD4DE; border-radius: 4px; }
|
||||
QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { width: 0; }
|
||||
QSplitter::handle { background: transparent; width: 12px; }
|
||||
QLabel#ProcessAvatar {
|
||||
color: #FFFFFF;
|
||||
background: #5B5BD6;
|
||||
border-radius: 12px;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
QLabel#ProcessName { font-size: 17px; font-weight: 700; color: #1D2230; }
|
||||
QLabel#PidBadge {
|
||||
color: #626B7A;
|
||||
background: #F0F2F6;
|
||||
border-radius: 7px;
|
||||
padding: 3px 7px;
|
||||
font-family: "Cascadia Mono";
|
||||
font-size: 9px;
|
||||
}
|
||||
QLabel#DetailValue {
|
||||
color: #242A38;
|
||||
font-size: 11px;
|
||||
}
|
||||
QLabel#PortValue {
|
||||
color: #4E4EC4;
|
||||
background: #F0F0FF;
|
||||
border-radius: 7px;
|
||||
padding: 7px 9px;
|
||||
font-family: "Cascadia Mono";
|
||||
}
|
||||
QTextEdit#CommandBox {
|
||||
color: #3D4656;
|
||||
background: #F7F8FB;
|
||||
border: 1px solid #E4E7ED;
|
||||
border-radius: 8px;
|
||||
padding: 7px;
|
||||
font-family: "Cascadia Mono";
|
||||
font-size: 9px;
|
||||
}
|
||||
QLabel#LimitedBanner {
|
||||
color: #805C1B;
|
||||
background: #FFF7E7;
|
||||
border-radius: 7px;
|
||||
padding: 7px;
|
||||
}
|
||||
QLabel#EmptyIcon {
|
||||
color: #6868D7;
|
||||
background: #EEEEFF;
|
||||
border-radius: 22px;
|
||||
font-size: 23px;
|
||||
font-weight: 700;
|
||||
}
|
||||
QLabel#EmptyTitle { color: #343A49; font-size: 14px; font-weight: 700; }
|
||||
QProgressBar#LoadingBar {
|
||||
min-height: 3px; max-height: 3px;
|
||||
border: none; background: #EEEFF4;
|
||||
}
|
||||
QProgressBar#LoadingBar::chunk { background: #5B5BD6; }
|
||||
QLabel#FooterStatus { color: #778092; font-size: 10px; }
|
||||
QToolTip {
|
||||
color: #FFFFFF;
|
||||
background: #252A38;
|
||||
border: none;
|
||||
padding: 6px;
|
||||
}
|
||||
QMessageBox { background: #FFFFFF; }
|
||||
"""
|
||||
764
processdock/ui.py
Normal file
764
processdock/ui.py
Normal file
@@ -0,0 +1,764 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtCore import QModelIndex, QObject, QRunnable, QSize, Qt, QThreadPool, QTimer, Signal, Slot
|
||||
from PySide6.QtGui import QCloseEvent, QColor, QFont, QGuiApplication, QIcon, QPainter, QPixmap
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QApplication,
|
||||
QCheckBox,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QHeaderView,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QMainWindow,
|
||||
QMessageBox,
|
||||
QProgressBar,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QSizePolicy,
|
||||
QSplitter,
|
||||
QStackedWidget,
|
||||
QStyle,
|
||||
QTableView,
|
||||
QTextEdit,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from processdock.models import (
|
||||
PROCESS_ROLE,
|
||||
ActionButtonDelegate,
|
||||
ProcessFilterProxyModel,
|
||||
ProcessTableModel,
|
||||
)
|
||||
from processdock.services import ActionResult, ProcessDockError, ProcessService
|
||||
from processdock.styles import APP_STYLE
|
||||
|
||||
|
||||
class WorkerSignals(QObject):
|
||||
result = Signal(object)
|
||||
error = Signal(str)
|
||||
finished = Signal()
|
||||
|
||||
|
||||
class FunctionWorker(QRunnable):
|
||||
def __init__(self, function: Callable[[], Any]) -> None:
|
||||
super().__init__()
|
||||
self.function = function
|
||||
self.signals = WorkerSignals()
|
||||
|
||||
@Slot()
|
||||
def run(self) -> None:
|
||||
try:
|
||||
result = self.function()
|
||||
except Exception as error: # delivered to the GUI thread
|
||||
self.signals.error.emit(str(error) or error.__class__.__name__)
|
||||
else:
|
||||
self.signals.result.emit(result)
|
||||
finally:
|
||||
self.signals.finished.emit()
|
||||
|
||||
|
||||
class SummaryCard(QFrame):
|
||||
def __init__(self, icon: str, title: str, value: str, subtitle: str, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("SummaryCard")
|
||||
self.setMinimumHeight(92)
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(16, 13, 16, 13)
|
||||
layout.setSpacing(12)
|
||||
|
||||
icon_label = QLabel(icon)
|
||||
icon_label.setObjectName("CardIcon")
|
||||
icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addWidget(icon_label)
|
||||
|
||||
text_layout = QVBoxLayout()
|
||||
text_layout.setSpacing(1)
|
||||
title_label = QLabel(title)
|
||||
title_label.setObjectName("CardTitle")
|
||||
self.value_label = QLabel(value)
|
||||
self.value_label.setObjectName("CardValue")
|
||||
self.subtitle_label = QLabel(subtitle)
|
||||
self.subtitle_label.setObjectName("CardSubtitle")
|
||||
text_layout.addWidget(title_label)
|
||||
text_layout.addWidget(self.value_label)
|
||||
text_layout.addWidget(self.subtitle_label)
|
||||
layout.addLayout(text_layout, 1)
|
||||
|
||||
def update_value(self, value: str, subtitle: str | None = None) -> None:
|
||||
self.value_label.setText(value)
|
||||
if subtitle is not None:
|
||||
self.subtitle_label.setText(subtitle)
|
||||
|
||||
|
||||
class DetailRow(QWidget):
|
||||
def __init__(self, key: str, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(12)
|
||||
key_label = QLabel(key)
|
||||
key_label.setObjectName("DetailKey")
|
||||
key_label.setFixedWidth(76)
|
||||
key_label.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft)
|
||||
self.value_label = QLabel("—")
|
||||
self.value_label.setObjectName("DetailValue")
|
||||
self.value_label.setTextFormat(Qt.TextFormat.PlainText)
|
||||
self.value_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||
self.value_label.setWordWrap(True)
|
||||
layout.addWidget(key_label)
|
||||
layout.addWidget(self.value_label, 1)
|
||||
|
||||
def set_value(self, value: str) -> None:
|
||||
self.value_label.setText(value or "—")
|
||||
|
||||
|
||||
class DetailPanel(QFrame):
|
||||
open_requested = Signal(object)
|
||||
copy_requested = Signal(str)
|
||||
terminate_requested = Signal(object, bool)
|
||||
|
||||
def __init__(self, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("DetailCard")
|
||||
self.setMinimumWidth(350)
|
||||
self.setMaximumWidth(430)
|
||||
self._process: dict[str, Any] | None = None
|
||||
|
||||
outer = QVBoxLayout(self)
|
||||
outer.setContentsMargins(0, 0, 0, 0)
|
||||
outer.setSpacing(0)
|
||||
|
||||
heading = QWidget()
|
||||
heading_layout = QHBoxLayout(heading)
|
||||
heading_layout.setContentsMargins(18, 16, 18, 12)
|
||||
title = QLabel("进程详情")
|
||||
title.setObjectName("SectionTitle")
|
||||
hint = QLabel("实时信息")
|
||||
hint.setObjectName("MutedLabel")
|
||||
heading_layout.addWidget(title)
|
||||
heading_layout.addStretch()
|
||||
heading_layout.addWidget(hint)
|
||||
outer.addWidget(heading)
|
||||
|
||||
self.stack = QStackedWidget()
|
||||
outer.addWidget(self.stack, 1)
|
||||
self._build_empty_page()
|
||||
self._build_detail_page()
|
||||
self.stack.setCurrentWidget(self.empty_page)
|
||||
|
||||
def _build_empty_page(self) -> None:
|
||||
self.empty_page = QWidget()
|
||||
layout = QVBoxLayout(self.empty_page)
|
||||
layout.setContentsMargins(34, 34, 34, 70)
|
||||
layout.addStretch()
|
||||
icon = QLabel("⌁")
|
||||
icon.setObjectName("EmptyIcon")
|
||||
icon.setFixedSize(54, 54)
|
||||
icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.empty_title = QLabel("选择一个进程")
|
||||
self.empty_title.setObjectName("EmptyTitle")
|
||||
self.empty_title.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.empty_hint = QLabel("点击左侧任意一行,查看路径、命令行和资源占用。")
|
||||
self.empty_hint.setObjectName("MutedLabel")
|
||||
self.empty_hint.setWordWrap(True)
|
||||
self.empty_hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addWidget(icon, alignment=Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addSpacing(12)
|
||||
layout.addWidget(self.empty_title)
|
||||
layout.addSpacing(4)
|
||||
layout.addWidget(self.empty_hint)
|
||||
layout.addStretch()
|
||||
self.stack.addWidget(self.empty_page)
|
||||
|
||||
def _build_detail_page(self) -> None:
|
||||
self.detail_page = QWidget()
|
||||
page_layout = QVBoxLayout(self.detail_page)
|
||||
page_layout.setContentsMargins(0, 0, 0, 0)
|
||||
page_layout.setSpacing(0)
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setFrameShape(QFrame.Shape.NoFrame)
|
||||
detail_content = QWidget()
|
||||
layout = QVBoxLayout(detail_content)
|
||||
layout.setContentsMargins(18, 6, 18, 18)
|
||||
layout.setSpacing(12)
|
||||
|
||||
identity = QWidget()
|
||||
identity_layout = QHBoxLayout(identity)
|
||||
identity_layout.setContentsMargins(0, 0, 0, 6)
|
||||
identity_layout.setSpacing(12)
|
||||
self.avatar = QLabel("P")
|
||||
self.avatar.setObjectName("ProcessAvatar")
|
||||
self.avatar.setFixedSize(50, 50)
|
||||
self.avatar.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
name_layout = QVBoxLayout()
|
||||
name_layout.setSpacing(4)
|
||||
self.process_name = QLabel("—")
|
||||
self.process_name.setObjectName("ProcessName")
|
||||
self.process_name.setTextFormat(Qt.TextFormat.PlainText)
|
||||
self.pid_badge = QLabel("PID —")
|
||||
self.pid_badge.setObjectName("PidBadge")
|
||||
self.pid_badge.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
|
||||
name_layout.addWidget(self.process_name)
|
||||
name_layout.addWidget(self.pid_badge)
|
||||
identity_layout.addWidget(self.avatar)
|
||||
identity_layout.addLayout(name_layout, 1)
|
||||
layout.addWidget(identity)
|
||||
|
||||
self.limited_banner = QLabel("部分信息受系统权限限制")
|
||||
self.limited_banner.setObjectName("LimitedBanner")
|
||||
self.limited_banner.setWordWrap(True)
|
||||
self.limited_banner.hide()
|
||||
layout.addWidget(self.limited_banner)
|
||||
|
||||
self.rows: dict[str, DetailRow] = {}
|
||||
for key, label in (
|
||||
("ports", "监听端口"),
|
||||
("exe", "程序路径"),
|
||||
("create_time", "启动时间"),
|
||||
("cpu", "CPU 使用率"),
|
||||
("memory", "内存占用"),
|
||||
("username", "运行用户"),
|
||||
("status", "进程状态"),
|
||||
("ppid", "父进程 PID"),
|
||||
):
|
||||
row = DetailRow(label)
|
||||
if key == "ports":
|
||||
row.value_label.setObjectName("PortValue")
|
||||
self.rows[key] = row
|
||||
layout.addWidget(row)
|
||||
|
||||
command_title = QLabel("启动命令")
|
||||
command_title.setObjectName("DetailKey")
|
||||
layout.addWidget(command_title)
|
||||
self.command_box = QTextEdit()
|
||||
self.command_box.setObjectName("CommandBox")
|
||||
self.command_box.setReadOnly(True)
|
||||
self.command_box.setMinimumHeight(76)
|
||||
self.command_box.setMaximumHeight(108)
|
||||
layout.addWidget(self.command_box)
|
||||
layout.addStretch()
|
||||
|
||||
actions = QWidget()
|
||||
actions_layout = QVBoxLayout(actions)
|
||||
actions_layout.setContentsMargins(18, 10, 18, 18)
|
||||
actions_layout.setSpacing(8)
|
||||
utility_layout = QHBoxLayout()
|
||||
utility_layout.setSpacing(8)
|
||||
self.open_button = QPushButton("打开文件位置")
|
||||
self.open_button.setObjectName("SecondaryButton")
|
||||
self.copy_button = QPushButton("复制启动命令")
|
||||
self.copy_button.setObjectName("SecondaryButton")
|
||||
utility_layout.addWidget(self.open_button)
|
||||
utility_layout.addWidget(self.copy_button)
|
||||
actions_layout.addLayout(utility_layout)
|
||||
|
||||
danger_layout = QHBoxLayout()
|
||||
danger_layout.setSpacing(8)
|
||||
self.normal_button = QPushButton("正常结束")
|
||||
self.normal_button.setObjectName("SoftDangerButton")
|
||||
self.force_button = QPushButton("强制结束")
|
||||
self.force_button.setObjectName("DangerButton")
|
||||
danger_layout.addWidget(self.normal_button)
|
||||
danger_layout.addWidget(self.force_button)
|
||||
actions_layout.addLayout(danger_layout)
|
||||
|
||||
self.open_button.clicked.connect(self._request_open)
|
||||
self.copy_button.clicked.connect(self._request_copy)
|
||||
self.normal_button.clicked.connect(lambda: self._request_terminate(False))
|
||||
self.force_button.clicked.connect(lambda: self._request_terminate(True))
|
||||
|
||||
scroll.setWidget(detail_content)
|
||||
page_layout.addWidget(scroll, 1)
|
||||
page_layout.addWidget(actions)
|
||||
self.stack.addWidget(self.detail_page)
|
||||
self.detail_scroll = scroll
|
||||
|
||||
def set_process(self, process: dict[str, Any]) -> None:
|
||||
self._process = process
|
||||
name = process["name"]
|
||||
self.avatar.setText((name[:1] or "P").upper())
|
||||
self.process_name.setText(name)
|
||||
self.pid_badge.setText(f'PID {process["pid"]}')
|
||||
self.limited_banner.setVisible(bool(process["access_limited"]))
|
||||
|
||||
ports = "、".join(str(port) for port in process["ports"]) or "未监听端口"
|
||||
port_tooltip = "\n".join(
|
||||
f'{item["protocol"]} {item["address"]}:{item["port"]}'
|
||||
for item in process["port_details"]
|
||||
)
|
||||
self.rows["ports"].set_value(ports)
|
||||
self.rows["ports"].value_label.setToolTip(port_tooltip)
|
||||
self.rows["exe"].set_value(process["exe"])
|
||||
self.rows["create_time"].set_value(process["create_time_text"])
|
||||
self.rows["cpu"].set_value(f'{process["cpu_percent"]:.1f}%')
|
||||
self.rows["memory"].set_value(
|
||||
f'{process["memory_text"]} ({process["memory_percent"]:.1f}%)'
|
||||
)
|
||||
self.rows["username"].set_value(process["username"])
|
||||
self.rows["status"].set_value(process["status_label"])
|
||||
self.rows["ppid"].set_value(str(process["ppid"]) if process["ppid"] else "—")
|
||||
self.command_box.setPlainText(process["command"] or "—")
|
||||
self.open_button.setEnabled(bool(process["can_open_location"]))
|
||||
self.copy_button.setEnabled(bool(process["command"]))
|
||||
self.normal_button.setEnabled(bool(process["can_terminate"]))
|
||||
self.force_button.setEnabled(bool(process["can_terminate"]))
|
||||
self.normal_button.setToolTip(
|
||||
"向进程发送常规结束请求" if process["can_terminate"] else "不能结束 ProcessDock 自身"
|
||||
)
|
||||
self.force_button.setToolTip("立即终止进程,未保存的数据可能丢失")
|
||||
self.stack.setCurrentIndex(1)
|
||||
|
||||
def clear(self, message: str = "点击左侧任意一行,查看路径、命令行和资源占用。") -> None:
|
||||
self._process = None
|
||||
self.empty_hint.setText(message)
|
||||
self.stack.setCurrentWidget(self.empty_page)
|
||||
|
||||
def set_action_busy(self, busy: bool) -> None:
|
||||
if not self._process:
|
||||
return
|
||||
self.open_button.setEnabled(not busy and bool(self._process["can_open_location"]))
|
||||
self.copy_button.setEnabled(not busy and bool(self._process["command"]))
|
||||
self.normal_button.setEnabled(not busy and bool(self._process["can_terminate"]))
|
||||
self.force_button.setEnabled(not busy and bool(self._process["can_terminate"]))
|
||||
|
||||
def _request_open(self) -> None:
|
||||
if self._process:
|
||||
self.open_requested.emit(self._process)
|
||||
|
||||
def _request_copy(self) -> None:
|
||||
if self._process and self._process["command"]:
|
||||
self.copy_requested.emit(self._process["command"])
|
||||
|
||||
def _request_terminate(self, force: bool) -> None:
|
||||
if self._process:
|
||||
self.terminate_requested.emit(self._process, force)
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
def __init__(
|
||||
self, service: ProcessService | None = None, auto_refresh: bool = True, parent=None
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.service = service or ProcessService()
|
||||
self.thread_pool = QThreadPool.globalInstance()
|
||||
self._workers: set[FunctionWorker] = set()
|
||||
self._refreshing = False
|
||||
self._refresh_pending = False
|
||||
self._closing = False
|
||||
self._selected_pid: int | None = None
|
||||
self._action_busy = False
|
||||
|
||||
self.setWindowTitle("ProcessDock · 进程与端口管理器")
|
||||
self.setWindowIcon(make_app_icon())
|
||||
self.setMinimumSize(1080, 700)
|
||||
self.resize(1360, 820)
|
||||
self.setStyleSheet(APP_STYLE)
|
||||
self._build_ui(auto_refresh)
|
||||
|
||||
self.auto_timer = QTimer(self)
|
||||
self.auto_timer.setInterval(3000)
|
||||
self.auto_timer.timeout.connect(self.refresh)
|
||||
self.auto_checkbox.toggled.connect(self._toggle_auto_refresh)
|
||||
if auto_refresh:
|
||||
self.auto_timer.start()
|
||||
QTimer.singleShot(0, self.refresh)
|
||||
|
||||
def _build_ui(self, auto_refresh: bool) -> None:
|
||||
root = QWidget()
|
||||
root.setObjectName("AppRoot")
|
||||
self.setCentralWidget(root)
|
||||
outer = QVBoxLayout(root)
|
||||
outer.setContentsMargins(20, 18, 20, 16)
|
||||
outer.setSpacing(12)
|
||||
|
||||
header = QFrame()
|
||||
header.setObjectName("HeaderCard")
|
||||
header_layout = QHBoxLayout(header)
|
||||
header_layout.setContentsMargins(16, 11, 16, 11)
|
||||
header_layout.setSpacing(12)
|
||||
mark = QLabel("P")
|
||||
mark.setObjectName("BrandMark")
|
||||
mark.setFixedSize(42, 42)
|
||||
mark.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
header_layout.addWidget(mark)
|
||||
brand = QVBoxLayout()
|
||||
brand.setSpacing(0)
|
||||
title = QLabel("ProcessDock")
|
||||
title.setObjectName("BrandTitle")
|
||||
subtitle = QLabel("进程与端口管理器")
|
||||
subtitle.setObjectName("BrandSubtitle")
|
||||
brand.addWidget(title)
|
||||
brand.addWidget(subtitle)
|
||||
header_layout.addLayout(brand)
|
||||
header_layout.addStretch()
|
||||
|
||||
self.search_box = QLineEdit()
|
||||
self.search_box.setObjectName("SearchBox")
|
||||
self.search_box.setPlaceholderText("搜索进程、PID、端口或路径")
|
||||
self.search_box.setClearButtonEnabled(True)
|
||||
self.search_box.textChanged.connect(self._filter_changed)
|
||||
header_layout.addWidget(self.search_box)
|
||||
|
||||
self.refresh_button = QPushButton("刷新")
|
||||
self.refresh_button.setObjectName("PrimaryButton")
|
||||
self.refresh_button.setIcon(
|
||||
self.style().standardIcon(QStyle.StandardPixmap.SP_BrowserReload)
|
||||
)
|
||||
self.refresh_button.clicked.connect(self.refresh)
|
||||
header_layout.addWidget(self.refresh_button)
|
||||
self.auto_checkbox = QCheckBox("自动刷新 3 秒")
|
||||
self.auto_checkbox.setChecked(auto_refresh)
|
||||
header_layout.addWidget(self.auto_checkbox)
|
||||
outer.addWidget(header)
|
||||
|
||||
cards_layout = QHBoxLayout()
|
||||
cards_layout.setSpacing(10)
|
||||
self.process_card = SummaryCard("进", "运行进程", "—", "正在读取")
|
||||
self.port_card = SummaryCard("端", "监听端口", "—", "TCP 与 UDP")
|
||||
self.cpu_card = SummaryCard("CPU", "系统 CPU", "—", "所有逻辑核心")
|
||||
self.memory_card = SummaryCard("存", "系统内存", "—", "正在读取")
|
||||
for card in (self.process_card, self.port_card, self.cpu_card, self.memory_card):
|
||||
cards_layout.addWidget(card, 1)
|
||||
outer.addLayout(cards_layout)
|
||||
|
||||
self.warning_banner = QLabel()
|
||||
self.warning_banner.setObjectName("WarningBanner")
|
||||
self.warning_banner.setWordWrap(True)
|
||||
self.warning_banner.hide()
|
||||
outer.addWidget(self.warning_banner)
|
||||
|
||||
splitter = QSplitter(Qt.Orientation.Horizontal)
|
||||
splitter.setChildrenCollapsible(False)
|
||||
outer.addWidget(splitter, 1)
|
||||
|
||||
table_card = QFrame()
|
||||
table_card.setObjectName("TableCard")
|
||||
table_layout = QVBoxLayout(table_card)
|
||||
table_layout.setContentsMargins(0, 0, 0, 0)
|
||||
table_layout.setSpacing(0)
|
||||
table_heading = QWidget()
|
||||
table_heading_layout = QHBoxLayout(table_heading)
|
||||
table_heading_layout.setContentsMargins(18, 14, 18, 11)
|
||||
table_title = QLabel("正在运行的进程")
|
||||
table_title.setObjectName("SectionTitle")
|
||||
self.count_label = QLabel("准备读取…")
|
||||
self.count_label.setObjectName("MutedLabel")
|
||||
table_heading_layout.addWidget(table_title)
|
||||
table_heading_layout.addStretch()
|
||||
table_heading_layout.addWidget(self.count_label)
|
||||
table_layout.addWidget(table_heading)
|
||||
|
||||
self.loading_bar = QProgressBar()
|
||||
self.loading_bar.setObjectName("LoadingBar")
|
||||
self.loading_bar.setRange(0, 0)
|
||||
self.loading_bar.setTextVisible(False)
|
||||
self.loading_bar.hide()
|
||||
table_layout.addWidget(self.loading_bar)
|
||||
|
||||
self.model = ProcessTableModel(self)
|
||||
self.proxy = ProcessFilterProxyModel(self)
|
||||
self.proxy.setSourceModel(self.model)
|
||||
self.table = QTableView()
|
||||
self.table.setModel(self.proxy)
|
||||
self.table.setAlternatingRowColors(True)
|
||||
self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
self.table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
|
||||
self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||||
self.table.setShowGrid(False)
|
||||
self.table.setWordWrap(False)
|
||||
self.table.setSortingEnabled(True)
|
||||
self.table.sortByColumn(1, Qt.SortOrder.AscendingOrder)
|
||||
self.table.verticalHeader().hide()
|
||||
self.table.verticalHeader().setDefaultSectionSize(48)
|
||||
header_view = self.table.horizontalHeader()
|
||||
header_view.setDefaultAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
header_view.setMinimumSectionSize(62)
|
||||
header_view.setSectionResizeMode(QHeaderView.ResizeMode.Interactive)
|
||||
header_view.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
|
||||
widths = (78, 190, 135, 82, 105, 130, 88, 82)
|
||||
for column, width in enumerate(widths):
|
||||
self.table.setColumnWidth(column, width)
|
||||
self.table.selectionModel().currentRowChanged.connect(self._selection_changed)
|
||||
self.action_delegate = ActionButtonDelegate(self.table)
|
||||
self.action_delegate.terminate_requested.connect(
|
||||
lambda process: self._confirm_terminate(process, False)
|
||||
)
|
||||
self.table.setItemDelegateForColumn(7, self.action_delegate)
|
||||
|
||||
self.table_stack = QStackedWidget()
|
||||
self.table_stack.addWidget(self.table)
|
||||
self.no_results_page = self._build_no_results_page()
|
||||
self.table_stack.addWidget(self.no_results_page)
|
||||
table_layout.addWidget(self.table_stack, 1)
|
||||
splitter.addWidget(table_card)
|
||||
|
||||
self.detail_panel = DetailPanel()
|
||||
self.detail_panel.open_requested.connect(self._open_location)
|
||||
self.detail_panel.copy_requested.connect(self._copy_command)
|
||||
self.detail_panel.terminate_requested.connect(self._confirm_terminate)
|
||||
splitter.addWidget(self.detail_panel)
|
||||
splitter.setStretchFactor(0, 1)
|
||||
splitter.setStretchFactor(1, 0)
|
||||
splitter.setSizes([910, 390])
|
||||
|
||||
footer = QHBoxLayout()
|
||||
self.footer_status = QLabel("就绪")
|
||||
self.footer_status.setObjectName("FooterStatus")
|
||||
self.footer_time = QLabel("尚未刷新")
|
||||
self.footer_time.setObjectName("FooterStatus")
|
||||
footer.addWidget(self.footer_status)
|
||||
footer.addStretch()
|
||||
footer.addWidget(self.footer_time)
|
||||
outer.addLayout(footer)
|
||||
|
||||
@staticmethod
|
||||
def _build_no_results_page() -> QWidget:
|
||||
page = QWidget()
|
||||
layout = QVBoxLayout(page)
|
||||
layout.addStretch()
|
||||
icon = QLabel("⌕")
|
||||
icon.setObjectName("EmptyIcon")
|
||||
icon.setFixedSize(54, 54)
|
||||
icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
title = QLabel("没有匹配的进程")
|
||||
title.setObjectName("EmptyTitle")
|
||||
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
hint = QLabel("换一个进程名称、PID 或端口试试。")
|
||||
hint.setObjectName("MutedLabel")
|
||||
hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addWidget(icon, alignment=Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addSpacing(10)
|
||||
layout.addWidget(title)
|
||||
layout.addWidget(hint)
|
||||
layout.addStretch()
|
||||
return page
|
||||
|
||||
@Slot()
|
||||
def refresh(self) -> None:
|
||||
if self._refreshing:
|
||||
self._refresh_pending = True
|
||||
return
|
||||
self._refreshing = True
|
||||
self.refresh_button.setEnabled(False)
|
||||
self.loading_bar.show()
|
||||
self._set_status("正在读取进程和端口…")
|
||||
|
||||
worker = FunctionWorker(self.service.snapshot)
|
||||
worker.signals.result.connect(self._apply_snapshot)
|
||||
worker.signals.error.connect(self._refresh_failed)
|
||||
worker.signals.finished.connect(self._refresh_finished)
|
||||
self._track_worker(worker)
|
||||
self.thread_pool.start(worker)
|
||||
|
||||
@Slot(object)
|
||||
def _apply_snapshot(self, snapshot: dict[str, Any]) -> None:
|
||||
if self._closing:
|
||||
return
|
||||
processes = snapshot["processes"]
|
||||
self.model.set_processes(processes)
|
||||
summary = snapshot["summary"]
|
||||
self.process_card.update_value(str(summary["process_count"]), "本机可见进程")
|
||||
self.port_card.update_value(str(summary["port_count"]), "TCP 监听 / UDP 绑定")
|
||||
self.cpu_card.update_value(f'{summary["cpu_percent"]:.1f}%', "所有逻辑核心")
|
||||
self.memory_card.update_value(
|
||||
f'{summary["memory_percent"]:.1f}%',
|
||||
f'{summary["memory_used"]} / {summary["memory_total"]}',
|
||||
)
|
||||
warnings = snapshot.get("warnings") or []
|
||||
self.warning_banner.setText(" ".join(warnings))
|
||||
self.warning_banner.setVisible(bool(warnings))
|
||||
self.footer_time.setText(f'上次刷新 {snapshot["refreshed_at"]}')
|
||||
self._set_status("数据已更新")
|
||||
self._update_visible_count()
|
||||
|
||||
if self._selected_pid is not None:
|
||||
selected = next(
|
||||
(process for process in processes if process["pid"] == self._selected_pid),
|
||||
None,
|
||||
)
|
||||
if selected is None:
|
||||
self._selected_pid = None
|
||||
self.detail_panel.clear("该进程已经结束,请重新选择。")
|
||||
else:
|
||||
self.detail_panel.set_process(selected)
|
||||
self._reselect_process(self._selected_pid)
|
||||
|
||||
@Slot(str)
|
||||
def _refresh_failed(self, message: str) -> None:
|
||||
self.warning_banner.setText(f"刷新失败:{message}")
|
||||
self.warning_banner.show()
|
||||
self._set_status("刷新失败")
|
||||
|
||||
@Slot()
|
||||
def _refresh_finished(self) -> None:
|
||||
self._refreshing = False
|
||||
self.refresh_button.setEnabled(True)
|
||||
self.loading_bar.hide()
|
||||
if self._refresh_pending and not self._closing:
|
||||
self._refresh_pending = False
|
||||
QTimer.singleShot(0, self.refresh)
|
||||
|
||||
def _track_worker(self, worker: FunctionWorker) -> None:
|
||||
self._workers.add(worker)
|
||||
worker.signals.finished.connect(lambda current=worker: self._workers.discard(current))
|
||||
|
||||
@Slot(str)
|
||||
def _filter_changed(self, query: str) -> None:
|
||||
self.proxy.set_query(query)
|
||||
self._update_visible_count()
|
||||
|
||||
def _update_visible_count(self) -> None:
|
||||
visible = self.proxy.rowCount()
|
||||
total = self.model.rowCount()
|
||||
self.count_label.setText(
|
||||
f"{visible} / {total} 个进程" if self.search_box.text().strip() else f"{total} 个进程"
|
||||
)
|
||||
self.table_stack.setCurrentWidget(self.table if visible else self.no_results_page)
|
||||
|
||||
@Slot(QModelIndex, QModelIndex)
|
||||
def _selection_changed(self, current: QModelIndex, previous: QModelIndex) -> None:
|
||||
del previous
|
||||
if not current.isValid():
|
||||
return
|
||||
process = current.data(PROCESS_ROLE)
|
||||
if process:
|
||||
self._selected_pid = process["pid"]
|
||||
self.detail_panel.set_process(process)
|
||||
|
||||
def _reselect_process(self, pid: int) -> None:
|
||||
for source_row, process in enumerate(self.model.processes):
|
||||
if process["pid"] != pid:
|
||||
continue
|
||||
source_index = self.model.index(source_row, 0)
|
||||
proxy_index = self.proxy.mapFromSource(source_index)
|
||||
if proxy_index.isValid():
|
||||
self.table.selectRow(proxy_index.row())
|
||||
return
|
||||
|
||||
@Slot(bool)
|
||||
def _toggle_auto_refresh(self, enabled: bool) -> None:
|
||||
if enabled:
|
||||
self.auto_timer.start()
|
||||
self._set_status("已开启自动刷新")
|
||||
else:
|
||||
self.auto_timer.stop()
|
||||
self._set_status("已暂停自动刷新")
|
||||
|
||||
@Slot(object, bool)
|
||||
def _confirm_terminate(self, process: dict[str, Any], force: bool) -> None:
|
||||
if self._action_busy or not process.get("can_terminate"):
|
||||
return
|
||||
name = process["name"]
|
||||
pid = process["pid"]
|
||||
box = QMessageBox(self)
|
||||
box.setIcon(QMessageBox.Icon.Warning)
|
||||
box.setWindowTitle("确认强制结束" if force else "确认结束进程")
|
||||
box.setText(f"确定要结束 {name}(PID {pid})吗?")
|
||||
box.setInformativeText(
|
||||
"强制结束可能导致未保存的数据丢失。"
|
||||
if force
|
||||
else "将先发送常规结束请求;如果进程没有响应,可再强制结束。"
|
||||
)
|
||||
confirm = box.addButton(
|
||||
"强制结束" if force else "正常结束", QMessageBox.ButtonRole.AcceptRole
|
||||
)
|
||||
box.addButton("取消", QMessageBox.ButtonRole.RejectRole)
|
||||
box.setDefaultButton(confirm)
|
||||
box.exec()
|
||||
if box.clickedButton() is not confirm:
|
||||
return
|
||||
|
||||
self._action_busy = True
|
||||
if self._selected_pid == pid:
|
||||
self.detail_panel.set_action_busy(True)
|
||||
self._set_status(f"正在结束 {name}…")
|
||||
worker = FunctionWorker(
|
||||
lambda: self.service.terminate(
|
||||
pid,
|
||||
force=force,
|
||||
expected_create_time=process.get("create_time"),
|
||||
)
|
||||
)
|
||||
worker.signals.result.connect(self._termination_finished)
|
||||
worker.signals.error.connect(self._action_failed)
|
||||
worker.signals.finished.connect(self._action_worker_finished)
|
||||
self._track_worker(worker)
|
||||
self.thread_pool.start(worker)
|
||||
|
||||
@Slot(object)
|
||||
def _termination_finished(self, result: ActionResult) -> None:
|
||||
self._set_status(result.message)
|
||||
if result.still_running:
|
||||
QMessageBox.warning(self, "进程仍在运行", result.message)
|
||||
self.refresh()
|
||||
|
||||
@Slot(str)
|
||||
def _action_failed(self, message: str) -> None:
|
||||
self._set_status("操作失败")
|
||||
QMessageBox.critical(self, "操作失败", message)
|
||||
|
||||
@Slot()
|
||||
def _action_worker_finished(self) -> None:
|
||||
self._action_busy = False
|
||||
self.detail_panel.set_action_busy(False)
|
||||
|
||||
@Slot(object)
|
||||
def _open_location(self, process: dict[str, Any]) -> None:
|
||||
if self._action_busy:
|
||||
return
|
||||
self._action_busy = True
|
||||
self.detail_panel.set_action_busy(True)
|
||||
worker = FunctionWorker(
|
||||
lambda: self.service.open_file_location(
|
||||
process["pid"], expected_create_time=process.get("create_time")
|
||||
)
|
||||
)
|
||||
worker.signals.result.connect(lambda result: self._set_status(result.message))
|
||||
worker.signals.error.connect(self._action_failed)
|
||||
worker.signals.finished.connect(self._action_worker_finished)
|
||||
self._track_worker(worker)
|
||||
self.thread_pool.start(worker)
|
||||
|
||||
@Slot(str)
|
||||
def _copy_command(self, command: str) -> None:
|
||||
QGuiApplication.clipboard().setText(command)
|
||||
self._set_status("启动命令已复制到剪贴板")
|
||||
|
||||
def _set_status(self, text: str) -> None:
|
||||
self.footer_status.setText(text)
|
||||
|
||||
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802
|
||||
self._closing = True
|
||||
self.auto_timer.stop()
|
||||
self.thread_pool.clear()
|
||||
super().closeEvent(event)
|
||||
|
||||
|
||||
def make_app_icon() -> QIcon:
|
||||
pixmap = QPixmap(64, 64)
|
||||
pixmap.fill(Qt.GlobalColor.transparent)
|
||||
painter = QPainter(pixmap)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.setBrush(QColor("#5B5BD6"))
|
||||
painter.drawRoundedRect(4, 4, 56, 56, 15, 15)
|
||||
painter.setPen(QColor("#FFFFFF"))
|
||||
font = QFont("Arial", 29, QFont.Weight.Bold)
|
||||
painter.setFont(font)
|
||||
painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "P")
|
||||
painter.end()
|
||||
return QIcon(pixmap)
|
||||
|
||||
|
||||
def create_window_for_testing(service: ProcessService | None = None) -> MainWindow:
|
||||
"""Small test hook used by the off-screen smoke check."""
|
||||
if QApplication.instance() is None:
|
||||
raise RuntimeError("A QApplication must exist before creating the window.")
|
||||
return MainWindow(service=service, auto_refresh=False)
|
||||
Reference in New Issue
Block a user