修复依赖缺失问题

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 sys
import os import os
import time 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.QtWidgets import QApplication
from PyQt6.QtGui import QIcon from PyQt6.QtGui import QIcon
from db.database import init_db from db.database import init_db
@ -12,6 +16,111 @@ from db import database
from app_info import __VERSION__, app_title 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): def get_resource_path(relative_path):
""" """
@ -78,6 +187,8 @@ def main():
if not _wake_existing_or_exit(): if not _wake_existing_or_exit():
return return
_install_requirements_on_startup()
QApplication.setAttribute(Qt.ApplicationAttribute.AA_Use96Dpi, True) QApplication.setAttribute(Qt.ApplicationAttribute.AA_Use96Dpi, True)
try: try:
QApplication.setHighDpiScaleFactorRoundingPolicy( QApplication.setHighDpiScaleFactorRoundingPolicy(
@ -111,16 +222,9 @@ def main():
ball.clicked.connect(lambda: panel.show_near(ball.pos(), BALL_SIZE)) ball.clicked.connect(lambda: panel.show_near(ball.pos(), BALL_SIZE))
ball.right_clicked.connect(lambda pos: panel.tray.contextMenu().exec(pos)) ball.right_clicked.connect(lambda pos: panel.tray.contextMenu().exec(pos))
# 全局快捷键 Alt+` 唤醒/隐藏主界面 # 全局快捷键 Alt+` 唤醒/隐藏主界面Win32 RegisterHotKey真正全局
from PyQt6.QtGui import QShortcut, QKeySequence _hotkey = GlobalHotkey(panel, ball)
_shortcut = QShortcut(QKeySequence("Alt+`"), panel) _hotkey.install()
_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)
has_saved = bool(database.get_setting("panel_x", "")) has_saved = bool(database.get_setting("panel_x", ""))
if has_saved: if has_saved:

View File

@ -1,7 +1,7 @@
"""标准对话框QMessageBox / QInputDialog / QFileDialog与当前主题一致。""" """标准对话框QMessageBox / QInputDialog / QFileDialog与当前主题一致。"""
from __future__ import annotations from __future__ import annotations
from PyQt6.QtCore import Qt from PyQt6.QtCore import Qt, QTimer
from PyQt6.QtGui import QTextOption from PyQt6.QtGui import QTextOption
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QFileDialog, QFileDialog,
@ -37,7 +37,6 @@ def stylesheet() -> str:
}} }}
QMessageBox QLabel#qt_msgbox_label {{ QMessageBox QLabel#qt_msgbox_label {{
min-width: 200px; min-width: 200px;
max-width: 560px;
}} }}
QMessageBox QLabel#qt_msgboxex_icon_label {{ QMessageBox QLabel#qt_msgboxex_icon_label {{
min-width: 28px; min-width: 28px;
@ -140,31 +139,74 @@ def _apply(w: QWidget | None) -> None:
def _prepare_qmessagebox(msg: QMessageBox) -> None: def _prepare_qmessagebox(msg: QMessageBox) -> None:
"""让提示框按内容换行并自适应宽高(避免固定宽度截断文字)。""" """让提示框按内容换行并自适应宽高。"""
lbl = msg.findChild(QLabel, "qt_msgbox_label") def _tune_labels() -> None:
if lbl is not None: # QMessageBox 里通常有两段文字:
lbl.setWordWrap(True) # - qt_msgbox_label主文本
lbl.setTextFormat(Qt.TextFormat.PlainText) # - 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: try:
lbl.setWordWrapMode(QTextOption.WrapMode.WrapAnywhere) w.setWordWrap(True) # type: ignore[attr-defined]
except AttributeError: except Exception:
pass pass
lbl.setMinimumWidth(200) if hasattr(w, "setTextFormat"):
lbl.setMaximumWidth(560) try:
lbl.setSizePolicy( w.setTextFormat(Qt.TextFormat.PlainText) # type: ignore[attr-defined]
QSizePolicy.Policy.Preferred, 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, QSizePolicy.Policy.MinimumExpanding,
) ) # type: ignore[attr-defined]
lay = msg.layout() except Exception:
if lay is not None: pass
lay.activate()
msg.setSizePolicy( msg.setSizePolicy(
QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred,
QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred,
) )
msg.setMinimumSize(0, 0) 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() msg.adjustSize()
QTimer.singleShot(0, lambda: (_tune_labels(), msg.adjustSize()))
def question( def question(

View File

@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
""" """
软件自动更新模块 软件自动更新模块
- 检查版本GET https://api.yunzer.cn/api/softwareupgrade/check?code=niumasortware - 检查版本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.QtCore import QThread, pyqtSignal, QObject
from PyQt6.QtWidgets import QProgressDialog, QMessageBox, QApplication from PyQt6.QtWidgets import QProgressDialog, QMessageBox, QApplication
import urllib.request import urllib.request
import ui.dialog_style as dialog_style
UPDATE_CHECK_URL = "https://api.yunzer.cn/api/softwareupgrade/check?code=niumasortware" 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: def _current_version() -> str:
@ -45,7 +44,6 @@ class _CheckWorker(QThread):
def run(self): def run(self):
try: try:
import json
with urllib.request.urlopen(UPDATE_CHECK_URL, timeout=10) as resp: with urllib.request.urlopen(UPDATE_CHECK_URL, timeout=10) as resp:
body = json.loads(resp.read().decode()) body = json.loads(resp.read().decode())
if body.get("code") == 200: if body.get("code") == 200:
@ -72,7 +70,6 @@ class _DownloadWorker(QThread):
if total_size > 0: if total_size > 0:
pct = min(100, int(count * block_size * 100 / total_size)) pct = min(100, int(count * block_size * 100 / total_size))
self.progress.emit(pct) self.progress.emit(pct)
urllib.request.urlretrieve(self._url, self._dest, _reporthook) urllib.request.urlretrieve(self._url, self._dest, _reporthook)
self.progress.emit(100) self.progress.emit(100)
self.finished.emit(self._dest) self.finished.emit(self._dest)
@ -80,144 +77,7 @@ class _DownloadWorker(QThread):
self.error.emit(str(e)) 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): def _replace_and_restart(new_exe: str):
"""
onedir 模式只替换 exe 本身dll 等文件不变
PowerShell 后台脚本等待原进程退出后替换并重启完全无窗口
"""
if not getattr(sys, "frozen", False): if not getattr(sys, "frozen", False):
subprocess.Popen([new_exe]) subprocess.Popen([new_exe])
QApplication.quit() QApplication.quit()
@ -231,14 +91,12 @@ $pid_target = {pid}
$src = '{new_exe.replace(chr(92), chr(92)*2)}' $src = '{new_exe.replace(chr(92), chr(92)*2)}'
$dst = '{current_exe.replace(chr(92), chr(92)*2)}' $dst = '{current_exe.replace(chr(92), chr(92)*2)}'
# 等待原进程退出,最多 30 秒
$waited = 0 $waited = 0
while ((Get-Process -Id $pid_target -ErrorAction SilentlyContinue) -and $waited -lt 30) {{ while ((Get-Process -Id $pid_target -ErrorAction SilentlyContinue) -and $waited -lt 30) {{
Start-Sleep -Milliseconds 500 Start-Sleep -Milliseconds 500
$waited += 0.5 $waited += 0.5
}} }}
# 重试覆盖,最多 10 次
$ok = $false $ok = $false
for ($i = 0; $i -lt 10; $i++) {{ for ($i = 0; $i -lt 10; $i++) {{
try {{ try {{
@ -255,7 +113,6 @@ if ($ok) {{
Start-Process $dst Start-Process $dst
}} }}
""" """
tmp = tempfile.NamedTemporaryFile( tmp = tempfile.NamedTemporaryFile(
delete=False, suffix=".ps1", mode="w", encoding="utf-8" delete=False, suffix=".ps1", mode="w", encoding="utf-8"
) )
@ -263,11 +120,9 @@ if ($ok) {{
tmp.close() tmp.close()
subprocess.Popen( subprocess.Popen(
[ ["powershell", "-WindowStyle", "Hidden",
"powershell", "-WindowStyle", "Hidden",
"-NonInteractive", "-ExecutionPolicy", "Bypass", "-NonInteractive", "-ExecutionPolicy", "Bypass",
"-File", tmp.name, "-File", tmp.name],
],
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NO_WINDOW, creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NO_WINDOW,
) )
QApplication.quit() QApplication.quit()
@ -292,7 +147,7 @@ class Updater(QObject):
if not _is_newer(latest, current): if not _is_newer(latest, current):
if not self._silent: if not self._silent:
QMessageBox.information(self._parent_widget, "检查更新", f"当前已是最新版本 v{current}") dialog_style.information(self._parent_widget, "检查更新", f"当前已是最新版本 v{current}")
return return
notes = data.get("releaseNotes", "") or "" notes = data.get("releaseNotes", "") or ""
@ -301,24 +156,21 @@ class Updater(QObject):
msg += f"\n\n更新内容:\n{notes}" msg += f"\n\n更新内容:\n{notes}"
msg += "\n\n是否立即下载更新?" msg += "\n\n是否立即下载更新?"
ret = QMessageBox.question( ret = dialog_style.question(self._parent_widget, "发现新版本", msg)
self._parent_widget, "发现新版本", msg,
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.Yes,
)
if ret != QMessageBox.StandardButton.Yes: if ret != QMessageBox.StandardButton.Yes:
return return
self._url = url
self._download(url) self._download(url)
def _on_check_error(self, err: str): def _on_check_error(self, err: str):
if not self._silent: 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): def _download(self, url: str):
# 全局安装:不要下载到安装目录(通常在 Program Files不可写 if getattr(sys, "frozen", False):
dest_dir = _update_work_dir() if getattr(sys, "frozen", False) else tempfile.gettempdir() dest_dir = os.path.dirname(sys.executable)
else:
dest_dir = tempfile.gettempdir()
dest = os.path.join(dest_dir, "_update_new.exe") dest = os.path.join(dest_dir, "_update_new.exe")
self._progress = QProgressDialog("正在下载更新...", "取消", 0, 100, self._parent_widget) self._progress = QProgressDialog("正在下载更新...", "取消", 0, 100, self._parent_widget)
@ -335,57 +187,8 @@ class Updater(QObject):
def _on_download_done(self, path: str): def _on_download_done(self, path: str):
self._progress.close() 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) _replace_and_restart(path)
def _on_download_error(self, err: str): def _on_download_error(self, err: str):
self._progress.close() self._progress.close()
QMessageBox.critical(self._parent_widget, "下载失败", f"下载更新失败:\n{err}") dialog_style.warning(self._parent_widget, "下载失败", f"下载更新失败:\n{err}")