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