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 0167c4a..aa9f03b 100644 Binary files a/ui/__pycache__/dialog_style.cpython-313.pyc and b/ui/__pycache__/dialog_style.cpython-313.pyc differ 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}")