Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 29 additions & 11 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,20 @@ on:
pull_request:
branches: [main, master, linux, linux-2.0]

# Keep CI read-only and free of secrets; release verification scans untrusted changes.
# No secrets are used anywhere in this workflow, and none should ever be added.
# The release-verification job exists partly to catch a credential that made it
# into the tree; giving that job access to credentials would be self-defeating.
permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

defaults:
run:
shell: bash

jobs:
# Python — the UI and its tests, on both platforms that ship a build.
python:
Expand All @@ -38,6 +44,7 @@ jobs:
cache-dependency-path: |
requirements-alpha.txt
requirements-dev.txt
requirements-uploader.txt

- name: Install system dependencies (Linux)
if: runner.os == 'Linux'
Expand All @@ -52,9 +59,11 @@ jobs:
- name: Install pinned dependencies
# The alpha lock is the ONLY install path. If it does not resolve on a
# supported interpreter, that is a release bug, not a CI inconvenience.
# The uploader set is installed too: tests/test_sftp_provider.py
# imports paramiko from the optional uploader sources.
run: |
python -m pip install --upgrade pip
python -m pip install -r requirements-alpha.txt -r requirements-dev.txt
python -m pip install -r requirements-alpha.txt -r requirements-dev.txt -r requirements-uploader.txt

- name: Assert the environment matches the lock
# Catches a transitive dependency that resolved to something other than
Expand Down Expand Up @@ -103,11 +112,10 @@ jobs:
QT_QPA_PLATFORM: offscreen
run: |
if [ "${{ runner.os }}" = "Linux" ]; then
# Every Linux test runs here; test_capture_backend_detection uses
# the engine built in "Prepare Linux test artifacts" (FFmpeg on
# LD_LIBRARY_PATH). Only Windows-only suites are left out.
python -m pytest tests/ -v --tb=short \
--ignore=tests/test_linux_appimage_report.py \
--ignore=tests/test_linux_runtime.py \
--ignore=tests/test_linux_tools.py \
--ignore=tests/test_capture_backend_detection.py \
--ignore=tests/test_windows_monitor.py \
--ignore=tests/test_windows_native_hotkeys.py \
--ignore=tests/test_windows_autostart.py \
Expand Down Expand Up @@ -214,6 +222,7 @@ jobs:
cache-dependency-path: |
requirements-alpha.txt
requirements-dev.txt
requirements-uploader.txt

- uses: microsoft/setup-msbuild@v2

Expand All @@ -239,6 +248,7 @@ jobs:
}

- name: Build (Release | x64)
shell: pwsh
run: >
msbuild FTHRcapture\FTHRcapture.sln
-p:Configuration=Release
Expand All @@ -263,18 +273,23 @@ jobs:
"engine OK, x64"

- name: Install pinned bundle dependencies
# requirements-uploader.txt is what the sealed uploader executable is
# frozen from. Without it PyInstaller silently builds a package that
# fails at runtime with "No module named 'paramiko'".
run: |
python -m pip install --upgrade pip
python -m pip install -r requirements-alpha.txt -r requirements-dev.txt
python -m pip install -r requirements-alpha.txt -r requirements-dev.txt -r requirements-uploader.txt
python -m pip check

- name: Build sealed optional upload packages
run: python tools/build_optional_uploaders.py

- name: Test built optional upload packages
# The python matrix has no sealed package, so the frozen-executable
# tests (including a real SFTP round trip) run here, after the build.
run: >
python -m pytest tests/test_uploader_plugin_boundary.py
-k "test_built_" --tb=short
python -m pytest tests/test_uploader_plugin_boundary.py tests/test_sftp_provider.py
-k "test_built_ or test_frozen_" --tb=short

- name: Build and verify the Windows bundle
shell: pwsh
Expand Down Expand Up @@ -391,6 +406,7 @@ jobs:
cache-dependency-path: |
requirements-alpha.txt
requirements-dev.txt
requirements-uploader.txt

- name: Install packaging dependencies
run: |
Expand All @@ -405,7 +421,7 @@ jobs:
libxext-dev libxfixes-dev libwayland-dev wayland-protocols libwayland-bin \
libpipewire-0.3-dev libdbus-1-dev
python -m pip install --upgrade pip
python -m pip install -r requirements-alpha.txt -r requirements-dev.txt
python -m pip install -r requirements-alpha.txt -r requirements-dev.txt -r requirements-uploader.txt
python -m pip check

