This commit is contained in:
Your Name
2026-08-22 10:46:13 +08:00
parent c06d293424
commit 43e5411b6a
39 changed files with 1607 additions and 300 deletions
+2 -2
View File
@@ -239,7 +239,6 @@ export interface WecomPromotionCustomerStatsParams {
userid?: string
chat_status?: 0 | 1 | 2
}
export interface WecomPromotionCustomerStatsSummary {
customer_count: number
messaged_customer_count: number
@@ -250,6 +249,7 @@ export interface WecomPromotionCustomerStatsSummary {
export interface WecomPromotionCustomerStatRow {
id?: number
userid?: string
external_userid_masked?: string
customer_id_masked?: string
customer_name_masked?: string
@@ -272,7 +272,7 @@ export interface WecomPromotionCustomerStatsResult {
lists?: WecomPromotionCustomerStatRow[]
total?: number
link_options?: Array<{ id: number | string; name: string }>
member_options?: Array<{ id: number; name: string; department_name?: string; dept_name?: string }>
member_options?: Array<{ id: number; userid?: string; name: string; department_name?: string; dept_name?: string }>
meta?: { last_synced_at?: string }
}
+6 -1
View File
@@ -1,10 +1,13 @@
import request from '@/utils/request'
export type DesktopPackageType = 'archive' | 'inno_setup'
export type DesktopPackage = {
url: string
sha256: string
size: number
filename: string
type: DesktopPackageType
}
export type DesktopWorkstationConfig = {
@@ -22,7 +25,9 @@ export type DesktopWorkstationConfig = {
}
export function getDesktopWorkstationConfig() {
return request.get({ url: '/setting.desktop_workstation/getConfig' }) as Promise<DesktopWorkstationConfig>
return request.get({
url: '/setting.desktop_workstation/getConfig'
}) as Promise<DesktopWorkstationConfig>
}
export function setDesktopWorkstationConfig(params: DesktopWorkstationConfig) {
@@ -220,7 +220,7 @@
show-icon
:closable="false"
title="消息次数回调尚未配置完成"
description="请在企业微信获客助手的 API 接入场景中配置本页“应用配置”展示的消息回调地址,并在服务器配置 contact_callback_token 与 contact_callback_aes_key。未完成前只能同步客户及聊天状态,无法得到精确接收消息次数。"
description="复用企业微信自建应用现有的“API 接收消息”地址即可,无需新增第二个回调;请确保该地址为 QywxExternalContactCallback/notify,且服务器 Token、AESKey 与企微后台一致。未完成前无法得到精确接收消息次数。"
/>
<el-alert
@@ -228,8 +228,8 @@
type="info"
show-icon
:closable="false"
title="消息统计依赖企业微信 ChatKey"
description="ChatKey 仅在事件产生后的 30 分钟内有效。服务端收到企业微信回调后会立即拉取会话详情;手动同步用于刷新客户清单,无法补取已过期的会话消息。"
title="获客助手通过事件与 ChatKey 返回收消息次数"
description="本功能复用现有回调并按事件类型处理 customer_acquisition,不会占用第二个回调。客户首次发消息会推送 customer_start_chat;累计第 3、5、10 次时推送 message_from_customer,服务端需在 30 分钟内用 ChatKey 查询成员、客户、获客链接和累计收消息次数。"
/>
<div class="customer-filter-bar">
@@ -271,21 +271,24 @@
</el-table-column>
<el-table-column label="承接成员 / 部门" min-width="175">
<template #default="{ row }">
<div class="member-cell"><strong>{{ row.member_name || row.owner_name || '未匹配成员' }}</strong><small>{{ row.department_name || row.dept_name || '未分部门' }}</small></div>
<div class="member-cell">
<strong>{{ row.member_name || row.owner_name || '未匹配成员' }}</strong>
<small>{{ row.department_name || row.dept_name || '未分部门' }} · {{ row.userid || '未绑定企微账号' }}</small>
</div>
</template>
</el-table-column>
<el-table-column label="是否发过消息" width="125" align="center">
<el-table-column label="客户开口" width="110" align="center">
<template #default="{ row }">
<span class="message-state" :class="`is-${customerChatState(row)}`">{{ customerChatStateLabel(row) }}</span>
</template>
</el-table-column>
<el-table-column label="接收消息" width="120" align="right">
<el-table-column label="累计消息" width="100" align="right">
<template #default="{ row }"><strong class="message-count" :class="{ muted: !customerMessageCountKnown(row) }">{{ customerMessageCountLabel(row) }}</strong></template>
</el-table-column>
<el-table-column label="最近同步时间" min-width="165">
<template #default="{ row }">{{ formatCustomerTime(row.last_synced_at || row.last_sync_time || row.synced_at || row.sync_time) }}</template>
</el-table-column>
<el-table-column label="最近消息时间" min-width="165">
<el-table-column label="最近消息事件" min-width="165">
<template #default="{ row }"><span :class="{ muted: !customerLastMessageTime(row) }">{{ formatCustomerTime(customerLastMessageTime(row)) }}</span></template>
</el-table-column>
<template #empty><el-empty :image-size="72" description="当前筛选范围暂无获客客户数据" /></template>
@@ -823,7 +826,8 @@ function remoteMemberLabel(row: any) {
function memberFilterLabel(member: any) {
const department = member.department_name || member.dept_name || ''
return department ? `${member.name} · ${department}` : member.name
const userid = member.userid ? ` · ${member.userid}` : ''
return department ? `${member.name} · ${department}${userid}` : `${member.name}${userid}`
}
function customerIdentifier(row: any) {
@@ -14,8 +14,9 @@
<div class="leading-6">
桌面端启动后会检测本页发布的版本请上传或填写与
<code>一键打包</code>
产物一致的 ZIP并填入打包目录中的 SHA-256安装包通常超过
200MB优先传到对象存储 / CDN 后粘贴地址
产物一致的安装包并填入打包目录中的 SHA-256Windows 推荐使用
Setup.exe用户点击立即更新后会自动安装并重启macOS 继续使用 ZIP
安装包通常超过 200MB优先传到对象存储 / CDN 后粘贴地址
</div>
</el-alert>
<div class="text-xl font-medium mb-[20px]">升级策略</div>
@@ -92,11 +93,32 @@
class="!border-none mt-4"
>
<div class="text-xl font-medium mb-[20px]">{{ item.label }}</div>
<el-form-item label="安装包类型">
<el-select v-model="formData.packages[item.key].type" class="w-80">
<el-option label="便携压缩包(ZIP" value="archive" />
<el-option
v-if="item.key === 'windows_x64'"
label="Windows 安装程序(Inno Setup EXE"
value="inno_setup"
/>
</el-select>
<div class="form-tips ml-3">
{{
formData.packages[item.key].type === 'inno_setup'
? '推荐:退出客户端后静默安装,必要时显示 Windows 权限确认。'
: '兼容旧版客户端的 ZIP 覆盖更新。'
}}
</div>
</el-form-item>
<el-form-item label="安装包地址">
<div class="w-[640px]">
<el-input
v-model.trim="formData.packages[item.key].url"
placeholder="https://...zip 或站内 uploads/file/... 路径"
:placeholder="
formData.packages[item.key].type === 'inno_setup'
? 'https://.../DoctorWorkstation-Setup-....exe'
: 'https://...zip 或站内 uploads/file/... 路径'
"
/>
</div>
</el-form-item>
@@ -111,11 +133,11 @@
@success="(response: any) => handleUploadSuccess(item.key, response)"
@change="(file: any) => handleUploadFile(item.key, file)"
>
<el-button type="primary" plain>选择 ZIP 并上传</el-button>
<el-button type="primary" plain>选择安装包并上传</el-button>
</upload>
<div class="form-tips">
仅建议上传较小的包大文件请先传到对象存储再把地址和 SHA-256
填到上方客户端只会安装 ZIP
填到上方Windows 自动安装程序必须使用 HTTPS 地址并开启证书校验
</div>
</div>
</el-form-item>
@@ -131,7 +153,11 @@
<div class="w-[480px]">
<el-input
v-model.trim="formData.packages[item.key].filename"
placeholder="DoctorWorkstation-Windows-x64-0.2.0.zip"
:placeholder="
formData.packages[item.key].type === 'inno_setup'
? 'DoctorWorkstation-Setup-Windows-x64-0.2.0.exe'
: 'DoctorWorkstation-Windows-x64-0.2.0.zip'
"
/>
</div>
</el-form-item>
@@ -161,18 +187,20 @@ import type { FormInstance } from 'element-plus'
import {
getDesktopWorkstationConfig,
setDesktopWorkstationConfig,
type DesktopPackage
type DesktopPackage,
type DesktopPackageType
} from '@/api/setting/desktop_workstation'
import Upload from '@/components/upload/index.vue'
import feedback from '@/utils/feedback'
type PlatformKey = 'windows_x64' | 'macos_arm64' | 'macos_x64'
const emptyPackage = (): DesktopPackage => ({
const emptyPackage = (type: DesktopPackageType = 'archive'): DesktopPackage => ({
url: '',
sha256: '',
size: 0,
filename: ''
filename: '',
type
})
const formRef = shallowRef<FormInstance>()
@@ -184,7 +212,7 @@ const formData = reactive({
title: '',
notes: '',
packages: {
windows_x64: emptyPackage(),
windows_x64: emptyPackage('inno_setup'),
macos_arm64: emptyPackage(),
macos_x64: emptyPackage()
}
@@ -242,6 +270,10 @@ const assignPackage = (key: PlatformKey, row: Partial<DesktopPackage> | undefine
current.sha256 = String(row?.sha256 || '')
current.size = Number(row?.size || 0)
current.filename = String(row?.filename || '')
current.type =
row?.type === 'inno_setup' || (key === 'windows_x64' && !row?.url && !row?.filename)
? 'inno_setup'
: 'archive'
}
const getData = async () => {
@@ -270,6 +302,11 @@ const handleUploadFile = async (key: PlatformKey, file: any) => {
if (!raw) return
formData.packages[key].filename = raw.name || formData.packages[key].filename
formData.packages[key].size = Number(raw.size || 0)
if (key === 'windows_x64' && raw.name?.toLowerCase().endsWith('.exe')) {
formData.packages[key].type = 'inno_setup'
} else if (raw.name?.toLowerCase().endsWith('.zip')) {
formData.packages[key].type = 'archive'
}
try {
formData.packages[key].sha256 = await sha256File(raw)
} catch (error) {
+2
View File
@@ -1,6 +1,8 @@
.env
.venv/
.venv-build/
.venv-win7-check/
.build-tools/
.uv-cache/
.uv-python/
__pycache__/
+6 -5
View File
@@ -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%
+4 -2
View File
@@ -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 1809Qt 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 拦截未签名内部测试版,请使用右键“打开”。
## 已实现范围
+24 -1
View File
@@ -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 1664 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
+8 -1
View File
@@ -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
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 424 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 886 KiB

-5
View File
@@ -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

+5
View File
@@ -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
+4
View File
@@ -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
+102
View File
@@ -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
+139
View File
@@ -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())
+53 -5
View File
@@ -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 {
+157
View File
@@ -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"
+3 -3
View File
@@ -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,
@@ -774,7 +774,7 @@ class ApplicationController(QObject):
@staticmethod
def _apply_window_icon(window: QWidget) -> None:
icon_file = resource_path("icon.svg")
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)
+8
View File
@@ -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",
+305 -34
View File
@@ -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)
@@ -352,6 +365,15 @@ class AppUpdateSession(QObject):
return
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)
@@ -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
+14 -51
View File
@@ -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:
+11 -16
View File
@@ -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):
+261
View File
@@ -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
+34
View File
@@ -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")
+95
View File
@@ -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()
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

+1
View File
@@ -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">
+1
View File
@@ -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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

@@ -740,7 +740,9 @@ class WecomPromotionLogic
'agent_id' => $agentId,
'secret_configured' => trim((string) config('qywx_customer_acquisition.secret', '')) !== '',
'callback_ready' => $callbackTokenConfigured && $callbackAesConfigured,
'callback_url' => rtrim($domain, '/') . '/api/qywx/external-contact/notify',
// 复用自建应用现有的「API 接收消息」入口;获客助手事件与其他应用事件
// 由同一个控制器按 Event/ChangeType 分发,不需要再配置第二个回调地址。
'callback_url' => rtrim($domain, '/') . '/api/QywxExternalContactCallback/notify',
'official_doc' => 'https://developer.work.weixin.qq.com/document/path/97297',
];
}
@@ -29,6 +29,8 @@ class DesktopWorkstationLogic extends BaseLogic
public const PLATFORMS = ['windows_x64', 'macos_arm64', 'macos_x64'];
public const PACKAGE_TYPES = ['archive', 'inno_setup'];
/**
* @notes 管理端读取配置(安装包地址补全域名)
*/
@@ -78,7 +80,7 @@ class DesktopWorkstationLogic extends BaseLogic
$enabled = (int) ($config['enabled'] ?? 0) === 1;
$packageKey = self::packageKey($platform, $arch);
$package = $packageKey !== ''
? self::plainPackage($config['packages'][$packageKey] ?? [])
? self::plainPackage($config['packages'][$packageKey] ?? [], $packageKey)
: self::emptyPackage();
$canInstall = $package['url'] !== '' && $package['sha256'] !== '';
$hasUpdate = $enabled && $latest !== '' && $current !== '' && self::compareVersion($current, $latest) < 0;
@@ -188,6 +190,7 @@ class DesktopWorkstationLogic extends BaseLogic
'sha256' => (string) ($params[$key . '_sha256'] ?? ''),
'size' => $params[$key . '_size'] ?? 0,
'filename' => (string) ($params[$key . '_filename'] ?? ''),
'type' => (string) ($params[$key . '_type'] ?? 'archive'),
];
}
}
@@ -210,7 +213,7 @@ class DesktopWorkstationLogic extends BaseLogic
{
$packages = [];
foreach (self::PLATFORMS as $key) {
$packages[$key] = self::publicPackage($config['packages'][$key] ?? []);
$packages[$key] = self::publicPackage($config['packages'][$key] ?? [], $key);
}
$config['packages'] = $packages;
return $config;
@@ -232,6 +235,7 @@ class DesktopWorkstationLogic extends BaseLogic
$sha256 = strtolower(trim((string) ($row['sha256'] ?? '')));
$filename = trim((string) ($row['filename'] ?? ''));
$size = (int) ($row['size'] ?? 0);
$type = self::normalizePackageType((string) ($row['type'] ?? ''), $key);
if ($persist) {
$filled = self::fillLocalPackageMeta($url, $sha256, $size, $filename);
$url = $filled['url'];
@@ -244,6 +248,7 @@ class DesktopWorkstationLogic extends BaseLogic
'sha256' => $sha256,
'size' => max(0, $size),
'filename' => mb_substr($filename, 0, 180),
'type' => $type,
];
}
return $normalized;
@@ -251,35 +256,51 @@ class DesktopWorkstationLogic extends BaseLogic
/**
* @param array<string, mixed> $row
* @return array{url:string,sha256:string,size:int,filename:string}
* @return array{url:string,sha256:string,size:int,filename:string,type:string}
*/
private static function publicPackage(array $row): array
private static function publicPackage(array $row, string $key): array
{
$plain = self::plainPackage($row);
$plain = self::plainPackage($row, $key);
$plain['url'] = $plain['url'] === '' ? '' : FileService::getFileUrl($plain['url']);
return $plain;
}
/**
* @param array<string, mixed> $row
* @return array{url:string,sha256:string,size:int,filename:string}
* @return array{url:string,sha256:string,size:int,filename:string,type:string}
*/
private static function plainPackage(array $row): array
private static function plainPackage(array $row, string $key = ''): array
{
return [
'url' => trim((string) ($row['url'] ?? '')),
'sha256' => strtolower(trim((string) ($row['sha256'] ?? ''))),
'size' => max(0, (int) ($row['size'] ?? 0)),
'filename' => (string) ($row['filename'] ?? ''),
'type' => self::normalizePackageType((string) ($row['type'] ?? ''), $key),
];
}
/**
* @return array{url:string,sha256:string,size:int,filename:string}
* @return array{url:string,sha256:string,size:int,filename:string,type:string}
*/
private static function emptyPackage(): array
{
return ['url' => '', 'sha256' => '', 'size' => 0, 'filename' => ''];
return ['url' => '', 'sha256' => '', 'size' => 0, 'filename' => '', 'type' => 'archive'];
}
public static function normalizePackageType(string $type, string $key = ''): string
{
$value = strtolower(trim($type));
if ($value === '') {
return 'archive';
}
if (!in_array($value, self::PACKAGE_TYPES, true)) {
return 'archive';
}
if ($value === 'inno_setup' && $key !== '' && $key !== 'windows_x64') {
return 'archive';
}
return $value;
}
/**
@@ -121,6 +121,7 @@ class DesktopWorkstationValidate extends BaseValidate
$url = trim((string) ($row['url'] ?? ''));
$sha256 = strtolower(trim((string) ($row['sha256'] ?? '')));
$filename = trim((string) ($row['filename'] ?? ''));
$type = strtolower(trim((string) ($row['type'] ?? 'archive')));
$size = $row['size'] ?? 0;
$labels = [
'windows_x64' => 'Windows 64 位',
@@ -128,6 +129,18 @@ class DesktopWorkstationValidate extends BaseValidate
'macos_x64' => 'macOS Intel',
];
$label = $labels[$key] ?? $key;
if (!in_array($type, DesktopWorkstationLogic::PACKAGE_TYPES, true)) {
return $label . '安装包类型不受支持';
}
if ($type === 'inno_setup' && $key !== 'windows_x64') {
return $label . '不能使用 Windows Inno Setup 安装包';
}
if ($type === 'inno_setup' && $filename !== '' && !str_ends_with(strtolower($filename), '.exe')) {
return $label . ' Inno Setup 文件名必须以 .exe 结尾';
}
if ($type === 'inno_setup' && preg_match('#^http://#i', $url)) {
return $label . '自动安装程序必须使用 HTTPS 下载地址';
}
if ($url !== '' && !$this->isAllowedPackageUrl($url)) {
return $label . '安装包地址必须是 http(s) 链接或站内 uploads 路径';
}
@@ -41,10 +41,11 @@ $config = [
'notes' => '稳定性更新',
'packages' => [
'windows_x64' => [
'url' => 'https://cdn.example.com/DoctorWorkstation-Windows-x64-0.2.0.zip',
'url' => 'https://cdn.example.com/DoctorWorkstation-Setup-Windows-x64-0.2.0.exe',
'sha256' => $sha,
'size' => 123,
'filename' => 'DoctorWorkstation-Windows-x64-0.2.0.zip',
'filename' => 'DoctorWorkstation-Setup-Windows-x64-0.2.0.exe',
'type' => 'inno_setup',
],
],
];
@@ -54,8 +55,10 @@ desktopUpdateExpect($optional['has_update'] === true, 'newer published version i
desktopUpdateExpect($optional['force'] === false, 'force stays off when above min version');
desktopUpdateExpect($optional['can_install'] === true, 'hashed package can be installed');
desktopUpdateExpect(
is_array($optional['package']) && $optional['package']['sha256'] === $sha,
'matching windows package is returned'
is_array($optional['package'])
&& $optional['package']['sha256'] === $sha
&& $optional['package']['type'] === 'inno_setup',
'matching Windows installer package is returned with its explicit type'
);
$forcedByMin = DesktopWorkstationLogic::evaluate($config, '0.1.0', 'windows', 'x64');
@@ -91,8 +94,9 @@ $adminView = file_get_contents(dirname(__DIR__, 2) . '/admin/src/views/setting/d
desktopUpdateExpect(is_string($adminView), 'admin view source is readable');
desktopUpdateExpect(
str_contains($adminView, 'setting.desktop_workstation/setConfig')
&& str_contains($adminView, 'force_update'),
'admin page can save force-update configuration'
&& str_contains($adminView, 'force_update')
&& str_contains($adminView, 'inno_setup'),
'admin page can save force-update and Inno Setup configuration'
);
$migration = file_get_contents(