This commit is contained in:
Your Name
2026-08-10 17:29:05 +08:00
parent 2199887c07
commit 9add23e019
129 changed files with 34157 additions and 59 deletions
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env bash
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"
if [[ "$(uname -s)" != "Darwin" ]]; then
echo "The macOS bundle must be built on macOS." >&2
exit 2
fi
if [[ ! -x "$python_bin" ]]; then
echo "Build Python was not found: $python_bin" >&2
exit 2
fi
if [[ "${SKIP_FRONTEND_INSTALL:-0}" != "1" ]]; then
npm ci --prefix "$companion_root" --no-audit --no-fund
fi
npm run build --prefix "$companion_root"
"$python_bin" -m PyInstaller \
--noconfirm \
--clean \
"$project_root/packaging/doctor_workstation.spec"
artifact="$project_root/dist/DoctorWorkstation.app"
helper="$(find "$artifact" -type f -name 'QtWebEngineProcess' -print -quit)"
resources="$(find "$artifact" -type f -name 'qtwebengine_resources*.pak' -print -quit)"
companion="$(find "$artifact" -type f -path '*video_companion_dist/index.html' -print -quit)"
executable="$artifact/Contents/MacOS/DoctorWorkstation"
[[ -n "$helper" ]] || { echo "QtWebEngineProcess is missing from the app." >&2; exit 1; }
[[ -n "$resources" ]] || { echo "QtWebEngine resources are missing from the app." >&2; exit 1; }
[[ -n "$companion" ]] || { echo "video_companion_dist is missing from the app." >&2; exit 1; }
[[ -x "$executable" ]] || { echo "Frozen application entry point is missing." >&2; exit 1; }
codesign --verify --deep --strict --verbose=2 "$artifact"
temp_root="${TMPDIR:-/tmp}"
temp_root="${temp_root%/}"
smoke_root="$(mktemp -d "$temp_root/doctor-workstation-smoke.XXXXXX")"
cleanup_smoke() {
case "$smoke_root" in
"$temp_root"/doctor-workstation-smoke.*) rm -rf -- "$smoke_root" ;;
*) echo "Refusing to remove unsafe smoke-test directory: $smoke_root" >&2 ;;
esac
}
trap cleanup_smoke EXIT
mkdir -p \
"$smoke_root/Library/Application Support" \
"$smoke_root/Library/Caches" \
"$smoke_root/xdg/config" \
"$smoke_root/xdg/state" \
"$smoke_root/xdg/cache" \
"$smoke_root/tmp"
set +e
env \
TMPDIR="$smoke_root/tmp" \
XDG_CONFIG_HOME="$smoke_root/xdg/config" \
XDG_STATE_HOME="$smoke_root/xdg/state" \
XDG_CACHE_HOME="$smoke_root/xdg/cache" \
DOCTOR_CONFIG_DIR="$smoke_root/doctor/config" \
DOCTOR_LOG_DIR="$smoke_root/doctor/logs" \
DOCTOR_API_BASE_URL="https://127.0.0.1:9" \
DOCTOR_DEMO_MODE="true" \
DOCTOR_VIDEO_MODE="embedded" \
DOCTOR_VIDEO_WEB_URL="" \
DOCTOR_VERIFY_SSL="true" \
DOCTOR_LOG_LEVEL="INFO" \
DOCTOR_SMOKE_TEST="1" \
HTTP_PROXY="http://127.0.0.1:9" \
HTTPS_PROXY="http://127.0.0.1:9" \
ALL_PROXY="http://127.0.0.1:9" \
NO_PROXY="" \
QT_QPA_PLATFORM="offscreen" \
"$executable" --smoke-test >"$smoke_root/stdout.txt" 2>"$smoke_root/stderr.txt" &
smoke_pid=$!
smoke_running=1
for _ in {1..300}; do
if ! kill -0 "$smoke_pid" 2>/dev/null; then
smoke_running=0
break
fi
sleep 0.1
done
if [[ "$smoke_running" == "1" ]]; then
kill "$smoke_pid" 2>/dev/null
wait "$smoke_pid" 2>/dev/null
smoke_status=124
else
wait "$smoke_pid"
smoke_status=$?
fi
set -e
if [[ "$smoke_status" -ne 0 ]]; then
cat "$smoke_root/stderr.txt" >&2
echo "Frozen application smoke test failed with exit code $smoke_status." >&2
exit 1
fi
diagnostics="$smoke_root/diagnostics.txt"
cat "$smoke_root/stdout.txt" "$smoke_root/stderr.txt" >"$diagnostics"
find "$smoke_root" -type f -name '*.log' -exec cat {} + >>"$diagnostics"
if grep -Eiq 'traceback \(most recent call last\)|unhandled exception|uncaught exception|fatal python error' "$diagnostics"; then
cat "$diagnostics" >&2
echo "Frozen application smoke-test logs contain an unhandled exception." >&2
exit 1
fi
echo "Frozen entry smoke test passed (--smoke-test, isolated demo mode)."
echo "Build complete: $artifact"
+171
View File
@@ -0,0 +1,171 @@
[CmdletBinding()]
param(
[string]$Python = ".venv-build\Scripts\python.exe",
[switch]$SkipFrontendInstall
)
$ErrorActionPreference = "Stop"
$ProjectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
$CompanionRoot = Join-Path $ProjectRoot "video_companion"
$Spec = Join-Path $ProjectRoot "packaging\doctor_workstation.spec"
function Invoke-FrozenSmokeTest {
param([Parameter(Mandatory = $true)][string]$Executable)
$TempRoot = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath())
$SmokeRoot = Join-Path $TempRoot ("doctor-workstation-smoke-" + [guid]::NewGuid().ToString("N"))
$SmokeRoot = [System.IO.Path]::GetFullPath($SmokeRoot)
if (-not ($SmokeRoot.StartsWith($TempRoot, [System.StringComparison]::OrdinalIgnoreCase)) -or
-not ([System.IO.Path]::GetFileName($SmokeRoot)).StartsWith("doctor-workstation-smoke-")) {
throw "Refusing to use an unsafe smoke-test directory: $SmokeRoot"
}
$Environment = @{
"APPDATA" = (Join-Path $SmokeRoot "AppData\Roaming")
"LOCALAPPDATA" = (Join-Path $SmokeRoot "AppData\Local")
"XDG_CONFIG_HOME" = (Join-Path $SmokeRoot "xdg\config")
"XDG_STATE_HOME" = (Join-Path $SmokeRoot "xdg\state")
"XDG_CACHE_HOME" = (Join-Path $SmokeRoot "xdg\cache")
"DOCTOR_CONFIG_DIR" = (Join-Path $SmokeRoot "doctor\config")
"DOCTOR_LOG_DIR" = (Join-Path $SmokeRoot "doctor\logs")
"DOCTOR_API_BASE_URL" = "https://127.0.0.1:9"
"DOCTOR_DEMO_MODE" = "true"
"DOCTOR_VIDEO_MODE" = "embedded"
"DOCTOR_VIDEO_WEB_URL" = ""
"DOCTOR_VERIFY_SSL" = "true"
"DOCTOR_LOG_LEVEL" = "INFO"
"DOCTOR_SMOKE_TEST" = "1"
"HTTP_PROXY" = "http://127.0.0.1:9"
"HTTPS_PROXY" = "http://127.0.0.1:9"
"ALL_PROXY" = "http://127.0.0.1:9"
"NO_PROXY" = ""
"QT_QPA_PLATFORM" = "offscreen"
}
$PreviousEnvironment = @{}
$StandardOutput = Join-Path $SmokeRoot "stdout.txt"
$StandardError = Join-Path $SmokeRoot "stderr.txt"
New-Item -ItemType Directory -Path $SmokeRoot -Force | Out-Null
New-Item -ItemType Directory -Path $Environment["APPDATA"] -Force | Out-Null
New-Item -ItemType Directory -Path $Environment["LOCALAPPDATA"] -Force | Out-Null
try {
foreach ($Name in $Environment.Keys) {
$PreviousEnvironment[$Name] = [Environment]::GetEnvironmentVariable($Name, "Process")
[Environment]::SetEnvironmentVariable($Name, $Environment[$Name], "Process")
}
# Windows PowerShell 5 can leave ExitCode unset on the object returned
# by Start-Process. System.Diagnostics.Process retains the real code
# and lets us drain redirected streams without risking a pipe deadlock.
$ProcessInfo = New-Object System.Diagnostics.ProcessStartInfo
$ProcessInfo.FileName = $Executable
$ProcessInfo.Arguments = "--smoke-test"
$ProcessInfo.UseShellExecute = $false
$ProcessInfo.CreateNoWindow = $true
$ProcessInfo.RedirectStandardOutput = $true
$ProcessInfo.RedirectStandardError = $true
$Process = New-Object System.Diagnostics.Process
$Process.StartInfo = $ProcessInfo
if (-not $Process.Start()) {
throw "Unable to start the frozen application smoke test"
}
$OutputTask = $Process.StandardOutput.ReadToEndAsync()
$ErrorTask = $Process.StandardError.ReadToEndAsync()
$TimedOut = -not $Process.WaitForExit(30000)
if ($TimedOut -and -not $Process.HasExited) {
$Process.Kill()
}
$Process.WaitForExit()
$OutputTask.Wait()
$ErrorTask.Wait()
Set-Content -LiteralPath $StandardOutput -Value $OutputTask.Result -Encoding UTF8
Set-Content -LiteralPath $StandardError -Value $ErrorTask.Result -Encoding UTF8
$DiagnosticFiles = @($StandardOutput, $StandardError)
$DiagnosticFiles += Get-ChildItem -LiteralPath $SmokeRoot -Recurse -Filter "*.log" -File |
Select-Object -ExpandProperty FullName
$Diagnostics = ($DiagnosticFiles | Where-Object { Test-Path -LiteralPath $_ } |
ForEach-Object { Get-Content -LiteralPath $_ -Raw -ErrorAction SilentlyContinue }) -join "`n"
if ($TimedOut) {
throw "Frozen application smoke test timed out after 30 seconds`n$Diagnostics"
}
if ($Process.ExitCode -ne 0) {
throw "Frozen application smoke test failed with exit code $($Process.ExitCode)`n$Diagnostics"
}
if ($Diagnostics -match "(?im)traceback \(most recent call last\)|unhandled exception|uncaught exception|fatal python error") {
throw "Frozen application smoke-test logs contain an unhandled exception"
}
Write-Host "Frozen entry smoke test passed (--smoke-test, isolated demo mode)."
}
finally {
foreach ($Name in $Environment.Keys) {
[Environment]::SetEnvironmentVariable($Name, $PreviousEnvironment[$Name], "Process")
}
if (Test-Path -LiteralPath $SmokeRoot) {
Remove-Item -LiteralPath $SmokeRoot -Recurse -Force
}
}
}
if (-not [System.IO.Path]::IsPathRooted($Python)) {
$Python = Join-Path $ProjectRoot $Python
}
if (-not (Test-Path -LiteralPath $Python -PathType Leaf)) {
throw "Build Python was not found: $Python"
}
$Npm = (Get-Command npm.cmd -ErrorAction Stop).Source
Push-Location $ProjectRoot
try {
if (-not $SkipFrontendInstall) {
& $Npm ci --prefix $CompanionRoot --no-audit --no-fund
if ($LASTEXITCODE -ne 0) { throw "npm ci failed" }
}
& $Npm run build --prefix $CompanionRoot
if ($LASTEXITCODE -ne 0) { throw "video companion build failed" }
& $Python -m PyInstaller --noconfirm --clean $Spec
if ($LASTEXITCODE -ne 0) { throw "PyInstaller build failed" }
$Artifact = Join-Path $ProjectRoot "dist\DoctorWorkstation"
$Helper = Get-ChildItem -LiteralPath $Artifact -Recurse -Filter "QtWebEngineProcess.exe" -File | Select-Object -First 1
$Resources = Get-ChildItem -LiteralPath $Artifact -Recurse -Filter "qtwebengine_resources*.pak" -File | Select-Object -First 1
$Companion = Get-ChildItem -LiteralPath $Artifact -Recurse -Filter "index.html" -File |
Where-Object { $_.FullName -like "*video_companion_dist*" } |
Select-Object -First 1
if (-not $Helper) { throw "QtWebEngineProcess.exe is missing from the artifact" }
if (-not $Resources) { throw "QtWebEngine Chromium resources are missing from the artifact" }
if (-not $Companion) { throw "video_companion_dist is missing from the artifact" }
$PythonBase = (& $Python -c "import sys; print(sys.base_prefix)").Trim()
if ($LASTEXITCODE -ne 0 -or -not $PythonBase) {
throw "Unable to resolve the build Python runtime directory"
}
foreach ($RuntimeDllName in @("libssl-3-x64.dll", "libcrypto-3-x64.dll")) {
$SourceRuntimeDll = Join-Path $PythonBase "DLLs\$RuntimeDllName"
$FrozenRuntimeDll = Join-Path $Artifact "_internal\$RuntimeDllName"
if (-not (Test-Path -LiteralPath $SourceRuntimeDll -PathType Leaf)) {
throw "Build Python runtime dependency is missing: $SourceRuntimeDll"
}
if (-not (Test-Path -LiteralPath $FrozenRuntimeDll -PathType Leaf)) {
throw "Frozen Python runtime dependency is missing: $FrozenRuntimeDll"
}
$SourceHash = (Get-FileHash -LiteralPath $SourceRuntimeDll -Algorithm SHA256).Hash
$FrozenHash = (Get-FileHash -LiteralPath $FrozenRuntimeDll -Algorithm SHA256).Hash
if ($SourceHash -ne $FrozenHash) {
throw "Frozen $RuntimeDllName does not match the build Python runtime"
}
}
$Executable = Join-Path $Artifact "DoctorWorkstation.exe"
if (-not (Test-Path -LiteralPath $Executable -PathType Leaf)) {
throw "Frozen application entry point is missing: $Executable"
}
Invoke-FrozenSmokeTest -Executable $Executable
Write-Host "Build complete: $Artifact"
}
finally {
Pop-Location
}
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "$0")" && pwd -P)"
project_root="$(cd "$script_dir/.." && pwd -P)"
operational_files=(
"$script_dir/macos_helpers.sh"
"$script_dir/run_macos.sh"
"$script_dir/package_macos.sh"
"$script_dir/build_macos.sh"
"$project_root/一键运行.command"
"$project_root/一键打包.command"
"$project_root/run_macos.command"
"$project_root/package_macos.command"
)
for file in "${operational_files[@]}"; do
[[ -f "$file" ]] || { printf 'Missing macOS entry file: %s\n' "$file" >&2; exit 1; }
/bin/bash -n "$file"
done
for file in \
"$project_root/一键运行.command" \
"$project_root/一键打包.command" \
"$project_root/run_macos.command" \
"$project_root/package_macos.command"; do
[[ -x "$file" ]] || { printf 'Finder entry is not executable: %s\n' "$file" >&2; exit 1; }
done
open_line="$(grep -nF '/usr/bin/open "$artifact"' "$script_dir/run_macos.sh" | head -n 1 | cut -d: -f1)"
source_line="$(grep -nF 'macos_ensure_uv' "$script_dir/run_macos.sh" | head -n 1 | cut -d: -f1)"
[[ -n "$open_line" && -n "$source_line" && "$open_line" -lt "$source_line" ]] || {
echo 'Built .app must be opened before source-environment preparation.' >&2
exit 1
}
grep -Fq 'sync --locked' "$script_dir/run_macos.sh"
grep -Fq 'sync --locked --extra build' "$script_dir/package_macos.sh"
grep -Fq 'ci --prefix "$project_root/video_companion"' "$script_dir/package_macos.sh"
grep -Fq '/bin/bash "$script_dir/build_macos.sh"' "$script_dir/package_macos.sh"
grep -Fq 'SHASUMS256.txt' "$script_dir/package_macos.sh"
grep -Fq '/usr/bin/ditto -c -k --sequesterRsrc --keepParent' "$script_dir/package_macos.sh"
grep -Fq '/usr/bin/shasum -a 256' "$script_dir/package_macos.sh"
grep -Fq 'scripts/run_macos.sh' "$project_root/一键运行.command"
grep -Fq 'scripts/run_macos.sh' "$project_root/run_macos.command"
grep -Fq 'scripts/package_macos.sh' "$project_root/一键打包.command"
grep -Fq 'scripts/package_macos.sh' "$project_root/package_macos.command"
if grep -En '(^|[[:space:]])(export[[:space:]]+)?HOME=' "${operational_files[@]}"; then
echo 'macOS entry scripts must not repurpose HOME.' >&2
exit 1
fi
if grep -Ein 'SDKSecret(Key)?|UserSig|userSig' "${operational_files[@]}"; then
echo 'macOS entry scripts must not contain RTC secrets or credentials.' >&2
exit 1
fi
set +e
trap_output="$(
CI=1 DOCTOR_NONINTERACTIVE=1 /bin/bash -c '
source "$1"
macos_install_exit_trap 1
exit 7
' macos-contract "$script_dir/macos_helpers.sh" 2>&1
)"
trap_status=$?
set -e
[[ "$trap_status" -eq 7 ]] || {
printf 'Non-interactive failure trap changed exit status to %s.\n' "$trap_status" >&2
exit 1
}
grep -Fq '操作失败' <<<"$trap_output"
echo 'macOS entrypoint contracts passed.'
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env bash
# Shared helpers for Finder-launched macOS entry points. This file is sourced.
MACOS_PAUSE_ON_SUCCESS=0
MACOS_UV_BIN=""
MACOS_TEMP_DIRS=()
macos_register_temp_dir() {
MACOS_TEMP_DIRS[${#MACOS_TEMP_DIRS[@]}]="$1"
}
macos_cleanup_temp_dirs() {
local directory
for directory in "${MACOS_TEMP_DIRS[@]}"; do
[[ -n "$directory" && -d "$directory" ]] || continue
case "$(basename "$directory")" in
doctor-uv.*|doctor-node.*)
rm -rf -- "$directory" || \
printf '临时目录清理失败:%s\n' "$directory" >&2
;;
*) printf '跳过不安全的临时目录清理目标:%s\n' "$directory" >&2 ;;
esac
done
}
macos_should_pause() {
[[ -t 0 && -t 1 && -z "${CI:-}" && "${DOCTOR_NONINTERACTIVE:-0}" != "1" ]]
}
macos_on_exit() {
local status="$1"
trap - EXIT
macos_cleanup_temp_dirs
if [[ "$status" -ne 0 ]]; then
printf '\n操作失败(退出码 %s)。请查看上方信息。\n' "$status" >&2
fi
if macos_should_pause && { [[ "$status" -ne 0 ]] || [[ "$MACOS_PAUSE_ON_SUCCESS" == "1" ]]; }; then
printf '\n按回车键关闭此窗口…'
IFS= read -r _ || true
fi
exit "$status"
}
macos_install_exit_trap() {
MACOS_PAUSE_ON_SUCCESS="${1:-0}"
trap 'macos_on_exit "$?"' EXIT
}
macos_die() {
printf '错误:%s\n' "$1" >&2
exit 1
}
macos_require_darwin() {
if [[ "$(uname -s)" != "Darwin" ]]; then
macos_die "此入口只能在 macOS 上运行。"
fi
}
macos_locate_uv() {
local candidate
candidate="$(command -v uv 2>/dev/null || true)"
if [[ -n "$candidate" && -x "$candidate" ]]; then
MACOS_UV_BIN="$candidate"
return 0
fi
if [[ -n "${HOME:-}" ]]; then
for candidate in "$HOME/.local/bin/uv" "$HOME/.cargo/bin/uv"; do
if [[ -x "$candidate" ]]; then
MACOS_UV_BIN="$candidate"
return 0
fi
done
fi
for candidate in /opt/homebrew/bin/uv /usr/local/bin/uv; do
if [[ -x "$candidate" ]]; then
MACOS_UV_BIN="$candidate"
return 0
fi
done
return 1
}
macos_ensure_uv() {
local curl_bin installer temp_root
if macos_locate_uv; then
return 0
fi
curl_bin="$(command -v curl 2>/dev/null || true)"
[[ -n "$curl_bin" ]] || macos_die "未找到 uv,也未找到用于安装 uv 的 curl。"
temp_root="${TMPDIR:-/tmp}"
temp_root="${temp_root%/}"
[[ -n "$temp_root" ]] || temp_root="/"
installer="$(mktemp -d "$temp_root/doctor-uv.XXXXXX")"
macos_register_temp_dir "$installer"
printf '未检测到 uv,正在通过官方 HTTPS 安装器安装…\n' >&2
if ! "$curl_bin" --proto '=https' --tlsv1.2 -fsSL \
'https://astral.sh/uv/install.sh' -o "$installer/install.sh"; then
macos_die "uv 安装器下载失败,请检查网络后重试。"
fi
if ! UV_NO_MODIFY_PATH=1 /bin/sh "$installer/install.sh"; then
macos_die "uv 安装失败。"
fi
hash -r
macos_locate_uv || macos_die "uv 已安装,但未在标准位置找到可执行文件。"
}
+151
View File
@@ -0,0 +1,151 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "$0")" && pwd -P)"
project_root="$(cd "$script_dir/.." && pwd -P)"
# shellcheck source=macos_helpers.sh
source "$script_dir/macos_helpers.sh"
macos_install_exit_trap 1
macos_require_darwin
NODE_BIN=""
NPM_BIN=""
use_node_pair() {
local node_candidate="$1"
local npm_candidate="$2"
local major version
[[ -x "$node_candidate" && -x "$npm_candidate" ]] || return 1
version="$("$node_candidate" --version 2>/dev/null || true)"
major="${version#v}"
major="${major%%.*}"
case "$major" in
''|*[!0-9]*) return 1 ;;
esac
[[ "$major" -ge 20 ]] || return 1
NODE_BIN="$node_candidate"
NPM_BIN="$npm_candidate"
export PATH="$(dirname "$NODE_BIN"):$PATH"
}
locate_node() {
local bin_dir node_candidate npm_candidate
node_candidate="$(command -v node 2>/dev/null || true)"
npm_candidate="$(command -v npm 2>/dev/null || true)"
if use_node_pair "$node_candidate" "$npm_candidate"; then
return 0
fi
for bin_dir in /opt/homebrew/bin /usr/local/bin; do
if use_node_pair "$bin_dir/node" "$bin_dir/npm"; then
return 0
fi
done
if [[ -n "${HOME:-}" ]]; then
for node_candidate in "$HOME"/.nvm/versions/node/*/bin/node; do
[[ -x "$node_candidate" ]] || continue
npm_candidate="$(dirname "$node_candidate")/npm"
if use_node_pair "$node_candidate" "$npm_candidate"; then
return 0
fi
done
fi
return 1
}
install_local_node() {
local architecture archive archive_path cache_root curl_bin download_dir
local expected extracted install_dir node_version shasums_path actual
node_version="${DOCTOR_NODE_VERSION:-22.14.0}"
case "$(uname -m)" in
arm64) architecture="arm64" ;;
x86_64) architecture="x64" ;;
*) macos_die "不支持的 Mac CPU 架构:$(uname -m)" ;;
esac
if [[ -n "${XDG_CACHE_HOME:-}" ]]; then
cache_root="$XDG_CACHE_HOME/DoctorWorkstation/tools"
elif [[ -n "${HOME:-}" ]]; then
cache_root="$HOME/Library/Caches/DoctorWorkstation/tools"
else
macos_die "无法确定 Node 工具缓存目录:HOME 与 XDG_CACHE_HOME 均未设置。"
fi
install_dir="$cache_root/node-v$node_version-darwin-$architecture"
if use_node_pair "$install_dir/bin/node" "$install_dir/bin/npm"; then
return 0
fi
if [[ -e "$install_dir" ]]; then
macos_die "Node 缓存不完整,请删除后重试:$install_dir"
fi
curl_bin="$(command -v curl 2>/dev/null || true)"
[[ -n "$curl_bin" ]] || macos_die "未找到用于下载 Node.js 的 curl。"
mkdir -p "$cache_root"
download_dir="$(mktemp -d "$cache_root/doctor-node.XXXXXX")"
macos_register_temp_dir "$download_dir"
archive="node-v$node_version-darwin-$architecture.tar.gz"
archive_path="$download_dir/$archive"
shasums_path="$download_dir/SHASUMS256.txt"
printf '未检测到 Node.js 20+ 与 npm,正在下载 Node.js %s%s)…\n' \
"$node_version" "$architecture"
if ! "$curl_bin" --proto '=https' --tlsv1.2 -fsSL \
"https://nodejs.org/dist/v$node_version/$archive" -o "$archive_path"; then
macos_die "Node.js 下载失败,请检查网络后重试。"
fi
if ! "$curl_bin" --proto '=https' --tlsv1.2 -fsSL \
"https://nodejs.org/dist/v$node_version/SHASUMS256.txt" -o "$shasums_path"; then
macos_die "Node.js 校验文件下载失败。"
fi
expected="$(/usr/bin/awk -v file="$archive" '$2 == file { print $1; exit }' "$shasums_path")"
actual="$(/usr/bin/shasum -a 256 "$archive_path" | /usr/bin/awk '{print $1}')"
[[ -n "$expected" && "$actual" == "$expected" ]] || macos_die "Node.js 下载包 SHA-256 校验失败。"
/usr/bin/tar -xzf "$archive_path" -C "$download_dir"
extracted="$download_dir/node-v$node_version-darwin-$architecture"
[[ -d "$extracted" ]] || macos_die "Node.js 下载包结构无效。"
if [[ ! -e "$install_dir" ]]; then
/bin/mv "$extracted" "$install_dir"
fi
use_node_pair "$install_dir/bin/node" "$install_dir/bin/npm" || \
macos_die "Node.js 安装完成,但 node/npm 无法执行。"
}
macos_ensure_uv
cd "$project_root"
printf '正在同步 Python 与 PyInstaller 构建依赖…\n'
"$MACOS_UV_BIN" sync --locked --extra build
python_bin="$project_root/.venv/bin/python"
[[ -x "$python_bin" ]] || macos_die "uv 未生成可用的构建 Python$python_bin"
locate_node || install_local_node
printf 'Node.js: %snpm: %s\n' \
"$("$NODE_BIN" --version)" "$("$NPM_BIN" --version)"
printf '正在安装锁定的 companion 依赖…\n'
"$NPM_BIN" ci --prefix "$project_root/video_companion" --no-audit --no-fund
printf '正在构建 DoctorWorkstation.app…\n'
PYINSTALLER_PYTHON="$python_bin" SKIP_FRONTEND_INSTALL=1 \
/bin/bash "$script_dir/build_macos.sh"
artifact="$project_root/dist/DoctorWorkstation.app"
project_version="$(/usr/bin/awk -F '"' '/^version = "/ { print $2; exit }' \
"$project_root/pyproject.toml")"
[[ -n "$project_version" ]] || macos_die "无法从 pyproject.toml 读取版本号。"
case "$(uname -m)" in
arm64) release_arch="arm64" ;;
x86_64) release_arch="x64" ;;
*) macos_die "不支持的 Mac CPU 架构:$(uname -m)" ;;
esac
release_zip="$project_root/dist/DoctorWorkstation-macOS-$release_arch-$project_version.zip"
checksum_file="$release_zip.sha256"
rm -f -- "$release_zip" "$checksum_file"
printf '正在生成可分发 ZIP…\n'
/usr/bin/ditto -c -k --sequesterRsrc --keepParent "$artifact" "$release_zip"
release_hash="$(/usr/bin/shasum -a 256 "$release_zip" | /usr/bin/awk '{print $1}')"
printf '%s %s\n' "$release_hash" "$(basename "$release_zip")" >"$checksum_file"
printf '\n打包成功:%s\n' "$artifact"
printf '分发包:%s\n' "$release_zip"
printf 'SHA-256%s\n' "$release_hash"
+198
View File
@@ -0,0 +1,198 @@
[CmdletBinding()]
param(
[switch]$ValidateOnly
)
$ErrorActionPreference = "Stop"
$ProjectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
$BuildScript = Join-Path $PSScriptRoot "build_windows.ps1"
$BuildEnvironment = Join-Path $ProjectRoot ".venv-build"
$BuildPython = Join-Path $BuildEnvironment "Scripts\python.exe"
$FallbackPython = Join-Path $ProjectRoot ".venv\Scripts\python.exe"
$CompanionRoot = Join-Path $ProjectRoot "video_companion"
$PackageLock = Join-Path $CompanionRoot "package-lock.json"
$Artifact = Join-Path $ProjectRoot "dist\DoctorWorkstation"
$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"
$ProjectMetadata = Join-Path $ProjectRoot "pyproject.toml"
function Test-BuildPython {
param([Parameter(Mandatory = $true)][string]$Candidate)
if (-not (Test-Path -LiteralPath $Candidate -PathType Leaf)) {
return $false
}
try {
& $Candidate -c "import sys, httpx, PyInstaller, PySide6; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)" 2>$null
return $LASTEXITCODE -eq 0
}
catch {
return $false
}
}
function Find-Application {
param([Parameter(Mandatory = $true)][string]$Name)
$Command = Get-Command $Name -CommandType Application -ErrorAction SilentlyContinue |
Select-Object -First 1
if ($Command) { return $Command.Source }
return $null
}
try {
foreach ($RequiredFile in @(
$BuildScript,
$PackageLock,
(Join-Path $ProjectRoot "uv.lock"),
$ProjectMetadata,
$ReleaseLauncherTemplate
)) {
if (-not (Test-Path -LiteralPath $RequiredFile -PathType Leaf)) {
throw "Required build file is missing: $RequiredFile"
}
}
$Npm = Find-Application -Name "npm.cmd"
$Node = Find-Application -Name "node.exe"
if (-not $Npm -or -not $Node) {
throw "Node.js and npm are required to build the video companion."
}
$NodeVersionText = (& $Node --version).Trim().TrimStart("v")
$NodeVersion = [version]$NodeVersionText
if ($NodeVersion.Major -lt 20) {
throw "Node.js 20 or newer is required; found $NodeVersionText"
}
$Uv = Find-Application -Name "uv"
if ($ValidateOnly) {
if (-not $Uv -and
-not (Test-BuildPython -Candidate $BuildPython) -and
-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."
exit 0
}
if ($Uv) {
Write-Host "Preparing locked Python build dependencies in $BuildEnvironment ..."
$PreviousProjectEnvironment = [Environment]::GetEnvironmentVariable(
"UV_PROJECT_ENVIRONMENT",
"Process"
)
[Environment]::SetEnvironmentVariable(
"UV_PROJECT_ENVIRONMENT",
$BuildEnvironment,
"Process"
)
Push-Location $ProjectRoot
try {
& $Uv sync --frozen --extra build
if ($LASTEXITCODE -ne 0) {
throw "uv sync for build dependencies failed with exit code $LASTEXITCODE"
}
}
finally {
Pop-Location
[Environment]::SetEnvironmentVariable(
"UV_PROJECT_ENVIRONMENT",
$PreviousProjectEnvironment,
"Process"
)
}
}
elseif (Test-BuildPython -Candidate $BuildPython) {
Write-Host "Using existing build environment: $BuildPython"
}
elseif (Test-BuildPython -Candidate $FallbackPython) {
$BuildPython = $FallbackPython
Write-Host "Using existing project environment with build dependencies: $BuildPython"
}
else {
throw "Install uv or prepare .venv-build with the project's build dependencies."
}
if (-not (Test-BuildPython -Candidate $BuildPython)) {
throw "The prepared build Python is unusable: $BuildPython"
}
Write-Host "Installing locked video companion dependencies..."
& $Npm ci --prefix $CompanionRoot --no-audit --no-fund
if ($LASTEXITCODE -ne 0) {
throw "npm ci failed with exit code $LASTEXITCODE"
}
Write-Host "Running the existing Windows release build..."
& $BuildScript -Python $BuildPython -SkipFrontendInstall
if (-not (Test-Path -LiteralPath $Executable -PathType Leaf)) {
throw "Build completed without the expected executable: $Executable"
}
$ProjectText = [System.IO.File]::ReadAllText($ProjectMetadata)
$VersionMatch = [regex]::Match(
$ProjectText,
'(?m)^\s*version\s*=\s*"([^"]+)"'
)
if (-not $VersionMatch.Success) {
throw "Unable to read the project version from $ProjectMetadata"
}
$ProjectVersion = $VersionMatch.Groups[1].Value
$ReleaseZip = Join-Path $DistributionRoot (
"DoctorWorkstation-Windows-x64-$ProjectVersion.zip"
)
$ChecksumFile = Join-Path $DistributionRoot "SHA256SUMS.txt"
Copy-Item -LiteralPath $ReleaseLauncherTemplate -Destination $ReleaseLauncher -Force
if (Test-Path -LiteralPath $ReleaseZip) {
Remove-Item -LiteralPath $ReleaseZip -Force
}
Write-Host "Creating the distributable ZIP..."
$SevenZip = Find-Application -Name "7z.exe"
if ($SevenZip) {
Push-Location $DistributionRoot
try {
& $SevenZip a -tzip -mx=5 -mmt=on $ReleaseZip `
".\DoctorWorkstation" ".\Start_DoctorWorkstation.bat"
if ($LASTEXITCODE -ne 0) {
throw "7-Zip failed with exit code $LASTEXITCODE"
}
}
finally {
Pop-Location
}
}
else {
Compress-Archive `
-LiteralPath @($Artifact, $ReleaseLauncher) `
-DestinationPath $ReleaseZip `
-CompressionLevel Optimal
}
if (-not (Test-Path -LiteralPath $ReleaseZip -PathType Leaf)) {
throw "Packaging completed without the expected ZIP: $ReleaseZip"
}
$ReleaseHash = (Get-FileHash -LiteralPath $ReleaseZip -Algorithm SHA256).Hash
$ChecksumLine = "$ReleaseHash $([System.IO.Path]::GetFileName($ReleaseZip))`r`n"
$Utf8WithoutBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($ChecksumFile, $ChecksumLine, $Utf8WithoutBom)
Write-Host "Windows package complete." -ForegroundColor Green
Write-Host "Artifact: $Artifact" -ForegroundColor Green
Write-Host "Release ZIP: $ReleaseZip" -ForegroundColor Green
Write-Host "SHA-256: $ReleaseHash" -ForegroundColor Green
exit 0
}
catch {
$FailureExitCode = if ($LASTEXITCODE -is [int] -and $LASTEXITCODE -ne 0) {
$LASTEXITCODE
}
else {
1
}
Write-Host "Windows packaging failed." -ForegroundColor Red
Write-Host $_.Exception.Message -ForegroundColor Red
exit $FailureExitCode
}
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "$0")" && pwd -P)"
project_root="$(cd "$script_dir/.." && pwd -P)"
# shellcheck source=macos_helpers.sh
source "$script_dir/macos_helpers.sh"
macos_install_exit_trap 0
macos_require_darwin
artifact="$project_root/dist/DoctorWorkstation.app"
artifact_executable="$artifact/Contents/MacOS/DoctorWorkstation"
if [[ -d "$artifact" && -x "$artifact_executable" ]]; then
printf '正在打开已构建应用:%s\n' "$artifact"
if /usr/bin/open "$artifact"; then
printf '应用已启动。\n'
exit 0
fi
macos_die "DoctorWorkstation.app 存在,但 Finder 无法打开它。"
fi
if [[ -e "$artifact" ]]; then
printf '检测到不完整的应用产物,将改为启动源码:%s\n' "$artifact" >&2
else
printf '尚无已构建应用,正在准备源码运行环境。\n'
fi
macos_ensure_uv
cd "$project_root"
printf '正在同步 Python 运行依赖(首次运行可能需要几分钟)…\n'
"$MACOS_UV_BIN" sync --locked
printf '正在启动医生工作站…\n'
"$MACOS_UV_BIN" run --frozen doctor-workstation
+133
View File
@@ -0,0 +1,133 @@
[CmdletBinding()]
param(
[switch]$ValidateOnly
)
$ErrorActionPreference = "Stop"
$ProjectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
$FrozenExecutable = Join-Path $ProjectRoot "dist\DoctorWorkstation\DoctorWorkstation.exe"
$ProjectPython = Join-Path $ProjectRoot ".venv\Scripts\python.exe"
$SourceRoot = Join-Path $ProjectRoot "src"
function Test-ProjectPython {
param([Parameter(Mandatory = $true)][string]$Candidate)
if (-not (Test-Path -LiteralPath $Candidate -PathType Leaf)) {
return $false
}
try {
& $Candidate -c "import sys, httpx, PySide6; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)" 2>$null
return $LASTEXITCODE -eq 0
}
catch {
return $false
}
}
function Find-Uv {
$Command = Get-Command uv -CommandType Application -ErrorAction SilentlyContinue |
Select-Object -First 1
if ($Command) { return $Command.Source }
return $null
}
try {
if (Test-Path -LiteralPath $FrozenExecutable -PathType Leaf) {
Write-Host "Using packaged application: $FrozenExecutable"
if ($ValidateOnly) {
Write-Host "Windows run entry validation passed."
exit 0
}
& $FrozenExecutable
$ApplicationExitCode = $LASTEXITCODE
if ($null -eq $ApplicationExitCode) { $ApplicationExitCode = 0 }
if ($ApplicationExitCode -ne 0) {
Write-Host "DoctorWorkstation exited with code $ApplicationExitCode" -ForegroundColor Red
}
exit $ApplicationExitCode
}
$Python = $null
if (Test-ProjectPython -Candidate $ProjectPython) {
$Python = $ProjectPython
Write-Host "Using existing project environment: $ProjectPython"
}
else {
$Uv = Find-Uv
if (-not $Uv) {
throw "No packaged application or usable .venv was found, and uv is not installed."
}
if ($ValidateOnly) {
Write-Host "Source fallback is available through uv: $Uv"
Write-Host "Windows run entry validation passed."
exit 0
}
Write-Host "Preparing the project environment with uv..."
$PreviousProjectEnvironment = [Environment]::GetEnvironmentVariable(
"UV_PROJECT_ENVIRONMENT",
"Process"
)
[Environment]::SetEnvironmentVariable(
"UV_PROJECT_ENVIRONMENT",
(Join-Path $ProjectRoot ".venv"),
"Process"
)
Push-Location $ProjectRoot
try {
& $Uv sync --frozen
if ($LASTEXITCODE -ne 0) {
throw "uv sync failed with exit code $LASTEXITCODE"
}
}
finally {
Pop-Location
[Environment]::SetEnvironmentVariable(
"UV_PROJECT_ENVIRONMENT",
$PreviousProjectEnvironment,
"Process"
)
}
if (-not (Test-ProjectPython -Candidate $ProjectPython)) {
throw "uv completed, but the project Python is still unusable: $ProjectPython"
}
$Python = $ProjectPython
}
if ($ValidateOnly) {
Write-Host "Windows run entry validation passed."
exit 0
}
$PreviousPythonPath = [Environment]::GetEnvironmentVariable("PYTHONPATH", "Process")
$env:PYTHONPATH = if ($PreviousPythonPath) {
$SourceRoot + [System.IO.Path]::PathSeparator + $PreviousPythonPath
}
else {
$SourceRoot
}
Push-Location $ProjectRoot
try {
& $Python -m doctor_workstation
$ApplicationExitCode = $LASTEXITCODE
}
finally {
Pop-Location
[Environment]::SetEnvironmentVariable("PYTHONPATH", $PreviousPythonPath, "Process")
}
if ($null -eq $ApplicationExitCode) { $ApplicationExitCode = 0 }
if ($ApplicationExitCode -ne 0) {
Write-Host "DoctorWorkstation exited with code $ApplicationExitCode" -ForegroundColor Red
}
exit $ApplicationExitCode
}
catch {
$FailureExitCode = if ($LASTEXITCODE -is [int] -and $LASTEXITCODE -ne 0) {
$LASTEXITCODE
}
else {
1
}
Write-Host "Unable to start DoctorWorkstation." -ForegroundColor Red
Write-Host $_.Exception.Message -ForegroundColor Red
exit $FailureExitCode
}