This commit is contained in:
Your Name
2026-08-11 09:12:51 +08:00
parent c3ceb0dd0f
commit cfe4c82c90
111 changed files with 26110 additions and 826 deletions
+9 -1
View File
@@ -2,7 +2,9 @@
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.
Both build scripts also launch the frozen entry point with `--smoke-test`. The smoke process uses a temporary user/config directory, demo mode, loopback-only proxy settings, and a 30-second deadline; a non-zero exit or an unhandled exception in its logs fails the build. This validates the packaged bootstrap without contacting a real backend.
`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.
@@ -14,6 +16,8 @@ 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.
## macOS
```bash
@@ -22,4 +26,8 @@ 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.
+18 -2
View File
@@ -4,6 +4,10 @@
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
@@ -18,11 +22,14 @@ 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
@@ -48,6 +55,15 @@ qt_webengine_hiddenimports = [
"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)],
@@ -56,10 +72,10 @@ analysis = Analysis(
(str(VIDEO_DIST), "video_companion_dist"),
(str(RESOURCES), "resources"),
],
hiddenimports=qt_webengine_hiddenimports,
hiddenimports=qt_webengine_hiddenimports + qt_multimedia_hiddenimports,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
runtime_hooks=[str(MEDIA_SMOKE_HOOK)],
excludes=[],
noarchive=False,
optimize=0,
+85
View File
@@ -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)