- name: Build AppImage
Expand All @@ -421,7 +437,9 @@ jobs:
python tools/verify_linux_glibc.py \
--manifest tools/linux_release_baseline.json \
--target build_output/AppDir \
--target "${image[0]}"
--target "${image[0]}" \
--target build/optional-uploader-linux/dist/FTHR-Uploader \
--target build/optional-uploader-linux/payload-elf
- name: Upload verified Linux AppImage
uses: actions/upload-artifact@v4
with:
Expand Down
9 changes: 9 additions & 0 deletions BUILDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,15 @@ python -m pip install -r requirements-alpha.txt -r requirements-dev.txt
`requirements-alpha.txt` is the lock and the only file a release build installs
from.

Building the optional uploader package (`tools/build_optional_uploaders.py` on
Windows, `tools/build_linux_uploader.py`, which `build_linux.sh` runs, on
Linux) also needs its own lock in the same environment. Both builders refuse
to run without it:

```bash
python -m pip install -r requirements-uploader.txt
```

> **Do not install `imageio-ffmpeg`.** It bundles a GPLv3 FFmpeg build. Having it
> in the environment risks PyInstaller pulling it into the bundle and placing the
> whole artifact under the GPL (AUDIT-005). It is excluded in `FTHR.spec` and
Expand Down
15 changes: 11 additions & 4 deletions FTHR.spec
Original file line number Diff line number Diff line change
Expand Up @@ -204,15 +204,22 @@ import json as _json
_REVIEWED_QT = _json.loads((ROOT / 'tools' / 'qt_runtime_manifest.json').read_text())
_APPROVED_QT_DLLS = {f'qt6{module.lower()}.dll'
for module in _REVIEWED_QT['approved_runtime_qt_modules']['windows']}
# Destination of the PySide6 wheel's QML import tree as PyInstaller's Qt hooks
# lay it out ('PySide6/qml/' for the Windows wheel). The filter must be
# anchored here: the application's own scenes live under ui/qml/ and a bare
# '/qml/' match drops VideoPreview.qml, Snapshot.qml and color.frag.qsb.
from PyInstaller.utils.hooks.qt import pyside6_library_info as _pyside6_info # noqa: E402
_QT_QML_DEST = (Path(_pyside6_info.qt_rel_dir) / 'qml').as_posix().casefold() + '/'
_KEPT_QML_DIRS = ('qtqml', 'qtqml/models', 'qtqml/workerscript',
'qtquick', 'qtmultimedia')


