The update script is spawned from inside the frozen app, so the child inherits _PYI_APPLICATION_HOME_DIR / _PYI_ARCHIVE_FILE. Combined with MEI dir names being derived from the pid, a new instance could end up loading python313.dll from the directory the old process was deleting, giving 'Failed to load Python DLL ... module not found'. - strip _PYI_* and PYINSTALLER_* from the environment of every child - run the new instance with its own TEMP under updates/runtime - wait for the old processes to actually exit before swapping files - retry the launch and verify the process is still alive - replace Get-FileHash / Get-ChildItem -Directory with PS 2.0 safe code
680 lines
26 KiB
Python
680 lines
26 KiB
Python
"""版本检查、自动下载与安装。"""
|
|
import hashlib
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
import urllib.parse
|
|
|
|
import requests
|
|
from PySide6.QtCore import Qt, QUrl, QTimer
|
|
from PySide6.QtGui import QColor, QDesktopServices
|
|
from PySide6.QtNetwork import QNetworkAccessManager, QNetworkReply, QNetworkRequest
|
|
from PySide6.QtWidgets import (
|
|
QApplication, QDialog, QFrame, QHBoxLayout, QLabel, QProgressBar, QPushButton,
|
|
QVBoxLayout, QWidget, QGraphicsDropShadowEffect,
|
|
)
|
|
|
|
from version import APP_VERSION, UPDATE_ENDPOINT
|
|
from widgets import make_icon, icon_button
|
|
|
|
|
|
UPDATE_QSS = """
|
|
#updateCard { background:#fffefa; border:1px solid #e6e1da; border-radius:18px; }
|
|
QPushButton#iconBtn { background:transparent; border:none; border-radius:7px; outline:none; }
|
|
QPushButton#iconBtn:hover { background:#f2efea; }
|
|
QPushButton:focus { outline:none; }
|
|
#updateEyebrow { color:#e6493f; font-size:9px; font-weight:700; letter-spacing:1.7px; }
|
|
#updateTitle { color:#22211f; font-size:20px; font-weight:700; }
|
|
#updateSub { color:#8c877f; font-size:12px; }
|
|
#updateMeta { color:#aaa59c; font-size:10.5px; }
|
|
#updateNotesTitle { color:#8c877f; font-size:10px; font-weight:600; letter-spacing:1px; }
|
|
#updateNote { color:#5f5b55; font-size:12px; }
|
|
#updateStatus { color:#8c877f; font-size:11px; }
|
|
QProgressBar#updateProgress {
|
|
border:none; border-radius:3px; background:#eeeae4; height:6px; text-align:center;
|
|
color:transparent;
|
|
}
|
|
QProgressBar#updateProgress::chunk { background:#e6493f; border-radius:3px; }
|
|
QPushButton#updateLater {
|
|
background:transparent; border:1px solid #e0dcd5; border-radius:8px;
|
|
min-height:36px; color:#6d6962; font-size:12px; padding:0 18px;
|
|
}
|
|
QPushButton#updateLater:hover { background:#f3f0eb; color:#242322; }
|
|
QPushButton#updatePrimary {
|
|
background:#e6493f; border:none; border-radius:8px; min-height:36px;
|
|
color:#ffffff; font-size:12px; font-weight:600; padding:0 20px;
|
|
}
|
|
QPushButton#updatePrimary:hover { background:#d93e35; }
|
|
QPushButton#updatePrimary:disabled { background:#e8e4de; color:#aaa59c; }
|
|
QPushButton#updateLink {
|
|
background:transparent; border:none; color:#9b968e; font-size:10.5px;
|
|
text-align:left; padding:0;
|
|
}
|
|
QPushButton#updateLink:hover { color:#625f5a; }
|
|
"""
|
|
|
|
|
|
def parse_version(value):
|
|
text = str(value or "").strip()
|
|
text = re.split(r"[-+]", text, maxsplit=1)[0]
|
|
return tuple(int(part) for part in re.findall(r"\d+", text))
|
|
|
|
|
|
def is_newer(latest, current=APP_VERSION):
|
|
left, right = parse_version(latest), parse_version(current)
|
|
if not left:
|
|
return False
|
|
size = max(len(left), len(right))
|
|
return left + (0,) * (size - len(left)) > right + (0,) * (size - len(right))
|
|
|
|
|
|
def fetch_update_info(endpoint=UPDATE_ENDPOINT):
|
|
response = requests.get(
|
|
endpoint,
|
|
timeout=10,
|
|
headers={"User-Agent": "NeteasePlayer/%s" % APP_VERSION, "Cache-Control": "no-cache"},
|
|
)
|
|
response.raise_for_status()
|
|
info = response.json()
|
|
if not isinstance(info, dict):
|
|
raise RuntimeError("更新信息格式错误")
|
|
if not info.get("version") or not info.get("download_url"):
|
|
raise RuntimeError("更新信息缺少必要字段")
|
|
return info
|
|
|
|
|
|
def human_size(size):
|
|
try:
|
|
size = float(size)
|
|
except (TypeError, ValueError):
|
|
return ""
|
|
if size >= 1024 ** 3:
|
|
return "%.2f GB" % (size / 1024 ** 3)
|
|
if size >= 1024 ** 2:
|
|
return "%.1f MB" % (size / 1024 ** 2)
|
|
return "%.0f KB" % (size / 1024)
|
|
|
|
|
|
def _safe_suffix(url):
|
|
suffix = os.path.splitext(urllib.parse.urlparse(url).path)[1].lower()
|
|
return suffix if suffix in (".exe", ".msi", ".zip") else ".exe"
|
|
|
|
|
|
CREATE_NO_WINDOW = 0x08000000
|
|
|
|
|
|
def _offset_dir(sub=""):
|
|
base = os.environ.get("LOCALAPPDATA") or tempfile.gettempdir()
|
|
path = os.path.join(base, "NeteasePlayer", "updates")
|
|
if sub:
|
|
path = os.path.join(path, sub)
|
|
os.makedirs(path, exist_ok=True)
|
|
return path
|
|
|
|
|
|
def _ps_quote(value):
|
|
return "'" + str(value).replace("'", "''") + "'"
|
|
|
|
|
|
def powershell_exe():
|
|
root = os.environ.get("SystemRoot") or os.environ.get("WINDIR") or r"C:\Windows"
|
|
path = os.path.join(root, "System32", "WindowsPowerShell", "v1.0", "powershell.exe")
|
|
return path if os.path.exists(path) else "powershell.exe"
|
|
|
|
|
|
def clean_env():
|
|
"""去掉 PyInstaller 的 _PYI_* / PYINSTALLER_* 变量后再启动子进程。
|
|
|
|
单文件程序解压到 %TEMP%\\_MEIxxxxxx,并通过 _PYI_APPLICATION_HOME_DIR
|
|
把这个路径告诉子进程。更新脚本本身是从播放器内部启动的,会一路继承到
|
|
新拉起的 exe 上,导致新实例复用旧解压目录;而旧进程退出时正在删除该
|
|
目录,于是出现 "Failed to load Python DLL ... 找不到指定的模块"。
|
|
"""
|
|
env = os.environ.copy()
|
|
for key in list(env):
|
|
upper = str(key).upper()
|
|
if upper.startswith("_PYI") or upper.startswith("PYINSTALLER_"):
|
|
env.pop(key, None)
|
|
return env
|
|
|
|
|
|
def build_install_script(current, downloaded, pid, digest="", parent_pid=None):
|
|
"""生成 Unicode 安全的更新脚本:校验副本后再交换文件,任何失败路径都会重新拉起程序。"""
|
|
current = os.path.abspath(current)
|
|
downloaded = os.path.abspath(downloaded)
|
|
log_dir = _offset_dir("logs")
|
|
log_path = os.path.join(log_dir, "update.log")
|
|
pids = [int(pid)]
|
|
if parent_pid and int(parent_pid) > 100 and int(parent_pid) not in pids:
|
|
# PyInstaller 单文件有两个进程:bootloader 才是持有 exe 文件的那个
|
|
pids.append(int(parent_pid))
|
|
script = r"""
|
|
$ErrorActionPreference = 'Continue'
|
|
$src = __SRC__
|
|
$dst = __DST__
|
|
$expected = __EXPECTED__
|
|
$logDir = __LOG_DIR__
|
|
$log = __LOG__
|
|
New-Item -ItemType Directory -Force -Path $logDir | Out-Null
|
|
function Write-UpdateLog($message) {
|
|
try {
|
|
Add-Content -LiteralPath $log -Encoding UTF8 -Value ("[" + (Get-Date -Format 'yyyy-MM-dd HH:mm:ss') + "] " + $message)
|
|
} catch {}
|
|
}
|
|
# 新实例必须自己解压,不能复用本进程(以及旧播放器)的 _MEI 目录。
|
|
# 而且 _MEI 目录名是按 pid 编码的,旧进程延迟清理时可能撞掉新进程刚建好的
|
|
# 同名目录(pid 复用),于是出现 "Failed to load Python DLL"。把新实例的运行
|
|
# 临时目录挪到我们自己的目录下,彻底避开这种竞争。
|
|
$runtime = Join-Path (Split-Path -Parent $logDir) "runtime"
|
|
function Prepare-Runtime {
|
|
try {
|
|
New-Item -ItemType Directory -Force -Path $runtime | Out-Null
|
|
# 不用 Get-ChildItem -Directory,兼容 Windows 7 自带的 PowerShell 2.0
|
|
Get-ChildItem -LiteralPath $runtime -Force -ErrorAction SilentlyContinue |
|
|
Where-Object { $_.PSIsContainer -and $_.Name -like "_MEI*" } |
|
|
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
|
|
$env:TEMP = $runtime
|
|
$env:TMP = $runtime
|
|
Write-UpdateLog ("runtime temp -> " + $runtime)
|
|
} catch {
|
|
Write-UpdateLog ("runtime temp failed: " + $_.Exception.Message)
|
|
}
|
|
}
|
|
function Get-Sha256($path) {
|
|
$stream = [System.IO.File]::Open($path, 'Open', 'Read', 'Read')
|
|
try {
|
|
$sha = New-Object System.Security.Cryptography.SHA256Managed
|
|
$bytes = $sha.ComputeHash($stream)
|
|
$sb = New-Object System.Text.StringBuilder
|
|
foreach ($b in $bytes) { [void]$sb.Append($b.ToString('x2')) }
|
|
return $sb.ToString()
|
|
} finally {
|
|
$stream.Close()
|
|
}
|
|
}
|
|
function Clear-FrozenEnv {
|
|
foreach ($name in @('_PYI_APPLICATION_HOME_DIR','_PYI_ARCHIVE_FILE','_PYI_PARENT_PROCESS_LEVEL','_PYI_SPLASH_IPC','PYINSTALLER_RESET_ENVIRONMENT','PYINSTALLER_STRICT_UNPACK_MODE','PYINSTALLER_SUPPRESS_SPLASH_SCREEN')) {
|
|
Remove-Item -Path ("Env:\" + $name) -Force -ErrorAction SilentlyContinue
|
|
}
|
|
}
|
|
function Start-App($path) {
|
|
if (-not $path) { return $false }
|
|
if (-not (Test-Path -LiteralPath $path)) {
|
|
Write-UpdateLog ("missing target: " + $path)
|
|
return $false
|
|
}
|
|
Clear-FrozenEnv
|
|
Prepare-Runtime
|
|
for ($attempt = 1; $attempt -le 3; $attempt++) {
|
|
try {
|
|
$proc = Start-Process -FilePath $path -WorkingDirectory (Split-Path -Parent $path) -PassThru -ErrorAction Stop
|
|
Start-Sleep -Milliseconds 2000
|
|
if (-not $proc.HasExited) {
|
|
Write-UpdateLog ("started: " + $path + " (pid=" + $proc.Id + ")")
|
|
return $true
|
|
}
|
|
Write-UpdateLog ("process exited at once, attempt " + $attempt)
|
|
} catch {
|
|
Write-UpdateLog ("start failed: " + $path + " :: " + $_.Exception.Message)
|
|
}
|
|
Start-Sleep -Milliseconds 1500
|
|
}
|
|
return $false
|
|
}
|
|
|
|
Write-UpdateLog "=== update begin; pids=__PIDS_TEXT__ ==="
|
|
Write-UpdateLog ("src=" + $src)
|
|
Write-UpdateLog ("dst=" + $dst)
|
|
Start-Sleep -Seconds 2
|
|
foreach ($p in @(__PIDS__)) {
|
|
try { Stop-Process -Id $p -Force -ErrorAction SilentlyContinue } catch {}
|
|
}
|
|
$gone = $false
|
|
$waited = 0
|
|
for ($i = 0; $i -lt 80; $i++) {
|
|
$alive = 0
|
|
foreach ($p in @(__PIDS__)) {
|
|
if (Get-Process -Id $p -ErrorAction SilentlyContinue) { $alive++ }
|
|
}
|
|
if ($alive -eq 0) { $gone = $true; break }
|
|
Start-Sleep -Milliseconds 250
|
|
$waited += 250
|
|
}
|
|
Write-UpdateLog ("old processes stopped=" + $gone + " after " + $waited + " ms")
|
|
Start-Sleep -Milliseconds 1500
|
|
|
|
$staged = $dst + ".update"
|
|
$backup = $dst + ".bak"
|
|
Remove-Item -LiteralPath $staged -Force -ErrorAction SilentlyContinue
|
|
|
|
$verified = $false
|
|
for ($i = 0; $i -lt 30; $i++) {
|
|
try {
|
|
Copy-Item -LiteralPath $src -Destination $staged -Force -ErrorAction Stop
|
|
if ($expected) {
|
|
# 用 .NET 而不是 Get-FileHash,兼容没有 PowerShell 4.0 的老系统
|
|
$actual = Get-Sha256 $staged
|
|
if ($actual -ne $expected.ToLower()) { throw "SHA256 mismatch" }
|
|
}
|
|
$verified = $true
|
|
Write-UpdateLog "staged copy verified"
|
|
break
|
|
} catch {
|
|
Write-UpdateLog ("stage attempt " + $i + " failed: " + $_.Exception.Message)
|
|
Start-Sleep -Milliseconds 500
|
|
}
|
|
}
|
|
|
|
if (-not $verified) {
|
|
Write-UpdateLog "stage failed; launching downloaded copy"
|
|
if (-not (Start-App $src)) { Start-App $dst }
|
|
Remove-Item -LiteralPath $staged -Force -ErrorAction SilentlyContinue
|
|
Remove-Item -LiteralPath $PSCommandPath -Force -ErrorAction SilentlyContinue
|
|
exit
|
|
}
|
|
|
|
$swapped = $false
|
|
for ($i = 0; $i -lt 30; $i++) {
|
|
try {
|
|
if (Test-Path -LiteralPath $dst) {
|
|
Move-Item -LiteralPath $dst -Destination $backup -Force -ErrorAction Stop
|
|
}
|
|
$swapped = $true
|
|
break
|
|
} catch {
|
|
Write-UpdateLog ("rename old attempt " + $i + " failed: " + $_.Exception.Message)
|
|
Start-Sleep -Milliseconds 500
|
|
}
|
|
}
|
|
|
|
if (-not $swapped) {
|
|
Write-UpdateLog "cannot rename running exe; starting downloaded copy"
|
|
if (-not (Start-App $src)) { Start-App $dst }
|
|
Remove-Item -LiteralPath $staged -Force -ErrorAction SilentlyContinue
|
|
Remove-Item -LiteralPath $PSCommandPath -Force -ErrorAction SilentlyContinue
|
|
exit
|
|
}
|
|
|
|
$installed = $false
|
|
try {
|
|
Move-Item -LiteralPath $staged -Destination $dst -Force -ErrorAction Stop
|
|
$installed = $true
|
|
Write-UpdateLog "new file in place"
|
|
} catch {
|
|
Write-UpdateLog ("swap failed: " + $_.Exception.Message)
|
|
if ((Test-Path -LiteralPath $backup) -and -not (Test-Path -LiteralPath $dst)) {
|
|
Move-Item -LiteralPath $backup -Destination $dst -Force -ErrorAction SilentlyContinue
|
|
Write-UpdateLog "rolled back from backup"
|
|
}
|
|
}
|
|
|
|
if ($installed) {
|
|
if (-not (Start-App $dst)) {
|
|
Write-UpdateLog "new version did not start; trying downloaded copy"
|
|
Start-App $src
|
|
}
|
|
} else {
|
|
if (-not (Start-App $dst)) { Start-App $src }
|
|
}
|
|
|
|
for ($i = 0; $i -lt 20; $i++) {
|
|
if (-not (Test-Path -LiteralPath $backup)) { break }
|
|
try {
|
|
[System.IO.File]::Delete($backup)
|
|
Write-UpdateLog "old backup cleaned"
|
|
break
|
|
} catch {
|
|
Start-Sleep -Milliseconds 500
|
|
}
|
|
}
|
|
Write-UpdateLog "=== update end ==="
|
|
Remove-Item -LiteralPath $PSCommandPath -Force -ErrorAction SilentlyContinue
|
|
""".replace("__SRC__", _ps_quote(downloaded)) \
|
|
.replace("__DST__", _ps_quote(current)) \
|
|
.replace("__EXPECTED__", _ps_quote(digest or "")) \
|
|
.replace("__LOG_DIR__", _ps_quote(log_dir)) \
|
|
.replace("__LOG__", _ps_quote(log_path)) \
|
|
.replace("__PIDS_TEXT__", ",".join(str(p) for p in pids)) \
|
|
.replace("__PIDS__", ", ".join(str(p) for p in pids))
|
|
return script, log_path
|
|
|
|
|
|
class UpdateDialog(QDialog):
|
|
"""发现新版本后立即自动下载,下载完成可一键重启安装。"""
|
|
|
|
def __init__(self, info, parent=None, on_state=None):
|
|
super().__init__(parent)
|
|
self.info = info
|
|
self.on_state = on_state
|
|
self.reply = None
|
|
self.file = None
|
|
self.ready_path = None
|
|
self.downloaded = 0
|
|
self.total = int(info.get("size") or 0)
|
|
self.setWindowFlags(Qt.Dialog | Qt.FramelessWindowHint)
|
|
self.setAttribute(Qt.WA_TranslucentBackground)
|
|
self.setModal(True)
|
|
self.setStyleSheet(UPDATE_QSS)
|
|
self._build_ui()
|
|
QTimer.singleShot(300, self.start_download)
|
|
|
|
def _build_ui(self):
|
|
outer = QVBoxLayout(self)
|
|
outer.setContentsMargins(18, 18, 18, 18)
|
|
card = QFrame(objectName="updateCard")
|
|
shadow = QGraphicsDropShadowEffect(card)
|
|
shadow.setBlurRadius(38)
|
|
shadow.setOffset(0, 12)
|
|
shadow.setColor(QColor(28, 27, 31, 65))
|
|
card.setGraphicsEffect(shadow)
|
|
outer.addWidget(card)
|
|
|
|
layout = QVBoxLayout(card)
|
|
layout.setContentsMargins(26, 22, 26, 22)
|
|
layout.setSpacing(0)
|
|
|
|
top = QHBoxLayout()
|
|
top.setSpacing(13)
|
|
icon_box = QLabel()
|
|
icon_box.setFixedSize(42, 42)
|
|
icon_box.setPixmap(make_icon("refresh", "#ffffff", 20).pixmap(42, 42))
|
|
icon_box.setStyleSheet("background:#e6493f; border-radius:12px;")
|
|
icon_box.setAlignment(Qt.AlignCenter)
|
|
top.addWidget(icon_box)
|
|
titles = QVBoxLayout()
|
|
titles.setSpacing(2)
|
|
titles.addWidget(QLabel("NEW VERSION", objectName="updateEyebrow"))
|
|
self.title = QLabel("发现新版本", objectName="updateTitle")
|
|
titles.addWidget(self.title)
|
|
top.addLayout(titles, 1)
|
|
close = icon_button("close", 32, 15, tooltip="稍后再说")
|
|
close.clicked.connect(self.hide)
|
|
top.addWidget(close)
|
|
layout.addLayout(top)
|
|
layout.addSpacing(16)
|
|
|
|
latest = self.info.get("version", "")
|
|
self.sub = QLabel("正在为你自动下载新版本", objectName="updateSub")
|
|
layout.addWidget(self.sub)
|
|
notes = self.info.get("notes") or []
|
|
date = self.info.get("release_date") or ""
|
|
size = human_size(self.info.get("size"))
|
|
meta_parts = ["v%s → v%s" % (APP_VERSION, latest)]
|
|
if date:
|
|
meta_parts.append(date)
|
|
if size:
|
|
meta_parts.append(size)
|
|
self.meta = QLabel(" · ".join(meta_parts), objectName="updateMeta")
|
|
layout.addWidget(self.meta)
|
|
layout.addSpacing(16)
|
|
|
|
notes_title = QLabel("本次更新", objectName="updateNotesTitle")
|
|
layout.addWidget(notes_title)
|
|
layout.addSpacing(7)
|
|
box = QWidget()
|
|
box_layout = QVBoxLayout(box)
|
|
box_layout.setContentsMargins(0, 0, 0, 0)
|
|
box_layout.setSpacing(6)
|
|
if not notes:
|
|
notes = ["稳定性与性能优化"]
|
|
for note in notes[:6]:
|
|
row = QHBoxLayout()
|
|
row.setSpacing(8)
|
|
dot = QLabel("·")
|
|
dot.setStyleSheet("color:#e6493f; font-size:16px; font-weight:700;")
|
|
dot.setFixedWidth(8)
|
|
item = QLabel(str(note), objectName="updateNote")
|
|
item.setWordWrap(True)
|
|
row.addWidget(dot, 0, Qt.AlignTop)
|
|
row.addWidget(item, 1)
|
|
box_layout.addLayout(row)
|
|
layout.addWidget(box)
|
|
layout.addSpacing(18)
|
|
|
|
self.progress = QProgressBar(objectName="updateProgress")
|
|
self.progress.setRange(0, 100)
|
|
self.progress.setValue(0)
|
|
self.progress.setTextVisible(False)
|
|
layout.addWidget(self.progress)
|
|
layout.addSpacing(7)
|
|
self.status = QLabel("准备下载…", objectName="updateStatus")
|
|
layout.addWidget(self.status)
|
|
layout.addSpacing(16)
|
|
|
|
footer = QHBoxLayout()
|
|
footer.setSpacing(9)
|
|
self.link = QPushButton("查看下载页", objectName="updateLink")
|
|
self.link.setCursor(Qt.PointingHandCursor)
|
|
self.link.clicked.connect(self._open_page)
|
|
footer.addWidget(self.link)
|
|
footer.addStretch()
|
|
self.later = QPushButton("稍后再说", objectName="updateLater")
|
|
self.later.setCursor(Qt.PointingHandCursor)
|
|
self.later.clicked.connect(self.hide)
|
|
footer.addWidget(self.later)
|
|
self.primary = QPushButton("正在下载…", objectName="updatePrimary")
|
|
self.primary.setCursor(Qt.PointingHandCursor)
|
|
self.primary.setEnabled(False)
|
|
self.primary.clicked.connect(self._primary_action)
|
|
footer.addWidget(self.primary)
|
|
layout.addLayout(footer)
|
|
|
|
def _open_page(self):
|
|
QDesktopServices.openUrl(QUrl(self.info.get("page_url") or "https://music.yuncishu.xyz/download/"))
|
|
|
|
def start_download(self):
|
|
if self.ready_path:
|
|
return
|
|
if self.reply:
|
|
self.reply.abort()
|
|
self.reply.deleteLater()
|
|
self.reply = None
|
|
if self.file:
|
|
try:
|
|
self.file.close()
|
|
except Exception:
|
|
pass
|
|
self.file = None
|
|
url = self.info.get("download_url")
|
|
filename = "NeteasePlayer-%s%s" % (self.info.get("version", "new"), _safe_suffix(url))
|
|
self.ready_path = os.path.join(_offset_dir(self.info.get("version", "new")), filename)
|
|
self.file = open(self.ready_path + ".part", "wb")
|
|
self.downloaded = 0
|
|
self.progress.setValue(0)
|
|
self.status.setText("正在连接下载服务器…")
|
|
self.primary.setText("正在下载…")
|
|
self.primary.setEnabled(False)
|
|
self._notify("downloading", "正在下载")
|
|
self.manager = QNetworkAccessManager(self)
|
|
request = QNetworkRequest(QUrl(url))
|
|
request.setRawHeader(b"User-Agent", ("NeteasePlayer/%s" % APP_VERSION).encode())
|
|
try:
|
|
request.setAttribute(
|
|
QNetworkRequest.Attribute.RedirectPolicyAttribute,
|
|
QNetworkRequest.RedirectPolicy.NoLessSafeRedirectPolicy,
|
|
)
|
|
except Exception:
|
|
pass
|
|
self.reply = self.manager.get(request)
|
|
self.reply.readyRead.connect(self._read)
|
|
self.reply.downloadProgress.connect(self._progress)
|
|
self.reply.finished.connect(self._finished)
|
|
|
|
def _read(self):
|
|
if not self.reply or not self.file:
|
|
return
|
|
data = bytes(self.reply.readAll())
|
|
if data:
|
|
self.file.write(data)
|
|
self.downloaded += len(data)
|
|
|
|
def _progress(self, received, total):
|
|
if total > 0:
|
|
self.total = total
|
|
percent = int(received * 100 / total)
|
|
self.progress.setValue(max(0, min(100, percent)))
|
|
self.status.setText("正在下载 %s / %s" % (human_size(received), human_size(total)))
|
|
else:
|
|
self.status.setText("正在下载 %s" % human_size(received))
|
|
|
|
def _finished(self):
|
|
reply = self.reply
|
|
if not reply:
|
|
return
|
|
self._read()
|
|
if self.file:
|
|
self.file.flush()
|
|
self.file.close()
|
|
self.file = None
|
|
if reply.error() != QNetworkReply.NetworkError.NoError:
|
|
self.status.setText("下载失败 · " + reply.errorString())
|
|
self.primary.setText("重新下载")
|
|
self.primary.setEnabled(True)
|
|
self.primary.setProperty("action", "retry")
|
|
self._notify("error", "更新下载失败")
|
|
reply.deleteLater()
|
|
self.reply = None
|
|
return
|
|
part_path = self.ready_path + ".part"
|
|
expected = int(self.info.get("size") or 0)
|
|
if expected and os.path.getsize(part_path) != expected:
|
|
self.status.setText("文件大小校验失败,请重新下载")
|
|
self.primary.setText("重新下载")
|
|
self.primary.setEnabled(True)
|
|
self.primary.setProperty("action", "retry")
|
|
self._notify("error", "更新文件不完整")
|
|
reply.deleteLater()
|
|
self.reply = None
|
|
return
|
|
digest = self.info.get("sha256")
|
|
if digest:
|
|
hasher = hashlib.sha256()
|
|
with open(part_path, "rb") as handle:
|
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
hasher.update(chunk)
|
|
if hasher.hexdigest().lower() != str(digest).lower():
|
|
self.status.setText("安全校验失败,已阻止安装")
|
|
self.primary.setText("重新下载")
|
|
self.primary.setEnabled(True)
|
|
self.primary.setProperty("action", "retry")
|
|
self._notify("error", "更新文件校验失败")
|
|
reply.deleteLater()
|
|
self.reply = None
|
|
return
|
|
os.replace(part_path, self.ready_path)
|
|
self.ready_path_final = self.ready_path
|
|
self.progress.setValue(100)
|
|
self.status.setText("下载完成 · 已通过完整性校验")
|
|
self.sub.setText("新版本已准备好,重启后即可使用")
|
|
self.primary.setText("重启并安装")
|
|
self.primary.setEnabled(True)
|
|
self.primary.setProperty("action", "install")
|
|
self._notify("ready", "更新已就绪")
|
|
reply.deleteLater()
|
|
self.reply = None
|
|
|
|
def _primary_action(self):
|
|
action = self.primary.property("action")
|
|
if action == "retry":
|
|
self.primary.setProperty("action", "")
|
|
self.start_download()
|
|
elif action == "page":
|
|
self._open_page()
|
|
else:
|
|
self.install()
|
|
|
|
def install(self):
|
|
path = getattr(self, "ready_path_final", None) or self.ready_path
|
|
if not path or not os.path.exists(path):
|
|
self.status.setText("安装包不存在,请重新下载")
|
|
return
|
|
if not getattr(sys, "frozen", False):
|
|
QDesktopServices.openUrl(QUrl.fromLocalFile(path))
|
|
self.status.setText("安装包已打开")
|
|
return
|
|
current = os.path.abspath(sys.executable)
|
|
parent_pid = None
|
|
try:
|
|
candidate = os.getppid()
|
|
if candidate and candidate > 100:
|
|
parent_pid = candidate
|
|
except Exception: # noqa: BLE001
|
|
parent_pid = None
|
|
script, log_path = build_install_script(
|
|
current, path, os.getpid(), self.info.get("sha256") or "", parent_pid)
|
|
# 放在更新目录而不是 Temp,避免脚本尚未启动就被系统清理
|
|
script_path = os.path.join(_offset_dir("logs"), "install.ps1")
|
|
try:
|
|
with open(script_path, "w", encoding="utf-8-sig") as handle:
|
|
handle.write(script)
|
|
except OSError as exc:
|
|
self._manual_install(path, "无法写入更新脚本:%s" % exc)
|
|
return
|
|
try:
|
|
proc = subprocess.Popen(
|
|
[
|
|
powershell_exe(), "-NoProfile", "-NonInteractive",
|
|
"-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden",
|
|
"-File", script_path,
|
|
],
|
|
creationflags=CREATE_NO_WINDOW,
|
|
close_fds=True,
|
|
env=clean_env(),
|
|
stdin=subprocess.DEVNULL,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
)
|
|
except Exception as exc: # noqa: BLE001
|
|
self._manual_install(path, "无法启动更新程序:%s" % exc)
|
|
return
|
|
# PowerShell 被安全策略或参数问题打断时会立刻退出,必须确认它真的活着
|
|
deadline = time.time() + 1.5
|
|
while time.time() < deadline:
|
|
if proc.poll() is not None:
|
|
break
|
|
time.sleep(0.1)
|
|
if proc.poll() is not None:
|
|
self._manual_install(path, "更新脚本未能运行")
|
|
return
|
|
self.status.setText("正在重启并安装,新版本会自动打开…")
|
|
self._notify("installing", "正在安装更新")
|
|
QApplication.quit()
|
|
|
|
def _manual_install(self, path, reason):
|
|
"""更新脚本无法代劳时,至少把新版本直接跑起来,不让用户只剩一个关掉的窗口。"""
|
|
try:
|
|
subprocess.Popen(
|
|
[path],
|
|
cwd=os.path.dirname(path),
|
|
creationflags=CREATE_NO_WINDOW,
|
|
close_fds=True,
|
|
env=clean_env(),
|
|
stdin=subprocess.DEVNULL,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
)
|
|
except Exception as exc: # noqa: BLE001
|
|
self.status.setText("%s;请前往下载页手动安装" % reason)
|
|
self.primary.setText("前往下载页")
|
|
self.primary.setEnabled(True)
|
|
self.primary.setProperty("action", "page")
|
|
return
|
|
self.status.setText("%s,已直接启动新版本" % reason)
|
|
self._notify("installing", "已启动新版本")
|
|
QApplication.quit()
|
|
|
|
def _notify(self, state, text):
|
|
if self.on_state:
|
|
self.on_state(state, text)
|
|
|
|
def showEvent(self, event):
|
|
super().showEvent(event)
|
|
parent = self.parentWidget()
|
|
if parent:
|
|
x = parent.x() + (parent.width() - self.width()) // 2
|
|
y = parent.y() + (parent.height() - self.height()) // 2
|
|
self.move(max(0, x), max(0, y))
|