first commit
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
# Video-enabled desktop packaging
|
||||
|
||||
The spec creates an `onedir` build and embeds `video_companion/dist` as `video_companion_dist`. Explicit QtWebEngine imports activate PyInstaller's maintained PySide6 hooks; the build scripts then fail if the resulting artifact does not contain `QtWebEngineProcess` or Chromium `.pak` resources.
|
||||
|
||||
`PySide6.QtMultimedia` and `PySide6.QtMultimediaWidgets` are also explicit hidden imports. Their maintained PyInstaller hooks collect the Python extension modules, `Qt6Multimedia`/`Qt6MultimediaWidgets` DLLs, dylibs or frameworks, `plugins/multimedia`, and the platform media backends. The build scripts assert those frozen files before accepting an artifact.
|
||||
|
||||
Both build scripts launch the frozen executable twice. `--media-smoke-test` is handled by a PyInstaller runtime hook before the normal application entry point: it imports both multimedia modules, constructs `QMediaPlayer`, `QAudioOutput`, and `QVideoWidget`, checks that a decoder backend exposes formats, runs one offscreen event-loop turn, and returns non-zero on any failure. The existing `--smoke-test` then validates the packaged application bootstrap. Each process uses a temporary user/config directory, loopback-only proxy settings, and a 30-second deadline; a non-zero exit or an unhandled exception in its logs fails the build.
|
||||
|
||||
Run the build on the target operating system. PyInstaller cannot cross-build Windows and macOS artifacts.
|
||||
|
||||
## Windows
|
||||
|
||||
```powershell
|
||||
.\scripts\build_windows.ps1
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## macOS
|
||||
|
||||
```bash
|
||||
bash ./scripts/build_macos.sh
|
||||
```
|
||||
|
||||
The default interpreter is `.venv-build/bin/python`. For release signing, export `MACOS_CODESIGN_IDENTITY` before building. The generated app includes camera/microphone usage descriptions and the main-process entitlements in `macos/entitlements.plist`.
|
||||
|
||||
For the one-click release ZIP and SHA-256 file, use `package_macos.command` (or `一键打包.command`). All root `.command` files and operational `scripts/*.sh` files must be tracked with mode `100755`; `scripts/check_macos_entrypoints.sh` verifies both filesystem executability and Git index mode before packaging.
|
||||
|
||||
Before notarization, verify the nested `QtWebEngineProcess.app` signature and preserve its Qt-provided helper entitlements. Sign nested code before the outer app, then notarize and staple the final distribution artifact.
|
||||
|
||||
`.env.example` is a source/deployment template and is intentionally not included in the release archives. Production endpoints and non-secret policy values should be injected into the process environment by the managed launcher/MDM. Never place passwords, tokens, UserSig, TRTC SecretKey, or other long-lived credentials in a release archive.
|
||||
@@ -0,0 +1,127 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
"""Cross-platform PyInstaller onedir spec for the video-enabled workstation.
|
||||
|
||||
PyInstaller's official PySide6 QtWebEngine hooks are activated by the explicit
|
||||
hidden imports below. Those hooks retain QtWebEngineProcess, Chromium .pak/
|
||||
ICU resources, locales, Qt plugins, and the macOS framework/helper layout.
|
||||
The explicit QtMultimedia imports likewise activate PyInstaller's official Qt
|
||||
dependency scan, which collects the QtMultimedia/QtMultimediaWidgets extension
|
||||
modules, Qt6Multimedia shared libraries/frameworks, multimedia plugins, and
|
||||
their platform media backends.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(SPEC).resolve().parent.parent
|
||||
SOURCE_ROOT = PROJECT_ROOT / "src"
|
||||
ENTRY_POINT = SOURCE_ROOT / "doctor_workstation" / "__main__.py"
|
||||
VIDEO_DIST = PROJECT_ROOT / "video_companion" / "dist"
|
||||
RESOURCES = PROJECT_ROOT / "resources"
|
||||
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"
|
||||
|
||||
if not ENTRY_POINT.is_file():
|
||||
raise SystemExit(f"Application entry point is missing: {ENTRY_POINT}")
|
||||
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}")
|
||||
|
||||
# Some Windows developer tools add an unrelated OpenSSL installation to PATH.
|
||||
# PyInstaller's dependency scanner would then pair Python's ``_ssl.pyd`` with
|
||||
# those incompatible DLLs. Put the running interpreter's DLL directory first
|
||||
# and collect the exact same files explicitly so the build is reproducible.
|
||||
python_runtime_binaries = []
|
||||
if sys.platform == "win32":
|
||||
python_dll_dir = Path(sys.base_prefix) / "DLLs"
|
||||
for dll_name in ("libssl-3-x64.dll", "libcrypto-3-x64.dll"):
|
||||
dll_path = python_dll_dir / dll_name
|
||||
if not dll_path.is_file():
|
||||
raise SystemExit(f"Python runtime dependency is missing: {dll_path}")
|
||||
python_runtime_binaries.append((str(dll_path), "."))
|
||||
os.environ["PATH"] = os.pathsep.join((str(python_dll_dir), os.environ.get("PATH", "")))
|
||||
|
||||
qt_webengine_hiddenimports = [
|
||||
# Importing these modules lets PyInstaller's official Qt hooks collect the
|
||||
# helper executable/app, resources, locales, frameworks, and plugins.
|
||||
"PySide6.QtWebChannel",
|
||||
"PySide6.QtWebEngineCore",
|
||||
"PySide6.QtWebEngineWidgets",
|
||||
"PySide6.QtNetwork",
|
||||
"PySide6.QtPrintSupport",
|
||||
]
|
||||
|
||||
qt_multimedia_hiddenimports = [
|
||||
# These are intentionally explicit instead of relying on imports hidden by
|
||||
# the application's optional media fallback. PyInstaller's official
|
||||
# hooks collect Qt6Multimedia*.dll/.dylib/framework, plugins/multimedia,
|
||||
# and the platform FFmpeg/native backend dependencies.
|
||||
"PySide6.QtMultimedia",
|
||||
"PySide6.QtMultimediaWidgets",
|
||||
]
|
||||
|
||||
analysis = Analysis(
|
||||
[str(ENTRY_POINT)],
|
||||
pathex=[str(SOURCE_ROOT)],
|
||||
binaries=python_runtime_binaries,
|
||||
datas=[
|
||||
(str(VIDEO_DIST), "video_companion_dist"),
|
||||
(str(RESOURCES), "resources"),
|
||||
],
|
||||
hiddenimports=qt_webengine_hiddenimports + qt_multimedia_hiddenimports,
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[str(MEDIA_SMOKE_HOOK)],
|
||||
excludes=[],
|
||||
noarchive=False,
|
||||
optimize=0,
|
||||
)
|
||||
|
||||
pyz = PYZ(analysis.pure)
|
||||
|
||||
is_macos = sys.platform == "darwin"
|
||||
exe = EXE(
|
||||
pyz,
|
||||
analysis.scripts,
|
||||
[],
|
||||
exclude_binaries=True,
|
||||
name="DoctorWorkstation",
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=False,
|
||||
console=False,
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
target_arch=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,
|
||||
)
|
||||
|
||||
collection = COLLECT(
|
||||
exe,
|
||||
analysis.binaries,
|
||||
analysis.datas,
|
||||
strip=False,
|
||||
upx=False,
|
||||
name="DoctorWorkstation",
|
||||
)
|
||||
|
||||
if is_macos:
|
||||
app = BUNDLE(
|
||||
collection,
|
||||
name="DoctorWorkstation.app",
|
||||
icon=None,
|
||||
bundle_identifier="com.zyt.doctor-workstation",
|
||||
info_plist={
|
||||
"CFBundleDisplayName": "甄养堂医生工作站",
|
||||
"NSCameraUsageDescription": "用于视频面诊时采集医生画面。",
|
||||
"NSMicrophoneUsageDescription": "用于视频面诊时采集医生语音。",
|
||||
"NSHighResolutionCapable": True,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.device.camera</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.microphone</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Frozen-process gate for the Qt multimedia runtime.
|
||||
|
||||
PyInstaller executes this file as a runtime hook. Normal application starts
|
||||
are untouched; ``--media-smoke-test`` exits before the application entry point
|
||||
after proving that the frozen Qt multimedia modules and a decoder backend can
|
||||
be loaded in an offscreen Qt event loop.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
MEDIA_SMOKE_ARGUMENT = "--media-smoke-test"
|
||||
|
||||
|
||||
def _emit(message: str, *, error: bool = False) -> None:
|
||||
# Windowed PyInstaller executables set stdout/stderr to None on Windows.
|
||||
# The exit status is the gate contract; diagnostics are best-effort.
|
||||
stream = sys.stderr if error else sys.stdout
|
||||
if stream is not None:
|
||||
print(message, file=stream, flush=True)
|
||||
|
||||
|
||||
def _run_media_smoke_gate() -> None:
|
||||
# Keep these imports inside the gate. Missing frozen extension modules or
|
||||
# linked Qt multimedia libraries must make this process fail, while normal
|
||||
# launches retain the application's existing fallback behaviour.
|
||||
from PySide6.QtCore import QTimer
|
||||
from PySide6.QtMultimedia import QAudioOutput, QMediaFormat, QMediaPlayer
|
||||
from PySide6.QtMultimediaWidgets import QVideoWidget
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
application = QApplication.instance()
|
||||
owns_application = application is None
|
||||
if application is None:
|
||||
application = QApplication(["DoctorWorkstationMediaSmoke"])
|
||||
|
||||
player = QMediaPlayer()
|
||||
audio_output = QAudioOutput()
|
||||
video_widget = QVideoWidget()
|
||||
player.setAudioOutput(audio_output)
|
||||
player.setVideoOutput(video_widget)
|
||||
|
||||
if not player.isAvailable():
|
||||
raise RuntimeError("Qt reports that no multimedia backend is available")
|
||||
|
||||
decode_formats = QMediaFormat().supportedFileFormats(QMediaFormat.ConversionMode.Decode)
|
||||
if not decode_formats:
|
||||
raise RuntimeError("Qt multimedia loaded without a supported decoder format")
|
||||
|
||||
video_widget.resize(16, 16)
|
||||
video_widget.show()
|
||||
QTimer.singleShot(0, application.quit)
|
||||
event_status = application.exec()
|
||||
video_widget.close()
|
||||
player.setVideoOutput(None)
|
||||
player.setAudioOutput(None)
|
||||
if event_status != 0:
|
||||
raise RuntimeError(f"Qt multimedia offscreen event loop exited with {event_status}")
|
||||
|
||||
# QApplication cannot be recreated safely in-process. The runtime hook is
|
||||
# a one-shot frozen gate, but retaining this distinction makes direct test
|
||||
# imports predictable.
|
||||
if owns_application:
|
||||
application.processEvents()
|
||||
|
||||
|
||||
def _dispatch() -> int | None:
|
||||
if MEDIA_SMOKE_ARGUMENT not in sys.argv[1:]:
|
||||
return None
|
||||
try:
|
||||
_run_media_smoke_gate()
|
||||
except Exception as exc:
|
||||
_emit(
|
||||
f"Frozen Qt multimedia smoke gate failed: {type(exc).__name__}: {exc}",
|
||||
error=True,
|
||||
)
|
||||
return 70
|
||||
_emit("Frozen Qt multimedia smoke gate passed.")
|
||||
return 0
|
||||
|
||||
|
||||
_media_smoke_status = _dispatch()
|
||||
if _media_smoke_status is not None:
|
||||
raise SystemExit(_media_smoke_status)
|
||||
@@ -0,0 +1,19 @@
|
||||
@echo off
|
||||
setlocal EnableExtensions DisableDelayedExpansion
|
||||
set "APPLICATION=%~dp0DoctorWorkstation\DoctorWorkstation.exe"
|
||||
|
||||
if not exist "%APPLICATION%" (
|
||||
echo DoctorWorkstation.exe was not found.
|
||||
echo Please keep this launcher beside the DoctorWorkstation folder.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
start "" "%APPLICATION%"
|
||||
if errorlevel 1 (
|
||||
echo DoctorWorkstation failed to start.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
exit /b 0
|
||||
@@ -0,0 +1,31 @@
|
||||
# UTF-8
|
||||
# Example PyInstaller version resource. Update all four version tuples together.
|
||||
VSVersionInfo(
|
||||
ffi=FixedFileInfo(
|
||||
filevers=(0, 1, 0, 0),
|
||||
prodvers=(0, 1, 0, 0),
|
||||
mask=0x3f,
|
||||
flags=0x0,
|
||||
OS=0x40004,
|
||||
fileType=0x1,
|
||||
subtype=0x0,
|
||||
date=(0, 0)
|
||||
),
|
||||
kids=[
|
||||
StringFileInfo([
|
||||
StringTable(
|
||||
'080404B0',
|
||||
[
|
||||
StringStruct('CompanyName', 'ZYT'),
|
||||
StringStruct('FileDescription', '医生工作台'),
|
||||
StringStruct('FileVersion', '0.1.0.0'),
|
||||
StringStruct('InternalName', 'DoctorWorkstation'),
|
||||
StringStruct('OriginalFilename', 'DoctorWorkstation.exe'),
|
||||
StringStruct('ProductName', '医生工作台'),
|
||||
StringStruct('ProductVersion', '0.1.0.0')
|
||||
]
|
||||
)
|
||||
]),
|
||||
VarFileInfo([VarStruct('Translation', [2052, 1200])])
|
||||
]
|
||||
)
|
||||
Reference in New Issue
Block a user