From cd4ffbfa9036f281e1b0418d7b41b63220747211 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=BF=97=E5=BC=BA?= <357099073@qq.com> Date: Wed, 8 Apr 2026 10:11:16 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BE=9D=E8=B5=96=E7=BC=BA?= =?UTF-8?q?=E5=A4=B1=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main.py | 128 +++++++++-- ui/__pycache__/dialog_style.cpython-313.pyc | Bin 12044 -> 13820 bytes ui/dialog_style.py | 84 ++++++-- ui/updater.py | 225 ++------------------ 4 files changed, 193 insertions(+), 244 deletions(-) diff --git a/main.py b/main.py index e865a7a..1812072 100644 --- a/main.py +++ b/main.py @@ -1,7 +1,11 @@ import sys import os import time -from PyQt6.QtCore import Qt +import ctypes +import ctypes.wintypes +import subprocess +import importlib.util +from PyQt6.QtCore import Qt, QAbstractNativeEventFilter from PyQt6.QtWidgets import QApplication from PyQt6.QtGui import QIcon from db.database import init_db @@ -12,6 +16,111 @@ from db import database from app_info import __VERSION__, app_title + +def _install_requirements_on_startup() -> None: + """ + 启动时仅在“检测到缺失依赖”时安装 requirements。 + 失败时不阻塞主程序启动。 + """ + if getattr(sys, "frozen", False): + # 打包态通常无法通过当前 exe 直接调用 pip,直接跳过 + return + + req_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "requirements.txt") + if not os.path.exists(req_path): + return + + module_name_map = { + "pyqt6": "PyQt6", + "pillow": "PIL", + } + + def _req_name(req_line: str) -> str: + s = req_line.split(";", 1)[0].strip() + s = s.split("[", 1)[0] + for sep in ("==", ">=", "<=", "~=", "!=", ">", "<"): + if sep in s: + s = s.split(sep, 1)[0].strip() + break + return s + + missing_reqs: list[str] = [] + try: + with open(req_path, "r", encoding="utf-8") as f: + for raw in f: + line = raw.strip() + if not line or line.startswith("#"): + continue + pkg_name = _req_name(line) + if not pkg_name: + continue + mod_name = module_name_map.get(pkg_name.lower(), pkg_name) + if importlib.util.find_spec(mod_name) is None: + missing_reqs.append(line) + except Exception: + return + + if not missing_reqs: + return + + cmd = [sys.executable, "-m", "pip", "install", *missing_reqs] + kwargs = { + "stdout": subprocess.DEVNULL, + "stderr": subprocess.DEVNULL, + "check": False, + } + if sys.platform == "win32": + kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW + try: + subprocess.run(cmd, **kwargs) + except Exception: + pass + + +class GlobalHotkey(QAbstractNativeEventFilter): + """用 Win32 RegisterHotKey 注册真正的全局快捷键 Alt+`""" + _HOTKEY_ID = 0x4E4D # 任意唯一 ID + _MOD_ALT = 0x0001 + _VK_BACKTICK = 0xC0 # VK_OEM_3 = ` 键 + + def __init__(self, panel: "PanelWindow", ball: "FloatBall"): + super().__init__() + self._panel = panel + self._ball = ball + self._hwnd = None + + def install(self): + if sys.platform != "win32": + return + # 用一个隐藏窗口的 HWND 来接收 WM_HOTKEY + self._hwnd = int(self._panel.winId()) + ctypes.windll.user32.RegisterHotKey( + self._hwnd, self._HOTKEY_ID, self._MOD_ALT, self._VK_BACKTICK + ) + QApplication.instance().installNativeEventFilter(self) + + def uninstall(self): + if self._hwnd: + ctypes.windll.user32.UnregisterHotKey(self._hwnd, self._HOTKEY_ID) + QApplication.instance().removeNativeEventFilter(self) + + def nativeEventFilter(self, event_type, message): + if event_type == b"windows_generic_MSG": + import ctypes + msg = ctypes.cast(int(message), ctypes.POINTER(ctypes.wintypes.MSG)).contents + WM_HOTKEY = 0x0312 + if msg.message == WM_HOTKEY and msg.wParam == self._HOTKEY_ID: + self._toggle() + return False, 0 + + def _toggle(self): + if self._panel.isVisible(): + self._panel.minimize_to_ball() + else: + self._panel.show_near(self._ball.pos(), BALL_SIZE) + self._panel.raise_() + self._panel.activateWindow() + # ===================== 打包兼容核心函数 ===================== def get_resource_path(relative_path): """ @@ -77,7 +186,9 @@ def _wake_existing_or_exit() -> bool: def main(): if not _wake_existing_or_exit(): return - + + _install_requirements_on_startup() + QApplication.setAttribute(Qt.ApplicationAttribute.AA_Use96Dpi, True) try: QApplication.setHighDpiScaleFactorRoundingPolicy( @@ -111,16 +222,9 @@ def main(): ball.clicked.connect(lambda: panel.show_near(ball.pos(), BALL_SIZE)) ball.right_clicked.connect(lambda pos: panel.tray.contextMenu().exec(pos)) - # 全局快捷键 Alt+` 唤醒/隐藏主界面 - from PyQt6.QtGui import QShortcut, QKeySequence - _shortcut = QShortcut(QKeySequence("Alt+`"), panel) - _shortcut.setContext(Qt.ShortcutContext.ApplicationShortcut) - def _toggle_panel(): - if panel.isVisible(): - panel.minimize_to_ball() - else: - panel.show_near(ball.pos(), BALL_SIZE) - _shortcut.activated.connect(_toggle_panel) + # 全局快捷键 Alt+` 唤醒/隐藏主界面(Win32 RegisterHotKey,真正全局) + _hotkey = GlobalHotkey(panel, ball) + _hotkey.install() has_saved = bool(database.get_setting("panel_x", "")) if has_saved: diff --git a/ui/__pycache__/dialog_style.cpython-313.pyc b/ui/__pycache__/dialog_style.cpython-313.pyc index 0167c4ad13e5564fa3cb8638765946f72a825fcb..aa9f03bbdf2da7685d1be72733ad5803e0778653 100644 GIT binary patch delta 5392 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 NSpIWo4Yq;Ue*n|!m=^#5 diff --git a/ui/dialog_style.py b/ui/dialog_style.py index 114f0c7..914890b 100644 --- a/ui/dialog_style.py +++ b/ui/dialog_style.py @@ -1,7 +1,7 @@ """标准对话框(QMessageBox / QInputDialog / QFileDialog)与当前主题一致。""" from __future__ import annotations -from PyQt6.QtCore import Qt +from PyQt6.QtCore import Qt, QTimer from PyQt6.QtGui import QTextOption from PyQt6.QtWidgets import ( QFileDialog, @@ -37,7 +37,6 @@ def stylesheet() -> str: }} QMessageBox QLabel#qt_msgbox_label {{ min-width: 200px; - max-width: 560px; }} QMessageBox QLabel#qt_msgboxex_icon_label {{ min-width: 28px; @@ -140,31 +139,74 @@ def _apply(w: QWidget | None) -> None: def _prepare_qmessagebox(msg: QMessageBox) -> None: - """让提示框按内容换行并自适应宽高(避免固定宽度截断文字)。""" - lbl = msg.findChild(QLabel, "qt_msgbox_label") - if lbl is not None: - lbl.setWordWrap(True) - lbl.setTextFormat(Qt.TextFormat.PlainText) - # 无空格长中文也能在边界处断行,而不是整段挤在一行被裁切 - try: - lbl.setWordWrapMode(QTextOption.WrapMode.WrapAnywhere) - except AttributeError: - pass - lbl.setMinimumWidth(200) - lbl.setMaximumWidth(560) - lbl.setSizePolicy( - QSizePolicy.Policy.Preferred, - QSizePolicy.Policy.MinimumExpanding, - ) - lay = msg.layout() - if lay is not None: - lay.activate() + """让提示框按内容换行并自适应宽高。""" + def _tune_labels() -> None: + # QMessageBox 里通常有两段文字: + # - qt_msgbox_label(主文本) + # - qt_msgbox_informativelabel(辅助文本/更长的提示) + # 部分平台会在显示/布局后才创建这些 label,因此这里支持延迟执行一次。 + for obj_name in ("qt_msgbox_label", "qt_msgbox_informativelabel"): + w = msg.findChild(QWidget, obj_name) + if w is None: + continue + + if hasattr(w, "setWordWrap"): + try: + w.setWordWrap(True) # type: ignore[attr-defined] + except Exception: + pass + if hasattr(w, "setTextFormat"): + try: + w.setTextFormat(Qt.TextFormat.PlainText) # type: ignore[attr-defined] + except Exception: + pass + if hasattr(w, "setWordWrapMode"): + try: + w.setWordWrapMode(QTextOption.WrapMode.WrapAnywhere) # type: ignore[attr-defined] + except Exception: + pass + if hasattr(w, "setTextElideMode"): + try: + w.setTextElideMode(Qt.TextElideMode.ElideNone) # type: ignore[attr-defined] + except Exception: + pass + if hasattr(w, "setMinimumWidth"): + try: + w.setMinimumWidth(0) # type: ignore[attr-defined] + except Exception: + pass + if hasattr(w, "setMaximumWidth"): + try: + w.setMaximumWidth(16777215) # type: ignore[attr-defined] + except Exception: + pass + if hasattr(w, "setSizePolicy"): + try: + w.setSizePolicy( + QSizePolicy.Policy.Expanding, + QSizePolicy.Policy.MinimumExpanding, + ) # type: ignore[attr-defined] + except Exception: + pass msg.setSizePolicy( QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred, ) msg.setMinimumSize(0, 0) + msg.setMaximumSize(16777215, 16777215) + + lay = msg.layout() + if lay is not None: + # QMessageBox 内部通常是 QGridLayout:第 1 列是文本列 + # 某些主题/平台会让文本列偏窄从而触发省略,这里显式拉伸该列并设置最小宽度。 + try: + lay.setColumnStretch(1, 1) + except Exception: + pass + lay.activate() + _tune_labels() msg.adjustSize() + QTimer.singleShot(0, lambda: (_tune_labels(), msg.adjustSize())) def question( diff --git a/ui/updater.py b/ui/updater.py index 5263907..d3c11c3 100644 --- a/ui/updater.py +++ b/ui/updater.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- """ 软件自动更新模块 - 检查版本:GET https://api.yunzer.cn/api/softwareupgrade/check?code=niumasortware @@ -13,12 +14,10 @@ import base64 from PyQt6.QtCore import QThread, pyqtSignal, QObject from PyQt6.QtWidgets import QProgressDialog, QMessageBox, QApplication import urllib.request +import ui.dialog_style as dialog_style UPDATE_CHECK_URL = "https://api.yunzer.cn/api/softwareupgrade/check?code=niumasortware" -APP_NAME = "CleanDesktopOrganizer" -# schtasks 的任务名在不同环境下可能需要/不需要前导 "\",这里两种都尝试 -UPDATE_TASK_NAMES = [r"\CleanDesktopOrganizer\Update", r"CleanDesktopOrganizer\Update"] def _current_version() -> str: @@ -45,7 +44,6 @@ class _CheckWorker(QThread): def run(self): try: - import json with urllib.request.urlopen(UPDATE_CHECK_URL, timeout=10) as resp: body = json.loads(resp.read().decode()) if body.get("code") == 200: @@ -72,7 +70,6 @@ class _DownloadWorker(QThread): if total_size > 0: pct = min(100, int(count * block_size * 100 / total_size)) self.progress.emit(pct) - urllib.request.urlretrieve(self._url, self._dest, _reporthook) self.progress.emit(100) self.finished.emit(self._dest) @@ -80,165 +77,26 @@ class _DownloadWorker(QThread): self.error.emit(str(e)) -def _programdata_dir() -> str: - base = os.environ.get("PROGRAMDATA") or r"C:\ProgramData" - return os.path.join(base, APP_NAME) - - -def _ensure_dir(p: str): - try: - os.makedirs(p, exist_ok=True) - except Exception: - pass - - -def _update_work_dir() -> str: - """ - 全局安装场景下,Program Files 不可写;更新文件与请求文件统一放 ProgramData。 - 该目录应由安装器创建并赋予 Users Modify 权限。 - """ - d = os.path.join(_programdata_dir(), "updates") - _ensure_dir(d) - if os.path.isdir(d): - return d - return tempfile.gettempdir() - - -def _request_file_path() -> str: - d = _programdata_dir() - _ensure_dir(d) - return os.path.join(d, "update_request.json") - - -def _task_exists(name: str) -> bool: - try: - r = subprocess.run( - ["schtasks", "/Query", "/TN", name], - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - creationflags=subprocess.CREATE_NO_WINDOW, - ) - return r.returncode == 0 - except Exception: - return False - - -def _run_update_task() -> bool: - for name in UPDATE_TASK_NAMES: - if not _task_exists(name): - continue - try: - r = subprocess.run( - ["schtasks", "/Run", "/TN", name], - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - creationflags=subprocess.CREATE_NO_WINDOW, - ) - if r.returncode == 0: - return True - except Exception: - continue - return False - - -def _run_update_helper_elevated() -> bool: - """ - 当计划任务不存在时的兜底:以管理员权限运行 update_helper.exe。 - 这会触发 UAC(无法完全静默),但能保证更新能完成。 - """ - if sys.platform != "win32": - return False - try: - helper = os.path.join(os.path.dirname(sys.executable), "update_helper.exe") - if not os.path.isfile(helper): - return False - # ShellExecuteW 返回值 > 32 表示成功启动 - r = ctypes.windll.shell32.ShellExecuteW(None, "runas", helper, None, None, 0) - return int(r) > 32 - except Exception: - return False - - -def _run_elevated_copy_and_restart(src: str, dst: str, pid: int) -> bool: - """ - 不依赖 update_helper.exe 的兜底方案: - 直接用管理员权限启动 PowerShell,等待原进程退出后覆盖 dst 并重启。 - """ - if sys.platform != "win32": - return False - try: - ps = rf""" -$pid_target = {int(pid)} -$src = '{str(src).replace("'", "''")}' -$dst = '{str(dst).replace("'", "''")}' - -$waited = 0 -while ((Get-Process -Id $pid_target -ErrorAction SilentlyContinue) -and $waited -lt 60) {{ - Start-Sleep -Milliseconds 500 - $waited += 0.5 -}} - -$ok = $false -for ($i = 0; $i -lt 20; $i++) {{ - try {{ - Copy-Item -Path $src -Destination $dst -Force - $ok = $true - break - }} catch {{ - Start-Sleep -Milliseconds 800 - }} -}} - -if ($ok) {{ - Remove-Item $src -Force -ErrorAction SilentlyContinue - Start-Process $dst -}} -""" - # PowerShell -EncodedCommand 需要 UTF-16LE + base64 - enc = base64.b64encode(ps.encode("utf-16le")).decode("ascii") - r = ctypes.windll.shell32.ShellExecuteW( - None, - "runas", - "powershell", - f"-NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand {enc}", - None, - 0, - ) - return int(r) > 32 - except Exception: - return False - - def _replace_and_restart(new_exe: str): - """ - onedir 模式:只替换 exe 本身,dll 等文件不变。 - 用 PowerShell 后台脚本等待原进程退出后替换并重启,完全无窗口。 - """ if not getattr(sys, "frozen", False): subprocess.Popen([new_exe]) QApplication.quit() return current_exe = sys.executable - pid = os.getpid() + pid = os.getpid() ps_script = f""" $pid_target = {pid} $src = '{new_exe.replace(chr(92), chr(92)*2)}' $dst = '{current_exe.replace(chr(92), chr(92)*2)}' -# 等待原进程退出,最多 30 秒 $waited = 0 while ((Get-Process -Id $pid_target -ErrorAction SilentlyContinue) -and $waited -lt 30) {{ Start-Sleep -Milliseconds 500 $waited += 0.5 }} -# 重试覆盖,最多 10 次 $ok = $false for ($i = 0; $i -lt 10; $i++) {{ try {{ @@ -255,7 +113,6 @@ if ($ok) {{ Start-Process $dst }} """ - tmp = tempfile.NamedTemporaryFile( delete=False, suffix=".ps1", mode="w", encoding="utf-8" ) @@ -263,11 +120,9 @@ if ($ok) {{ tmp.close() subprocess.Popen( - [ - "powershell", "-WindowStyle", "Hidden", - "-NonInteractive", "-ExecutionPolicy", "Bypass", - "-File", tmp.name, - ], + ["powershell", "-WindowStyle", "Hidden", + "-NonInteractive", "-ExecutionPolicy", "Bypass", + "-File", tmp.name], creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NO_WINDOW, ) QApplication.quit() @@ -292,7 +147,7 @@ class Updater(QObject): if not _is_newer(latest, current): if not self._silent: - QMessageBox.information(self._parent_widget, "检查更新", f"当前已是最新版本 v{current}") + dialog_style.information(self._parent_widget, "检查更新", f"当前已是最新版本 v{current}") return notes = data.get("releaseNotes", "") or "" @@ -301,24 +156,21 @@ class Updater(QObject): msg += f"\n\n更新内容:\n{notes}" msg += "\n\n是否立即下载更新?" - ret = QMessageBox.question( - self._parent_widget, "发现新版本", msg, - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - QMessageBox.StandardButton.Yes, - ) + ret = dialog_style.question(self._parent_widget, "发现新版本", msg) if ret != QMessageBox.StandardButton.Yes: return - self._url = url self._download(url) def _on_check_error(self, err: str): if not self._silent: - QMessageBox.warning(self._parent_widget, "检查更新失败", f"无法连接更新服务器:\n{err}") + dialog_style.warning(self._parent_widget, "检查更新失败", f"无法连接更新服务器:\n{err}") def _download(self, url: str): - # 全局安装:不要下载到安装目录(通常在 Program Files,不可写) - dest_dir = _update_work_dir() if getattr(sys, "frozen", False) else tempfile.gettempdir() + if getattr(sys, "frozen", False): + dest_dir = os.path.dirname(sys.executable) + else: + dest_dir = tempfile.gettempdir() dest = os.path.join(dest_dir, "_update_new.exe") self._progress = QProgressDialog("正在下载更新...", "取消", 0, 100, self._parent_widget) @@ -335,57 +187,8 @@ class Updater(QObject): def _on_download_done(self, path: str): self._progress.close() - # frozen 且全局安装:交给计划任务(最高权限)覆盖安装目录 exe - if getattr(sys, "frozen", False): - try: - req = { - "src": path, - "dst": sys.executable, - "pid": os.getpid(), - "restart": True, - } - with open(_request_file_path(), "w", encoding="utf-8") as f: - json.dump(req, f, ensure_ascii=False) - except Exception as e: - QMessageBox.critical(self._parent_widget, "更新失败", f"无法准备更新任务:\n{e}") - return - - if _run_update_task(): - QApplication.quit() - return - - # 兜底策略 1:计划任务不存在时,提示并可通过 UAC 直接运行 update_helper.exe 完成一次更新 - if _run_update_helper_elevated(): - QApplication.quit() - return - - # 兜底策略 1.5:如果安装目录里没有 update_helper.exe,则直接用管理员 PowerShell 完成覆盖 - if _run_elevated_copy_and_restart(path, sys.executable, os.getpid()): - QApplication.quit() - return - - # 兜底策略 2:如果安装目录可写(非常少见),尝试旧的自替换方式 - try: - can_write = os.access(sys.executable, os.W_OK) - except Exception: - can_write = False - if can_write: - _replace_and_restart(path) - return - - QMessageBox.critical( - self._parent_widget, - "更新失败", - "已下载更新,但未找到/无法运行更新计划任务(需要管理员权限)。\n\n" - "请尝试:\n" - "1) 以管理员身份重新安装一次安装包(用于创建更新计划任务)\n" - "2) 或在“任务计划程序”检查是否存在任务:CleanDesktopOrganizer\\Update\n" - "3) 也可尝试用管理员权限运行 update_helper.exe 完成一次更新", - ) - return - _replace_and_restart(path) def _on_download_error(self, err: str): self._progress.close() - QMessageBox.critical(self._parent_widget, "下载失败", f"下载更新失败:\n{err}") + dialog_style.warning(self._parent_widget, "下载失败", f"下载更新失败:\n{err}")