From ff8aa0269d36789ab8e1a6a917b475508e8add3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=89=AB=E5=9C=B0=E5=83=A7?= <357099073@qq.com> Date: Wed, 8 Apr 2026 00:07:40 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E8=A7=A3=E9=99=A4=E5=8D=A0?= =?UTF-8?q?=E7=94=A8=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- installer/niumasoftware.generated.iss | 2 +- main.py | 22 + requirements.txt | 3 +- ui/__pycache__/dock.cpython-314.pyc | Bin 102236 -> 103143 bytes .../settings_window.cpython-314.pyc | Bin 33418 -> 37146 bytes ui/dock.py | 22 +- ui/settings_window.py | 59 ++- ui/unlocker.py | 382 ++++++++++++++++++ ui/win_handle_scan.py | 341 ++++++++++++++++ 9 files changed, 823 insertions(+), 8 deletions(-) create mode 100644 ui/unlocker.py create mode 100644 ui/win_handle_scan.py diff --git a/installer/niumasoftware.generated.iss b/installer/niumasoftware.generated.iss index 1ce4ffe..d0df1be 100644 --- a/installer/niumasoftware.generated.iss +++ b/installer/niumasoftware.generated.iss @@ -1,4 +1,4 @@ -#define AppVersion "0.0.3" +#define AppVersion "0.0.4" ; Inno Setup 安装脚本(全局安装) ; 产物约定(统一放在 dist\niumasoftware 下): ; - dist\niumasoftware\niumasoftware.exe (主程序,PyInstaller onedir) diff --git a/main.py b/main.py index 0169df0..4082c38 100644 --- a/main.py +++ b/main.py @@ -3,6 +3,7 @@ import os import time from PyQt6.QtCore import Qt from PyQt6.QtWidgets import QApplication +from PyQt6.QtGui import QIcon from db.database import init_db from ui.dock import PanelWindow, _set_autostart from ui.ball import FloatBall, BALL_SIZE @@ -21,6 +22,18 @@ def get_resource_path(relative_path): return os.path.join(os.path.abspath("."), relative_path) # ========================================================== +def _set_windows_app_user_model_id(appid: str): + """让任务栏图标/分组按 AppID 识别(开发态避免显示 Python 图标)。""" + if sys.platform != "win32": + return + try: + import ctypes + + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(appid) + except Exception: + pass + + def _wake_existing_or_exit() -> bool: """ Windows 单实例: @@ -75,6 +88,15 @@ def main(): app = QApplication(sys.argv) app.setQuitOnLastWindowClosed(False) + _set_windows_app_user_model_id("niumasoftware") + + # 任务栏/窗口图标:开发态用 ico;打包后也能通过资源路径找到 + try: + ico_path = get_resource_path("logo.ico") + if os.path.exists(ico_path): + app.setWindowIcon(QIcon(ico_path)) + except Exception: + pass init_db() theme.load() diff --git a/requirements.txt b/requirements.txt index f76ea70..c2386db 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ PyQt6>=6.4.0 -pillow>=9.0.0 \ No newline at end of file +pillow>=9.0.0 +psutil>=5.9.0 \ No newline at end of file diff --git a/ui/__pycache__/dock.cpython-314.pyc b/ui/__pycache__/dock.cpython-314.pyc index 5640f6b0b602a12ca6d34e652d36c9b7db395f77..4b5584dba17b23a2346978621b904348391ddaf7 100644 GIT binary patch delta 6958 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 diff --git a/ui/__pycache__/settings_window.cpython-314.pyc b/ui/__pycache__/settings_window.cpython-314.pyc index e7889bb45aa2179dfe22f22f9cf6000929ab04ba..6552fac6c74009884f039a43a6d02d833d5f954b 100644 GIT binary patch delta 7353 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 UQ1Z25$rO@&pyl5M{PLmy1ACf|YybcN delta 4907 zcmb7H3vg7`8NO$qc@gs1Y@VCfh7C(hcmx9EO_7j5fDjM_l595Fn`B|LyPSI$NJN8- zprC;;zgAHzXl)g$HM&|+rVh4hojOk2k&0fYj#d;IwF+9X9Uc4s=VnEjvB*yH-E;r* zpa1;lf1Z2y1BSi349R85lM*ESy1Ep)53ZyQn-Pn@WoUW!f+f+*Hn-TDGu6!IEHz8KvzBZ%2iI6ya+~wiJe_vngsmh)Dw5i*nNoXPr*XEC zwvr^ZsGT__cl=p;S?-<;HufmI$*O1d5+U}RV3t1bV3OpOyljWmDXFYoQVm_Hs?jcm zSo;*{GK`O`VU?^sBU1{*wc8O>)D%I<6qGDMNfnfALCF!6G(pJ~lsrL67Zit}CBYW%=QvBYYkJg*GM%?&B5cjqJJ=24J-}*Y7 zU`ApF`xz3`;oIE!B^`@nMw&e_2wT@!+gf7Oi_eax*tl&;Y;^J2);b|FA=){~#@I-d zC%?s*8J4Fknmir(A{K=A#~yos@5AtXirX>+U)`nfSxPHQhq}}ic)HQlLoh#W3oC{3 zG-pX2GH52hDi3O=fIlcJaY%3#>OWqFFMJAINOM>Uaj1+yPWpbUB{ks|^JmG?@$@7H zr)*EN8rYvPGnpA)WfjXR&9Ba4h%g?mhB*6NRtAms3g&^3-NDMEqxRPSZK60m`A92(bL%o7xiEowqS=M(4Xhm0;#SrP&llS(yO6_e(2R=8d6F3OZhUYr zf?_}@#sd;-aW{!#iN2AbwD1Jxr3Iw2!;?)o-hV&A*|Dp|B zsb%@>L0DYY@I!4h;}&lKwHeV@cjM##Xmb?)TSnSsUG-7&W?~H?EO%SE15(TF^*ixR zv!ZgV+^5pbBRci%(u|YBogTFMNs_1GT%xQ z+Yr!Z!VJ5Jup3s+sj-MKUjjFD9`$C1GILActs^i`Tm z>F1$HSaF+lV^Pd-vBc09&{G_1tsdde!C$J2m9FY~s_rd+Qo&CiFAN>60)5m}J!5vSY8QWc0xMgO=4EUygE*pk9 z4HbE*JRTW2)eL@Vpu$K)F1ruzX~SJPn>&UudDm!0qtX`e&<}aftc+Polg>{Kno;Kr`ZDHA8q1-@Fs*SaA}268=yn zcx6K)%Z?^>v{F*9>ztEvach#uIe44wKCzE*cPfddVzi=Xqui@5_hR{{Hpf3ANF(@| zSdxUQ%?nXinQMlSqM3xvO&df0pl0>?c@URsaUM^fKOlQNd>f>6*%C$SPn4tSU2}52 zMOg~jpijTge3xmhSfUCIUrW)$UE_K^x}s8jH5~7r$hGMC?z7Ayljbwv-JU3>?_IrR z@}+j5dEpF(zSBE$ihh~IWKbiDC(sl4krXibWTp3#3|NNpG^eZZe=?1t{rq1(}Jj-l3h)agE;IUmyYzEMYg> z4Ku>WuX+*L`A^&GcF5dJThRq-`R3LV%Kz9-)abY<2zsHIXeDo8iOfp)uGroV?`*cC zgbSOS>O^#itLFC$7kTLNhdv0yyhLbnO9^FlOba2d5Y?I}R})%rSgI7WgAi1vis-Cw zr8Vo%b6ocjNT11H8M^VQgoDf!_&7cm2b1 z7g3!^bwG(+E_P4muagY&4Oa+<@Qi``V#le|I_=K`^_E@iTTv;U)%8?C%=UlG=t=s#O&$)rBE0>+zUQ zyduUDW0Q*`ex5;OJAxL!S@mv}l@PY}T6#D5di3w8xQM?(|KcNGYLoHx`ytF1kkLuy zBa-;!=h;-JwYb{BkS`LD>v;ef@J~`&;H9x*b`H*t<)(jOSr*4q$JTx+(Niq^A1mar AE&u=k diff --git a/ui/dock.py b/ui/dock.py index 9efc5b0..a26ebb0 100644 --- a/ui/dock.py +++ b/ui/dock.py @@ -318,10 +318,14 @@ class PanelWindow(QWidget): self.setWindowTitle(app_title()) # 默认不置顶;仅图钉开启时与悬浮球一并置顶(见 _apply_pin_window_layer) self.setWindowFlags(Qt.WindowType.FramelessWindowHint) - # 任务栏/开始菜单图标:使用项目 logo.png - logo_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "logo.png") - if os.path.exists(logo_path): - self.setWindowIcon(QIcon(logo_path)) + # 任务栏/开始菜单图标:优先 ico,其次 png + base_dir = os.path.dirname(os.path.dirname(__file__)) + logo_ico = os.path.join(base_dir, "logo.ico") + logo_png = os.path.join(base_dir, "logo.png") + if os.path.exists(logo_ico): + self.setWindowIcon(QIcon(logo_ico)) + elif os.path.exists(logo_png): + self.setWindowIcon(QIcon(logo_png)) self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) self.setAcceptDrops(True) self.setMinimumSize(MIN_W, MIN_H) @@ -512,6 +516,7 @@ class PanelWindow(QWidget): ("管理员运行 CMD", "fa5s.terminal", self._open_admin_cmd), ("管理员运行 PowerShell", "fa5b.windows", self._open_admin_powershell), ("打开默认浏览器", "fa5s.globe", self._open_default_browser), + ("解除占用", "fa5s.unlock", self._open_unlocker), ] def _build_ui(self): @@ -958,6 +963,15 @@ class PanelWindow(QWidget): import webbrowser webbrowser.open("https://www.baidu.com") + def _open_unlocker(self): + try: + from ui.unlocker import UnlockDialog + except Exception as e: + dialog_style.warning(self, "无法打开解除占用工具", f"加载模块失败:{e}") + return + dlg = UnlockDialog(self) + dlg.exec() + def _open_wechat_multi(self): from ui.wechat_multi import WechatMultiDialog dlg = WechatMultiDialog(self) diff --git a/ui/settings_window.py b/ui/settings_window.py index cf7b16b..ade3156 100644 --- a/ui/settings_window.py +++ b/ui/settings_window.py @@ -3,7 +3,7 @@ import os from PyQt6.QtWidgets import ( QDialog, QHBoxLayout, QVBoxLayout, QListWidget, QListWidgetItem, QStackedWidget, QWidget, QLabel, QSlider, QPushButton, - QCheckBox, QGroupBox, QFormLayout, QSpinBox, QFrame + QCheckBox, QGroupBox, QFormLayout, QSpinBox, QFrame, QLineEdit, QFileDialog ) from PyQt6.QtCore import Qt, QSize from PyQt6.QtWidgets import QMessageBox @@ -12,6 +12,7 @@ import qtawesome as qta from db import database import ui.theme as theme import ui.dialog_style as dialog_style +from app_info import APP_NAME, __VERSION__ class SettingsWindow(QDialog): @@ -51,6 +52,7 @@ class SettingsWindow(QDialog): ("fa5s.paint-brush", "外观"), ("fa5s.window-maximize", "窗口"), ("fa5s.rocket", "启动"), + ("fa5s.folder-open", "缓存"), ("fa5s.heart", "捐赠"), ("fa5s.eraser", "初始化"), ] @@ -75,6 +77,7 @@ class SettingsWindow(QDialog): self.stack.addWidget(self._page_appearance()) self.stack.addWidget(self._page_window()) self.stack.addWidget(self._page_startup()) + self.stack.addWidget(self._page_cache()) self.stack.addWidget(self._page_donate()) self.stack.addWidget(self._page_initialization()) # 此时 stack 已就绪,允许导航回调正常工作 @@ -241,13 +244,65 @@ class SettingsWindow(QDialog): layout.addWidget(self._divider()) layout.addWidget(self._section_title("关于")) - about = QLabel("桌面文件整理 v1.0\n整理你的桌面快捷方式,保持桌面干净。") + about = QLabel(f"{APP_NAME} v{__VERSION__}\n整理你的桌面快捷方式,保持桌面干净。") about.setStyleSheet("color:#888; font-size:12px; line-height:1.6;") layout.addWidget(about) layout.addStretch() return page + # ── 缓存页 ─────────────────────────────────────────── + def _page_cache(self) -> QWidget: + page = QWidget() + layout = QVBoxLayout(page) + layout.setContentsMargins(24, 20, 24, 20) + layout.setSpacing(16) + + layout.addWidget(self._section_title("缓存目录")) + + desc = QLabel("下载的免安装软件/安装包默认保存到该目录。") + desc.setWordWrap(True) + desc.setStyleSheet("color:#888; font-size:12px; line-height:1.6;") + layout.addWidget(desc) + + row = QHBoxLayout() + row.setSpacing(10) + + self._cache_path_edit = QLineEdit() + self._cache_path_edit.setPlaceholderText("请选择缓存目录…") + self._cache_path_edit.setText(database.get_setting("cache_dir", "")) + + browse = QPushButton("浏览…") + browse.clicked.connect(self._choose_cache_dir) + + save = QPushButton("保存") + save.clicked.connect(self._save_cache_dir) + + row.addWidget(self._cache_path_edit) + row.addWidget(browse) + row.addWidget(save) + layout.addLayout(row) + + layout.addStretch() + return page + + def _choose_cache_dir(self): + cur = "" + if hasattr(self, "_cache_path_edit"): + cur = self._cache_path_edit.text().strip() + start = cur if cur else os.path.expanduser("~") + p = QFileDialog.getExistingDirectory(self, "选择缓存目录", start) + if p: + self._cache_path_edit.setText(p) + + def _save_cache_dir(self): + p = self._cache_path_edit.text().strip() if hasattr(self, "_cache_path_edit") else "" + if p and not os.path.isdir(p): + dialog_style.warning(self, "无效目录", "选择的缓存目录不存在,请重新选择。") + return + database.set_setting("cache_dir", p) + dialog_style.information(self, "已保存", "缓存目录已保存。") + # ── 捐赠页 ─────────────────────────────────────────── def _page_donate(self) -> QWidget: page = QWidget() diff --git a/ui/unlocker.py b/ui/unlocker.py new file mode 100644 index 0000000..94dcd83 --- /dev/null +++ b/ui/unlocker.py @@ -0,0 +1,382 @@ +from __future__ import annotations + +import os +from typing import List + +import psutil +from PyQt6.QtCore import Qt, pyqtSignal, QThread, QTimer +from PyQt6.QtWidgets import ( + QDialog, + QVBoxLayout, + QHBoxLayout, + QWidget, + QLabel, + QListWidget, + QListWidgetItem, + QPushButton, +) + +import ui.dialog_style as dialog_style +import ui.theme as theme + + +class _ScanWorker(QThread): + done = pyqtSignal(str, list) + error = pyqtSignal(str, str) + + def __init__(self, file_path: str): + super().__init__() + self._file_path = file_path + + def run(self): + try: + target = os.path.abspath(self._file_path) + + found: list[dict] = [] + if os.name == "nt": + try: + from ui.win_handle_scan import find_file_locks + + locks = find_file_locks(target) + # 聚合:pid -> handles + mp: dict[int, list[int]] = {} + for lk in locks: + mp.setdefault(lk.pid, []).append(int(lk.handle)) + for pid, handles in mp.items(): + name = "" + try: + name = psutil.Process(pid).name() + except Exception: + name = f"PID {pid}" + found.append({"pid": pid, "name": name, "handles": handles}) + except Exception: + found = [] + + # 兜底:psutil(可能漏报,但至少不会空白) + if not found: + target_norm = os.path.normcase(os.path.abspath(target)) + for proc in psutil.process_iter(["pid", "name", "open_files"]): + try: + files = proc.info.get("open_files") or [] + for f in files: + if os.path.normcase(f.path) == target_norm: + pid = int(proc.info.get("pid") or 0) + name = proc.info.get("name") or f"PID {pid}" + found.append({"pid": pid, "name": str(name), "handles": []}) + break + except (psutil.NoSuchProcess, psutil.AccessDenied): + continue + + self.done.emit(os.path.normcase(os.path.abspath(target)), found) + except Exception as e: + self.error.emit(self._file_path, str(e)) + + +class _DropArea(QWidget): + file_dropped = pyqtSignal(str) + + def __init__(self, parent: QWidget | None = None): + super().__init__(parent) + self.setAcceptDrops(True) + self.setMinimumHeight(120) + + def dragEnterEvent(self, event): + if event.mimeData().hasUrls(): + event.acceptProposedAction() + else: + event.ignore() + + def dragMoveEvent(self, event): + if event.mimeData().hasUrls(): + event.acceptProposedAction() + else: + event.ignore() + + def dropEvent(self, event): + urls = event.mimeData().urls() + if not urls: + return + local = urls[0].toLocalFile() + if local: + self.file_dropped.emit(local) + + def paintEvent(self, event): + from PyQt6.QtGui import QPainter, QPen, QBrush, QColor + + super().paintEvent(event) + p = QPainter(self) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + rect = self.rect().adjusted(6, 6, -6, -6) + t = theme.current() + border = t.get("search_border", "#888") + bg = t.get("search_bg", "rgba(0,0,0,20)") + p.setBrush(QBrush(QColor(bg))) + pen = QPen(QColor(border)) + pen.setStyle(Qt.PenStyle.DashLine) + pen.setWidth(1) + p.setPen(pen) + p.drawRoundedRect(rect, 8, 8) + p.end() + + +class UnlockDialog(QDialog): + def __init__(self, parent: QWidget | None = None): + super().__init__(parent) + self.setWindowTitle("解除占用") + self.setMinimumSize(520, 420) + self._targets: List[psutil.Process] = [] + self._file_path: str | None = None + self._scan_worker: _ScanWorker | None = None + self._loading_timer: QTimer | None = None + self._loading_step = 0 + self._build_ui() + self._apply_theme() + + def _build_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(18, 16, 18, 16) + layout.setSpacing(10) + + intro = QLabel( + "将被占用的文件拖拽到下方虚线框内,我会尝试找出正在占用它的程序," + "并在下方列出,方便你一键结束相关进程。" + ) + intro.setWordWrap(True) + self._intro_lbl = intro + layout.addWidget(intro) + + self._drop = _DropArea(self) + lbl = QLabel("拖拽文件到这里") + lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._drop_hint_lbl = lbl + layout_drop = QVBoxLayout(self._drop) + layout_drop.addStretch() + layout_drop.addWidget(lbl) + layout_drop.addStretch() + self._drop.file_dropped.connect(self._on_file_dropped) + layout.addWidget(self._drop) + + self._path_lbl = QLabel("当前文件:未选择") + layout.addWidget(self._path_lbl) + + self._list = QListWidget() + layout.addWidget(self._list, 1) + + btn_row = QHBoxLayout() + btn_row.addStretch() + self._unlock_btn = QPushButton("解除占用(结束相关进程)") + self._unlock_btn.clicked.connect(self._on_unlock) + btn_close = QPushButton("关闭") + btn_close.clicked.connect(self.close) + btn_row.addWidget(self._unlock_btn) + btn_row.addWidget(btn_close) + layout.addLayout(btn_row) + self._close_btn = btn_close + + def _apply_theme(self): + t = theme.current() + is_dark = theme.name() == "dark" + + # 兼容 panel_bg 为 rgba 的情况 + panel_bg = t.get("panel_bg", "rgba(30,30,30,240)") + if panel_bg.startswith("rgba"): + tmp = panel_bg.replace("rgba", "rgb") + head = tmp.rsplit(",", 1)[0] + panel_rgb = head + ")" + else: + panel_rgb = panel_bg + + txt = t.get("search_color", "#eee") + sub = "#888" if is_dark else "#666" + border = t.get("panel_border", t.get("search_border", "#666")) + inp_bg = t.get("search_bg", "rgba(0,0,0,20)") + hover = t.get("header_hover", t.get("menu_selected", "rgba(128,128,128,40)")) + + self.setStyleSheet( + f""" + QDialog, QWidget {{ + background: {panel_rgb}; + color: {txt}; + }} + QListWidget {{ + background: {inp_bg}; + border: 1px solid {t.get('search_border', border)}; + border-radius: 8px; + }} + QListWidget::item {{ + padding: 6px 8px; + border-radius: 6px; + }} + QListWidget::item:selected {{ + background: #4a9eff; + color: white; + }} + QListWidget::item:hover:!selected {{ + background: {hover}; + }} + QPushButton {{ + background: {inp_bg}; + color: {txt}; + border: 1px solid {t.get('search_border', border)}; + border-radius: 6px; + padding: 6px 14px; + font-size: 12px; + }} + QPushButton:hover {{ + border-color: #4a9eff; + color: #4a9eff; + }} + """ + ) + + if hasattr(self, "_path_lbl"): + self._path_lbl.setStyleSheet(f"color:{sub}; font-size:11px; background:transparent;") + if hasattr(self, "_intro_lbl"): + self._intro_lbl.setStyleSheet("background:transparent;") + if hasattr(self, "_drop_hint_lbl"): + self._drop_hint_lbl.setStyleSheet(f"color:{sub}; background:transparent;") + + def _on_file_dropped(self, path: str): + self._file_path = os.path.abspath(path) + self._path_lbl.setText(f"当前文件:{self._file_path}") + self._start_scan() + + def _set_loading(self, loading: bool): + if loading: + self._unlock_btn.setEnabled(False) + self._list.clear() + self._list.addItem(QListWidgetItem("正在扫描占用进程…")) + if self._loading_timer is None: + self._loading_timer = QTimer(self) + self._loading_timer.setInterval(250) + self._loading_timer.timeout.connect(self._tick_loading) + self._loading_step = 0 + self._loading_timer.start() + else: + self._unlock_btn.setEnabled(True) + if self._loading_timer is not None: + self._loading_timer.stop() + + def _tick_loading(self): + # 简易“加载动画”:点点点 + self._loading_step = (self._loading_step + 1) % 4 + dots = "." * self._loading_step + txt = f"正在扫描占用进程{dots}" + if self._list.count() > 0: + it = self._list.item(0) + if it is not None: + it.setText(txt) + + def _start_scan(self): + self._list.clear() + self._targets.clear() + if not self._file_path: + return + # 取消上一次扫描(如果仍在跑) + if self._scan_worker is not None and self._scan_worker.isRunning(): + try: + self._scan_worker.requestInterruption() + except Exception: + pass + self._set_loading(True) + self._scan_worker = _ScanWorker(self._file_path) + self._scan_worker.done.connect(self._on_scan_done) + self._scan_worker.error.connect(self._on_scan_error) + self._scan_worker.start() + + def _on_scan_error(self, path: str, err: str): + self._set_loading(False) + dialog_style.warning(self, "扫描失败", f"无法扫描占用进程:\n{err}") + + def _on_scan_done(self, norm_target: str, found: list): + self._set_loading(False) + self._list.clear() + self._targets.clear() + + if not found: + item = QListWidgetItem("未检测到占用该文件的进程。") + self._list.addItem(item) + return + + self._lock_infos = found # type: ignore[attr-defined] + # 终止进程时使用 + procs: list[psutil.Process] = [] + for it in found: + try: + pid = int(it.get("pid") or 0) + if pid <= 0: + continue + procs.append(psutil.Process(pid)) + except Exception: + continue + self._targets = procs + + for it in found: + pid = int(it.get("pid") or 0) + name = str(it.get("name") or f"PID {pid}") + hcnt = len(it.get("handles") or []) + suffix = f" (句柄 {hcnt})" if hcnt > 0 else "" + txt = f"{name} (PID {pid}){suffix}" + self._list.addItem(QListWidgetItem(txt)) + + def _on_unlock(self): + if not self._targets: + dialog_style.information(self, "提示", "当前没有检测到需要解除的占用进程。") + return + + # 优先:尝试关闭句柄(不杀进程) + closed_any = False + if os.name == "nt" and hasattr(self, "_lock_infos"): + try: + from ui.win_handle_scan import close_remote_handle + + for it in getattr(self, "_lock_infos", []): + pid = int(it.get("pid") or 0) + for hv in it.get("handles") or []: + try: + if close_remote_handle(pid, int(hv)): + closed_any = True + except Exception: + continue + except Exception: + pass + + if closed_any: + dialog_style.information(self, "已完成", "已尝试关闭相关文件句柄(不结束进程)。") + self._start_scan() + return + + failed = [] + for p in self._targets: + try: + p.terminate() + except (psutil.NoSuchProcess, psutil.AccessDenied): + continue + + # 简单等待一会儿,再尝试强杀 + for p in list(self._targets): + try: + p.wait(timeout=1.0) + except (psutil.TimeoutExpired, psutil.NoSuchProcess): + pass + + for p in self._targets: + try: + if p.is_running(): + try: + p.kill() + except (psutil.NoSuchProcess, psutil.AccessDenied): + failed.append(p) + except psutil.NoSuchProcess: + continue + + if failed: + dialog_style.warning( + self, + "部分失败", + "部分进程无法结束,可能需要以管理员身份运行或手动关闭相关程序。", + ) + else: + dialog_style.information(self, "已完成", "相关占用进程已尝试结束。") + self._start_scan() + diff --git a/ui/win_handle_scan.py b/ui/win_handle_scan.py new file mode 100644 index 0000000..78303d6 --- /dev/null +++ b/ui/win_handle_scan.py @@ -0,0 +1,341 @@ +from __future__ import annotations + +import ctypes +import os +from ctypes import wintypes +from dataclasses import dataclass + + +kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) +ntdll = ctypes.WinDLL("ntdll", use_last_error=True) +advapi32 = ctypes.WinDLL("advapi32", use_last_error=True) + + +PROCESS_DUP_HANDLE = 0x0040 +PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 +DUPLICATE_SAME_ACCESS = 0x00000002 +DUPLICATE_CLOSE_SOURCE = 0x00000001 + +GENERIC_READ = 0x80000000 +FILE_SHARE_READ = 0x00000001 +FILE_SHARE_WRITE = 0x00000002 +FILE_SHARE_DELETE = 0x00000004 +OPEN_EXISTING = 3 +FILE_ATTRIBUTE_NORMAL = 0x00000080 + + +SystemExtendedHandleInformation = 64 + + +class SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX(ctypes.Structure): + _fields_ = [ + ("Object", ctypes.c_void_p), + ("UniqueProcessId", ctypes.c_void_p), + ("HandleValue", ctypes.c_void_p), + ("GrantedAccess", wintypes.ULONG), + ("CreatorBackTraceIndex", wintypes.USHORT), + ("ObjectTypeIndex", wintypes.USHORT), + ("HandleAttributes", wintypes.ULONG), + ("Reserved", wintypes.ULONG), + ] + + +class SYSTEM_HANDLE_INFORMATION_EX(ctypes.Structure): + _fields_ = [ + ("NumberOfHandles", ctypes.c_void_p), + ("Reserved", ctypes.c_void_p), + ("Handles", SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX * 1), + ] + + +NtQuerySystemInformation = ntdll.NtQuerySystemInformation +NtQuerySystemInformation.argtypes = [ + wintypes.ULONG, + wintypes.LPVOID, + wintypes.ULONG, + wintypes.PULONG, +] +NtQuerySystemInformation.restype = wintypes.LONG + + +kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD] +kernel32.OpenProcess.restype = wintypes.HANDLE + +kernel32.CloseHandle.argtypes = [wintypes.HANDLE] +kernel32.CloseHandle.restype = wintypes.BOOL + +kernel32.DuplicateHandle.argtypes = [ + wintypes.HANDLE, + wintypes.HANDLE, + wintypes.HANDLE, + ctypes.POINTER(wintypes.HANDLE), + wintypes.DWORD, + wintypes.BOOL, + wintypes.DWORD, +] +kernel32.DuplicateHandle.restype = wintypes.BOOL + +kernel32.GetCurrentProcess.argtypes = [] +kernel32.GetCurrentProcess.restype = wintypes.HANDLE + +kernel32.GetFinalPathNameByHandleW.argtypes = [ + wintypes.HANDLE, + wintypes.LPWSTR, + wintypes.DWORD, + wintypes.DWORD, +] +kernel32.GetFinalPathNameByHandleW.restype = wintypes.DWORD + +kernel32.GetCurrentProcessId.argtypes = [] +kernel32.GetCurrentProcessId.restype = wintypes.DWORD + +kernel32.CreateFileW.argtypes = [ + wintypes.LPCWSTR, + wintypes.DWORD, + wintypes.DWORD, + wintypes.LPVOID, + wintypes.DWORD, + wintypes.DWORD, + wintypes.HANDLE, +] +kernel32.CreateFileW.restype = wintypes.HANDLE + + +def _normalize_path(p: str) -> str: + p = os.path.abspath(p) + p = os.path.normcase(p) + return p + + +def _normalize_final_path(p: str) -> str: + # GetFinalPathNameByHandleW 常见返回:\\?\C:\path\file 或 \\?\UNC\server\share\path + if p.startswith("\\\\?\\UNC\\"): + p = "\\\\" + p[len("\\\\?\\UNC\\") :] + elif p.startswith("\\\\?\\"): + p = p[len("\\\\?\\") :] + return _normalize_path(p) + + +def _try_enable_debug_privilege() -> None: + # best-effort:没有也能工作,只是会漏掉部分系统进程 + TOKEN_ADJUST_PRIVILEGES = 0x0020 + TOKEN_QUERY = 0x0008 + SE_PRIVILEGE_ENABLED = 0x0002 + + class LUID(ctypes.Structure): + _fields_ = [("LowPart", wintypes.DWORD), ("HighPart", wintypes.LONG)] + + class LUID_AND_ATTRIBUTES(ctypes.Structure): + _fields_ = [("Luid", LUID), ("Attributes", wintypes.DWORD)] + + class TOKEN_PRIVILEGES(ctypes.Structure): + _fields_ = [("PrivilegeCount", wintypes.DWORD), ("Privileges", LUID_AND_ATTRIBUTES * 1)] + + OpenProcessToken = advapi32.OpenProcessToken + OpenProcessToken.argtypes = [wintypes.HANDLE, wintypes.DWORD, ctypes.POINTER(wintypes.HANDLE)] + OpenProcessToken.restype = wintypes.BOOL + + LookupPrivilegeValueW = advapi32.LookupPrivilegeValueW + LookupPrivilegeValueW.argtypes = [wintypes.LPCWSTR, wintypes.LPCWSTR, ctypes.POINTER(LUID)] + LookupPrivilegeValueW.restype = wintypes.BOOL + + AdjustTokenPrivileges = advapi32.AdjustTokenPrivileges + AdjustTokenPrivileges.argtypes = [ + wintypes.HANDLE, + wintypes.BOOL, + ctypes.POINTER(TOKEN_PRIVILEGES), + wintypes.DWORD, + wintypes.PVOID, + wintypes.PVOID, + ] + AdjustTokenPrivileges.restype = wintypes.BOOL + + token = wintypes.HANDLE() + if not OpenProcessToken(kernel32.GetCurrentProcess(), TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY, ctypes.byref(token)): + return + try: + luid = LUID() + if not LookupPrivilegeValueW(None, "SeDebugPrivilege", ctypes.byref(luid)): + return + tp = TOKEN_PRIVILEGES() + tp.PrivilegeCount = 1 + tp.Privileges[0].Luid = luid + tp.Privileges[0].Attributes = SE_PRIVILEGE_ENABLED + AdjustTokenPrivileges(token, False, ctypes.byref(tp), 0, None, None) + finally: + kernel32.CloseHandle(token) + + +@dataclass(frozen=True) +class FileLock: + pid: int + handle: int + path: str + + +def _query_system_handles(max_size: int = 1 << 28) -> tuple[ctypes.Array, int] | tuple[None, int]: + """返回 (buffer, handle_count);失败返回 (None, 0)。""" + size = 1 << 20 + while True: + buf = ctypes.create_string_buffer(size) + ret_len = wintypes.ULONG(0) + status = NtQuerySystemInformation( + SystemExtendedHandleInformation, buf, size, ctypes.byref(ret_len) + ) + if status == 0: # STATUS_SUCCESS + info = ctypes.cast(buf, ctypes.POINTER(SYSTEM_HANDLE_INFORMATION_EX)).contents + n = int(info.NumberOfHandles) + return buf, n + # STATUS_INFO_LENGTH_MISMATCH = 0xC0000004 (signed: -1073741820) + if status in (-1073741820, 0xC0000004): + size = max(size * 2, int(ret_len.value) + 0x10000) + if size > max_size: + return None, 0 + continue + return None, 0 + + +def _get_file_object_type_index(buf: ctypes.Array, handle_count: int, sample_path: str) -> int | None: + """ + 通过“本进程打开一个文件句柄”,再在系统句柄表中找到它对应的 ObjectTypeIndex。 + 这样可以只扫描 File 类型句柄,速度会快很多。 + """ + try: + h = kernel32.CreateFileW( + sample_path, + GENERIC_READ, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + None, + OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL, + None, + ) + if not h or h == wintypes.HANDLE(-1).value: + return None + except Exception: + return None + + try: + cur_pid = int(kernel32.GetCurrentProcessId()) + hv = int(ctypes.cast(h, ctypes.c_size_t).value) + + n = int(handle_count) + if n <= 0: + return None + array_type = SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX * n + info = ctypes.cast(buf, ctypes.POINTER(SYSTEM_HANDLE_INFORMATION_EX)).contents + handles = ctypes.cast(ctypes.addressof(info.Handles), ctypes.POINTER(array_type)).contents + + for ent in handles: + pid = int(ctypes.cast(ent.UniqueProcessId, ctypes.c_size_t).value) + if pid != cur_pid: + continue + val = int(ctypes.cast(ent.HandleValue, ctypes.c_size_t).value) + if val == hv: + return int(ent.ObjectTypeIndex) + return None + finally: + try: + kernel32.CloseHandle(h) + except Exception: + pass + + +def find_file_locks(target_path: str, *, max_handles: int = 200000) -> list[FileLock]: + """ + 返回当前系统中占用 target_path 的 (pid, handle) 列表。 + - 基于 SystemExtendedHandleInformation + DuplicateHandle + GetFinalPathNameByHandleW + """ + _try_enable_debug_privilege() + target_norm = _normalize_path(target_path) + + buf, n = _query_system_handles() + if buf is None or n <= 0: + return [] + + if n <= 0: + return [] + if n > max_handles: + n = max_handles + + # 重新按实际数量解释数组 + array_type = SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX * n + info = ctypes.cast(buf, ctypes.POINTER(SYSTEM_HANDLE_INFORMATION_EX)).contents + handles = ctypes.cast(ctypes.addressof(info.Handles), ctypes.POINTER(array_type)).contents + + cur_proc = kernel32.GetCurrentProcess() + locks: list[FileLock] = [] + + file_type_index = _get_file_object_type_index(buf, int(info.NumberOfHandles), target_path) + # 进程句柄缓存:避免每个 handle 都 OpenProcess/CloseHandle + proc_cache: dict[int, wintypes.HANDLE] = {} + + for h in handles: + pid = int(ctypes.cast(h.UniqueProcessId, ctypes.c_size_t).value) + hv = int(ctypes.cast(h.HandleValue, ctypes.c_size_t).value) + if pid <= 0 or hv <= 0: + continue + if file_type_index is not None and int(h.ObjectTypeIndex) != int(file_type_index): + continue + + ph = proc_cache.get(pid) + if not ph: + ph = kernel32.OpenProcess( + PROCESS_DUP_HANDLE | PROCESS_QUERY_LIMITED_INFORMATION, False, pid + ) + if not ph: + continue + proc_cache[pid] = ph + try: + dup = wintypes.HANDLE() + if not kernel32.DuplicateHandle(ph, wintypes.HANDLE(hv), cur_proc, ctypes.byref(dup), 0, False, DUPLICATE_SAME_ACCESS): + continue + try: + # 获取路径 + bufw = ctypes.create_unicode_buffer(4096) + got = kernel32.GetFinalPathNameByHandleW(dup, bufw, len(bufw), 0) + if got == 0 or got >= len(bufw): + continue + final_norm = _normalize_final_path(bufw.value) + if final_norm == target_norm: + locks.append(FileLock(pid=pid, handle=hv, path=final_norm)) + finally: + kernel32.CloseHandle(dup) + finally: + pass + + for ph in proc_cache.values(): + try: + kernel32.CloseHandle(ph) + except Exception: + pass + + return locks + + +def close_remote_handle(pid: int, handle_value: int) -> bool: + """ + 关闭目标进程中指定 handle(不结束进程)。 + 需要足够权限(通常管理员 + SeDebugPrivilege 才更稳)。 + """ + _try_enable_debug_privilege() + ph = kernel32.OpenProcess(PROCESS_DUP_HANDLE, False, int(pid)) + if not ph: + return False + try: + dup = wintypes.HANDLE() + ok = kernel32.DuplicateHandle( + ph, + wintypes.HANDLE(int(handle_value)), + kernel32.GetCurrentProcess(), + ctypes.byref(dup), + 0, + False, + DUPLICATE_CLOSE_SOURCE, + ) + if ok and dup: + kernel32.CloseHandle(dup) + return bool(ok) + finally: + kernel32.CloseHandle(ph) +