更新
This commit is contained in:
@@ -26,7 +26,7 @@ from doctor_workstation.config import AppConfig
|
||||
from doctor_workstation.core import Session
|
||||
from doctor_workstation.core.errors import AuthenticationExpiredError
|
||||
from doctor_workstation.logging_setup import configure_logging
|
||||
from doctor_workstation.resources import resource_path, video_dist_path
|
||||
from doctor_workstation.resources import app_icon_path, video_dist_path
|
||||
from doctor_workstation.services import (
|
||||
DemoDoctorRepository,
|
||||
RemoteDoctorRepository,
|
||||
@@ -772,9 +772,9 @@ class ApplicationController(QObject):
|
||||
LOGGER.warning("video lifecycle cleanup exceeded its bounded deadline")
|
||||
return complete
|
||||
|
||||
@staticmethod
|
||||
def _apply_window_icon(window: QWidget) -> None:
|
||||
icon_file = resource_path("icon.svg")
|
||||
@staticmethod
|
||||
def _apply_window_icon(window: QWidget) -> None:
|
||||
icon_file = app_icon_path()
|
||||
if icon_file.exists():
|
||||
window.setWindowIcon(QIcon(str(icon_file)))
|
||||
|
||||
@@ -858,7 +858,7 @@ def _create_application(argv: list[str]) -> QApplication:
|
||||
application.setOrganizationName("ZhenYangTang")
|
||||
application.setOrganizationDomain("zhenyangtang.com")
|
||||
application.setQuitOnLastWindowClosed(True)
|
||||
icon_file = resource_path("icon.svg")
|
||||
icon_file = app_icon_path()
|
||||
if icon_file.exists():
|
||||
application.setWindowIcon(QIcon(str(icon_file)))
|
||||
apply_theme(application)
|
||||
|
||||
@@ -17,6 +17,14 @@ def resource_path(*parts: str) -> Path:
|
||||
return project_root().joinpath("resources", *parts)
|
||||
|
||||
|
||||
def app_icon_path() -> Path:
|
||||
return resource_path("branding", "app-icon.png")
|
||||
|
||||
|
||||
def brand_lockup_path() -> Path:
|
||||
return resource_path("branding", "brand-lockup.png")
|
||||
|
||||
|
||||
def video_dist_path() -> Path:
|
||||
candidates = (
|
||||
project_root() / "video_companion" / "dist" / "index.html",
|
||||
|
||||
@@ -30,6 +30,9 @@ CHECK_ENDPOINT = "setting.desktop_workstation/check"
|
||||
MAX_PACKAGE_BYTES = 2 * 1024 * 1024 * 1024
|
||||
WINDOWS_EXE_NAME = "DoctorWorkstation.exe"
|
||||
MACOS_APP_NAME = "DoctorWorkstation.app"
|
||||
PACKAGE_TYPE_ARCHIVE = "archive"
|
||||
PACKAGE_TYPE_INNO_SETUP = "inno_setup"
|
||||
SUPPORTED_PACKAGE_TYPES = {PACKAGE_TYPE_ARCHIVE, PACKAGE_TYPE_INNO_SETUP}
|
||||
ProgressCallback = Callable[[int, int], None]
|
||||
CancelCallback = Callable[[], bool]
|
||||
|
||||
@@ -44,6 +47,7 @@ class UpdatePackage:
|
||||
sha256: str
|
||||
size: int
|
||||
filename: str
|
||||
type: str = PACKAGE_TYPE_ARCHIVE
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -132,36 +136,81 @@ def frozen_install_root() -> Path | None:
|
||||
return executable.parent
|
||||
|
||||
|
||||
def parse_update_offer(payload: dict[str, Any] | None, *, current_version: str) -> UpdateOffer:
|
||||
def parse_update_offer(
|
||||
payload: dict[str, Any] | None,
|
||||
*,
|
||||
current_version: str,
|
||||
platform_name: str | None = None,
|
||||
arch: str | None = None,
|
||||
) -> UpdateOffer:
|
||||
data = dict(payload or {})
|
||||
expected_platform = platform_name or current_platform()
|
||||
expected_arch = arch or current_arch()
|
||||
response_platform = str(data.get("platform") or expected_platform).strip().lower()
|
||||
response_arch = str(data.get("arch") or expected_arch).strip().lower()
|
||||
package_payload = data.get("package")
|
||||
package = None
|
||||
if isinstance(package_payload, dict):
|
||||
url = str(package_payload.get("url") or "").strip()
|
||||
sha256 = str(package_payload.get("sha256") or "").strip().lower()
|
||||
filename = str(package_payload.get("filename") or "").strip()
|
||||
package_type = str(package_payload.get("type") or PACKAGE_TYPE_ARCHIVE).strip().lower()
|
||||
try:
|
||||
size = max(0, int(package_payload.get("size") or 0))
|
||||
except (TypeError, ValueError):
|
||||
size = 0
|
||||
if url:
|
||||
package = UpdatePackage(url=url, sha256=sha256, size=size, filename=filename)
|
||||
has_update = bool(data.get("has_update"))
|
||||
can_install = bool(data.get("can_install")) and package is not None and bool(package.sha256)
|
||||
package_type_supported = package_type in SUPPORTED_PACKAGE_TYPES
|
||||
package_platform_supported = (
|
||||
package_type != PACKAGE_TYPE_INNO_SETUP or expected_platform == "windows"
|
||||
)
|
||||
if url and package_type_supported and package_platform_supported:
|
||||
package = UpdatePackage(
|
||||
url=url,
|
||||
sha256=sha256,
|
||||
size=size,
|
||||
filename=filename,
|
||||
type=package_type,
|
||||
)
|
||||
normalized_current = normalize_version(current_version) or "0.0.0"
|
||||
latest_version = normalize_version(str(data.get("latest_version") or "")) or ""
|
||||
response_matches = response_platform == expected_platform and response_arch == expected_arch
|
||||
has_update = (
|
||||
bool(data.get("has_update"))
|
||||
and bool(data.get("enabled"))
|
||||
and bool(latest_version)
|
||||
and compare_version(normalized_current, latest_version) < 0
|
||||
and response_matches
|
||||
)
|
||||
digest_is_valid = bool(
|
||||
package is not None
|
||||
and len(package.sha256) == 64
|
||||
and all(character in "0123456789abcdef" for character in package.sha256)
|
||||
)
|
||||
installer_transport_is_valid = bool(
|
||||
package is None
|
||||
or package.type != PACKAGE_TYPE_INNO_SETUP
|
||||
or _is_secure_installer_url(package.url)
|
||||
)
|
||||
can_install = (
|
||||
bool(data.get("can_install"))
|
||||
and package is not None
|
||||
and digest_is_valid
|
||||
and installer_transport_is_valid
|
||||
and has_update
|
||||
)
|
||||
return UpdateOffer(
|
||||
has_update=has_update,
|
||||
force=bool(data.get("force")) and can_install,
|
||||
enabled=bool(data.get("enabled")),
|
||||
current_version=normalize_version(str(data.get("current_version") or current_version))
|
||||
or current_version,
|
||||
latest_version=normalize_version(str(data.get("latest_version") or "")) or "",
|
||||
current_version=normalized_current,
|
||||
latest_version=latest_version,
|
||||
min_version=normalize_version(str(data.get("min_version") or "")) or "",
|
||||
title=str(data.get("title") or "").strip(),
|
||||
notes=str(data.get("notes") or "").strip(),
|
||||
platform=str(data.get("platform") or current_platform()),
|
||||
arch=str(data.get("arch") or current_arch()),
|
||||
platform=response_platform,
|
||||
arch=response_arch,
|
||||
package=package if can_install else None,
|
||||
can_install=has_update and can_install,
|
||||
can_install=can_install,
|
||||
)
|
||||
|
||||
|
||||
@@ -186,7 +235,12 @@ def fetch_update_offer(
|
||||
raise AppUpdateError(str(error)) from error
|
||||
if payload is not None and not isinstance(payload, dict):
|
||||
raise AppUpdateError("升级检测返回的数据格式不正确")
|
||||
return parse_update_offer(payload if isinstance(payload, dict) else {}, current_version=version)
|
||||
return parse_update_offer(
|
||||
payload if isinstance(payload, dict) else {},
|
||||
current_version=version,
|
||||
platform_name=platform_name or current_platform(),
|
||||
arch=arch or current_arch(),
|
||||
)
|
||||
|
||||
|
||||
def safe_extract_zip(archive: Path, destination: Path) -> None:
|
||||
@@ -219,9 +273,7 @@ def discover_payload(extracted_root: Path, *, platform_name: str | None = None)
|
||||
if not candidates:
|
||||
raise AppUpdateError("安装包中未找到 DoctorWorkstation.app")
|
||||
return sorted(candidates, key=lambda path: len(path.relative_to(root).parts))[0]
|
||||
executables = [
|
||||
path for path in root.rglob(WINDOWS_EXE_NAME) if path.is_file()
|
||||
]
|
||||
executables = [path for path in root.rglob(WINDOWS_EXE_NAME) if path.is_file()]
|
||||
if not executables:
|
||||
raise AppUpdateError("安装包中未找到 DoctorWorkstation.exe")
|
||||
|
||||
@@ -250,17 +302,24 @@ def download_package(
|
||||
digest = (sha256 or "").strip().lower()
|
||||
if len(digest) != 64 or any(character not in "0123456789abcdef" for character in digest):
|
||||
raise AppUpdateError("安装包缺少有效的 SHA-256,已取消下载")
|
||||
if expected_size < 0 or expected_size > MAX_PACKAGE_BYTES:
|
||||
raise AppUpdateError("安装包声明的文件大小不正确")
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
partial = destination.with_name(f"{destination.name}.part")
|
||||
partial.unlink(missing_ok=True)
|
||||
timeout = httpx.Timeout(connect=30.0, read=None, write=30.0, pool=30.0)
|
||||
hasher = hashlib.sha256()
|
||||
received = 0
|
||||
try:
|
||||
with httpx.Client(
|
||||
verify=verify,
|
||||
follow_redirects=True,
|
||||
timeout=timeout,
|
||||
transport=transport,
|
||||
) as client, client.stream("GET", target) as response:
|
||||
with (
|
||||
httpx.Client(
|
||||
verify=verify,
|
||||
follow_redirects=True,
|
||||
timeout=timeout,
|
||||
transport=transport,
|
||||
) as client,
|
||||
client.stream("GET", target) as response,
|
||||
):
|
||||
if not 200 <= response.status_code < 300:
|
||||
raise AppUpdateError(f"下载安装包失败(HTTP {response.status_code})")
|
||||
try:
|
||||
@@ -269,7 +328,9 @@ def download_package(
|
||||
total = expected_size
|
||||
if total > MAX_PACKAGE_BYTES:
|
||||
raise AppUpdateError("安装包超过允许的最大体积")
|
||||
with destination.open("wb") as handle:
|
||||
if expected_size > 0 and total > 0 and total != expected_size:
|
||||
raise AppUpdateError("安装包文件大小与发布信息不一致")
|
||||
with partial.open("wb") as handle:
|
||||
for chunk in response.iter_bytes(256 * 1024):
|
||||
if cancelled is not None and cancelled():
|
||||
raise AppUpdateError("已取消下载")
|
||||
@@ -282,24 +343,61 @@ def download_package(
|
||||
hasher.update(chunk)
|
||||
if progress is not None:
|
||||
progress(received, total)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
except AppUpdateError:
|
||||
destination.unlink(missing_ok=True)
|
||||
partial.unlink(missing_ok=True)
|
||||
raise
|
||||
except httpx.TimeoutException as error:
|
||||
destination.unlink(missing_ok=True)
|
||||
partial.unlink(missing_ok=True)
|
||||
raise AppUpdateError("下载安装包超时") from error
|
||||
except httpx.RequestError as error:
|
||||
destination.unlink(missing_ok=True)
|
||||
partial.unlink(missing_ok=True)
|
||||
raise AppUpdateError(f"下载安装包失败:{error}") from error
|
||||
if expected_size > 0 and received != expected_size:
|
||||
partial.unlink(missing_ok=True)
|
||||
raise AppUpdateError("安装包文件大小与发布信息不一致")
|
||||
actual = hasher.hexdigest()
|
||||
if actual != digest:
|
||||
destination.unlink(missing_ok=True)
|
||||
partial.unlink(missing_ok=True)
|
||||
raise AppUpdateError("安装包校验失败,文件可能已损坏或被替换")
|
||||
os.replace(partial, destination)
|
||||
if progress is not None:
|
||||
progress(received, received if total <= 0 else total)
|
||||
return destination
|
||||
|
||||
|
||||
def _is_secure_installer_url(url: str) -> bool:
|
||||
parsed = urlsplit(url.strip())
|
||||
local_hosts = {"localhost", "127.0.0.1", "::1"}
|
||||
return parsed.scheme == "https" or (
|
||||
parsed.scheme == "http" and (parsed.hostname or "").lower() in local_hosts
|
||||
)
|
||||
|
||||
|
||||
def validate_installer_download_policy(url: str, *, verify_ssl: bool) -> None:
|
||||
"""Require authenticated transport before automatically executing an installer."""
|
||||
|
||||
if not verify_ssl:
|
||||
raise AppUpdateError("自动安装 Windows 更新必须开启 HTTPS 证书校验")
|
||||
if not _is_secure_installer_url(url):
|
||||
raise AppUpdateError("自动安装 Windows 更新仅允许使用 HTTPS 下载地址")
|
||||
|
||||
|
||||
def validate_windows_installer(installer: Path) -> Path:
|
||||
candidate = installer.resolve()
|
||||
if candidate.suffix.lower() != ".exe" or not candidate.is_file():
|
||||
raise AppUpdateError("Windows 更新包不是有效的 Setup.exe 安装程序")
|
||||
try:
|
||||
with candidate.open("rb") as handle:
|
||||
header = handle.read(2)
|
||||
except OSError as error:
|
||||
raise AppUpdateError(f"无法读取 Windows 安装程序:{error}") from error
|
||||
if header != b"MZ":
|
||||
raise AppUpdateError("Windows 更新包不是有效的 PE 安装程序")
|
||||
return candidate
|
||||
|
||||
|
||||
def apply_extracted_update(payload: Path, *, install_root: Path | None = None) -> None:
|
||||
target_root = install_root or frozen_install_root()
|
||||
if target_root is None:
|
||||
@@ -309,14 +407,77 @@ def apply_extracted_update(payload: Path, *, install_root: Path | None = None) -
|
||||
if not payload.exists():
|
||||
raise AppUpdateError("解压后的安装包不完整")
|
||||
log_file = payload.parent / "apply.log"
|
||||
restart_exe = _restart_executable(payload)
|
||||
restart_exe = _installed_restart_executable(target_root)
|
||||
script = _write_apply_script(
|
||||
payload=payload,
|
||||
install_root=target_root,
|
||||
restart_exe=restart_exe,
|
||||
log_file=log_file,
|
||||
)
|
||||
_spawn_applier(script, payload=payload, install_root=target_root, restart_exe=restart_exe, log_file=log_file)
|
||||
_spawn_applier(
|
||||
script,
|
||||
payload=payload,
|
||||
install_root=target_root,
|
||||
restart_exe=restart_exe,
|
||||
log_file=log_file,
|
||||
)
|
||||
|
||||
|
||||
def apply_downloaded_update(
|
||||
payload: Path,
|
||||
*,
|
||||
package_type: str,
|
||||
install_root: Path | None = None,
|
||||
) -> None:
|
||||
if package_type == PACKAGE_TYPE_ARCHIVE:
|
||||
apply_extracted_update(payload, install_root=install_root)
|
||||
return
|
||||
if package_type == PACKAGE_TYPE_INNO_SETUP:
|
||||
apply_inno_setup_update(payload, install_root=install_root)
|
||||
return
|
||||
raise AppUpdateError("不支持的桌面更新包类型")
|
||||
|
||||
|
||||
def apply_inno_setup_update(
|
||||
installer: Path,
|
||||
*,
|
||||
install_root: Path | None = None,
|
||||
) -> None:
|
||||
if sys.platform != "win32":
|
||||
raise AppUpdateError("Inno Setup 更新仅支持 Windows")
|
||||
target_root = install_root or frozen_install_root()
|
||||
if target_root is None:
|
||||
raise AppUpdateError("当前为源码运行,无法自动安装 Windows 更新")
|
||||
target_root = target_root.resolve()
|
||||
installer = validate_windows_installer(installer)
|
||||
restart_exe = _installed_restart_executable(target_root)
|
||||
helper_log_file = installer.parent / "update_helper.log"
|
||||
installer_log_file = installer.parent / "inno_setup.log"
|
||||
try:
|
||||
script = _write_inno_setup_script(
|
||||
installer=installer,
|
||||
restart_exe=restart_exe,
|
||||
helper_log_file=helper_log_file,
|
||||
installer_log_file=installer_log_file,
|
||||
)
|
||||
_spawn_inno_setup_applier(
|
||||
script,
|
||||
installer=installer,
|
||||
restart_exe=restart_exe,
|
||||
helper_log_file=helper_log_file,
|
||||
installer_log_file=installer_log_file,
|
||||
)
|
||||
except OSError as error:
|
||||
raise AppUpdateError(f"无法启动 Windows 更新助手:{error}") from error
|
||||
|
||||
|
||||
def _installed_restart_executable(install_root: Path) -> Path:
|
||||
if install_root.suffix == ".app" or (install_root / "Contents" / "MacOS").is_dir():
|
||||
return install_root
|
||||
exe = install_root / WINDOWS_EXE_NAME
|
||||
if exe.is_file():
|
||||
return exe
|
||||
raise AppUpdateError("当前安装目录中找不到可重启的工作站程序")
|
||||
|
||||
|
||||
def _restart_executable(payload: Path) -> Path:
|
||||
@@ -352,14 +513,14 @@ def _write_apply_script(
|
||||
"function Write-Log([string]$Message) {",
|
||||
' Add-Content -LiteralPath $LogFile -Value ("{0} {1}" -f (Get-Date -Format o), $Message)',
|
||||
"}",
|
||||
"Write-Log \"waiting for pid $TargetPid\"",
|
||||
'Write-Log "waiting for pid $TargetPid"',
|
||||
"while (Get-Process -Id $TargetPid -ErrorAction SilentlyContinue) { Start-Sleep -Milliseconds 400 }",
|
||||
"Start-Sleep -Seconds 1",
|
||||
"Write-Log \"copy $Payload -> $InstallDir\"",
|
||||
'Write-Log "copy $Payload -> $InstallDir"',
|
||||
'$result = (Start-Process -FilePath "robocopy.exe" -ArgumentList @($Payload, $InstallDir, "/E", "/IS", "/IT", "/R:3", "/W:2", "/NFL", "/NDL", "/NJH", "/NJS", "/NC", "/NS", "/NP") -Wait -PassThru).ExitCode',
|
||||
"Write-Log \"robocopy exit $result\"",
|
||||
"if ($result -ge 8) { Write-Log \"copy failed\"; exit $result }",
|
||||
"Write-Log \"restart $RestartExe\"",
|
||||
'Write-Log "robocopy exit $result"',
|
||||
'if ($result -ge 8) { Write-Log "copy failed"; exit $result }',
|
||||
'Write-Log "restart $RestartExe"',
|
||||
"Start-Process -FilePath $RestartExe -WorkingDirectory ([System.IO.Path]::GetDirectoryName($RestartExe))",
|
||||
"exit 0",
|
||||
"",
|
||||
@@ -399,6 +560,115 @@ def _write_apply_script(
|
||||
return script
|
||||
|
||||
|
||||
def _write_inno_setup_script(
|
||||
*,
|
||||
installer: Path,
|
||||
restart_exe: Path,
|
||||
helper_log_file: Path,
|
||||
installer_log_file: Path,
|
||||
) -> Path:
|
||||
script = installer.parent / "install_update.ps1"
|
||||
script.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"param(",
|
||||
" [Parameter(Mandatory=$true)][int]$TargetPid,",
|
||||
" [Parameter(Mandatory=$true)][string]$Installer,",
|
||||
" [Parameter(Mandatory=$true)][string]$RestartExe,",
|
||||
" [Parameter(Mandatory=$true)][string]$HelperLogFile,",
|
||||
" [Parameter(Mandatory=$true)][string]$InstallerLogFile",
|
||||
")",
|
||||
'$ErrorActionPreference = "Stop"',
|
||||
"function Write-Log([string]$Message) {",
|
||||
' Add-Content -LiteralPath $HelperLogFile -Value ("{0} {1}" -f (Get-Date -Format o), $Message)',
|
||||
"}",
|
||||
"function Restart-Application {",
|
||||
" if (Test-Path -LiteralPath $RestartExe -PathType Leaf) {",
|
||||
' Write-Log "restart $RestartExe"',
|
||||
" Start-Process -FilePath $RestartExe -WorkingDirectory ([System.IO.Path]::GetDirectoryName($RestartExe))",
|
||||
" } else {",
|
||||
' Write-Log "restart executable missing: $RestartExe"',
|
||||
" }",
|
||||
"}",
|
||||
'Write-Log "waiting for pid $TargetPid"',
|
||||
"while (Get-Process -Id $TargetPid -ErrorAction SilentlyContinue) { Start-Sleep -Milliseconds 400 }",
|
||||
"Start-Sleep -Milliseconds 600",
|
||||
"$arguments = @(",
|
||||
' "/VERYSILENT",',
|
||||
' "/SUPPRESSMSGBOXES",',
|
||||
' "/SP-",',
|
||||
' "/NORESTART",',
|
||||
' "/RESTARTEXITCODE=3010",',
|
||||
' "/CLOSEAPPLICATIONS",',
|
||||
' "/NOFORCECLOSEAPPLICATIONS",',
|
||||
' "/NORESTARTAPPLICATIONS",',
|
||||
' ("/LOG=`"{0}`"" -f $InstallerLogFile)',
|
||||
")",
|
||||
"try {",
|
||||
' Write-Log "launch installer $Installer"',
|
||||
" $process = Start-Process -FilePath $Installer -ArgumentList $arguments -Wait -PassThru -WindowStyle Hidden",
|
||||
" $exitCode = $process.ExitCode",
|
||||
' Write-Log "installer exit $exitCode"',
|
||||
"} catch {",
|
||||
' Write-Log ("installer launch failed: {0}" -f $_.Exception.Message)',
|
||||
" Restart-Application",
|
||||
" exit 1",
|
||||
"}",
|
||||
"if ($exitCode -eq 0 -or $exitCode -eq 3010) {",
|
||||
' Write-Log "installation completed"',
|
||||
" Restart-Application",
|
||||
" exit 0",
|
||||
"}",
|
||||
'Write-Log "installation failed or was cancelled; restarting existing application"',
|
||||
"Restart-Application",
|
||||
"exit $exitCode",
|
||||
"",
|
||||
]
|
||||
),
|
||||
encoding="utf-8-sig",
|
||||
)
|
||||
del restart_exe, helper_log_file, installer_log_file
|
||||
return script
|
||||
|
||||
|
||||
def _spawn_inno_setup_applier(
|
||||
script: Path,
|
||||
*,
|
||||
installer: Path,
|
||||
restart_exe: Path,
|
||||
helper_log_file: Path,
|
||||
installer_log_file: Path,
|
||||
) -> None:
|
||||
flags = getattr(subprocess, "DETACHED_PROCESS", 0)
|
||||
flags |= getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
||||
flags |= getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||
subprocess.Popen(
|
||||
[
|
||||
"powershell.exe",
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-WindowStyle",
|
||||
"Hidden",
|
||||
"-File",
|
||||
str(script),
|
||||
"-TargetPid",
|
||||
str(os.getpid()),
|
||||
"-Installer",
|
||||
str(installer),
|
||||
"-RestartExe",
|
||||
str(restart_exe),
|
||||
"-HelperLogFile",
|
||||
str(helper_log_file),
|
||||
"-InstallerLogFile",
|
||||
str(installer_log_file),
|
||||
],
|
||||
close_fds=True,
|
||||
creationflags=flags,
|
||||
cwd=str(script.parent),
|
||||
)
|
||||
|
||||
|
||||
def _spawn_applier(
|
||||
script: Path,
|
||||
*,
|
||||
@@ -459,5 +729,6 @@ def prepare_update_workspace(config_dir: Path, version: str) -> Path:
|
||||
def package_filename(package: UpdatePackage, version: str) -> str:
|
||||
name = Path(package.filename or urlsplit(package.url).path).name
|
||||
if not name:
|
||||
name = f"DoctorWorkstation-{version}.zip"
|
||||
suffix = ".exe" if package.type == PACKAGE_TYPE_INNO_SETUP else ".zip"
|
||||
name = f"DoctorWorkstation-{version}{suffix}"
|
||||
return name
|
||||
|
||||
@@ -7,7 +7,7 @@ import os
|
||||
import sys
|
||||
import traceback
|
||||
from collections.abc import Callable
|
||||
from dataclasses import replace
|
||||
from dataclasses import dataclass, replace
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -26,9 +26,10 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
from doctor_workstation.services.app_update import (
|
||||
PACKAGE_TYPE_INNO_SETUP,
|
||||
AppUpdateError,
|
||||
UpdateOffer,
|
||||
apply_extracted_update,
|
||||
apply_downloaded_update,
|
||||
current_app_version,
|
||||
discover_payload,
|
||||
download_package,
|
||||
@@ -38,12 +39,20 @@ from doctor_workstation.services.app_update import (
|
||||
package_filename,
|
||||
prepare_update_workspace,
|
||||
safe_extract_zip,
|
||||
validate_installer_download_policy,
|
||||
validate_windows_installer,
|
||||
)
|
||||
from doctor_workstation.ui.widgets import friendly_error, show_toast
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _PreparedUpdate:
|
||||
package_type: str
|
||||
payload: Path
|
||||
|
||||
|
||||
def _format_bytes(value: int) -> str:
|
||||
size = max(0, int(value))
|
||||
if size < 1024:
|
||||
@@ -100,7 +109,11 @@ class AppUpdateDialog(QDialog):
|
||||
self.setWindowTitle(title)
|
||||
self.setModal(True)
|
||||
self.setMinimumWidth(520)
|
||||
flags = Qt.WindowType.Dialog | Qt.WindowType.WindowTitleHint | Qt.WindowType.WindowSystemMenuHint
|
||||
flags = (
|
||||
Qt.WindowType.Dialog
|
||||
| Qt.WindowType.WindowTitleHint
|
||||
| Qt.WindowType.WindowSystemMenuHint
|
||||
)
|
||||
if not offer.force:
|
||||
flags |= Qt.WindowType.WindowCloseButtonHint
|
||||
self.setWindowFlags(flags)
|
||||
@@ -350,10 +363,19 @@ class AppUpdateSession(QObject):
|
||||
current = offer.current_version or current_app_version()
|
||||
show_toast(parent, f"当前已是最新版本 {current}。", "success")
|
||||
return
|
||||
if not offer.force and not interactive and offer.latest_version in self._dismissed:
|
||||
return
|
||||
if offer.force and not is_frozen_install():
|
||||
offer = replace(offer, force=False)
|
||||
if not offer.force and not interactive and offer.latest_version in self._dismissed:
|
||||
return
|
||||
if offer.package is not None and offer.package.type == PACKAGE_TYPE_INNO_SETUP:
|
||||
config = getattr(self.host, "config", None)
|
||||
try:
|
||||
validate_installer_download_policy(
|
||||
offer.package.url,
|
||||
verify_ssl=bool(getattr(config, "verify_ssl", True)),
|
||||
)
|
||||
except AppUpdateError:
|
||||
offer = replace(offer, force=False, package=None, can_install=False)
|
||||
if offer.force and not is_frozen_install():
|
||||
offer = replace(offer, force=False)
|
||||
self._present(offer)
|
||||
|
||||
def _present(self, offer: UpdateOffer) -> None:
|
||||
@@ -381,7 +403,7 @@ class AppUpdateSession(QObject):
|
||||
if package is None:
|
||||
return
|
||||
if not is_frozen_install():
|
||||
dialog.show_error("当前为源码运行,无法自动安装。请使用发布 ZIP 安装后再更新。")
|
||||
dialog.show_error("当前为源码运行,无法自动安装。请先安装正式发布版后再更新。")
|
||||
return
|
||||
if frozen_install_root() is None:
|
||||
dialog.show_error("无法确定当前安装目录,已取消自动更新。")
|
||||
@@ -398,27 +420,38 @@ class AppUpdateSession(QObject):
|
||||
signals = _TaskSignals()
|
||||
self._signals = signals
|
||||
|
||||
def job() -> Path:
|
||||
def job() -> _PreparedUpdate:
|
||||
workspace = prepare_update_workspace(config_dir, offer.latest_version)
|
||||
archive = workspace / package_filename(package, offer.latest_version)
|
||||
downloaded = workspace / package_filename(package, offer.latest_version)
|
||||
if package.type == PACKAGE_TYPE_INNO_SETUP:
|
||||
validate_installer_download_policy(package.url, verify_ssl=verify)
|
||||
download_package(
|
||||
package.url,
|
||||
archive,
|
||||
downloaded,
|
||||
sha256=package.sha256,
|
||||
verify=verify,
|
||||
expected_size=package.size,
|
||||
progress=lambda received, total: signals.progress.emit(received, total),
|
||||
cancelled=lambda: self._cancel,
|
||||
)
|
||||
if package.type == PACKAGE_TYPE_INNO_SETUP:
|
||||
signals.status.emit("正在校验 Windows 安装程序…")
|
||||
return _PreparedUpdate(
|
||||
package_type=package.type,
|
||||
payload=validate_windows_installer(downloaded),
|
||||
)
|
||||
signals.status.emit("正在校验并解压安装包…")
|
||||
extracted = workspace / "payload"
|
||||
safe_extract_zip(archive, extracted)
|
||||
return discover_payload(extracted)
|
||||
safe_extract_zip(downloaded, extracted)
|
||||
return _PreparedUpdate(
|
||||
package_type=package.type,
|
||||
payload=discover_payload(extracted),
|
||||
)
|
||||
|
||||
worker = _Task(job, signals)
|
||||
signals.progress.connect(dialog.show_download_progress)
|
||||
signals.status.connect(dialog.show_status)
|
||||
signals.result.connect(lambda payload: self._finish_install(dialog, payload))
|
||||
signals.result.connect(lambda prepared: self._finish_install(dialog, prepared))
|
||||
signals.error.connect(lambda error, _tb: self._install_failed(dialog, error))
|
||||
QThreadPool.globalInstance().start(worker)
|
||||
|
||||
@@ -431,13 +464,21 @@ class AppUpdateSession(QObject):
|
||||
message = str(error) if isinstance(error, AppUpdateError) else friendly_error(error)
|
||||
dialog.show_error(f"更新失败:{message}")
|
||||
|
||||
def _finish_install(self, dialog: AppUpdateDialog, payload: object) -> None:
|
||||
if not isinstance(payload, Path):
|
||||
dialog.show_error("安装包解压结果无效。")
|
||||
def _finish_install(self, dialog: AppUpdateDialog, prepared: object) -> None:
|
||||
if not isinstance(prepared, _PreparedUpdate):
|
||||
dialog.show_error("安装包准备结果无效。")
|
||||
return
|
||||
dialog.show_status("即将关闭并完成安装…", determinate=True)
|
||||
message = (
|
||||
"即将关闭程序并自动安装,系统可能会请求管理员权限…"
|
||||
if prepared.package_type == PACKAGE_TYPE_INNO_SETUP
|
||||
else "即将关闭并完成安装…"
|
||||
)
|
||||
dialog.show_status(message, determinate=True)
|
||||
try:
|
||||
apply_extracted_update(payload)
|
||||
apply_downloaded_update(
|
||||
prepared.payload,
|
||||
package_type=prepared.package_type,
|
||||
)
|
||||
except AppUpdateError as error:
|
||||
dialog.show_error(str(error))
|
||||
return
|
||||
|
||||
@@ -41,6 +41,7 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
from doctor_workstation import __version__
|
||||
from doctor_workstation.resources import app_icon_path, brand_lockup_path
|
||||
|
||||
from .theme import crisp_pixmap
|
||||
from .widgets import BusyOverlay, MessageBanner, friendly_error, invoke, run_async
|
||||
@@ -134,47 +135,7 @@ class _BrandPanel(QWidget):
|
||||
self.setMinimumWidth(320)
|
||||
self.setMaximumWidth(824)
|
||||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
|
||||
@staticmethod
|
||||
def _draw_mark(painter: QPainter, rect: QRectF, *, faint: bool = False) -> None:
|
||||
alpha = 112 if faint else 255
|
||||
gradient = QLinearGradient(rect.topLeft(), rect.bottomRight())
|
||||
gradient.setColorAt(0.0, QColor(122, 137, 255, alpha))
|
||||
gradient.setColorAt(1.0, QColor(71, 82, 238, alpha))
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.setBrush(gradient)
|
||||
painter.drawRoundedRect(rect, rect.width() * 0.27, rect.width() * 0.27)
|
||||
|
||||
pen = QPen(
|
||||
QColor(255, 255, 255, 235 if not faint else 150),
|
||||
max(2.0, rect.width() / 24),
|
||||
)
|
||||
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
|
||||
painter.setPen(pen)
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
bag = QRectF(
|
||||
rect.left() + rect.width() * 0.24,
|
||||
rect.top() + rect.height() * 0.35,
|
||||
rect.width() * 0.52,
|
||||
rect.height() * 0.38,
|
||||
)
|
||||
painter.drawRoundedRect(bag, rect.width() * 0.06, rect.width() * 0.06)
|
||||
handle = QRectF(
|
||||
rect.left() + rect.width() * 0.39,
|
||||
rect.top() + rect.height() * 0.25,
|
||||
rect.width() * 0.22,
|
||||
rect.height() * 0.15,
|
||||
)
|
||||
painter.drawRoundedRect(handle, rect.width() * 0.04, rect.width() * 0.04)
|
||||
cx, cy = rect.center().x(), rect.top() + rect.height() * 0.54
|
||||
painter.drawLine(
|
||||
QPointF(cx - rect.width() * 0.13, cy), QPointF(cx + rect.width() * 0.13, cy)
|
||||
)
|
||||
painter.drawLine(
|
||||
QPointF(cx, cy - rect.height() * 0.13),
|
||||
QPointF(cx, cy + rect.height() * 0.13),
|
||||
)
|
||||
self._brand_lockup = QPixmap(str(brand_lockup_path()))
|
||||
|
||||
@staticmethod
|
||||
def _draw_cube(painter: QPainter, center: QPointF, size: float, kind: str) -> None:
|
||||
@@ -340,10 +301,17 @@ class _BrandPanel(QWidget):
|
||||
self._draw_illustration(painter, width, height)
|
||||
|
||||
left = 64.0 if width >= 620 else 38.0
|
||||
self._draw_mark(painter, QRectF(left, 51, 70, 70))
|
||||
painter.setPen(QColor("#14224A"))
|
||||
painter.setFont(_font(28, QFont.Weight.Bold))
|
||||
painter.drawText(QPointF(left + 93, 98), "甄养堂医疗")
|
||||
if not self._brand_lockup.isNull():
|
||||
logo_width = 180.0
|
||||
logo_height = logo_width * (
|
||||
self._brand_lockup.height() / self._brand_lockup.width()
|
||||
)
|
||||
painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
|
||||
painter.drawPixmap(
|
||||
QRectF(left, 24, logo_width, logo_height),
|
||||
self._brand_lockup,
|
||||
QRectF(self._brand_lockup.rect()),
|
||||
)
|
||||
|
||||
tag_rect = QRectF(left, 238, 119, 40)
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
@@ -919,12 +887,7 @@ class LoginWindow(QMainWindow):
|
||||
|
||||
@staticmethod
|
||||
def _window_icon() -> QIcon:
|
||||
pixmap = crisp_pixmap(64)
|
||||
painter = QPainter(pixmap)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
_BrandPanel._draw_mark(painter, QRectF(1, 1, 62, 62))
|
||||
painter.end()
|
||||
return QIcon(pixmap)
|
||||
return QIcon(str(app_icon_path()))
|
||||
|
||||
@staticmethod
|
||||
def _account_icon() -> QIcon:
|
||||
|
||||
@@ -14,7 +14,6 @@ from PySide6.QtGui import (
|
||||
QFont,
|
||||
QIcon,
|
||||
QKeySequence,
|
||||
QLinearGradient,
|
||||
QMouseEvent,
|
||||
QPainter,
|
||||
QPen,
|
||||
@@ -40,7 +39,8 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from .theme import crisp_pixmap
|
||||
from doctor_workstation.resources import app_icon_path
|
||||
|
||||
from .dialogs.ai_consult import can_open_ai_consult
|
||||
from .dialogs.ai_consult_picker import select_and_present_ai_consult
|
||||
from .dialogs.local_audio_queue import LocalAudioQueueDialog
|
||||
@@ -52,6 +52,7 @@ from .pages import (
|
||||
PrescriptionsPage,
|
||||
ReceptionPage,
|
||||
)
|
||||
from .theme import crisp_pixmap
|
||||
from .widgets import (
|
||||
EmptyState,
|
||||
StatusBadge,
|
||||
@@ -575,25 +576,19 @@ def _painted_navigation_icon(kind: str, size: int = 18) -> QIcon:
|
||||
|
||||
|
||||
class _ShellBrandMark(QWidget):
|
||||
"""Paint the supplied indigo M mark without a font-glyph asset."""
|
||||
"""Render the approved application brand mark in the navigation rail."""
|
||||
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._brand_pixmap = QPixmap(str(app_icon_path()))
|
||||
|
||||
def paintEvent(self, event: Any) -> None: # noqa: N802 - Qt virtual
|
||||
del event
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
|
||||
rect = QRectF(self.rect()).adjusted(0.75, 0.75, -0.75, -0.75)
|
||||
gradient = QLinearGradient(rect.topLeft(), rect.bottomRight())
|
||||
gradient.setColorAt(0.0, QColor("#6F7BFA"))
|
||||
gradient.setColorAt(1.0, QColor("#5260ED"))
|
||||
painter.setPen(QPen(QColor(117, 130, 255, 150), 1.0))
|
||||
painter.setBrush(gradient)
|
||||
painter.drawRoundedRect(rect, 10, 10)
|
||||
painter.setPen(QColor("#FFFFFF"))
|
||||
font = QFont(painter.font())
|
||||
font.setPixelSize(18)
|
||||
font.setWeight(QFont.Weight.Bold)
|
||||
painter.setFont(font)
|
||||
painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, "M")
|
||||
if not self._brand_pixmap.isNull():
|
||||
painter.drawPixmap(rect, self._brand_pixmap, QRectF(self._brand_pixmap.rect()))
|
||||
|
||||
|
||||
class _AssistantRobot(QWidget):
|
||||
|
||||
Reference in New Issue
Block a user