修复依赖缺失问题

This commit is contained in:
李志强 2026-04-08 10:11:16 +08:00
parent 535b83cd56
commit cd4ffbfa90
4 changed files with 193 additions and 244 deletions

126
main.py
View File

@ -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):
"""
@ -78,6 +187,8 @@ 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:

View File

@ -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)
# 无空格长中文也能在边界处断行,而不是整段挤在一行被裁切
"""让提示框按内容换行并自适应宽高。"""
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:
lbl.setWordWrapMode(QTextOption.WrapMode.WrapAnywhere)
except AttributeError:
w.setWordWrap(True) # type: ignore[attr-defined]
except Exception:
pass
lbl.setMinimumWidth(200)
lbl.setMaximumWidth(560)
lbl.setSizePolicy(
QSizePolicy.Policy.Preferred,
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,
)
lay = msg.layout()
if lay is not None:
lay.activate()
) # 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(

View File

@ -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,144 +77,7 @@ 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()
@ -231,14 +91,12 @@ $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",
["powershell", "-WindowStyle", "Hidden",
"-NonInteractive", "-ExecutionPolicy", "Bypass",
"-File", tmp.name,
],
"-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}")