完成软件更新功能

This commit is contained in:
扫地僧 2026-04-08 00:36:09 +08:00
parent ff8aa0269d
commit 948d23074a
4 changed files with 145 additions and 9 deletions

View File

@ -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') + '"';

View File

@ -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') + '"';

View File

@ -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)

View File

@ -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