def _keep_reviewed_qt_runtime(entry):
dest = str(entry[0]).replace('\\', '/').casefold()
name = dest.rsplit('/', 1)[-1]
if '/qml/' in dest:
qml_parent = dest.split('/qml/', 1)[1].rsplit('/', 1)[0]
if qml_parent not in ('qtqml', 'qtqml/models', 'qtqml/workerscript',
'qtquick', 'qtmultimedia'):
if dest.startswith(_QT_QML_DEST):
qml_parent = dest[len(_QT_QML_DEST):].rpartition('/')[0]
if qml_parent not in _KEPT_QML_DIRS:
return False
# Qt on supported Windows versions resolves the operating system ICU.
# Never collect unrelated ICU copies which happen to be on PATH: a newer
Expand Down
46 changes: 41 additions & 5 deletions FTHR_linux.spec
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ a = Analysis(
(str(ASSETS_DIR / 'fthr_logo.png'), 'assets'),
(str(ASSETS_DIR / 'preview_desktop.png'), 'assets'),
(str(ASSETS_DIR / 'gary.png'), 'assets'),
(str(ASSETS_DIR / 'gatze.png'), 'assets'),
(str(PLUGIN_BUNDLE), 'plugin-packages'),
# Licence paperwork must travel INSIDE the bundle (AUDIT-005), so a
# portable copy is as complete as an installed one.
Expand All @@ -170,11 +171,21 @@ a = Analysis(
*_asset_tree(ASSETS_DIR / 'fonts', 'assets/fonts'),
*_asset_tree(ASSETS_DIR / 'icons', 'assets/icons'),
*_asset_tree(ASSETS_DIR / 'sounds', 'assets/sounds'),
# The capture card runs this file in a child interpreter, and the
# editor loads its QML scenes and precompiled shader (color.frag.qsb)
# from beside ui/quick_*.py at runtime.
(str(UI_DIR / 'ui' / 'capture_card_process.py'), 'ui'),
*_asset_tree(UI_DIR / 'ui' / 'qml', 'ui/qml'),
],
hiddenimports=[
'PySide6.QtDBus',
'PySide6.QtMultimedia',
'PySide6.QtMultimediaWidgets',
# The 2.0 editor renders previews and snapshots in QQuickWidget scenes.
'PySide6.QtQuick',
'PySide6.QtQuickWidgets',
'PySide6.QtQml',
'PySide6.QtNetwork',
'sounddevice',
'numpy',
'cv2',
Expand Down Expand Up @@ -202,6 +213,7 @@ a = Analysis(
'core.ffmpeg_playback',
'core.game_detector',
'core.hotkey_manager',
'core.instance_activation',
'core.mic_recorder',
'core.presets_manager',
'core.settings_manager',
Expand All @@ -214,8 +226,9 @@ a = Analysis(
# Exclude imageio_ffmpeg; use the project-pinned FFmpeg runtime beside
# the engine. See core/ffmpeg_tools.py.
'imageio_ffmpeg',
# Qt modules we don't use (Widgets-only app)
'PySide6.QtQuick', 'PySide6.QtQml', 'PySide6.QtWebEngine',
# Qt modules we don't use. Quick/QML power the editor's video preview
# and snapshots, so they are filtered below rather than excluded.
'PySide6.QtWebEngine',
'PySide6.QtWebEngineCore', 'PySide6.QtBluetooth', 'PySide6.QtPositioning',
'PySide6.QtSensors', 'PySide6.QtLocation', 'PySide6.Qt3D',
'PySide6.QtPdf', 'PySide6.QtPdfWidgets', 'PySide6.QtNfc',
Expand All @@ -236,10 +249,22 @@ a = Analysis(
noarchive=False,
)

# QtGui's generic hook also sees the optional Virtual Keyboard plugin. It is a
# GPL-only Qt module and drags in unused QML/Quick libraries. Excludes stop
# Qt hooks collect every optional QML module and its libraries. Excludes stop
# Python imports but not hook-added binaries, so filter the actual artifact
# TOCs as a second, deterministic boundary.
# TOCs as a second, deterministic boundary: keep the QML runtime the
# application's QtQuick and QtMultimedia scenes import, and only the Qt
# libraries reviewed in tools/qt_runtime_manifest.json. The Virtual Keyboard
# is a GPL-only Qt module and never ships.
import json as _json # noqa: E402
_REVIEWED_QT = _json.loads(
(ROOT / 'tools' / 'qt_runtime_manifest.json').read_text(encoding='utf-8'))
_APPROVED_QT_SONAMES = {
f'libqt6{module.lower()}.so'
for module in _REVIEWED_QT['approved_runtime_qt_modules']['linux']}
# Destination of the wheel's QML import tree, e.g. pyside6/qt/qml/.
_QT_QML_DEST = (_QT6_PLUGIN_DEST.parent / 'qml').as_posix().casefold() + '/'
_KEPT_QML_DIRS = ('qtqml', 'qtqml/models', 'qtqml/workerscript',
'qtquick', 'qtmultimedia')
# The compiler runtime stays on the host. PyInstaller puts _internal first on
# LD_LIBRARY_PATH, so a bundled libstdc++/libgcc_s built on the Ubuntu 22.04
# baseline shadows the host's newer copy for every library the process or its
Expand All @@ -255,6 +280,17 @@ def _keep_reviewed_qt_runtime(entry):
dest = str(entry[0]).replace('\\', '/').casefold()
name = dest.rsplit('/', 1)[-1]
forbidden_prefixes = ('libqt6virtualkeyboard',)
# QML debugger/profiler plugins are development tooling, and
# libqmldbg_quick3dprofiler links the GPL-only Quick 3D.
if '/plugins/qmltooling/' in dest:
return False
if dest.startswith(_QT_QML_DEST):
qml_parent = dest[len(_QT_QML_DEST):].rpartition('/')[0]
if qml_parent not in _KEPT_QML_DIRS:
return False
if name.startswith('libqt6') and '.so' in name:
if name.split('.so', 1)[0] + '.so' not in _APPROVED_QT_SONAMES:
return False
return (name not in _SKIPPED_QT_PLUGINS
and name not in _HOST_TOOLCHAIN_LIBS
and 'virtualkeyboard' not in dest
Expand Down
Loading
Loading