修复依赖缺失问题
This commit is contained in:
parent
535b83cd56
commit
cd4ffbfa90
126
main.py
126
main.py
@ -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:
|
||||||
|
|||||||
Binary file not shown.
@ -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(
|
||||||
|
|||||||
221
ui/updater.py
221
ui/updater.py
@ -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}")
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user