diff --git a/installer/niumasoftware.generated.iss b/installer/niumasoftware.generated.iss index d0df1be..c8a519f 100644 --- a/installer/niumasoftware.generated.iss +++ b/installer/niumasoftware.generated.iss @@ -49,7 +49,7 @@ Name: "{autodesktop}\牛马软件柜"; Filename: "{app}\niumasoftware.exe"; Task Filename: "{app}\niumasoftware.exe"; Description: "运行 牛马软件柜"; Flags: nowait postinstall skipifsilent [UninstallRun] -Filename: "{sys}\schtasks.exe"; Parameters: "/Delete /TN ""CleanDesktopOrganizer\Update"" /F"; Flags: runhidden +Filename: "{sys}\schtasks.exe"; Parameters: "/Delete /TN ""\CleanDesktopOrganizer\Update"" /F"; Flags: runhidden [Code] procedure CreateUpdateTask(); @@ -60,7 +60,7 @@ begin // 计划任务:以 SYSTEM 运行,按需触发(/SC ONDEMAND) // 注意:TR 不带参数,update_helper.exe 会读取 ProgramData 请求文件 Cmd := - '/Create /F /TN "CleanDesktopOrganizer\Update" ' + + '/Create /F /TN "\CleanDesktopOrganizer\Update" ' + '/RU "SYSTEM" /RL HIGHEST /SC ONDEMAND ' + '/TR "' + ExpandConstant('{app}\update_helper.exe') + '"'; diff --git a/installer/niumasoftware.iss b/installer/niumasoftware.iss index 698bdc4..8a812fb 100644 --- a/installer/niumasoftware.iss +++ b/installer/niumasoftware.iss @@ -48,7 +48,7 @@ Name: "{autodesktop}\牛马软件柜"; Filename: "{app}\niumasoftware.exe"; Task Filename: "{app}\niumasoftware.exe"; Description: "运行 牛马软件柜"; Flags: nowait postinstall skipifsilent [UninstallRun] -Filename: "{sys}\schtasks.exe"; Parameters: "/Delete /TN ""CleanDesktopOrganizer\Update"" /F"; Flags: runhidden +Filename: "{sys}\schtasks.exe"; Parameters: "/Delete /TN ""\CleanDesktopOrganizer\Update"" /F"; Flags: runhidden [Code] procedure CreateUpdateTask(); @@ -59,7 +59,7 @@ begin // 计划任务:以 SYSTEM 运行,按需触发(/SC ONDEMAND) // 注意:TR 不带参数,update_helper.exe 会读取 ProgramData 请求文件 Cmd := - '/Create /F /TN "CleanDesktopOrganizer\Update" ' + + '/Create /F /TN "\CleanDesktopOrganizer\Update" ' + '/RU "SYSTEM" /RL HIGHEST /SC ONDEMAND ' + '/TR "' + ExpandConstant('{app}\update_helper.exe') + '"'; diff --git a/ui/updater.py b/ui/updater.py index 0d46484..5263907 100644 --- a/ui/updater.py +++ b/ui/updater.py @@ -8,6 +8,8 @@ import sys import subprocess import tempfile import json +import ctypes +import base64 from PyQt6.QtCore import QThread, pyqtSignal, QObject from PyQt6.QtWidgets import QProgressDialog, QMessageBox, QApplication import urllib.request @@ -15,7 +17,8 @@ import urllib.request UPDATE_CHECK_URL = "https://api.yunzer.cn/api/softwareupgrade/check?code=niumasortware" APP_NAME = "CleanDesktopOrganizer" -UPDATE_TASK_NAME = r"CleanDesktopOrganizer\Update" +# schtasks 的任务名在不同环境下可能需要/不需要前导 "\",这里两种都尝试 +UPDATE_TASK_NAMES = [r"\CleanDesktopOrganizer\Update", r"CleanDesktopOrganizer\Update"] def _current_version() -> str: @@ -107,10 +110,10 @@ def _request_file_path() -> str: return os.path.join(d, "update_request.json") -def _run_update_task() -> bool: +def _task_exists(name: str) -> bool: try: r = subprocess.run( - ["schtasks", "/Run", "/TN", UPDATE_TASK_NAME], + ["schtasks", "/Query", "/TN", name], capture_output=True, text=True, encoding="utf-8", @@ -122,6 +125,94 @@ def _run_update_task() -> bool: 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 等文件不变。 @@ -263,8 +354,34 @@ class Updater(QObject): QApplication.quit() return - # 兜底:如果计划任务不存在/失败,尝试旧的自替换方式(仅当安装目录可写时有效) - _replace_and_restart(path) + # 兜底策略 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) diff --git a/update_helper.py b/update_helper.py index f65f112..2d34d21 100644 --- a/update_helper.py +++ b/update_helper.py @@ -18,6 +18,7 @@ import time APP_NAME = "CleanDesktopOrganizer" REQUEST_FILE = "update_request.json" +LOG_FILE = "update.log" def _programdata_dir() -> str: @@ -29,6 +30,16 @@ def _request_path() -> str: return os.path.join(_programdata_dir(), REQUEST_FILE) +def _log(msg: str): + try: + os.makedirs(_programdata_dir(), exist_ok=True) + p = os.path.join(_programdata_dir(), LOG_FILE) + with open(p, "a", encoding="utf-8") as f: + f.write(msg.rstrip() + "\n") + except Exception: + pass + + def _read_request() -> dict: p = _request_path() with open(p, "r", encoding="utf-8") as f: @@ -79,6 +90,7 @@ def main() -> int: except FileNotFoundError: return 0 except Exception: + _log("ERROR: cannot read request file") return 2 src = str(req.get("src") or "") @@ -87,8 +99,11 @@ def main() -> int: restart = bool(req.get("restart", True)) if not src or not dst: + _log(f"ERROR: bad request src={src!r} dst={dst!r} pid={pid}") return 3 + _log(f"START: pid={pid} src={src} dst={dst}") + # 等待主程序退出,最多 60 秒 waited = 0.0 while _pid_exists(pid) and waited < 60.0: @@ -96,6 +111,7 @@ def main() -> int: waited += 0.5 ok = _copy_with_retries(src, dst, retries=15) + _log(f"COPY: ok={ok}") try: os.remove(src) except Exception: @@ -110,10 +126,13 @@ def main() -> int: if ok and restart: try: subprocess.Popen([dst], creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NO_WINDOW) + _log("RESTART: started") except Exception: + _log("RESTART: failed") pass return 0 + _log("DONE: failed") return 4