完成软件更新功能
This commit is contained in:
parent
ff8aa0269d
commit
948d23074a
@ -49,7 +49,7 @@ Name: "{autodesktop}\牛马软件柜"; Filename: "{app}\niumasoftware.exe"; Task
|
|||||||
Filename: "{app}\niumasoftware.exe"; Description: "运行 牛马软件柜"; Flags: nowait postinstall skipifsilent
|
Filename: "{app}\niumasoftware.exe"; Description: "运行 牛马软件柜"; Flags: nowait postinstall skipifsilent
|
||||||
|
|
||||||
[UninstallRun]
|
[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]
|
[Code]
|
||||||
procedure CreateUpdateTask();
|
procedure CreateUpdateTask();
|
||||||
@ -60,7 +60,7 @@ begin
|
|||||||
// 计划任务:以 SYSTEM 运行,按需触发(/SC ONDEMAND)
|
// 计划任务:以 SYSTEM 运行,按需触发(/SC ONDEMAND)
|
||||||
// 注意:TR 不带参数,update_helper.exe 会读取 ProgramData 请求文件
|
// 注意:TR 不带参数,update_helper.exe 会读取 ProgramData 请求文件
|
||||||
Cmd :=
|
Cmd :=
|
||||||
'/Create /F /TN "CleanDesktopOrganizer\Update" ' +
|
'/Create /F /TN "\CleanDesktopOrganizer\Update" ' +
|
||||||
'/RU "SYSTEM" /RL HIGHEST /SC ONDEMAND ' +
|
'/RU "SYSTEM" /RL HIGHEST /SC ONDEMAND ' +
|
||||||
'/TR "' + ExpandConstant('{app}\update_helper.exe') + '"';
|
'/TR "' + ExpandConstant('{app}\update_helper.exe') + '"';
|
||||||
|
|
||||||
|
|||||||
@ -48,7 +48,7 @@ Name: "{autodesktop}\牛马软件柜"; Filename: "{app}\niumasoftware.exe"; Task
|
|||||||
Filename: "{app}\niumasoftware.exe"; Description: "运行 牛马软件柜"; Flags: nowait postinstall skipifsilent
|
Filename: "{app}\niumasoftware.exe"; Description: "运行 牛马软件柜"; Flags: nowait postinstall skipifsilent
|
||||||
|
|
||||||
[UninstallRun]
|
[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]
|
[Code]
|
||||||
procedure CreateUpdateTask();
|
procedure CreateUpdateTask();
|
||||||
@ -59,7 +59,7 @@ begin
|
|||||||
// 计划任务:以 SYSTEM 运行,按需触发(/SC ONDEMAND)
|
// 计划任务:以 SYSTEM 运行,按需触发(/SC ONDEMAND)
|
||||||
// 注意:TR 不带参数,update_helper.exe 会读取 ProgramData 请求文件
|
// 注意:TR 不带参数,update_helper.exe 会读取 ProgramData 请求文件
|
||||||
Cmd :=
|
Cmd :=
|
||||||
'/Create /F /TN "CleanDesktopOrganizer\Update" ' +
|
'/Create /F /TN "\CleanDesktopOrganizer\Update" ' +
|
||||||
'/RU "SYSTEM" /RL HIGHEST /SC ONDEMAND ' +
|
'/RU "SYSTEM" /RL HIGHEST /SC ONDEMAND ' +
|
||||||
'/TR "' + ExpandConstant('{app}\update_helper.exe') + '"';
|
'/TR "' + ExpandConstant('{app}\update_helper.exe') + '"';
|
||||||
|
|
||||||
|
|||||||
125
ui/updater.py
125
ui/updater.py
@ -8,6 +8,8 @@ import sys
|
|||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
import json
|
import json
|
||||||
|
import ctypes
|
||||||
|
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
|
||||||
@ -15,7 +17,8 @@ import urllib.request
|
|||||||
|
|
||||||
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"
|
APP_NAME = "CleanDesktopOrganizer"
|
||||||
UPDATE_TASK_NAME = r"CleanDesktopOrganizer\Update"
|
# schtasks 的任务名在不同环境下可能需要/不需要前导 "\",这里两种都尝试
|
||||||
|
UPDATE_TASK_NAMES = [r"\CleanDesktopOrganizer\Update", r"CleanDesktopOrganizer\Update"]
|
||||||
|
|
||||||
|
|
||||||
def _current_version() -> str:
|
def _current_version() -> str:
|
||||||
@ -107,10 +110,10 @@ def _request_file_path() -> str:
|
|||||||
return os.path.join(d, "update_request.json")
|
return os.path.join(d, "update_request.json")
|
||||||
|
|
||||||
|
|
||||||
def _run_update_task() -> bool:
|
def _task_exists(name: str) -> bool:
|
||||||
try:
|
try:
|
||||||
r = subprocess.run(
|
r = subprocess.run(
|
||||||
["schtasks", "/Run", "/TN", UPDATE_TASK_NAME],
|
["schtasks", "/Query", "/TN", name],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
@ -122,6 +125,94 @@ def _run_update_task() -> bool:
|
|||||||
return False
|
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 等文件不变。
|
onedir 模式:只替换 exe 本身,dll 等文件不变。
|
||||||
@ -263,10 +354,36 @@ class Updater(QObject):
|
|||||||
QApplication.quit()
|
QApplication.quit()
|
||||||
return
|
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)
|
_replace_and_restart(path)
|
||||||
return
|
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):
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import time
|
|||||||
|
|
||||||
APP_NAME = "CleanDesktopOrganizer"
|
APP_NAME = "CleanDesktopOrganizer"
|
||||||
REQUEST_FILE = "update_request.json"
|
REQUEST_FILE = "update_request.json"
|
||||||
|
LOG_FILE = "update.log"
|
||||||
|
|
||||||
|
|
||||||
def _programdata_dir() -> str:
|
def _programdata_dir() -> str:
|
||||||
@ -29,6 +30,16 @@ def _request_path() -> str:
|
|||||||
return os.path.join(_programdata_dir(), REQUEST_FILE)
|
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:
|
def _read_request() -> dict:
|
||||||
p = _request_path()
|
p = _request_path()
|
||||||
with open(p, "r", encoding="utf-8") as f:
|
with open(p, "r", encoding="utf-8") as f:
|
||||||
@ -79,6 +90,7 @@ def main() -> int:
|
|||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return 0
|
return 0
|
||||||
except Exception:
|
except Exception:
|
||||||
|
_log("ERROR: cannot read request file")
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
src = str(req.get("src") or "")
|
src = str(req.get("src") or "")
|
||||||
@ -87,8 +99,11 @@ def main() -> int:
|
|||||||
restart = bool(req.get("restart", True))
|
restart = bool(req.get("restart", True))
|
||||||
|
|
||||||
if not src or not dst:
|
if not src or not dst:
|
||||||
|
_log(f"ERROR: bad request src={src!r} dst={dst!r} pid={pid}")
|
||||||
return 3
|
return 3
|
||||||
|
|
||||||
|
_log(f"START: pid={pid} src={src} dst={dst}")
|
||||||
|
|
||||||
# 等待主程序退出,最多 60 秒
|
# 等待主程序退出,最多 60 秒
|
||||||
waited = 0.0
|
waited = 0.0
|
||||||
while _pid_exists(pid) and waited < 60.0:
|
while _pid_exists(pid) and waited < 60.0:
|
||||||
@ -96,6 +111,7 @@ def main() -> int:
|
|||||||
waited += 0.5
|
waited += 0.5
|
||||||
|
|
||||||
ok = _copy_with_retries(src, dst, retries=15)
|
ok = _copy_with_retries(src, dst, retries=15)
|
||||||
|
_log(f"COPY: ok={ok}")
|
||||||
try:
|
try:
|
||||||
os.remove(src)
|
os.remove(src)
|
||||||
except Exception:
|
except Exception:
|
||||||
@ -110,10 +126,13 @@ def main() -> int:
|
|||||||
if ok and restart:
|
if ok and restart:
|
||||||
try:
|
try:
|
||||||
subprocess.Popen([dst], creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NO_WINDOW)
|
subprocess.Popen([dst], creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NO_WINDOW)
|
||||||
|
_log("RESTART: started")
|
||||||
except Exception:
|
except Exception:
|
||||||
|
_log("RESTART: failed")
|
||||||
pass
|
pass
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
_log("DONE: failed")
|
||||||
return 4
|
return 4
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user