diff --git a/admin/src/api/first_visit.ts b/admin/src/api/first_visit.ts index 3f3148074..7592d31d5 100644 --- a/admin/src/api/first_visit.ts +++ b/admin/src/api/first_visit.ts @@ -230,27 +230,27 @@ export function wecomPromotionDeleteLink(params: { id: number }) { return request.post({ url: '/firstvisit.wecomPromotion/deleteLink', params }) } -export type WecomPromotionCustomerChatStatus = '' | 'messaged' | 'silent' | 'unknown' - -export interface WecomPromotionCustomerStatsParams { - page_no: number - page_size: number - promotion_link_id?: number - userid?: string - chat_status?: 0 | 1 | 2 -} - -export interface WecomPromotionCustomerStatsSummary { +export type WecomPromotionCustomerChatStatus = '' | 'messaged' | 'silent' | 'unknown' + +export interface WecomPromotionCustomerStatsParams { + page_no: number + page_size: number + promotion_link_id?: number + userid?: string + chat_status?: 0 | 1 | 2 +} +export interface WecomPromotionCustomerStatsSummary { customer_count: number messaged_customer_count: number message_customer_rate: number - received_message_count: number - message_count_known_count: number -} - -export interface WecomPromotionCustomerStatRow { - id?: number - external_userid_masked?: string + received_message_count: number + message_count_known_count: number +} + +export interface WecomPromotionCustomerStatRow { + id?: number + userid?: string + external_userid_masked?: string customer_id_masked?: string customer_name_masked?: string link_id?: number | string @@ -260,21 +260,21 @@ export interface WecomPromotionCustomerStatRow { department_name?: string dept_name?: string has_messaged?: boolean | number - chat_status?: 0 | 1 | 2 - message_count_known?: boolean | number - received_message_count?: number - last_synced_at?: string - last_message_at?: string -} + chat_status?: 0 | 1 | 2 + message_count_known?: boolean | number + received_message_count?: number + last_synced_at?: string + last_message_at?: string +} export interface WecomPromotionCustomerStatsResult { summary?: Partial 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 }> - meta?: { last_synced_at?: string } -} + member_options?: Array<{ id: number; userid?: string; name: string; department_name?: string; dept_name?: string }> + meta?: { last_synced_at?: string } +} /** 获客客户消息统计:服务端继续按当前角色和部门数据范围收窄。 */ export function wecomPromotionCustomerStats(params: WecomPromotionCustomerStatsParams) { diff --git a/admin/src/api/setting/desktop_workstation.ts b/admin/src/api/setting/desktop_workstation.ts index c5840b420..5077a7bde 100644 --- a/admin/src/api/setting/desktop_workstation.ts +++ b/admin/src/api/setting/desktop_workstation.ts @@ -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 + return request.get({ + url: '/setting.desktop_workstation/getConfig' + }) as Promise } export function setDesktopWorkstationConfig(params: DesktopWorkstationConfig) { diff --git a/admin/src/views/first_visit/wecom_promotion/index.vue b/admin/src/views/first_visit/wecom_promotion/index.vue index c056d19d4..f8d42c940 100644 --- a/admin/src/views/first_visit/wecom_promotion/index.vue +++ b/admin/src/views/first_visit/wecom_promotion/index.vue @@ -187,11 +187,11 @@

获客客户消息统计

统计当前权限范围内由获客助手链接添加的客户,以及客户向承接成员发送消息的情况。

-
-
- 最近同步 {{ customerStats.meta.last_synced_at }} - 同步客户 -
+ +
+ 最近同步 {{ customerStats.meta.last_synced_at }} + 同步客户 +
@@ -207,11 +207,11 @@
消息客户率{{ formatCustomerRate(customerStats.summary.message_customer_rate) }}

已发消息客户占获客客户比例

-
- -
累计接收消息数{{ formatNumber(customerStats.summary.received_message_count) }}

已精确统计 {{ formatNumber(customerStats.summary.message_count_known_count) }} 位客户

-
-
+
+ +
累计接收消息数{{ formatNumber(customerStats.summary.received_message_count) }}

已精确统计 {{ formatNumber(customerStats.summary.message_count_known_count) }} 位客户

+
+ - - + +
@@ -244,13 +244,13 @@ - + - - + + 查询 重置 @@ -269,23 +269,26 @@
{{ customerLinkName(row) }}{{ customerLinkId(row) }}
- - - - + + + + - - - + + + - + @@ -429,7 +432,7 @@ import { wecomPromotionSyncRemoteLinks, wecomPromotionToggleLink } from '@/api/first_visit' -import type { WecomPromotionCustomerChatStatus } from '@/api/first_visit' +import type { WecomPromotionCustomerChatStatus } from '@/api/first_visit' import WecomFloatingWidgetBuilder from './components/WecomFloatingWidgetBuilder.vue' @@ -458,19 +461,19 @@ const linkForm = reactive({ id: 0, pool_id: 0, name: '', group_name: '默认分 const customerStatsLoading = ref(false) const customerStatsLoaded = ref(false) const syncingCustomers = ref(false) -const customerFilters = reactive<{ - link_id: number | string - userid: string - chat_status: WecomPromotionCustomerChatStatus -}>({ link_id: '', userid: '', chat_status: '' }) -const customerPager = reactive({ page_no: 1, page_size: 20, total: 0 }) -const customerStats = reactive({ - summary: { customer_count: 0, messaged_customer_count: 0, message_customer_rate: 0, received_message_count: 0, message_count_known_count: 0 }, +const customerFilters = reactive<{ + link_id: number | string + userid: string + chat_status: WecomPromotionCustomerChatStatus +}>({ link_id: '', userid: '', chat_status: '' }) +const customerPager = reactive({ page_no: 1, page_size: 20, total: 0 }) +const customerStats = reactive({ + summary: { customer_count: 0, messaged_customer_count: 0, message_customer_rate: 0, received_message_count: 0, message_count_known_count: 0 }, rows: [] as any[], link_options: [] as Array<{ id: number | string; name: string }>, member_options: [] as Array<{ id: number; userid?: string; name: string; department_name?: string; dept_name?: string }>, - meta: { last_synced_at: '' } -}) + meta: { last_synced_at: '' } +}) const selectedPool = computed(() => overview.pools.find((item: any) => Number(item.id) === selectedPoolId.value)) const selectedLinks = computed(() => overview.links.filter((item: any) => Number(item.pool_id) === selectedPoolId.value)) @@ -630,10 +633,10 @@ async function loadCustomerStats() { page_no: customerPager.page_no, page_size: customerPager.page_size, promotion_link_id: customerFilters.link_id ? Number(customerFilters.link_id) : undefined, - userid: customerFilters.userid || undefined, - chat_status: customerFilters.chat_status === '' - ? undefined - : (customerFilters.chat_status === 'messaged' ? 1 : (customerFilters.chat_status === 'unknown' ? 2 : 0)) + userid: customerFilters.userid || undefined, + chat_status: customerFilters.chat_status === '' + ? undefined + : (customerFilters.chat_status === 'messaged' ? 1 : (customerFilters.chat_status === 'unknown' ? 2 : 0)) }) const summary = result?.summary || result?.stats || {} const customerCount = numberFrom(summary, ['customer_count', 'total_customers', 'customer_total', 'total']) @@ -643,9 +646,9 @@ async function loadCustomerStats() { Object.assign(customerStats.summary, { customer_count: customerCount, messaged_customer_count: messagedCount, - message_customer_rate: hasRate ? numberFrom(summary, rateKeys) : (customerCount ? messagedCount / customerCount * 100 : 0), - received_message_count: numberFrom(summary, ['received_message_count', 'recv_msg_cnt', 'recv_msg_count', 'total_recv_msg_cnt', 'message_count']), - message_count_known_count: numberFrom(summary, ['message_count_known_count', 'exact_message_customer_count']) + message_customer_rate: hasRate ? numberFrom(summary, rateKeys) : (customerCount ? messagedCount / customerCount * 100 : 0), + received_message_count: numberFrom(summary, ['received_message_count', 'recv_msg_cnt', 'recv_msg_count', 'total_recv_msg_cnt', 'message_count']), + message_count_known_count: numberFrom(summary, ['message_count_known_count', 'exact_message_customer_count']) }) customerStats.rows = Array.isArray(result?.lists) ? result.lists @@ -661,9 +664,9 @@ async function loadCustomerStats() { return Number(current || 0) > Number(latest || 0) ? current : latest }, '') const syncedAt = result?.meta?.last_synced_at || result?.last_synced_at || latestSyncTime || customerStats.meta.last_synced_at || '' - Object.assign(customerStats.meta, result?.meta || {}, { - last_synced_at: syncedAt ? formatCustomerTime(syncedAt) : '' - }) + Object.assign(customerStats.meta, result?.meta || {}, { + last_synced_at: syncedAt ? formatCustomerTime(syncedAt) : '' + }) customerStatsLoaded.value = true } catch (error: any) { ElMessage.error(error?.message || '获客客户统计加载失败') @@ -677,8 +680,8 @@ function applyCustomerFilters() { loadCustomerStats() } -function resetCustomerFilters() { - Object.assign(customerFilters, { link_id: '', userid: '', chat_status: '' }) +function resetCustomerFilters() { + Object.assign(customerFilters, { link_id: '', userid: '', chat_status: '' }) customerPager.page_no = 1 loadCustomerStats() } @@ -821,10 +824,11 @@ function remoteMemberLabel(row: any) { return row.is_official ? '未返回成员范围' : '手工配置' } -function memberFilterLabel(member: any) { - const department = member.department_name || member.dept_name || '' - return department ? `${member.name} · ${department}` : member.name -} +function memberFilterLabel(member: any) { + const department = member.department_name || member.dept_name || '' + const userid = member.userid ? ` · ${member.userid}` : '' + return department ? `${member.name} · ${department}${userid}` : `${member.name}${userid}` +} function customerIdentifier(row: any) { const masked = row.external_userid_masked || row.customer_id_masked || row.customer_masked || row.customer_name_masked @@ -879,16 +883,16 @@ function customerChatStateLabel(row: any) { return state === 'messaged' ? '已发消息' : (state === 'unknown' ? '状态未知' : '未发消息') } -function customerMessageCountLabel(row: any) { - if (customerMessageCountKnown(row)) return formatNumber(customerMessageCount(row)) - const state = customerChatState(row) - if (state === 'silent') return '0' - return state === 'unknown' ? '未知' : '待回调' -} - -function customerLastMessageTime(row: any) { - return row.last_message_at || row.last_chat_time || row.last_message_time || row.last_msg_time || row.event_time || '' -} +function customerMessageCountLabel(row: any) { + if (customerMessageCountKnown(row)) return formatNumber(customerMessageCount(row)) + const state = customerChatState(row) + if (state === 'silent') return '0' + return state === 'unknown' ? '未知' : '待回调' +} + +function customerLastMessageTime(row: any) { + return row.last_message_at || row.last_chat_time || row.last_message_time || row.last_msg_time || row.event_time || '' +} function formatCustomerTime(value: unknown) { if (value === undefined || value === null || value === '' || value === 0 || value === '0') return '-' @@ -998,7 +1002,7 @@ h1, h2, h3, p { margin: 0; } .chatkey-alert { margin-top: 14px; }.chatkey-alert :deep(.el-alert__title) { font-size: 12px; }.chatkey-alert :deep(.el-alert__description) { line-height: 1.6; } .customer-filter-bar { margin-top: 14px; padding: 12px 14px 0; border: 1px solid var(--line); border-radius: 9px; background: #f8fafb; } .customer-filter-bar :deep(.el-form-item) { margin-right: 12px; margin-bottom: 12px; }.customer-filter-bar :deep(.el-form-item__label) { height: 22px; padding: 0; color: #67768a; font-size: 11px; line-height: 22px; }.customer-filter-bar .el-select { width: 220px; }.customer-filter-bar .filter-actions { align-self: flex-end; margin-right: 0; } -.customer-table { margin-top: 14px; --el-table-header-bg-color: #f7f9fb; }.customer-table :deep(th.el-table__cell) { color: #67768a; font-weight: 500; }.customer-identifier { color: #53657a; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 11px; }.message-state { display: inline-flex; align-items: center; min-height: 24px; padding: 0 9px; border-radius: 12px; font-size: 10px; }.message-state.is-messaged { color: #16895f; background: #eaf8ef; }.message-state.is-silent { color: #788696; background: #eef2f5; }.message-state.is-unknown { color: #a66a1f; background: #fff3df; }.message-count { color: #253348; font-variant-numeric: tabular-nums; } +.customer-table { margin-top: 14px; --el-table-header-bg-color: #f7f9fb; }.customer-table :deep(th.el-table__cell) { color: #67768a; font-weight: 500; }.customer-identifier { color: #53657a; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 11px; }.message-state { display: inline-flex; align-items: center; min-height: 24px; padding: 0 9px; border-radius: 12px; font-size: 10px; }.message-state.is-messaged { color: #16895f; background: #eaf8ef; }.message-state.is-silent { color: #788696; background: #eef2f5; }.message-state.is-unknown { color: #a66a1f; background: #fff3df; }.message-count { color: #253348; font-variant-numeric: tabular-nums; } .customer-pagination { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 14px; color: #8491a2; font-size: 11px; } .configuration-panel { margin-bottom: 16px; padding: 16px; border: 1px solid; border-radius: 10px; }.configuration-panel.is-ready { border-color: #cce8e3; background: #f5fbfa; }.configuration-panel.is-pending { border-color: #f0d8b6; background: #fffaf2; } .configuration-state { display: flex; align-items: center; gap: 12px; }.configuration-state > span { display: grid; width: 36px; height: 36px; place-items: center; border-radius: 9px; color: #fff; background: var(--teal); font-size: 18px; }.is-pending .configuration-state > span { background: #e69a43; }.configuration-state strong { font-size: 14px; }.configuration-state p { margin-top: 4px; color: #718094; font-size: 11px; } diff --git a/admin/src/views/setting/desktop_workstation/index.vue b/admin/src/views/setting/desktop_workstation/index.vue index c436c7609..09842a9e9 100644 --- a/admin/src/views/setting/desktop_workstation/index.vue +++ b/admin/src/views/setting/desktop_workstation/index.vue @@ -14,8 +14,9 @@
桌面端启动后会检测本页发布的版本。请上传或填写与 一键打包 - 产物一致的 ZIP,并填入打包目录中的 SHA-256。安装包通常超过 - 200MB,优先传到对象存储 / CDN 后粘贴地址。 + 产物一致的安装包,并填入打包目录中的 SHA-256。Windows 推荐使用 + Setup.exe,用户点击“立即更新”后会自动安装并重启;macOS 继续使用 ZIP。 + 安装包通常超过 200MB,优先传到对象存储 / CDN 后粘贴地址。
升级策略
@@ -92,11 +93,32 @@ class="!border-none mt-4" >
{{ item.label }}
+ + + + + +
+ {{ + formData.packages[item.key].type === 'inno_setup' + ? '推荐:退出客户端后静默安装,必要时显示 Windows 权限确认。' + : '兼容旧版客户端的 ZIP 覆盖更新。' + }} +
+
@@ -111,11 +133,11 @@ @success="(response: any) => handleUploadSuccess(item.key, response)" @change="(file: any) => handleUploadFile(item.key, file)" > - 选择 ZIP 并上传 + 选择安装包并上传
仅建议上传较小的包。大文件请先传到对象存储,再把地址和 SHA-256 - 填到上方。客户端只会安装 ZIP。 + 填到上方。Windows 自动安装程序必须使用 HTTPS 地址并开启证书校验。
@@ -131,7 +153,11 @@
@@ -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() @@ -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 | 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) { diff --git a/app/.gitignore b/app/.gitignore index a8da8f342..a5034dcb2 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -1,6 +1,8 @@ .env .venv/ .venv-build/ +.venv-win7-check/ +.build-tools/ .uv-cache/ .uv-python/ __pycache__/ diff --git a/app/Build_DoctorWorkstation.bat b/app/Build_DoctorWorkstation.bat index 606721eb4..046cc87b9 100644 --- a/app/Build_DoctorWorkstation.bat +++ b/app/Build_DoctorWorkstation.bat @@ -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% diff --git a/app/README.md b/app/README.md index f7a65226c..a45568910 100644 --- a/app/README.md +++ b/app/README.md @@ -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 拦截未签名内部测试版,请使用右键“打开”。 ## 已实现范围 diff --git a/app/packaging/README.md b/app/packaging/README.md index d763c0db3..55bf2c377 100644 --- a/app/packaging/README.md +++ b/app/packaging/README.md @@ -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-.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-.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 diff --git a/app/packaging/doctor_workstation.spec b/app/packaging/doctor_workstation.spec index cdae864ee..b3bf3d4a0 100644 --- a/app/packaging/doctor_workstation.spec +++ b/app/packaging/doctor_workstation.spec @@ -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": "甄养堂医生工作站", diff --git a/app/packaging/windows/doctor_workstation.iss b/app/packaging/windows/doctor_workstation.iss new file mode 100644 index 000000000..7ad91a69b --- /dev/null +++ b/app/packaging/windows/doctor_workstation.iss @@ -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 diff --git a/app/resources/branding/app-icon.icns b/app/resources/branding/app-icon.icns new file mode 100644 index 000000000..d3716ae96 Binary files /dev/null and b/app/resources/branding/app-icon.icns differ diff --git a/app/resources/branding/app-icon.ico b/app/resources/branding/app-icon.ico new file mode 100644 index 000000000..2ded01d6b Binary files /dev/null and b/app/resources/branding/app-icon.ico differ diff --git a/app/resources/branding/app-icon.png b/app/resources/branding/app-icon.png new file mode 100644 index 000000000..8f37638c1 Binary files /dev/null and b/app/resources/branding/app-icon.png differ diff --git a/app/resources/branding/brand-lockup.png b/app/resources/branding/brand-lockup.png new file mode 100644 index 000000000..9d7a53aea Binary files /dev/null and b/app/resources/branding/brand-lockup.png differ diff --git a/app/resources/branding/brand-master.png b/app/resources/branding/brand-master.png new file mode 100644 index 000000000..a36b3a318 Binary files /dev/null and b/app/resources/branding/brand-master.png differ diff --git a/app/resources/icon.svg b/app/resources/icon.svg deleted file mode 100644 index b5b78948c..000000000 --- a/app/resources/icon.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/app/scripts/build_macos.sh b/app/scripts/build_macos.sh index de1cba568..2a3f75311 100755 --- a/app/scripts/build_macos.sh +++ b/app/scripts/build_macos.sh @@ -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 diff --git a/app/scripts/build_windows.ps1 b/app/scripts/build_windows.ps1 index 57c8ec441..49a35e858 100644 --- a/app/scripts/build_windows.ps1 +++ b/app/scripts/build_windows.ps1 @@ -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 diff --git a/app/scripts/ensure_inno_setup.ps1 b/app/scripts/ensure_inno_setup.ps1 new file mode 100644 index 000000000..7f6f841aa --- /dev/null +++ b/app/scripts/ensure_inno_setup.ps1 @@ -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, '([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 diff --git a/app/scripts/generate_brand_assets.py b/app/scripts/generate_brand_assets.py new file mode 100644 index 000000000..de945cae5 --- /dev/null +++ b/app/scripts/generate_brand_assets.py @@ -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()) diff --git a/app/scripts/package_windows.ps1 b/app/scripts/package_windows.ps1 index 908131891..6bb078bfe 100644 --- a/app/scripts/package_windows.ps1 +++ b/app/scripts/package_windows.ps1 @@ -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 { diff --git a/app/scripts/smoke_windows_installer.ps1 b/app/scripts/smoke_windows_installer.ps1 new file mode 100644 index 000000000..728ef56ce --- /dev/null +++ b/app/scripts/smoke_windows_installer.ps1 @@ -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" diff --git a/app/src/doctor_workstation/app.py b/app/src/doctor_workstation/app.py index ce99da71f..6e19721f4 100644 --- a/app/src/doctor_workstation/app.py +++ b/app/src/doctor_workstation/app.py @@ -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) diff --git a/app/src/doctor_workstation/resources.py b/app/src/doctor_workstation/resources.py index fbee0b182..aeedce85d 100644 --- a/app/src/doctor_workstation/resources.py +++ b/app/src/doctor_workstation/resources.py @@ -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", diff --git a/app/src/doctor_workstation/services/app_update.py b/app/src/doctor_workstation/services/app_update.py index 4cd86c6ff..350472bb2 100644 --- a/app/src/doctor_workstation/services/app_update.py +++ b/app/src/doctor_workstation/services/app_update.py @@ -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 diff --git a/app/src/doctor_workstation/ui/dialogs/app_update.py b/app/src/doctor_workstation/ui/dialogs/app_update.py index 42d4ab437..c6721d400 100644 --- a/app/src/doctor_workstation/ui/dialogs/app_update.py +++ b/app/src/doctor_workstation/ui/dialogs/app_update.py @@ -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 diff --git a/app/src/doctor_workstation/ui/login.py b/app/src/doctor_workstation/ui/login.py index 1fd46372b..ef400ab0b 100644 --- a/app/src/doctor_workstation/ui/login.py +++ b/app/src/doctor_workstation/ui/login.py @@ -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: diff --git a/app/src/doctor_workstation/ui/shell.py b/app/src/doctor_workstation/ui/shell.py index d7d524eea..7ceb93c34 100644 --- a/app/src/doctor_workstation/ui/shell.py +++ b/app/src/doctor_workstation/ui/shell.py @@ -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): diff --git a/app/tests/test_app_update.py b/app/tests/test_app_update.py index fa785b5c4..0dba8e05d 100644 --- a/app/tests/test_app_update.py +++ b/app/tests/test_app_update.py @@ -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 diff --git a/app/tests/test_one_click_entrypoints.py b/app/tests/test_one_click_entrypoints.py index 60ab6ff4a..2c4d8347a 100644 --- a/app/tests/test_one_click_entrypoints.py +++ b/app/tests/test_one_click_entrypoints.py @@ -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") diff --git a/app/tests/test_packaging_brand_icons.py b/app/tests/test_packaging_brand_icons.py new file mode 100644 index 000000000..e9cfd84e9 --- /dev/null +++ b/app/tests/test_packaging_brand_icons.py @@ -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(" 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 '' 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() diff --git a/app/video_companion/dist/favicon.png b/app/video_companion/dist/favicon.png new file mode 100644 index 000000000..8cb126187 Binary files /dev/null and b/app/video_companion/dist/favicon.png differ diff --git a/app/video_companion/dist/index.html b/app/video_companion/dist/index.html index 7352e7c45..e2eae4635 100644 --- a/app/video_companion/dist/index.html +++ b/app/video_companion/dist/index.html @@ -4,6 +4,7 @@ + 视频面诊 diff --git a/app/video_companion/index.html b/app/video_companion/index.html index 8facfff4e..721364dd5 100644 --- a/app/video_companion/index.html +++ b/app/video_companion/index.html @@ -4,6 +4,7 @@ + 视频面诊 diff --git a/app/video_companion/public/favicon.png b/app/video_companion/public/favicon.png new file mode 100644 index 000000000..8cb126187 Binary files /dev/null and b/app/video_companion/public/favicon.png differ diff --git a/server/app/adminapi/logic/firstvisit/WecomPromotionLogic.php b/server/app/adminapi/logic/firstvisit/WecomPromotionLogic.php index 5e0812080..7788de43e 100644 --- a/server/app/adminapi/logic/firstvisit/WecomPromotionLogic.php +++ b/server/app/adminapi/logic/firstvisit/WecomPromotionLogic.php @@ -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', ]; } diff --git a/server/app/adminapi/logic/setting/DesktopWorkstationLogic.php b/server/app/adminapi/logic/setting/DesktopWorkstationLogic.php index c95dbb8c4..df1fc5fa4 100644 --- a/server/app/adminapi/logic/setting/DesktopWorkstationLogic.php +++ b/server/app/adminapi/logic/setting/DesktopWorkstationLogic.php @@ -27,7 +27,9 @@ class DesktopWorkstationLogic extends BaseLogic { public const CONFIG_TYPE = 'desktop_workstation'; - public const PLATFORMS = ['windows_x64', 'macos_arm64', 'macos_x64']; + public const PLATFORMS = ['windows_x64', 'macos_arm64', 'macos_x64']; + + public const PACKAGE_TYPES = ['archive', 'inno_setup']; /** * @notes 管理端读取配置(安装包地址补全域名) @@ -76,10 +78,10 @@ class DesktopWorkstationLogic extends BaseLogic $latest = self::normalizeVersion((string) ($config['latest_version'] ?? '')); $minVersion = self::normalizeVersion((string) ($config['min_version'] ?? '')); $enabled = (int) ($config['enabled'] ?? 0) === 1; - $packageKey = self::packageKey($platform, $arch); - $package = $packageKey !== '' - ? self::plainPackage($config['packages'][$packageKey] ?? []) - : self::emptyPackage(); + $packageKey = self::packageKey($platform, $arch); + $package = $packageKey !== '' + ? self::plainPackage($config['packages'][$packageKey] ?? [], $packageKey) + : self::emptyPackage(); $canInstall = $package['url'] !== '' && $package['sha256'] !== ''; $hasUpdate = $enabled && $latest !== '' && $current !== '' && self::compareVersion($current, $latest) < 0; $belowMin = $minVersion !== '' && $current !== '' && self::compareVersion($current, $minVersion) < 0; @@ -186,8 +188,9 @@ class DesktopWorkstationLogic extends BaseLogic $packages[$key] = [ 'url' => (string) ($params[$key . '_url'] ?? ''), 'sha256' => (string) ($params[$key . '_sha256'] ?? ''), - 'size' => $params[$key . '_size'] ?? 0, - 'filename' => (string) ($params[$key . '_filename'] ?? ''), + '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; @@ -230,8 +233,9 @@ class DesktopWorkstationLogic extends BaseLogic $url = FileService::setFileUrl($url); } $sha256 = strtolower(trim((string) ($row['sha256'] ?? ''))); - $filename = trim((string) ($row['filename'] ?? '')); - $size = (int) ($row['size'] ?? 0); + $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']; @@ -242,8 +246,9 @@ class DesktopWorkstationLogic extends BaseLogic $normalized[$key] = [ 'url' => $url, 'sha256' => $sha256, - 'size' => max(0, $size), - 'filename' => mb_substr($filename, 0, 180), + 'size' => max(0, $size), + 'filename' => mb_substr($filename, 0, 180), + 'type' => $type, ]; } return $normalized; @@ -251,36 +256,52 @@ class DesktopWorkstationLogic extends BaseLogic /** * @param array $row - * @return array{url:string,sha256:string,size:int,filename:string} - */ - private static function publicPackage(array $row): array - { - $plain = self::plainPackage($row); + * @return array{url:string,sha256:string,size:int,filename:string,type:string} + */ + private static function publicPackage(array $row, string $key): array + { + $plain = self::plainPackage($row, $key); $plain['url'] = $plain['url'] === '' ? '' : FileService::getFileUrl($plain['url']); return $plain; } /** * @param array $row - * @return array{url:string,sha256:string,size:int,filename:string} - */ - private static function plainPackage(array $row): array - { - return [ + * @return array{url:string,sha256:string,size:int,filename:string,type:string} + */ + 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'] ?? ''), + '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} - */ - private static function emptyPackage(): array - { - return ['url' => '', 'sha256' => '', 'size' => 0, 'filename' => '']; - } + * @return array{url:string,sha256:string,size:int,filename:string,type:string} + */ + private static function emptyPackage(): array + { + 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; + } /** * @return array{url:string,sha256:string,size:int,filename:string} diff --git a/server/app/adminapi/validate/setting/DesktopWorkstationValidate.php b/server/app/adminapi/validate/setting/DesktopWorkstationValidate.php index 87b5a4dc6..21fa60d3f 100644 --- a/server/app/adminapi/validate/setting/DesktopWorkstationValidate.php +++ b/server/app/adminapi/validate/setting/DesktopWorkstationValidate.php @@ -120,14 +120,27 @@ class DesktopWorkstationValidate extends BaseValidate unset($data); $url = trim((string) ($row['url'] ?? '')); $sha256 = strtolower(trim((string) ($row['sha256'] ?? ''))); - $filename = trim((string) ($row['filename'] ?? '')); - $size = $row['size'] ?? 0; + $filename = trim((string) ($row['filename'] ?? '')); + $type = strtolower(trim((string) ($row['type'] ?? 'archive'))); + $size = $row['size'] ?? 0; $labels = [ 'windows_x64' => 'Windows 64 位', 'macos_arm64' => 'macOS Apple 芯片', 'macos_x64' => 'macOS Intel', ]; - $label = $labels[$key] ?? $key; + $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 路径'; } diff --git a/server/tests/DesktopWorkstationUpdateContractTest.php b/server/tests/DesktopWorkstationUpdateContractTest.php index 92a7bd850..68ffa0f0b 100644 --- a/server/tests/DesktopWorkstationUpdateContractTest.php +++ b/server/tests/DesktopWorkstationUpdateContractTest.php @@ -40,11 +40,12 @@ $config = [ 'title' => '医生工作站 0.2.0', 'notes' => '稳定性更新', 'packages' => [ - 'windows_x64' => [ - 'url' => 'https://cdn.example.com/DoctorWorkstation-Windows-x64-0.2.0.zip', - 'sha256' => $sha, - 'size' => 123, - 'filename' => 'DoctorWorkstation-Windows-x64-0.2.0.zip', + 'windows_x64' => [ + 'url' => 'https://cdn.example.com/DoctorWorkstation-Setup-Windows-x64-0.2.0.exe', + 'sha256' => $sha, + 'size' => 123, + 'filename' => 'DoctorWorkstation-Setup-Windows-x64-0.2.0.exe', + 'type' => 'inno_setup', ], ], ]; @@ -53,10 +54,12 @@ $optional = DesktopWorkstationLogic::evaluate($config, '0.1.8', 'windows', 'x64' desktopUpdateExpect($optional['has_update'] === true, 'newer published version is an update'); 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' -); +desktopUpdateExpect( + 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'); desktopUpdateExpect($forcedByMin['force'] === true, 'below min version forces upgrade'); @@ -90,10 +93,11 @@ desktopUpdateExpect( $adminView = file_get_contents(dirname(__DIR__, 2) . '/admin/src/views/setting/desktop_workstation/index.vue'); 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, 'setting.desktop_workstation/setConfig') + && str_contains($adminView, 'force_update') + && str_contains($adminView, 'inno_setup'), + 'admin page can save force-update and Inno Setup configuration' +); $migration = file_get_contents( dirname(__DIR__) . '/sql/1.9.20260821/add_desktop_workstation_update_menu.sql'