更新
@@ -1,6 +1,8 @@
|
||||
.env
|
||||
.venv/
|
||||
.venv-build/
|
||||
.venv-win7-check/
|
||||
.build-tools/
|
||||
.uv-cache/
|
||||
.uv-python/
|
||||
__pycache__/
|
||||
|
||||
@@ -6,12 +6,13 @@ set "POWERSHELL_EXE=%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe"
|
||||
"%POWERSHELL_EXE%" -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%PROJECT_ROOT%scripts\package_windows.ps1" %*
|
||||
set "RESULT=%ERRORLEVEL%"
|
||||
|
||||
if "%RESULT%"=="0" (
|
||||
if "%RESULT%"=="0" if /I "%~1"=="-ValidateOnly" (
|
||||
echo.
|
||||
echo Package ready in: %PROJECT_ROOT%dist
|
||||
if /I not "%~1"=="-ValidateOnly" (
|
||||
start "" "%SystemRoot%\explorer.exe" "%PROJECT_ROOT%dist"
|
||||
)
|
||||
echo Validation complete. No installer was generated.
|
||||
) else if "%RESULT%"=="0" (
|
||||
echo.
|
||||
echo Installer and portable package ready in: %PROJECT_ROOT%dist
|
||||
start "" "%SystemRoot%\explorer.exe" "%PROJECT_ROOT%dist"
|
||||
) else (
|
||||
echo.
|
||||
echo DoctorWorkstation packaging failed. Exit code: %RESULT%
|
||||
|
||||
@@ -9,16 +9,18 @@
|
||||
Windows 直接在项目根目录双击:
|
||||
|
||||
- `一键运行_医生工作站.bat`:优先启动现有成品;没有成品时自动使用 `uv` 准备源码环境并运行。
|
||||
- `一键打包_医生工作站.bat`:自动同步锁定的 Python/Node 依赖,检查冻结 QtWebEngine/QtMultimedia 文件,执行应用与媒体离屏冒烟验证,最后生成 `dist/DoctorWorkstation-Windows-x64-<版本>.zip` 和 SHA-256 文件。
|
||||
- `一键打包_医生工作站.bat`:自动同步锁定的 Python/Node 依赖,检查冻结 QtWebEngine/QtMultimedia 文件,执行应用与媒体离屏冒烟验证,最后生成 `dist/DoctorWorkstation-Setup-Windows-x64-<版本>.exe` 安装包、便携 ZIP 和 SHA-256 文件。首次使用会自动下载固定版本的 Inno Setup,并校验下载文件的 SHA-256 与数字签名。
|
||||
|
||||
英文稳定别名分别是 `Run_DoctorWorkstation.bat` 和 `Build_DoctorWorkstation.bat`。分发 ZIP 解压后,可直接双击其中的 `Start_DoctorWorkstation.bat`。
|
||||
|
||||
发布新版本时,可在管理后台的“医生工作站升级包”中为 Windows 选择 `Inno Setup EXE`,填入一键打包生成的 Setup 地址、SHA-256、文件大小和文件名。客户端启动时自动检测;发现新版本后,用户点击“立即更新”即可完成下载、校验、退出、静默安装和自动重启。生产地址必须使用 HTTPS 并开启证书校验,正式 Setup 还应配置组织的 Authenticode 代码签名。
|
||||
|
||||
macOS 在 Finder 中双击:
|
||||
|
||||
- `一键运行.command`:优先打开现有 `DoctorWorkstation.app`,否则自动准备源码环境并运行。
|
||||
- `一键打包.command`:构建、QtWebEngine/QtMultimedia 文件门禁、签名检查和两项冻结冒烟验证后,生成 `.app`、可分发 ZIP 及 SHA-256 文件。
|
||||
|
||||
Windows 打包机需预先安装 `uv` 与 Node.js 20+;脚本会自动处理项目虚拟环境和锁定依赖。首次打包需要联网下载依赖,之后会复用本机缓存。macOS 发布源码中的根 `.command` 与操作型 `scripts/*.sh` 必须以 Git mode `100755` 跟踪;源码压缩包在传输中丢失权限时,可在项目目录执行一次 `chmod +x *.command scripts/*.sh`。若 Gatekeeper 拦截未签名内部测试版,请使用右键“打开”。
|
||||
Windows 打包机需预先安装 `uv` 与 Node.js 20+;脚本会自动处理项目虚拟环境、锁定依赖和安装器编译器。首次打包需要联网下载依赖,之后会复用本机缓存。当前 Python 3.11+/PySide6/Qt 6 成品的真实最低系统是 Windows 10 1809;Qt 6 不支持 Windows 7,不能只降低安装器版本门槛来伪装兼容。Windows 7 SP1 必须另行维护 Python 3.8.10 + PySide2/Qt 5.15 的遗留构建,并在干净 Win7 虚拟机完成视频、媒体、安装、升级和卸载验收。macOS 发布源码中的根 `.command` 与操作型 `scripts/*.sh` 必须以 Git mode `100755` 跟踪;源码压缩包在传输中丢失权限时,可在项目目录执行一次 `chmod +x *.command scripts/*.sh`。若 Gatekeeper 拦截未签名内部测试版,请使用右键“打开”。
|
||||
|
||||
## 已实现范围
|
||||
|
||||
|
||||
@@ -8,6 +8,21 @@ Both build scripts launch the frozen executable twice. `--media-smoke-test` is h
|
||||
|
||||
Run the build on the target operating system. PyInstaller cannot cross-build Windows and macOS artifacts.
|
||||
|
||||
## Brand assets
|
||||
|
||||
The approved source artwork is kept byte-for-byte at `resources/branding/brand-master.png`.
|
||||
`app-icon.png`, the multi-size Windows `app-icon.ico`, the macOS `app-icon.icns`,
|
||||
the login-page `brand-lockup.png`, and the video companion favicon are deterministic
|
||||
derivatives of that master. Regenerate them after replacing the approved master:
|
||||
|
||||
```powershell
|
||||
uv run --no-project --with pillow==11.3.0 python scripts/generate_brand_assets.py
|
||||
```
|
||||
|
||||
The full lockup is reserved for large brand placements. Window, taskbar, shortcut,
|
||||
installer, uninstaller, Dock, and browser icons use the text-free pictorial mark so
|
||||
the identity remains legible at 16–64 pixels.
|
||||
|
||||
## Windows
|
||||
|
||||
```powershell
|
||||
@@ -16,7 +31,15 @@ Run the build on the target operating system. PyInstaller cannot cross-build Win
|
||||
|
||||
The default interpreter is `.venv-build\Scripts\python.exe`; override it with `-Python C:\path\to\python.exe`.
|
||||
|
||||
For the one-click release ZIP and SHA-256 manifest, run `Build_DoctorWorkstation.bat`. It prepares locked dependencies, invokes the build/file/smoke gates, and archives only after all gates pass.
|
||||
For the one-click Windows installer, portable ZIP, and SHA-256 manifest, run `Build_DoctorWorkstation.bat` (or double-click `一键打包_医生工作站.bat`). It prepares locked dependencies, invokes the build/file/smoke gates, then compiles `DoctorWorkstation-Setup-Windows-x64-<version>.exe` with a pinned Inno Setup compiler. The compiler is downloaded from the official release on first use and accepted only after both its pinned SHA-256 and Authenticode signer pass validation. The portable ZIP is retained as a secondary artifact.
|
||||
|
||||
The current Python 3.11+/PySide6/Qt 6 runtime requires Windows 10 version 1809 or newer, so the installer declares `MinVersion=10.0.17763`. Do not lower that installer value to claim Windows 7 compatibility: Qt 6 does not support Windows 7. A real Windows 7 SP1 build requires a separately maintained legacy runtime (Python 3.8.10, PySide2/Qt 5.15, and a compatible freezer), plus clean Windows 7 SP1 VM validation for QtWebEngine, multimedia, installation, upgrade, and uninstall behavior.
|
||||
|
||||
After packaging, `scripts/smoke_windows_installer.ps1` silently installs the newest Setup artifact for the current user into an isolated temporary directory, runs the installed executable's smoke gate, silently uninstalls it, and verifies that the executable was removed. It intentionally retains only its small logs and isolated user-data directory under `%TEMP%` for diagnosis.
|
||||
|
||||
To publish an automatic Windows update, open **System settings → Doctor workstation update** in the admin site and select `Windows installer (Inno Setup EXE)`. Upload the generated `DoctorWorkstation-Setup-Windows-x64-<version>.exe` (or use an HTTPS CDN URL), then copy its SHA-256, byte size, and filename from the packaging output. New clients download and verify the installer, close themselves, run Inno Setup silently, and restart only after the installer succeeds. Existing ZIP metadata remains supported for older releases.
|
||||
|
||||
Automatic installer execution requires an HTTPS download with certificate verification (loopback development URLs are the only exception). Production Setup artifacts should also be Authenticode-signed before publication; the current local build can create an unsigned installer when no organization signing certificate is configured.
|
||||
|
||||
## macOS
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ SOURCE_ROOT = PROJECT_ROOT / "src"
|
||||
ENTRY_POINT = SOURCE_ROOT / "doctor_workstation" / "__main__.py"
|
||||
VIDEO_DIST = PROJECT_ROOT / "video_companion" / "dist"
|
||||
RESOURCES = PROJECT_ROOT / "resources"
|
||||
WINDOWS_ICON = RESOURCES / "branding" / "app-icon.ico"
|
||||
MACOS_ICON = RESOURCES / "branding" / "app-icon.icns"
|
||||
ENTITLEMENTS = PROJECT_ROOT / "packaging" / "macos" / "entitlements.plist"
|
||||
VERSION_FILE = PROJECT_ROOT / "packaging" / "windows" / "version_info.txt"
|
||||
MEDIA_SMOKE_HOOK = PROJECT_ROOT / "packaging" / "runtime_media_smoke.py"
|
||||
@@ -30,6 +32,10 @@ if not (VIDEO_DIST / "index.html").is_file():
|
||||
raise SystemExit("Build video_companion before running PyInstaller")
|
||||
if not MEDIA_SMOKE_HOOK.is_file():
|
||||
raise SystemExit(f"Frozen multimedia smoke hook is missing: {MEDIA_SMOKE_HOOK}")
|
||||
if sys.platform == "win32" and not WINDOWS_ICON.is_file():
|
||||
raise SystemExit(f"Windows application icon is missing: {WINDOWS_ICON}")
|
||||
if sys.platform == "darwin" and not MACOS_ICON.is_file():
|
||||
raise SystemExit(f"macOS application icon is missing: {MACOS_ICON}")
|
||||
|
||||
# Some Windows developer tools add an unrelated OpenSSL installation to PATH.
|
||||
# PyInstaller's dependency scanner would then pair Python's ``_ssl.pyd`` with
|
||||
@@ -98,6 +104,7 @@ exe = EXE(
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
target_arch=None,
|
||||
icon=str(WINDOWS_ICON) if sys.platform == "win32" else None,
|
||||
codesign_identity=os.environ.get("MACOS_CODESIGN_IDENTITY") if is_macos else None,
|
||||
entitlements_file=str(ENTITLEMENTS) if is_macos else None,
|
||||
version=str(VERSION_FILE) if sys.platform == "win32" else None,
|
||||
@@ -116,7 +123,7 @@ if is_macos:
|
||||
app = BUNDLE(
|
||||
collection,
|
||||
name="DoctorWorkstation.app",
|
||||
icon=None,
|
||||
icon=str(MACOS_ICON),
|
||||
bundle_identifier="com.zyt.doctor-workstation",
|
||||
info_plist={
|
||||
"CFBundleDisplayName": "甄养堂医生工作站",
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
#ifndef AppVersion
|
||||
#error AppVersion must be provided by scripts/package_windows.ps1
|
||||
#endif
|
||||
#ifndef SourceDir
|
||||
#error SourceDir must be provided by scripts/package_windows.ps1
|
||||
#endif
|
||||
#ifndef OutputDir
|
||||
#error OutputDir must be provided by scripts/package_windows.ps1
|
||||
#endif
|
||||
#ifndef SetupBaseName
|
||||
#error SetupBaseName must be provided by scripts/package_windows.ps1
|
||||
#endif
|
||||
#ifndef ChineseMessagesFile
|
||||
#error ChineseMessagesFile must be provided by scripts/package_windows.ps1
|
||||
#endif
|
||||
#ifndef AppIconFile
|
||||
#error AppIconFile must be provided by scripts/package_windows.ps1
|
||||
#endif
|
||||
|
||||
#define AppName "甄养堂医生工作站"
|
||||
#define AppPublisher "ZYT"
|
||||
#define AppExecutableName "DoctorWorkstation.exe"
|
||||
#define AppIdValue "{{07D97DE8-3DF5-492D-AB2A-BE58FC1A040D}"
|
||||
|
||||
[Setup]
|
||||
AppId={#AppIdValue}
|
||||
AppName={#AppName}
|
||||
AppVersion={#AppVersion}
|
||||
AppVerName={#AppName} {#AppVersion}
|
||||
AppPublisher={#AppPublisher}
|
||||
DefaultDirName={autopf}\ZYT\DoctorWorkstation
|
||||
DefaultGroupName={#AppName}
|
||||
DisableProgramGroupPage=yes
|
||||
OutputDir={#OutputDir}
|
||||
OutputBaseFilename={#SetupBaseName}
|
||||
SetupIconFile={#AppIconFile}
|
||||
Compression=lzma2/max
|
||||
SolidCompression=yes
|
||||
WizardStyle=modern
|
||||
PrivilegesRequired=admin
|
||||
PrivilegesRequiredOverridesAllowed=dialog commandline
|
||||
UsePreviousAppDir=yes
|
||||
UsePreviousPrivileges=yes
|
||||
ArchitecturesAllowed=x64compatible
|
||||
ArchitecturesInstallIn64BitMode=x64compatible
|
||||
MinVersion=10.0.17763
|
||||
UninstallDisplayIcon={app}\{#AppExecutableName},0
|
||||
CloseApplications=yes
|
||||
RestartApplications=no
|
||||
SetupLogging=yes
|
||||
|
||||
[Languages]
|
||||
Name: "chinesesimplified"; MessagesFile: "{#ChineseMessagesFile}"
|
||||
|
||||
[Tasks]
|
||||
Name: "desktopicon"; Description: "创建桌面快捷方式"; GroupDescription: "附加快捷方式:"; Flags: unchecked
|
||||
|
||||
[Files]
|
||||
Source: "{#SourceDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
|
||||
|
||||
[Icons]
|
||||
Name: "{autoprograms}\{#AppName}"; Filename: "{app}\{#AppExecutableName}"; WorkingDir: "{app}"; IconFilename: "{app}\{#AppExecutableName}"; IconIndex: 0
|
||||
Name: "{autodesktop}\{#AppName}"; Filename: "{app}\{#AppExecutableName}"; WorkingDir: "{app}"; IconFilename: "{app}\{#AppExecutableName}"; IconIndex: 0; Tasks: desktopicon
|
||||
|
||||
[Run]
|
||||
Filename: "{app}\{#AppExecutableName}"; Description: "启动 {#AppName}"; WorkingDir: "{app}"; Flags: nowait postinstall skipifsilent
|
||||
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 424 KiB |
|
After Width: | Height: | Size: 544 KiB |
|
After Width: | Height: | Size: 886 KiB |
@@ -1,5 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="真阳医生工作台">
|
||||
<rect width="64" height="64" rx="16" fill="#123F3A"/>
|
||||
<path d="M29 14h6a3 3 0 0 1 3 3v10h9a3 3 0 0 1 3 3v5a3 3 0 0 1-3 3h-9v9a3 3 0 0 1-3 3h-6a3 3 0 0 1-3-3v-9h-9a3 3 0 0 1-3-3v-5a3 3 0 0 1 3-3h9V17a3 3 0 0 1 3-3Z" fill="#F6F4EE"/>
|
||||
<path d="M42 16c-1 8-5 13-12 15 2-7 6-12 12-15Z" fill="#79B9A4"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 418 B |
@@ -4,6 +4,7 @@ set -euo pipefail
|
||||
project_root="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
python_bin="${PYINSTALLER_PYTHON:-$project_root/.venv-build/bin/python}"
|
||||
companion_root="$project_root/video_companion"
|
||||
macos_icon="$project_root/resources/branding/app-icon.icns"
|
||||
|
||||
if [[ "$(uname -s)" != "Darwin" ]]; then
|
||||
echo "The macOS bundle must be built on macOS." >&2
|
||||
@@ -13,6 +14,10 @@ if [[ ! -x "$python_bin" ]]; then
|
||||
echo "Build Python was not found: $python_bin" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ ! -f "$macos_icon" ]]; then
|
||||
echo "macOS application icon was not found: $macos_icon" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ "${SKIP_FRONTEND_INSTALL:-0}" != "1" ]]; then
|
||||
npm ci --prefix "$companion_root" --no-audit --no-fund
|
||||
|
||||
@@ -8,6 +8,7 @@ $ErrorActionPreference = "Stop"
|
||||
$ProjectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
|
||||
$CompanionRoot = Join-Path $ProjectRoot "video_companion"
|
||||
$Spec = Join-Path $ProjectRoot "packaging\doctor_workstation.spec"
|
||||
$WindowsIcon = Join-Path $ProjectRoot "resources\branding\app-icon.ico"
|
||||
|
||||
function Invoke-FrozenGate {
|
||||
param(
|
||||
@@ -153,6 +154,9 @@ if (-not [System.IO.Path]::IsPathRooted($Python)) {
|
||||
if (-not (Test-Path -LiteralPath $Python -PathType Leaf)) {
|
||||
throw "Build Python was not found: $Python"
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $WindowsIcon -PathType Leaf)) {
|
||||
throw "Windows application icon was not found: $WindowsIcon"
|
||||
}
|
||||
|
||||
$Npm = (Get-Command npm.cmd -ErrorAction Stop).Source
|
||||
Push-Location $ProjectRoot
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ProjectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
|
||||
$ToolVersion = "6.7.3"
|
||||
$ToolRoot = Join-Path $ProjectRoot ".build-tools\inno-setup-$ToolVersion"
|
||||
$Compiler = Join-Path $ToolRoot "ISCC.exe"
|
||||
$DownloadRoot = Join-Path $ProjectRoot ".build-tools\downloads"
|
||||
$Installer = Join-Path $DownloadRoot "innosetup-$ToolVersion.exe"
|
||||
$InstallerUrl = (
|
||||
"https://github.com/jrsoftware/issrc/releases/download/" +
|
||||
"is-6_7_3/innosetup-$ToolVersion.exe"
|
||||
)
|
||||
$InstallerSha256 = "9C73C3BAE7ED48D44112A0F48E66742C00090BDB5BEF71D9D3C056C66E97B732"
|
||||
$LanguageRoot = Join-Path $ProjectRoot ".build-tools\inno-languages"
|
||||
$ChineseMessages = Join-Path $LanguageRoot "ChineseSimplified.isl"
|
||||
$ChineseMessagesUrl = (
|
||||
"https://raw.githubusercontent.com/jrsoftware/issrc/" +
|
||||
"6ef32198ef1f7b7b375cd4b6b90896c2a58eb4c2/Files/Languages/ChineseSimplified.isl"
|
||||
)
|
||||
$ChineseMessagesSha256 = "E0B0B350E2245F3C5E65586DFE43D574F6E7F06F2261149ABA284954B3FC9A8D"
|
||||
|
||||
function Test-Compiler {
|
||||
param([Parameter(Mandatory = $true)][string]$Candidate)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $Candidate -PathType Leaf)) {
|
||||
return $false
|
||||
}
|
||||
$ReleaseNotes = Join-Path (Split-Path -Parent $Candidate) "whatsnew.htm"
|
||||
if (-not (Test-Path -LiteralPath $ReleaseNotes -PathType Leaf)) {
|
||||
return $false
|
||||
}
|
||||
$ReleaseText = [System.IO.File]::ReadAllText($ReleaseNotes)
|
||||
$VersionMatch = [regex]::Match($ReleaseText, '<span class="ver">([0-9.]+)')
|
||||
return $VersionMatch.Success -and $VersionMatch.Groups[1].Value -eq $ToolVersion
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $LanguageRoot -Force | Out-Null
|
||||
if (Test-Path -LiteralPath $ChineseMessages -PathType Leaf) {
|
||||
$ExistingLanguageHash = (Get-FileHash -LiteralPath $ChineseMessages -Algorithm SHA256).Hash
|
||||
if ($ExistingLanguageHash -ne $ChineseMessagesSha256) {
|
||||
Remove-Item -LiteralPath $ChineseMessages -Force
|
||||
}
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $ChineseMessages -PathType Leaf)) {
|
||||
Write-Host "Downloading the pinned Simplified Chinese installer messages..."
|
||||
Invoke-WebRequest -Uri $ChineseMessagesUrl -OutFile $ChineseMessages -UseBasicParsing
|
||||
}
|
||||
$ActualLanguageHash = (Get-FileHash -LiteralPath $ChineseMessages -Algorithm SHA256).Hash
|
||||
if ($ActualLanguageHash -ne $ChineseMessagesSha256) {
|
||||
throw "Inno Setup language checksum mismatch. Expected $ChineseMessagesSha256; found $ActualLanguageHash"
|
||||
}
|
||||
|
||||
foreach ($Candidate in @(
|
||||
$Compiler,
|
||||
(Join-Path ${env:ProgramFiles(x86)} "Inno Setup 6\ISCC.exe"),
|
||||
(Join-Path $env:LOCALAPPDATA "Programs\Inno Setup 6\ISCC.exe")
|
||||
)) {
|
||||
if ($Candidate -and (Test-Compiler -Candidate $Candidate)) {
|
||||
Write-Output (Resolve-Path -LiteralPath $Candidate).Path
|
||||
exit 0
|
||||
}
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $DownloadRoot -Force | Out-Null
|
||||
if (Test-Path -LiteralPath $Installer -PathType Leaf) {
|
||||
$ExistingHash = (Get-FileHash -LiteralPath $Installer -Algorithm SHA256).Hash
|
||||
if ($ExistingHash -ne $InstallerSha256) {
|
||||
Remove-Item -LiteralPath $Installer -Force
|
||||
}
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $Installer -PathType Leaf)) {
|
||||
Write-Host "Downloading pinned Inno Setup $ToolVersion compiler..."
|
||||
Invoke-WebRequest -Uri $InstallerUrl -OutFile $Installer -UseBasicParsing
|
||||
}
|
||||
|
||||
$ActualHash = (Get-FileHash -LiteralPath $Installer -Algorithm SHA256).Hash
|
||||
if ($ActualHash -ne $InstallerSha256) {
|
||||
throw "Inno Setup download checksum mismatch. Expected $InstallerSha256; found $ActualHash"
|
||||
}
|
||||
$Signature = Get-AuthenticodeSignature -LiteralPath $Installer
|
||||
if ($Signature.Status -ne [System.Management.Automation.SignatureStatus]::Valid) {
|
||||
throw "Inno Setup download does not have a valid Authenticode signature: $($Signature.Status)"
|
||||
}
|
||||
if (-not $Signature.SignerCertificate -or
|
||||
$Signature.SignerCertificate.Subject -notmatch "O=Pyrsys B\.V\.") {
|
||||
throw "Inno Setup download has an unexpected signer"
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $ToolRoot -Force | Out-Null
|
||||
Write-Host "Installing the pinned Inno Setup compiler into $ToolRoot ..."
|
||||
& $Installer /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /CURRENTUSER "/DIR=$ToolRoot"
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Inno Setup compiler installation failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
if (-not (Test-Compiler -Candidate $Compiler)) {
|
||||
throw "Inno Setup installation completed without ISCC.exe: $Compiler"
|
||||
}
|
||||
|
||||
Write-Output $Compiler
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Generate deterministic application-brand assets from the approved master PNG."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
DEFAULT_MASTER = PROJECT_ROOT / "resources" / "branding" / "brand-master.png"
|
||||
BRANDING_ROOT = PROJECT_ROOT / "resources" / "branding"
|
||||
VIDEO_PUBLIC_ROOT = PROJECT_ROOT / "video_companion" / "public"
|
||||
ICON_SIZES = (16, 20, 24, 32, 40, 48, 64, 128, 256)
|
||||
|
||||
|
||||
def _transparent_connected_background(image: Image.Image) -> Image.Image:
|
||||
"""Remove only near-white pixels connected to the crop boundary.
|
||||
|
||||
The logo contains intentional white ECG strokes. A global color-key would
|
||||
erase them, whereas a connected-background mask preserves enclosed whites.
|
||||
"""
|
||||
|
||||
rgb = image.convert("RGB")
|
||||
candidates = Image.new("L", rgb.size)
|
||||
candidates.putdata(
|
||||
[
|
||||
255
|
||||
if min(pixel) >= 185 and max(pixel) - min(pixel) <= 70
|
||||
else 0
|
||||
for pixel in rgb.getdata()
|
||||
]
|
||||
)
|
||||
ImageDraw.floodfill(candidates, (0, 0), 128, thresh=0)
|
||||
alpha = candidates.point(lambda value: 0 if value == 128 else 255)
|
||||
rgba = rgb.convert("RGBA")
|
||||
rgba.putalpha(alpha)
|
||||
return rgba
|
||||
|
||||
|
||||
def _square_icon(image: Image.Image, size: int = 1024, padding: int = 72) -> Image.Image:
|
||||
available = size - padding * 2
|
||||
scale = min(available / image.width, available / image.height)
|
||||
rendered = image.resize(
|
||||
(round(image.width * scale), round(image.height * scale)),
|
||||
Image.Resampling.LANCZOS,
|
||||
)
|
||||
tile = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
tile_mask = Image.new("L", (size, size), 0)
|
||||
ImageDraw.Draw(tile_mask).rounded_rectangle(
|
||||
(24, 24, size - 24, size - 24),
|
||||
radius=190,
|
||||
fill=255,
|
||||
)
|
||||
white_tile = Image.new("RGBA", (size, size), (255, 255, 255, 255))
|
||||
tile.paste(white_tile, mask=tile_mask)
|
||||
tile.alpha_composite(
|
||||
rendered.convert("RGBA"),
|
||||
((size - rendered.width) // 2, (size - rendered.height) // 2),
|
||||
)
|
||||
return tile
|
||||
|
||||
|
||||
def generate(master_path: Path) -> tuple[Path, ...]:
|
||||
master = Image.open(master_path).convert("RGB")
|
||||
if master.size != (1254, 1254):
|
||||
raise ValueError(f"expected a 1254x1254 brand master, got {master.size}")
|
||||
|
||||
BRANDING_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
VIDEO_PUBLIC_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
lockup_bbox = _transparent_connected_background(master).getchannel("A").getbbox()
|
||||
if lockup_bbox is None:
|
||||
raise ValueError("brand lockup extraction produced an empty image")
|
||||
left, top, right, bottom = lockup_bbox
|
||||
full_lockup = master.crop(
|
||||
(
|
||||
max(0, left - 28),
|
||||
max(0, top - 28),
|
||||
min(master.width, right + 28),
|
||||
min(master.height, bottom + 28),
|
||||
)
|
||||
)
|
||||
|
||||
# The supplied artwork places the pictorial mark wholly above y=720. The
|
||||
# crop intentionally excludes the Chinese and English lockup for legible
|
||||
# Windows/macOS small icons.
|
||||
mark_crop = master.crop((300, 110, 980, 720))
|
||||
mark_bbox = _transparent_connected_background(mark_crop).getchannel("A").getbbox()
|
||||
if mark_bbox is None:
|
||||
raise ValueError("application icon extraction produced an empty image")
|
||||
left, top, right, bottom = mark_bbox
|
||||
app_icon = _square_icon(
|
||||
mark_crop.crop(
|
||||
(
|
||||
max(0, left - 12),
|
||||
max(0, top - 12),
|
||||
min(mark_crop.width, right + 12),
|
||||
min(mark_crop.height, bottom + 12),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
lockup_path = BRANDING_ROOT / "brand-lockup.png"
|
||||
icon_png_path = BRANDING_ROOT / "app-icon.png"
|
||||
icon_ico_path = BRANDING_ROOT / "app-icon.ico"
|
||||
icon_icns_path = BRANDING_ROOT / "app-icon.icns"
|
||||
favicon_path = VIDEO_PUBLIC_ROOT / "favicon.png"
|
||||
|
||||
full_lockup.save(lockup_path, optimize=True)
|
||||
app_icon.save(icon_png_path, optimize=True)
|
||||
app_icon.save(
|
||||
icon_ico_path,
|
||||
format="ICO",
|
||||
sizes=[(size, size) for size in ICON_SIZES],
|
||||
)
|
||||
app_icon.save(icon_icns_path, format="ICNS")
|
||||
app_icon.resize((64, 64), Image.Resampling.LANCZOS).save(
|
||||
favicon_path,
|
||||
optimize=True,
|
||||
)
|
||||
|
||||
return lockup_path, icon_png_path, icon_ico_path, icon_icns_path, favicon_path
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--master", type=Path, default=DEFAULT_MASTER)
|
||||
args = parser.parse_args()
|
||||
master_path = args.master.resolve()
|
||||
if not master_path.is_file():
|
||||
parser.error(f"brand master is missing: {master_path}")
|
||||
for output in generate(master_path):
|
||||
print(output)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -17,8 +17,12 @@ $Executable = Join-Path $Artifact "DoctorWorkstation.exe"
|
||||
$DistributionRoot = Join-Path $ProjectRoot "dist"
|
||||
$ReleaseLauncherTemplate = Join-Path $ProjectRoot "packaging\windows\start_release.bat"
|
||||
$ReleaseLauncher = Join-Path $DistributionRoot "Start_DoctorWorkstation.bat"
|
||||
$InstallerDefinition = Join-Path $ProjectRoot "packaging\windows\doctor_workstation.iss"
|
||||
$EnsureInstallerCompiler = Join-Path $PSScriptRoot "ensure_inno_setup.ps1"
|
||||
$InstallerMessagesFile = Join-Path $ProjectRoot ".build-tools\inno-languages\ChineseSimplified.isl"
|
||||
$ProjectMetadata = Join-Path $ProjectRoot "pyproject.toml"
|
||||
$MediaSmokeHook = Join-Path $ProjectRoot "packaging\runtime_media_smoke.py"
|
||||
$WindowsIcon = Join-Path $ProjectRoot "resources\branding\app-icon.ico"
|
||||
|
||||
function Test-BuildPython {
|
||||
param([Parameter(Mandatory = $true)][string]$Candidate)
|
||||
@@ -51,7 +55,10 @@ try {
|
||||
(Join-Path $ProjectRoot "uv.lock"),
|
||||
$ProjectMetadata,
|
||||
$MediaSmokeHook,
|
||||
$ReleaseLauncherTemplate
|
||||
$WindowsIcon,
|
||||
$ReleaseLauncherTemplate,
|
||||
$InstallerDefinition,
|
||||
$EnsureInstallerCompiler
|
||||
)) {
|
||||
if (-not (Test-Path -LiteralPath $RequiredFile -PathType Leaf)) {
|
||||
throw "Required build file is missing: $RequiredFile"
|
||||
@@ -76,7 +83,7 @@ try {
|
||||
-not (Test-BuildPython -Candidate $FallbackPython)) {
|
||||
throw "Neither uv nor a usable Python environment with build dependencies was found."
|
||||
}
|
||||
Write-Host "Windows package entry validation passed."
|
||||
Write-Host "Windows package entry validation passed. No artifacts were generated."
|
||||
exit 0
|
||||
}
|
||||
|
||||
@@ -161,6 +168,8 @@ try {
|
||||
$ReleaseZip = Join-Path $DistributionRoot (
|
||||
"DoctorWorkstation-Windows-x64-$ProjectVersion.zip"
|
||||
)
|
||||
$InstallerBaseName = "DoctorWorkstation-Setup-Windows-x64-$ProjectVersion"
|
||||
$InstallerArtifact = Join-Path $DistributionRoot "$InstallerBaseName.exe"
|
||||
$ChecksumFile = Join-Path $DistributionRoot "SHA256SUMS.txt"
|
||||
Copy-Item -LiteralPath $ReleaseLauncherTemplate -Destination $ReleaseLauncher -Force
|
||||
if (Test-Path -LiteralPath $ReleaseZip) {
|
||||
@@ -192,15 +201,54 @@ try {
|
||||
throw "Packaging completed without the expected ZIP: $ReleaseZip"
|
||||
}
|
||||
|
||||
if (Test-Path -LiteralPath $InstallerArtifact) {
|
||||
Remove-Item -LiteralPath $InstallerArtifact -Force
|
||||
}
|
||||
Write-Host "Preparing the pinned Inno Setup compiler..."
|
||||
$InnoCompiler = (& $EnsureInstallerCompiler | Select-Object -Last 1)
|
||||
if (-not $InnoCompiler -or
|
||||
-not (Test-Path -LiteralPath $InnoCompiler -PathType Leaf)) {
|
||||
throw "Unable to locate the Inno Setup compiler"
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $InstallerMessagesFile -PathType Leaf)) {
|
||||
throw "Simplified Chinese installer messages are missing: $InstallerMessagesFile"
|
||||
}
|
||||
|
||||
Write-Host "Creating the Windows installer..."
|
||||
& $InnoCompiler `
|
||||
"/DAppVersion=$ProjectVersion" `
|
||||
"/DSourceDir=$Artifact" `
|
||||
"/DOutputDir=$DistributionRoot" `
|
||||
"/DSetupBaseName=$InstallerBaseName" `
|
||||
"/DChineseMessagesFile=$InstallerMessagesFile" `
|
||||
"/DAppIconFile=$WindowsIcon" `
|
||||
$InstallerDefinition
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Inno Setup failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $InstallerArtifact -PathType Leaf)) {
|
||||
throw "Installer compilation completed without the expected artifact: $InstallerArtifact"
|
||||
}
|
||||
|
||||
$ReleaseHash = (Get-FileHash -LiteralPath $ReleaseZip -Algorithm SHA256).Hash
|
||||
$ChecksumLine = "$ReleaseHash $([System.IO.Path]::GetFileName($ReleaseZip))`r`n"
|
||||
$InstallerHash = (Get-FileHash -LiteralPath $InstallerArtifact -Algorithm SHA256).Hash
|
||||
$ChecksumLines = @(
|
||||
"$InstallerHash $([System.IO.Path]::GetFileName($InstallerArtifact))",
|
||||
"$ReleaseHash $([System.IO.Path]::GetFileName($ReleaseZip))"
|
||||
)
|
||||
$Utf8WithoutBom = New-Object System.Text.UTF8Encoding($false)
|
||||
[System.IO.File]::WriteAllText($ChecksumFile, $ChecksumLine, $Utf8WithoutBom)
|
||||
[System.IO.File]::WriteAllText(
|
||||
$ChecksumFile,
|
||||
(($ChecksumLines -join "`r`n") + "`r`n"),
|
||||
$Utf8WithoutBom
|
||||
)
|
||||
|
||||
Write-Host "Windows package complete." -ForegroundColor Green
|
||||
Write-Host "Artifact: $Artifact" -ForegroundColor Green
|
||||
Write-Host "Installer: $InstallerArtifact" -ForegroundColor Green
|
||||
Write-Host "Release ZIP: $ReleaseZip" -ForegroundColor Green
|
||||
Write-Host "SHA-256: $ReleaseHash" -ForegroundColor Green
|
||||
Write-Host "Installer SHA-256: $InstallerHash" -ForegroundColor Green
|
||||
Write-Host "ZIP SHA-256: $ReleaseHash" -ForegroundColor Green
|
||||
exit 0
|
||||
}
|
||||
catch {
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Installer
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
$ProjectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
|
||||
|
||||
function Get-AssociatedIconHash {
|
||||
param([Parameter(Mandatory = $true)][string]$FilePath)
|
||||
|
||||
$Icon = [System.Drawing.Icon]::ExtractAssociatedIcon($FilePath)
|
||||
if (-not $Icon) {
|
||||
throw "Unable to extract the Windows icon from: $FilePath"
|
||||
}
|
||||
$Bitmap = New-Object System.Drawing.Bitmap 32, 32
|
||||
$Graphics = [System.Drawing.Graphics]::FromImage($Bitmap)
|
||||
$Stream = New-Object System.IO.MemoryStream
|
||||
$Hasher = [System.Security.Cryptography.SHA256]::Create()
|
||||
try {
|
||||
$Graphics.Clear([System.Drawing.Color]::Transparent)
|
||||
$Graphics.DrawIcon($Icon, 0, 0)
|
||||
$Bitmap.Save($Stream, [System.Drawing.Imaging.ImageFormat]::Png)
|
||||
$Hash = $Hasher.ComputeHash($Stream.ToArray())
|
||||
return ([System.BitConverter]::ToString($Hash)).Replace("-", "")
|
||||
}
|
||||
finally {
|
||||
$Hasher.Dispose()
|
||||
$Stream.Dispose()
|
||||
$Graphics.Dispose()
|
||||
$Bitmap.Dispose()
|
||||
$Icon.Dispose()
|
||||
}
|
||||
}
|
||||
if (-not $Installer) {
|
||||
$Installer = Get-ChildItem `
|
||||
-LiteralPath (Join-Path $ProjectRoot "dist") `
|
||||
-Filter "DoctorWorkstation-Setup-Windows-x64-*.exe" `
|
||||
-File |
|
||||
Sort-Object LastWriteTime -Descending |
|
||||
Select-Object -First 1 -ExpandProperty FullName
|
||||
}
|
||||
if (-not $Installer -or -not (Test-Path -LiteralPath $Installer -PathType Leaf)) {
|
||||
throw "Windows installer was not found: $Installer"
|
||||
}
|
||||
$Installer = (Resolve-Path -LiteralPath $Installer).Path
|
||||
$InstallerIconHash = Get-AssociatedIconHash -FilePath $Installer
|
||||
|
||||
$TempBase = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath())
|
||||
$SmokeRoot = [System.IO.Path]::GetFullPath((Join-Path $TempBase (
|
||||
"doctor-workstation-installer-smoke-" + [guid]::NewGuid().ToString("N")
|
||||
)))
|
||||
if (-not $SmokeRoot.StartsWith($TempBase, [System.StringComparison]::OrdinalIgnoreCase) -or
|
||||
-not ([System.IO.Path]::GetFileName($SmokeRoot)).StartsWith(
|
||||
"doctor-workstation-installer-smoke-"
|
||||
)) {
|
||||
throw "Refusing to use an unsafe installer smoke directory: $SmokeRoot"
|
||||
}
|
||||
|
||||
$InstallDirectory = Join-Path $SmokeRoot "install"
|
||||
$SetupLog = Join-Path $SmokeRoot "setup.log"
|
||||
$UninstallLog = Join-Path $SmokeRoot "uninstall.log"
|
||||
New-Item -ItemType Directory -Path $SmokeRoot -Force | Out-Null
|
||||
|
||||
$SetupProcess = Start-Process `
|
||||
-FilePath $Installer `
|
||||
-ArgumentList @(
|
||||
"/VERYSILENT",
|
||||
"/SUPPRESSMSGBOXES",
|
||||
"/NORESTART",
|
||||
"/CURRENTUSER",
|
||||
"/DIR=$InstallDirectory",
|
||||
"/MERGETASKS=!desktopicon",
|
||||
"/LOG=$SetupLog"
|
||||
) `
|
||||
-Wait `
|
||||
-PassThru `
|
||||
-WindowStyle Hidden
|
||||
if ($SetupProcess.ExitCode -ne 0) {
|
||||
throw "Installer exited with code $($SetupProcess.ExitCode). See $SetupLog"
|
||||
}
|
||||
|
||||
$InstalledExecutable = Join-Path $InstallDirectory "DoctorWorkstation.exe"
|
||||
if (-not (Test-Path -LiteralPath $InstalledExecutable -PathType Leaf)) {
|
||||
throw "Installed executable is missing: $InstalledExecutable"
|
||||
}
|
||||
$Uninstaller = Join-Path $InstallDirectory "unins000.exe"
|
||||
if (-not (Test-Path -LiteralPath $Uninstaller -PathType Leaf)) {
|
||||
throw "Uninstaller is missing: $Uninstaller"
|
||||
}
|
||||
$InstalledIconHash = Get-AssociatedIconHash -FilePath $InstalledExecutable
|
||||
$UninstallerIconHash = Get-AssociatedIconHash -FilePath $Uninstaller
|
||||
if ($InstalledIconHash -ne $InstallerIconHash) {
|
||||
throw "Installed executable icon does not match the installer brand icon"
|
||||
}
|
||||
if ($UninstallerIconHash -ne $InstallerIconHash) {
|
||||
throw "Uninstaller icon does not match the installer brand icon"
|
||||
}
|
||||
|
||||
$Environment = @{
|
||||
"DOCTOR_CONFIG_DIR" = (Join-Path $SmokeRoot "config")
|
||||
"DOCTOR_LOG_DIR" = (Join-Path $SmokeRoot "logs")
|
||||
"DOCTOR_API_BASE_URL" = "https://127.0.0.1:9"
|
||||
"DOCTOR_DEMO_MODE" = "true"
|
||||
"DOCTOR_VIDEO_MODE" = "embedded"
|
||||
"DOCTOR_SMOKE_TEST" = "1"
|
||||
"QT_QPA_PLATFORM" = "offscreen"
|
||||
}
|
||||
$PreviousEnvironment = @{}
|
||||
$ApplicationExitCode = $null
|
||||
$UninstallExitCode = $null
|
||||
try {
|
||||
foreach ($Name in $Environment.Keys) {
|
||||
$PreviousEnvironment[$Name] = [Environment]::GetEnvironmentVariable($Name, "Process")
|
||||
[Environment]::SetEnvironmentVariable($Name, $Environment[$Name], "Process")
|
||||
}
|
||||
$ApplicationProcess = Start-Process `
|
||||
-FilePath $InstalledExecutable `
|
||||
-ArgumentList "--smoke-test" `
|
||||
-Wait `
|
||||
-PassThru `
|
||||
-WindowStyle Hidden
|
||||
$ApplicationExitCode = $ApplicationProcess.ExitCode
|
||||
}
|
||||
finally {
|
||||
foreach ($Name in $Environment.Keys) {
|
||||
[Environment]::SetEnvironmentVariable($Name, $PreviousEnvironment[$Name], "Process")
|
||||
}
|
||||
if (Test-Path -LiteralPath $Uninstaller -PathType Leaf) {
|
||||
$UninstallProcess = Start-Process `
|
||||
-FilePath $Uninstaller `
|
||||
-ArgumentList @(
|
||||
"/VERYSILENT",
|
||||
"/SUPPRESSMSGBOXES",
|
||||
"/NORESTART",
|
||||
"/LOG=$UninstallLog"
|
||||
) `
|
||||
-Wait `
|
||||
-PassThru `
|
||||
-WindowStyle Hidden
|
||||
$UninstallExitCode = $UninstallProcess.ExitCode
|
||||
}
|
||||
}
|
||||
if ($ApplicationExitCode -ne 0) {
|
||||
throw "Installed application smoke test exited with code $ApplicationExitCode"
|
||||
}
|
||||
if ($UninstallExitCode -ne 0) {
|
||||
throw "Uninstaller exited with code $UninstallExitCode. See $UninstallLog"
|
||||
}
|
||||
Start-Sleep -Milliseconds 500
|
||||
if (Test-Path -LiteralPath $InstalledExecutable) {
|
||||
throw "Uninstaller left the installed executable behind: $InstalledExecutable"
|
||||
}
|
||||
|
||||
Write-Host "Installer icon/install/start/uninstall smoke test passed." -ForegroundColor Green
|
||||
Write-Host "Smoke logs and isolated user data: $SmokeRoot"
|
||||
@@ -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):
|
||||
|
||||
@@ -9,16 +9,25 @@ from pathlib import Path
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from doctor_workstation.services import app_update
|
||||
from doctor_workstation.services.api_client import ApiClient
|
||||
from doctor_workstation.services.app_update import (
|
||||
PACKAGE_TYPE_ARCHIVE,
|
||||
PACKAGE_TYPE_INNO_SETUP,
|
||||
AppUpdateError,
|
||||
UpdatePackage,
|
||||
apply_extracted_update,
|
||||
apply_inno_setup_update,
|
||||
compare_version,
|
||||
discover_payload,
|
||||
download_package,
|
||||
fetch_update_offer,
|
||||
normalize_version,
|
||||
package_filename,
|
||||
parse_update_offer,
|
||||
safe_extract_zip,
|
||||
validate_installer_download_policy,
|
||||
validate_windows_installer,
|
||||
)
|
||||
|
||||
|
||||
@@ -54,6 +63,106 @@ def test_parse_offer_requires_hash_before_install() -> None:
|
||||
assert offer.package is None
|
||||
|
||||
|
||||
def test_parse_offer_accepts_explicit_inno_setup_type() -> None:
|
||||
offer = parse_update_offer(
|
||||
{
|
||||
"has_update": True,
|
||||
"enabled": True,
|
||||
"latest_version": "0.2.0",
|
||||
"platform": "windows",
|
||||
"arch": "x64",
|
||||
"package": {
|
||||
"url": "https://cdn.example.com/DoctorWorkstation-Setup.exe",
|
||||
"sha256": "a" * 64,
|
||||
"size": 123,
|
||||
"filename": "DoctorWorkstation-Setup.exe",
|
||||
"type": "inno_setup",
|
||||
},
|
||||
"can_install": True,
|
||||
},
|
||||
current_version="0.1.0",
|
||||
platform_name="windows",
|
||||
arch="x64",
|
||||
)
|
||||
assert offer.can_install is True
|
||||
assert offer.package is not None
|
||||
assert offer.package.type == PACKAGE_TYPE_INNO_SETUP
|
||||
|
||||
|
||||
def test_parse_offer_disables_insecure_inno_setup_transport() -> None:
|
||||
offer = parse_update_offer(
|
||||
{
|
||||
"has_update": True,
|
||||
"force": True,
|
||||
"enabled": True,
|
||||
"latest_version": "0.2.0",
|
||||
"platform": "windows",
|
||||
"arch": "x64",
|
||||
"package": {
|
||||
"url": "http://cdn.example.com/DoctorWorkstation-Setup.exe",
|
||||
"sha256": "a" * 64,
|
||||
"type": "inno_setup",
|
||||
},
|
||||
"can_install": True,
|
||||
},
|
||||
current_version="0.1.0",
|
||||
platform_name="windows",
|
||||
arch="x64",
|
||||
)
|
||||
assert offer.has_update is True
|
||||
assert offer.can_install is False
|
||||
assert offer.force is False
|
||||
assert offer.package is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("package_type", ["msi", "script", "unknown"])
|
||||
def test_parse_offer_rejects_unknown_package_type(package_type: str) -> None:
|
||||
offer = parse_update_offer(
|
||||
{
|
||||
"has_update": True,
|
||||
"latest_version": "0.2.0",
|
||||
"platform": "windows",
|
||||
"arch": "x64",
|
||||
"package": {
|
||||
"url": "https://cdn.example.com/update.bin",
|
||||
"sha256": "a" * 64,
|
||||
"type": package_type,
|
||||
},
|
||||
"can_install": True,
|
||||
},
|
||||
current_version="0.1.0",
|
||||
platform_name="windows",
|
||||
arch="x64",
|
||||
)
|
||||
assert offer.can_install is False
|
||||
assert offer.force is False
|
||||
assert offer.package is None
|
||||
|
||||
|
||||
def test_parse_offer_rejects_stale_or_wrong_platform_response() -> None:
|
||||
base = {
|
||||
"has_update": True,
|
||||
"latest_version": "0.1.0",
|
||||
"platform": "windows",
|
||||
"arch": "x64",
|
||||
"can_install": False,
|
||||
}
|
||||
stale = parse_update_offer(
|
||||
base,
|
||||
current_version="0.1.0",
|
||||
platform_name="windows",
|
||||
arch="x64",
|
||||
)
|
||||
wrong_platform = parse_update_offer(
|
||||
{**base, "latest_version": "0.2.0", "platform": "macos"},
|
||||
current_version="0.1.0",
|
||||
platform_name="windows",
|
||||
arch="x64",
|
||||
)
|
||||
assert stale.has_update is False
|
||||
assert wrong_platform.has_update is False
|
||||
|
||||
|
||||
def test_fetch_update_offer_uses_check_endpoint() -> None:
|
||||
requests: list[httpx.Request] = []
|
||||
|
||||
@@ -162,3 +271,155 @@ def test_download_package_rejects_hash_mismatch(tmp_path: Path) -> None:
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
assert not destination.exists()
|
||||
|
||||
|
||||
def test_download_package_rejects_declared_size_mismatch(tmp_path: Path) -> None:
|
||||
payload = b"short"
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
del request
|
||||
return httpx.Response(200, content=payload)
|
||||
|
||||
destination = tmp_path / "pkg.exe"
|
||||
with pytest.raises(AppUpdateError, match="文件大小"):
|
||||
download_package(
|
||||
"https://cdn.example.com/pkg.exe",
|
||||
destination,
|
||||
sha256=hashlib.sha256(payload).hexdigest(),
|
||||
expected_size=len(payload) + 1,
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
assert not destination.exists()
|
||||
assert not (tmp_path / "pkg.exe.part").exists()
|
||||
|
||||
|
||||
def test_windows_installer_download_policy_requires_verified_https() -> None:
|
||||
with pytest.raises(AppUpdateError, match="HTTPS"):
|
||||
validate_installer_download_policy(
|
||||
"http://cdn.example.com/setup.exe",
|
||||
verify_ssl=True,
|
||||
)
|
||||
with pytest.raises(AppUpdateError, match="证书校验"):
|
||||
validate_installer_download_policy(
|
||||
"https://cdn.example.com/setup.exe",
|
||||
verify_ssl=False,
|
||||
)
|
||||
validate_installer_download_policy(
|
||||
"http://127.0.0.1/setup.exe",
|
||||
verify_ssl=True,
|
||||
)
|
||||
|
||||
|
||||
def test_validate_windows_installer_requires_exe_and_pe_header(tmp_path: Path) -> None:
|
||||
installer = tmp_path / "Setup.exe"
|
||||
installer.write_bytes(b"MZ" + b"\0" * 32)
|
||||
assert validate_windows_installer(installer) == installer.resolve()
|
||||
|
||||
invalid = tmp_path / "invalid.exe"
|
||||
invalid.write_bytes(b"PK")
|
||||
with pytest.raises(AppUpdateError, match="PE"):
|
||||
validate_windows_installer(invalid)
|
||||
|
||||
|
||||
def test_inno_setup_applier_waits_installs_and_restarts_installed_exe(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
install_root = tmp_path / "installed"
|
||||
install_root.mkdir()
|
||||
installed_exe = install_root / "DoctorWorkstation.exe"
|
||||
installed_exe.write_bytes(b"MZ")
|
||||
installer = tmp_path / "DoctorWorkstation-Setup.exe"
|
||||
installer.write_bytes(b"MZ" + b"\0" * 32)
|
||||
spawned: dict[str, Path] = {}
|
||||
|
||||
monkeypatch.setattr(app_update.sys, "platform", "win32")
|
||||
|
||||
def capture_spawn(
|
||||
script: Path,
|
||||
*,
|
||||
installer: Path,
|
||||
restart_exe: Path,
|
||||
helper_log_file: Path,
|
||||
installer_log_file: Path,
|
||||
) -> None:
|
||||
spawned.update(
|
||||
script=script,
|
||||
installer=installer,
|
||||
restart_exe=restart_exe,
|
||||
helper_log_file=helper_log_file,
|
||||
installer_log_file=installer_log_file,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(app_update, "_spawn_inno_setup_applier", capture_spawn)
|
||||
apply_inno_setup_update(installer, install_root=install_root)
|
||||
|
||||
script_text = spawned["script"].read_text(encoding="utf-8-sig")
|
||||
assert spawned["installer"] == installer.resolve()
|
||||
assert spawned["restart_exe"] == installed_exe
|
||||
assert "/VERYSILENT" in script_text
|
||||
assert "/RESTARTEXITCODE=3010" in script_text
|
||||
assert "/NOFORCECLOSEAPPLICATIONS" in script_text
|
||||
assert "$HelperLogFile" in script_text
|
||||
assert "$InstallerLogFile" in script_text
|
||||
assert "Restart-Application" in script_text
|
||||
|
||||
|
||||
def test_archive_applier_restarts_from_install_root(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
payload = tmp_path / "payload"
|
||||
payload.mkdir()
|
||||
(payload / "DoctorWorkstation.exe").write_bytes(b"MZ")
|
||||
install_root = tmp_path / "installed"
|
||||
install_root.mkdir()
|
||||
installed_exe = install_root / "DoctorWorkstation.exe"
|
||||
installed_exe.write_bytes(b"MZ")
|
||||
captured: dict[str, Path] = {}
|
||||
script = tmp_path / "apply.ps1"
|
||||
script.write_text("", encoding="utf-8")
|
||||
|
||||
def capture_script(**kwargs: Path) -> Path:
|
||||
captured.update(kwargs)
|
||||
return script
|
||||
|
||||
monkeypatch.setattr(app_update, "_write_apply_script", capture_script)
|
||||
monkeypatch.setattr(app_update, "_spawn_applier", lambda *args, **kwargs: None)
|
||||
apply_extracted_update(payload, install_root=install_root)
|
||||
assert captured["restart_exe"] == installed_exe
|
||||
|
||||
|
||||
def test_inno_setup_applier_reports_helper_start_failure(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
install_root = tmp_path / "installed"
|
||||
install_root.mkdir()
|
||||
(install_root / "DoctorWorkstation.exe").write_bytes(b"MZ")
|
||||
installer = tmp_path / "Setup.exe"
|
||||
installer.write_bytes(b"MZ")
|
||||
monkeypatch.setattr(app_update.sys, "platform", "win32")
|
||||
|
||||
def fail_spawn(*args: object, **kwargs: object) -> None:
|
||||
del args, kwargs
|
||||
raise OSError("blocked")
|
||||
|
||||
monkeypatch.setattr(app_update, "_spawn_inno_setup_applier", fail_spawn)
|
||||
|
||||
with pytest.raises(AppUpdateError, match="无法启动 Windows 更新助手"):
|
||||
apply_inno_setup_update(installer, install_root=install_root)
|
||||
|
||||
|
||||
def test_package_filename_defaults_match_package_type() -> None:
|
||||
archive = UpdatePackage("https://cdn.example.com/", "a" * 64, 0, "")
|
||||
installer = UpdatePackage(
|
||||
"https://cdn.example.com/",
|
||||
"a" * 64,
|
||||
0,
|
||||
"",
|
||||
type=PACKAGE_TYPE_INNO_SETUP,
|
||||
)
|
||||
assert package_filename(archive, "0.2.0").endswith(".zip")
|
||||
assert package_filename(installer, "0.2.0").endswith(".exe")
|
||||
assert archive.type == PACKAGE_TYPE_ARCHIVE
|
||||
|
||||
@@ -20,6 +20,9 @@ def test_windows_one_click_entrypoints_and_release_pipeline() -> None:
|
||||
|
||||
run_script = read("scripts/run_windows.ps1")
|
||||
package_script = read("scripts/package_windows.ps1")
|
||||
compiler_script = read("scripts/ensure_inno_setup.ps1")
|
||||
installer_definition = read("packaging/windows/doctor_workstation.iss")
|
||||
installer_smoke = read("scripts/smoke_windows_installer.ps1")
|
||||
release_launcher = read("packaging/windows/start_release.bat")
|
||||
|
||||
assert run_script.index("$FrozenExecutable") < run_script.index("Find-Uv")
|
||||
@@ -32,11 +35,42 @@ def test_windows_one_click_entrypoints_and_release_pipeline() -> None:
|
||||
assert "& $Npm ci --prefix" in package_script
|
||||
assert "build_windows.ps1" in package_script
|
||||
assert "DoctorWorkstation-Windows-x64-$ProjectVersion.zip" in package_script
|
||||
assert "DoctorWorkstation-Setup-Windows-x64-$ProjectVersion" in package_script
|
||||
assert "Get-FileHash" in package_script
|
||||
assert "ensure_inno_setup.ps1" in package_script
|
||||
assert "doctor_workstation.iss" in package_script
|
||||
assert package_script.index("& $BuildScript") < package_script.index("& $InnoCompiler")
|
||||
assert "Start_DoctorWorkstation.bat" in package_script
|
||||
assert "DoctorWorkstation\\DoctorWorkstation.exe" in release_launcher
|
||||
assert "explorer.exe" in read("Build_DoctorWorkstation.bat")
|
||||
|
||||
assert "6.7.3" in compiler_script
|
||||
assert "Get-FileHash" in compiler_script
|
||||
assert "Get-AuthenticodeSignature" in compiler_script
|
||||
assert "9C73C3BAE7ED48D44112A0F48E66742C00090BDB5BEF71D9D3C056C66E97B732" in (
|
||||
compiler_script
|
||||
)
|
||||
assert "E0B0B350E2245F3C5E65586DFE43D574F6E7F06F2261149ABA284954B3FC9A8D" in (
|
||||
compiler_script
|
||||
)
|
||||
|
||||
assert "AppId=" in installer_definition
|
||||
assert "MinVersion=10.0.17763" in installer_definition
|
||||
assert "ArchitecturesAllowed=x64compatible" in installer_definition
|
||||
assert "PrivilegesRequiredOverridesAllowed=dialog commandline" in installer_definition
|
||||
assert "UsePreviousAppDir=yes" in installer_definition
|
||||
assert "UsePreviousPrivileges=yes" in installer_definition
|
||||
assert "recursesubdirs createallsubdirs" in installer_definition
|
||||
assert "{autoprograms}" in installer_definition
|
||||
assert "{autodesktop}" in installer_definition
|
||||
assert "UninstallDisplayIcon=" in installer_definition
|
||||
assert "ChineseMessagesFile" in installer_definition
|
||||
assert '"/CURRENTUSER"' in installer_smoke
|
||||
assert '-ArgumentList "--smoke-test"' in installer_smoke
|
||||
assert "unins000.exe" in installer_smoke
|
||||
assert "-WindowStyle Hidden" in installer_smoke
|
||||
assert "Uninstaller left the installed executable behind" in installer_smoke
|
||||
|
||||
|
||||
def test_debug_launcher_reuses_an_isolated_persistent_profile() -> None:
|
||||
debug_script = read("Debug_DoctorWorkstation.bat")
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import struct
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
BRANDING_ROOT = PROJECT_ROOT / "resources" / "branding"
|
||||
MASTER_SHA256 = "c76f19b9a1c89c23d9923a0903c673dc5127acecc640272e4974241021f1100e"
|
||||
|
||||
|
||||
def _png_info(path: Path) -> tuple[int, int, int]:
|
||||
data = path.read_bytes()
|
||||
assert data.startswith(b"\x89PNG\r\n\x1a\n")
|
||||
assert data[12:16] == b"IHDR"
|
||||
width, height = struct.unpack(">II", data[16:24])
|
||||
color_type = data[25]
|
||||
return width, height, color_type
|
||||
|
||||
|
||||
def _ico_sizes(path: Path) -> set[tuple[int, int]]:
|
||||
data = path.read_bytes()
|
||||
reserved, image_type, count = struct.unpack_from("<HHH", data)
|
||||
assert reserved == 0
|
||||
assert image_type == 1
|
||||
sizes: set[tuple[int, int]] = set()
|
||||
for index in range(count):
|
||||
width, height = struct.unpack_from("BB", data, 6 + index * 16)
|
||||
sizes.add((width or 256, height or 256))
|
||||
return sizes
|
||||
|
||||
|
||||
def test_brand_assets_are_complete_and_derived_from_the_approved_master() -> None:
|
||||
master = BRANDING_ROOT / "brand-master.png"
|
||||
lockup = BRANDING_ROOT / "brand-lockup.png"
|
||||
icon_png = BRANDING_ROOT / "app-icon.png"
|
||||
icon_ico = BRANDING_ROOT / "app-icon.ico"
|
||||
icon_icns = BRANDING_ROOT / "app-icon.icns"
|
||||
favicon = PROJECT_ROOT / "video_companion" / "public" / "favicon.png"
|
||||
|
||||
assert hashlib.sha256(master.read_bytes()).hexdigest() == MASTER_SHA256
|
||||
assert _png_info(master)[:2] == (1254, 1254)
|
||||
assert _png_info(lockup)[0] >= 800
|
||||
assert _png_info(lockup)[1] >= 900
|
||||
assert _png_info(icon_png) == (1024, 1024, 6)
|
||||
assert _png_info(favicon)[:2] == (64, 64)
|
||||
assert {(16, 16), (32, 32), (48, 48), (64, 64), (256, 256)} <= _ico_sizes(
|
||||
icon_ico
|
||||
)
|
||||
|
||||
icns = icon_icns.read_bytes()
|
||||
assert icns.startswith(b"icns")
|
||||
assert struct.unpack(">I", icns[4:8])[0] == len(icns)
|
||||
|
||||
|
||||
def test_runtime_and_packaging_use_the_same_brand_icon() -> None:
|
||||
app_source = (PROJECT_ROOT / "src" / "doctor_workstation" / "app.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
resources_source = (
|
||||
PROJECT_ROOT / "src" / "doctor_workstation" / "resources.py"
|
||||
).read_text(encoding="utf-8")
|
||||
login_source = (
|
||||
PROJECT_ROOT / "src" / "doctor_workstation" / "ui" / "login.py"
|
||||
).read_text(encoding="utf-8")
|
||||
shell_source = (
|
||||
PROJECT_ROOT / "src" / "doctor_workstation" / "ui" / "shell.py"
|
||||
).read_text(encoding="utf-8")
|
||||
spec = (PROJECT_ROOT / "packaging" / "doctor_workstation.spec").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
installer = (
|
||||
PROJECT_ROOT / "packaging" / "windows" / "doctor_workstation.iss"
|
||||
).read_text(encoding="utf-8")
|
||||
package_script = (PROJECT_ROOT / "scripts" / "package_windows.ps1").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
companion_html = (PROJECT_ROOT / "video_companion" / "index.html").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
assert 'resource_path("branding", "app-icon.png")' in resources_source
|
||||
assert "app_icon_path()" in app_source
|
||||
assert "brand_lockup_path()" in login_source
|
||||
assert "return QIcon(str(app_icon_path()))" in login_source
|
||||
assert "QPixmap(str(app_icon_path()))" in shell_source
|
||||
assert 'icon=str(WINDOWS_ICON) if sys.platform == "win32" else None' in spec
|
||||
assert "icon=str(MACOS_ICON)" in spec
|
||||
assert "SetupIconFile={#AppIconFile}" in installer
|
||||
assert installer.count('IconFilename: "{app}\\{#AppExecutableName}"') == 2
|
||||
assert '"/DAppIconFile=$WindowsIcon"' in package_script
|
||||
assert '<link rel="icon" type="image/png" href="/favicon.png" />' in companion_html
|
||||
assert "icon.svg" not in app_source
|
||||
assert "_draw_mark" not in login_source
|
||||
assert not (PROJECT_ROOT / "resources" / "icon.svg").exists()
|
||||
|
After Width: | Height: | Size: 6.0 KiB |
@@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<link rel="icon" type="image/png" href="./favicon.png" />
|
||||
<title>视频面诊</title>
|
||||
<script type="module" crossorigin src="./assets/index-DhmAWjut.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-BsDbRyxy.css">
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<title>视频面诊</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
After Width: | Height: | Size: 6.0 KiB |