500 lines
18 KiB
Python
500 lines
18 KiB
Python
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
|