"""版本检查、自动下载与安装。""" 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))