diff --git a/.github/AGENTS.md b/.github/AGENTS.md index 467aa60..2b73cb3 100644 --- a/.github/AGENTS.md +++ b/.github/AGENTS.md @@ -8,6 +8,9 @@ exceptions; it does not define the source test suite or imply that every package - `workflows/deploy-pypi.yml` is both packaging coverage and the publication graph. Pull requests and ordinary pushes build artifacts; tag pushes additionally publish Python distributions, create the GitHub release, and enable the dependent WinGet flow. Preserve permission, secret, environment, `if`, and `needs` boundaries. +- `workflows/daily-matrix-build.yml` owns the shared binary matrix, called by `deploy-pypi.yml` and also run daily or + manually. Its standalone runs build, verify, and upload artifacts with read-only repository permissions; publication + remains in `deploy-pypi.yml`. - Treat each matrix row as a supported product target with explicit runner OS/architecture, Python, Qt/PySide source, native-binding toolchain, compatibility floor, `Deploy.py` output, and upload pattern. Artifact names and architecture checks must agree; never infer target architecture from the host label alone. diff --git a/.github/workflows/daily-matrix-build.yml b/.github/workflows/daily-matrix-build.yml new file mode 100644 index 0000000..05953f4 --- /dev/null +++ b/.github/workflows/daily-matrix-build.yml @@ -0,0 +1,832 @@ +# Copyright (C) 2024–present Loren Eteval & contributors +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +name: Daily matrix build + +on: + schedule: + # Daily at 02:17 UTC (10:17 Asia/Shanghai). + - cron: '17 2 * * *' + workflow_dispatch: + workflow_call: + +permissions: + contents: read + +defaults: + run: + shell: bash + +jobs: + deploy-binaries: + name: Deploy ${{ matrix.id }} on ${{ matrix.os }} Python ${{ matrix.python-version }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - id: linux-amd64 + os: ubuntu-22.04 + python-version: "3.13" + python-architecture: x64 + pyside6-version: "6.8.3" + windows-compatibility: "" + windows-architecture: "" + - id: linux-arm64 + os: ubuntu-22.04-arm + python-version: "3.11" + python-architecture: arm64 + pyside6-version: "6.5.3" + windows-compatibility: "" + windows-architecture: "" + - id: windows7-amd64 + os: windows-2025 + python-version: "3.13" + python-architecture: x64 + pyside6-version: "6.8.3" + windows-compatibility: windows7 + windows-architecture: amd64 + - id: windows10-amd64 + os: windows-2025 + python-version: "3.13" + python-architecture: x64 + pyside6-version: "6.8.3" + windows-compatibility: windows10 + windows-architecture: amd64 + - id: windows10-arm64 + os: windows-11-vs2026-arm + python-version: "3.13" + python-architecture: arm64 + pyside6-version: "6.11.2" + windows-compatibility: windows10 + windows-architecture: arm64 + - id: macos-intel + os: macos-15-intel + python-version: "3.13" + python-architecture: x64 + pyside6-version: "6.8.3" + windows-compatibility: "" + windows-architecture: "" + - id: macos-arm64 + os: macos-14 + python-version: "3.13" + python-architecture: arm64 + pyside6-version: "6.8.3" + windows-compatibility: "" + windows-architecture: "" + env: + GO_WIN7_VERSION: "1.26.7" + WIX_VERSION: "6.0.2" + WIX_INSTALLER_SHA256: "A8A5CC7443353CEF3AB900C60CD7A3A5EE601746319D104AC7B12AD0CED2345C" + steps: + - uses: actions/checkout@v7 + - name: Install macOS dependencies + run: | + brew install create-dmg + if: runner.os == 'macOS' + # Remove problematic brew libs if Intel Mac + # Credits: https://github.com/RimSort/RimSort + - name: Remove problematic brew libs on Intel Mac + run: | + brew remove --force --ignore-dependencies openssl@3 + brew cleanup openssl@3 + if: runner.os == 'macOS' && runner.arch == 'X64' + - name: Install Linux dependencies + run: | + sudo apt update + + # if [ "$RUNNER_ARCH" == "ARM64" ]; then + # sudo apt install -y \ + # libevent-2.1-7 \ + # libwebp7 \ + # libminizip1 + # fi + + sudo apt install -y \ + libxcb-cursor0 \ + qt6-base-dev \ + patchelf \ + ccache \ + zlib1g-dev + sudo apt install -y flatpak flatpak-builder elfutils patchelf + sudo apt install -y rpm + flatpak remote-add --if-not-exists --user flathub https://flathub.org/repo/flathub.flatpakrepo + flatpak update + flatpak install -y flathub org.kde.Platform//6.8 org.kde.Sdk//6.8 + + if [ "$RUNNER_ARCH" == "X64" ]; then + appimage_arch="x86_64" + elif [ "$RUNNER_ARCH" == "ARM64" ]; then + appimage_arch="aarch64" + else + echo "Unknown runner architecture: $RUNNER_ARCH" + exit 1 + fi + + appimage_tools=".appimage-tools" + mkdir "$appimage_tools" + echo "${PWD}/${appimage_tools}" >> "$GITHUB_PATH" + + wget -P "$appimage_tools" \ + "https://github.com/AppImage/appimagetool/releases/latest/download/appimagetool-${appimage_arch}.AppImage" + chmod +x "$appimage_tools"/* + if: runner.os == 'Linux' + - name: Install Windows dependencies + shell: pwsh + run: | + $toolsetArchitecture = switch ($env:RUNNER_ARCH) { + 'X64' { 'x64' } + 'ARM64' { 'arm64' } + default { throw "Unknown runner architecture: $env:RUNNER_ARCH" } + } + + $installer = Join-Path $env:RUNNER_TEMP 'wix-cli-x64.msi' + $installerUrl = "https://github.com/wixtoolset/wix/releases/download/v$env:WIX_VERSION/wix-cli-x64.msi" + Invoke-WebRequest -Uri $installerUrl -OutFile $installer + + $installerHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $installer).Hash + + if ($installerHash -ne $env:WIX_INSTALLER_SHA256) { + throw "Unexpected WiX installer SHA-256: $installerHash" + } + + $msi = Start-Process msiexec.exe -ArgumentList @( + '/i', + $installer, + '/quiet', + '/norestart', + '/log', + (Join-Path $env:RUNNER_TEMP 'wix-install.log') + ) -WindowStyle Hidden -Wait -PassThru + + if ($msi.ExitCode -notin @(0, 3010)) { + throw "WiX installation failed with exit code $($msi.ExitCode)" + } + + $toolsetRoot = 'C:\Program Files\WiX Toolset v6.0\bin' + $toolsetRoot | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + (Join-Path $toolsetRoot $toolsetArchitecture) | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + if: runner.os == 'Windows' + - name: Check Windows dependencies + run: | + wix --version + wix extension add -g "WixToolset.UI.wixext/${WIX_VERSION}" + wix extension list -g + if: runner.os == 'Windows' + - name: Set up Python for Windows7 + uses: LorenEteval/setup-python-win7@v1 + with: + python-version: ${{ matrix.python-version }} + check-latest: true + if: matrix.id == 'windows7-amd64' + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: ${{ matrix.python-version }} + architecture: ${{ matrix.python-architecture }} + check-latest: true + if: matrix.id != 'windows7-amd64' + - name: Verify runner and Python architecture + env: + EXPECTED_ARCHITECTURE: ${{ matrix.python-architecture }} + run: | + python - <<'PY' + import os + import platform + import struct + import sys + + aliases = { + 'x64': {'amd64', 'x86_64'}, + 'arm64': {'arm64', 'aarch64'}, + } + expected = os.environ['EXPECTED_ARCHITECTURE'] + runner_architecture = os.environ.get('RUNNER_ARCH', '').casefold() + machine = platform.machine().casefold() + pointer_width = struct.calcsize('P') * 8 + + print(f'RUNNER_OS={os.environ.get("RUNNER_OS")}') + print(f'RUNNER_ARCH={os.environ.get("RUNNER_ARCH")}') + print(f'Python={sys.version}') + print(f'platform.machine()={platform.machine()}') + print(f'pointer width={pointer_width}') + + if runner_architecture != expected: + raise SystemExit( + f'expected {expected} runner, got {runner_architecture or "unknown"}' + ) + + if machine not in aliases[expected] or pointer_width != 64: + raise SystemExit( + f'expected native {expected} Python, got {machine}/{pointer_width}-bit' + ) + PY + - name: Set up PySide6 for Windows7 + uses: LorenEteval/setup-pyside6-win7@v1 + with: + pyside6-version: ${{ matrix.pyside6-version }} + if: matrix.id == 'windows7-amd64' + - name: Install Python build and runtime dependencies + run: | + python -c "import sys; print(sys.version)" + python -m pip install --upgrade pip + python -m pip install setuptools wheel + + python - <<'PY' + import re + from pathlib import Path + + excluded = { + 'pyside6', + 'pyside6-addons', + 'pyside6-essentials', + 'xray-core', + 'hysteria', + 'hysteria2', + 'tun2socks', + } + + requirements = [] + + for line in Path('requirements.txt').read_text(encoding='utf-8').splitlines(): + match = re.match(r'\s*([A-Za-z0-9_.-]+)', line) + + if match and match.group(1).casefold().replace('_', '-') in excluded: + continue + + requirements.append(line) + + Path('.binary-requirements.txt').write_text( + '\n'.join(requirements) + '\n', + encoding='utf-8', + ) + PY + + if [[ "${{ matrix.id }}" != "windows7-amd64" ]]; then + pyside_packages=( + "PySide6-Essentials==${{ matrix.pyside6-version }}" + ) + + if [[ "$RUNNER_OS" != "Linux" ]]; then + pyside_packages+=( + "PySide6-Addons==${{ matrix.pyside6-version }}" + ) + fi + + if [[ "$RUNNER_OS" == "Windows" ]]; then + pyside_binary_policy=( + "--only-binary=PySide6-Essentials,PySide6-Addons" + ) + else + pyside_binary_policy=() + fi + + python -m pip install \ + "${pyside_binary_policy[@]}" \ + "${pyside_packages[@]}" + fi + + if [ "$RUNNER_OS" == "Linux" ] && [ "$RUNNER_ARCH" == "ARM64" ]; then + python -m pip install "numpy<2" + fi + + python -m pip install -r .binary-requirements.txt + python -m pip install nuitka imageio requests + python -c "import importlib.metadata; print('zxing-cpp', importlib.metadata.version('zxing-cpp'))" + python -c "from PySide6 import QtCore; print(f'PySide6/Qt {QtCore.__version__}')" + - name: Verify Linux Essentials-only Qt environment + if: runner.os == 'Linux' + env: + EXPECTED_PYSIDE6_VERSION: ${{ matrix.pyside6-version }} + run: | + python - <<'PY' + import importlib.metadata + import importlib.util + import os + import platform + import sys + + from PySide6 import QtCore + + expected = os.environ['EXPECTED_PYSIDE6_VERSION'] + essentials = importlib.metadata.version('PySide6-Essentials') + + try: + addons = importlib.metadata.version('PySide6-Addons') + except importlib.metadata.PackageNotFoundError: + addons = None + + print(f'Python={sys.version}') + print(f'platform.machine()={platform.machine()}') + print(f'PySide6-Essentials={essentials}') + print(f'PySide6-Addons={addons or "absent"}') + print(f'Qt={QtCore.qVersion()}') + + if essentials != expected: + raise SystemExit( + f'expected PySide6-Essentials {expected}, got {essentials}' + ) + + if QtCore.__version__ != expected: + raise SystemExit( + f'expected PySide6/Qt {expected}, got {QtCore.__version__}' + ) + + if addons is not None: + raise SystemExit(f'PySide6-Addons must be absent, got {addons}') + + for name in ( + 'PySide6.QtWebChannel', + 'PySide6.QtWebEngineCore', + 'PySide6.QtWebEngineWidgets', + ): + if importlib.util.find_spec(name) is not None: + raise SystemExit(f'{name} must be absent from the Linux environment') + PY + - name: Install patched Go for Windows7 + run: | + go_name="go-for-win7-windows-amd64" + go_file="${go_name}.zip" + + curl --fail --location --output "${go_file}" \ + "https://github.com/XTLS/go-win7/releases/download/patched-${GO_WIN7_VERSION}/${go_file}" + 7z x "${go_file}" "-o${go_name}" -y + + go_root="$(cygpath -w "$PWD/${go_name}")" + echo "GOROOT=${go_root}" >> $GITHUB_ENV + echo "CGO_ENABLED=1" >> $GITHUB_ENV + echo "${go_root}\\bin" >> $GITHUB_PATH + if: matrix.id == 'windows7-amd64' + - name: Set up Go for Windows10 + uses: actions/setup-go@v7 + with: + go-version: "1.26" + check-latest: true + cache: false + if: matrix.id == 'windows10-amd64' + - name: Build Windows AMD64 bindings from source + shell: pwsh + env: + CC: gcc + CXX: g++ + CGO_ENABLED: "1" + CMAKE_BUILD_PARALLEL_LEVEL: "2" + GOPROXY: https://proxy.golang.org,direct + run: | + $ErrorActionPreference = 'Stop' + $PSNativeCommandUseErrorActionPreference = $true + + Write-Output "Go executable: $((Get-Command go -ErrorAction Stop).Source)" + go version + go env GOROOT GOOS GOARCH CGO_ENABLED + Write-Output "GCC executable: $((Get-Command gcc -ErrorAction Stop).Source)" + gcc -dumpmachine + + $temporaryDirectory = Join-Path $env:RUNNER_TEMP 'bindings' + New-Item -ItemType Directory -Force -Path $temporaryDirectory | Out-Null + foreach ($name in 'TMPDIR', 'TEMP', 'TMP') { + Set-Item -Path "Env:$name" -Value $temporaryDirectory + } + + Write-Output "Configured temporary directory: $temporaryDirectory" + python -c "import tempfile; print(f'Python temporary directory: {tempfile.gettempdir()}')" + + python -m pip install ` + "setuptools>=68" ` + wheel ` + "cmake>=3.15" ` + "pybind11>=3.0.1,<3.1" + + $wheelDirectory = Join-Path $temporaryDirectory 'wheels' + New-Item -ItemType Directory -Force -Path $wheelDirectory | Out-Null + + $bindings = @( + 'Xray-core' + 'hysteria2' + 'tun2socks' + ) + + foreach ($binding in $bindings) { + python -m pip wheel ` + --no-cache-dir ` + --no-build-isolation ` + --no-deps ` + --no-binary=:all: ` + --wheel-dir $wheelDirectory ` + $binding + } + + $wheels = @( + Get-ChildItem -LiteralPath $wheelDirectory -Filter '*.whl' -File | + Sort-Object Name + ) + Write-Output 'Locally built wheels:' + + foreach ($wheel in $wheels) { + Write-Output " $($wheel.Name)" + } + + $unexpectedWheels = @($wheels | Where-Object Name -NotLike '*win_amd64.whl') + + if ($wheels.Count -ne 3 -or $unexpectedWheels.Count -ne 0) { + throw 'Expected exactly three locally built win_amd64 wheels' + } + + $wheelPaths = @($wheels.FullName) + python -m pip install ` + --no-index ` + --force-reinstall ` + --no-deps ` + $wheelPaths + if: matrix.id == 'windows7-amd64' || matrix.id == 'windows10-amd64' + - name: Install pre-built Hysteria1 wheel + run: | + python -m pip install \ + --only-binary=hysteria \ + --no-cache-dir \ + --no-deps \ + hysteria + if: matrix.id == 'windows7-amd64' || matrix.id == 'windows10-amd64' + - name: Install supported binding wheels + run: | + if [[ "$RUNNER_OS" == "Windows" ]]; then + binary_policy="--only-binary=Xray-core,hysteria,hysteria2,tun2socks" + else + binary_policy="" + fi + + python -m pip install \ + ${binary_policy} \ + --no-cache-dir \ + --no-deps \ + Xray-core \ + hysteria \ + hysteria2 \ + tun2socks + if: matrix.id != 'windows7-amd64' && matrix.id != 'windows10-amd64' + - name: Verify Windows native and UI imports + run: | + python - <<'PY' + import importlib + + for name in ('xray', 'hysteria', 'hysteria2', 'tun2socks'): + module = importlib.import_module(name) + print(f'{name}: {module.__file__}') + + # Native extension imports fail here if a wheel has the wrong machine type. + print('All native bindings imported successfully') + + # Some Windows PySide6 distributions intentionally omit Qt WebEngine. + # The application must still import and use its non-WebEngine map fallback. + from Furious.Widget.EndpointInfoWidget import EndpointInfoWidget + + print(f'Endpoint UI imported successfully: {EndpointInfoWidget.__name__}') + PY + if: runner.os == 'Windows' + - name: Verify Linux application imports without WebEngine + if: runner.os == 'Linux' + env: + QT_QPA_PLATFORM: offscreen + run: | + python - <<'PY' + import importlib + + import Furious + import Furious.Application + import Furious.Window + from PySide6 import QtCore + from PySide6.QtWidgets import QApplication + + endpointModule = importlib.import_module( + 'Furious.Widget.EndpointInfoWidget' + ) + + if endpointModule.QWebEngineView is not None: + raise SystemExit('Qt WebEngine must be unavailable in Linux releases') + + application = QApplication.instance() or QApplication([]) + mapWidget = endpointModule._createEndpointMapWidget(None) + + if not isinstance( + mapWidget, + endpointModule._UnavailableEndpointMapWidget, + ): + raise SystemExit('the endpoint map did not use its no-WebEngine fallback') + + mapWidget.close() + mapWidget.deleteLater() + application.sendPostedEvents( + None, + QtCore.QEvent.Type.DeferredDelete, + ) + application.processEvents() + + print(f'Furious imported successfully: {Furious.__name__}') + print('Application and window modules imported successfully') + print('Endpoint UI created its no-WebEngine fallback successfully') + PY + - name: Verify Qt WebEngine imports + if: runner.os == 'macOS' + run: | + python - <<'PY' + from PySide6 import QtWebChannel, QtWebEngineCore, QtWebEngineWidgets + + print(f'QtWebChannel imported successfully: {QtWebChannel.__name__}') + print(f'QtWebEngineCore imported successfully: {QtWebEngineCore.__name__}') + print(f'QtWebEngineWidgets imported successfully: {QtWebEngineWidgets.__name__}') + PY + - name: Download latest asset files + run: | + python Deploy.py --download + - name: Run deploy script + env: + APPIMAGE_EXTRACT_AND_RUN: "1" + run: | + if [[ -n "${{ matrix.windows-compatibility }}" ]]; then + export WIN_VER_COMPATIBLE="${{ matrix.windows-compatibility }}" + fi + python Deploy.py + - name: Verify Linux distribution excludes WebEngine + if: runner.os == 'Linux' + run: | + python - <<'PY' + from pathlib import Path + + root = Path('Furious-Deploy/Furious.dist') + + if not root.is_dir(): + raise SystemExit(f'Nuitka distribution is missing: {root}') + + forbidden = ('qtwebchannel', 'qtwebengine') + unexpected = [] + + for path in root.rglob('*'): + relative = path.relative_to(root).as_posix().casefold() + + if any(name in relative for name in forbidden): + unexpected.append(relative) + + if unexpected: + raise SystemExit( + 'WebEngine/Addons artifacts found in the Linux distribution:\n' + + '\n'.join(f' {path}' for path in unexpected) + ) + + print(f'No Qt WebEngine or Qt WebChannel artifacts found below {root}') + PY + - name: Verify Flatpak native dependencies + if: runner.os == 'Linux' + run: | + bundle="$(find . -maxdepth 1 -name 'Furious-*.flatpak' -print -quit)" + + if [[ -z "$bundle" ]]; then + echo 'Flatpak bundle was not generated' + exit 1 + fi + + flatpak install --user --noninteractive --reinstall -y "$bundle" + + cleanup() { + flatpak uninstall \ + --user \ + --noninteractive \ + --delete-data \ + -y \ + com.Furious.Furious || true + } + + trap cleanup EXIT + + flatpak run --user --command=bash com.Furious.Furious -c ' + set -uo pipefail + + application_root=/app/lib/Furious + dependency_report=/tmp/furious-flatpak-missing-dependencies + failures=0 + unsupported_qt_plugins=( + "$application_root/PySide6/qt-plugins/imageformats/libqpdf.so" + "$application_root/PySide6/qt-plugins/imageformats/libqtiff.so" + "$application_root/PySide6/qt-plugins/imageformats/libqwebp.so" + "$application_root/PySide6/qt-plugins/platforms/libqeglfs.so" + "$application_root/PySide6/qt-plugins/egldeviceintegrations" + ) + + : > "$dependency_report" + + if [[ ! -x "$application_root/Furious.bin" ]]; then + printf "Packaged application is missing or not executable: %s\n" \ + "$application_root/Furious.bin" >> "$dependency_report" + failures=1 + fi + + double_conversion="$( + find /app/lib -type f -name "libdouble-conversion.so.*" \ + -print -quit 2>> "$dependency_report" + )" + + if [[ -z "$double_conversion" ]]; then + printf "Bundled libdouble-conversion.so.3 was not found below /app/lib\n" \ + >> "$dependency_report" + failures=1 + else + printf "Bundled double-conversion library: %s\n" "$double_conversion" + fi + + for plugin in "${unsupported_qt_plugins[@]}"; do + if [[ -e "$plugin" ]]; then + printf "Unsupported Qt plugin was packaged: %s\n" "$plugin" \ + >> "$dependency_report" + failures=1 + fi + done + + if ! command -v ldd >/dev/null; then + printf "The Flatpak runtime does not provide ldd\n" \ + >> "$dependency_report" + failures=1 + else + while IFS= read -r binary; do + if ! dependencies="$(ldd "$binary" 2>&1)"; then + printf "%s\n%s\n\n" \ + "Failed to inspect $binary:" \ + "$dependencies" >> "$dependency_report" + failures=1 + elif printf "%s\n" "$dependencies" | grep -Fq "not found"; then + printf "%s\n%s\n\n" \ + "Missing dependencies for $binary:" \ + "$dependencies" >> "$dependency_report" + failures=1 + fi + done < <( + find "$application_root" \ + -type f \ + \( -name "*.bin" -o -name "*.so" -o -name "*.so.*" \) \ + -print + ) + fi + + if ((failures)); then + cat "$dependency_report" + exit 1 + fi + + echo "Flatpak native dependency check passed" + ' + - name: Verify macOS application bundle + if: runner.os == 'macOS' + shell: bash + run: | + app='app/Furious-GUI.app' + source_app='Furious-Deploy/Furious-GUI.app' + macos_root="${app}/Contents/MacOS" + qt_root="${macos_root}/PySide6/Qt" + webengine_framework="${qt_root}/lib/QtWebEngineCore.framework" + relocated_qt_root="${app}/Contents/Frameworks/PySide6/Qt" + relocated_webengine_framework="${relocated_qt_root}/lib/QtWebEngineCore.framework" + webengine_helper="${relocated_webengine_framework}/Helpers/QtWebEngineProcess.app/Contents/MacOS/QtWebEngineProcess" + failures=0 + + check() { + local description="$1" + local status + shift + + echo "::group::${description}" + + if "$@"; then + status=0 + echo "PASS: ${description} (exit code ${status})" + else + status=$? + echo "::error title=macOS bundle verification::FAIL: ${description} (exit code ${status})" + failures=$((failures + 1)) + fi + + echo '::endgroup::' + } + + echo "Application bundle: ${app}" + echo "Qt runtime root: ${qt_root}" + echo "Relocated Qt root: ${relocated_qt_root}" + echo "WebEngine framework link: ${webengine_framework}" + echo "Relocated WebEngine framework: ${relocated_webengine_framework}" + echo "WebEngine helper: ${webengine_helper}" + + check 'Application bundle exists' test -d "${app}" + check 'Nuitka source application bundle exists' test -d "${source_app}" + framework_paths=( + "${webengine_framework}" + "${relocated_webengine_framework}" + "${relocated_webengine_framework}/Versions/Current" + "${relocated_webengine_framework}/QtWebEngineCore" + ) + check 'Relocated framework paths can be inspected' ls -ld "${framework_paths[@]}" + check 'MacOS WebEngine framework entry is a symbolic link' test -L "${webengine_framework}" + check 'Framework Current version is a symbolic link' test -L "${relocated_webengine_framework}/Versions/Current" + check 'Framework top-level binary is a symbolic link' test -L "${relocated_webengine_framework}/QtWebEngineCore" + check 'WebEngine process helper is executable' test -x "${webengine_helper}" + resource_paths=( + "${macos_root}/icudtl.dat" + "${macos_root}/qtwebengine_resources.pak" + ) + check 'WebEngine resource metadata can be inspected' ls -l@ "${resource_paths[@]}" + check 'Chromium ICU data exists' test -f "${resource_paths[0]}" + check 'Chromium resource pack exists' test -f "${resource_paths[1]}" + check 'Nuitka source code signature is valid' codesign --verify --deep --strict --verbose=2 "${source_app}" + check 'Staged application code signature is valid' codesign --verify --deep --strict --verbose=2 "${app}" + size_paths=( + "${app}" + "${app}/Contents/Frameworks" + "${app}/Contents/MacOS" + ) + check 'Application bundle sizes can be measured' du -sh "${size_paths[@]}" + + if ((failures > 0)); then + echo "::error title=macOS bundle verification::${failures} check(s) failed" + exit 1 + fi + - name: Verify packaged Windows binaries + env: + EXPECTED_WINDOWS_ARCHITECTURE: ${{ matrix.windows-architecture }} + WINDOWS_COMPATIBILITY: ${{ matrix.windows-compatibility }} + run: | + python - <<'PY' + import os + import struct + from pathlib import Path + + machineCodes = { + 'amd64': 0x8664, + 'arm64': 0xAA64, + } + expected = os.environ['EXPECTED_WINDOWS_ARCHITECTURE'] + expectedMachine = machineCodes[expected] + root = Path('Furious-Deploy/Furious.dist') + compatibilityDll = root / 'api-ms-win-core-path-l1-1-0.dll' + + if ( + os.environ['WINDOWS_COMPATIBILITY'] == 'windows7' + and not compatibilityDll.is_file() + ): + raise SystemExit(f'Windows 7 compatibility DLL is missing: {compatibilityDll}') + + binaries = sorted( + { + *root.rglob('*.exe'), + *root.rglob('*.pyd'), + *([compatibilityDll] if compatibilityDll.is_file() else []), + } + ) + + if not binaries: + raise SystemExit(f'no packaged Windows binaries found below {root}') + + for binary in binaries: + with binary.open('rb') as stream: + if stream.read(2) != b'MZ': + raise SystemExit(f'{binary} is not a PE binary') + + stream.seek(0x3C) + peOffset = struct.unpack('> "$GITHUB_PATH" - - wget -P "$appimage_tools" \ - "https://github.com/AppImage/appimagetool/releases/latest/download/appimagetool-${appimage_arch}.AppImage" - chmod +x "$appimage_tools"/* - if: runner.os == 'Linux' - - name: Install Windows dependencies - shell: pwsh - run: | - $toolsetArchitecture = switch ($env:RUNNER_ARCH) { - 'X64' { 'x64' } - 'ARM64' { 'arm64' } - default { throw "Unknown runner architecture: $env:RUNNER_ARCH" } - } - - $installer = Join-Path $env:RUNNER_TEMP 'wix-cli-x64.msi' - $installerUrl = "https://github.com/wixtoolset/wix/releases/download/v$env:WIX_VERSION/wix-cli-x64.msi" - Invoke-WebRequest -Uri $installerUrl -OutFile $installer - - $installerHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $installer).Hash - - if ($installerHash -ne $env:WIX_INSTALLER_SHA256) { - throw "Unexpected WiX installer SHA-256: $installerHash" - } - - $msi = Start-Process msiexec.exe -ArgumentList @( - '/i', - $installer, - '/quiet', - '/norestart', - '/log', - (Join-Path $env:RUNNER_TEMP 'wix-install.log') - ) -WindowStyle Hidden -Wait -PassThru - - if ($msi.ExitCode -notin @(0, 3010)) { - throw "WiX installation failed with exit code $($msi.ExitCode)" - } - - $toolsetRoot = 'C:\Program Files\WiX Toolset v6.0\bin' - $toolsetRoot | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - (Join-Path $toolsetRoot $toolsetArchitecture) | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - if: runner.os == 'Windows' - - name: Check Windows dependencies - run: | - wix --version - wix extension add -g "WixToolset.UI.wixext/${WIX_VERSION}" - wix extension list -g - if: runner.os == 'Windows' - - name: Set up Python for Windows7 - uses: LorenEteval/setup-python-win7@v1 - with: - python-version: ${{ matrix.python-version }} - check-latest: true - if: matrix.id == 'windows7-amd64' - - name: Set up Python - uses: actions/setup-python@v7 - with: - python-version: ${{ matrix.python-version }} - architecture: ${{ matrix.python-architecture }} - check-latest: true - if: matrix.id != 'windows7-amd64' - - name: Verify runner and Python architecture - env: - EXPECTED_ARCHITECTURE: ${{ matrix.python-architecture }} - run: | - python - <<'PY' - import os - import platform - import struct - import sys - - aliases = { - 'x64': {'amd64', 'x86_64'}, - 'arm64': {'arm64', 'aarch64'}, - } - expected = os.environ['EXPECTED_ARCHITECTURE'] - runner_architecture = os.environ.get('RUNNER_ARCH', '').casefold() - machine = platform.machine().casefold() - pointer_width = struct.calcsize('P') * 8 - - print(f'RUNNER_OS={os.environ.get("RUNNER_OS")}') - print(f'RUNNER_ARCH={os.environ.get("RUNNER_ARCH")}') - print(f'Python={sys.version}') - print(f'platform.machine()={platform.machine()}') - print(f'pointer width={pointer_width}') - - if runner_architecture != expected: - raise SystemExit( - f'expected {expected} runner, got {runner_architecture or "unknown"}' - ) - - if machine not in aliases[expected] or pointer_width != 64: - raise SystemExit( - f'expected native {expected} Python, got {machine}/{pointer_width}-bit' - ) - PY - - name: Set up PySide6 for Windows7 - uses: LorenEteval/setup-pyside6-win7@v1 - with: - pyside6-version: ${{ matrix.pyside6-version }} - if: matrix.id == 'windows7-amd64' - - name: Install Python build and runtime dependencies - run: | - python -c "import sys; print(sys.version)" - python -m pip install --upgrade pip - python -m pip install setuptools wheel - - python - <<'PY' - import re - from pathlib import Path - - excluded = { - 'pyside6', - 'pyside6-addons', - 'pyside6-essentials', - 'xray-core', - 'hysteria', - 'hysteria2', - 'tun2socks', - } - - requirements = [] - - for line in Path('requirements.txt').read_text(encoding='utf-8').splitlines(): - match = re.match(r'\s*([A-Za-z0-9_.-]+)', line) - - if match and match.group(1).casefold().replace('_', '-') in excluded: - continue - - requirements.append(line) - - Path('.binary-requirements.txt').write_text( - '\n'.join(requirements) + '\n', - encoding='utf-8', - ) - PY - - if [[ "${{ matrix.id }}" != "windows7-amd64" ]]; then - pyside_packages=( - "PySide6-Essentials==${{ matrix.pyside6-version }}" - ) - - if [[ "$RUNNER_OS" != "Linux" ]]; then - pyside_packages+=( - "PySide6-Addons==${{ matrix.pyside6-version }}" - ) - fi - - if [[ "$RUNNER_OS" == "Windows" ]]; then - pyside_binary_policy=( - "--only-binary=PySide6-Essentials,PySide6-Addons" - ) - else - pyside_binary_policy=() - fi - - python -m pip install \ - "${pyside_binary_policy[@]}" \ - "${pyside_packages[@]}" - fi - - if [ "$RUNNER_OS" == "Linux" ] && [ "$RUNNER_ARCH" == "ARM64" ]; then - python -m pip install "numpy<2" - fi - - python -m pip install -r .binary-requirements.txt - python -m pip install nuitka imageio requests - python -c "import importlib.metadata; print('zxing-cpp', importlib.metadata.version('zxing-cpp'))" - python -c "from PySide6 import QtCore; print(f'PySide6/Qt {QtCore.__version__}')" - - name: Verify Linux Essentials-only Qt environment - if: runner.os == 'Linux' - env: - EXPECTED_PYSIDE6_VERSION: ${{ matrix.pyside6-version }} - run: | - python - <<'PY' - import importlib.metadata - import importlib.util - import os - import platform - import sys - - from PySide6 import QtCore - - expected = os.environ['EXPECTED_PYSIDE6_VERSION'] - essentials = importlib.metadata.version('PySide6-Essentials') - - try: - addons = importlib.metadata.version('PySide6-Addons') - except importlib.metadata.PackageNotFoundError: - addons = None - - print(f'Python={sys.version}') - print(f'platform.machine()={platform.machine()}') - print(f'PySide6-Essentials={essentials}') - print(f'PySide6-Addons={addons or "absent"}') - print(f'Qt={QtCore.qVersion()}') - - if essentials != expected: - raise SystemExit( - f'expected PySide6-Essentials {expected}, got {essentials}' - ) - - if QtCore.__version__ != expected: - raise SystemExit( - f'expected PySide6/Qt {expected}, got {QtCore.__version__}' - ) - - if addons is not None: - raise SystemExit(f'PySide6-Addons must be absent, got {addons}') - - for name in ( - 'PySide6.QtWebChannel', - 'PySide6.QtWebEngineCore', - 'PySide6.QtWebEngineWidgets', - ): - if importlib.util.find_spec(name) is not None: - raise SystemExit(f'{name} must be absent from the Linux environment') - PY - - name: Install patched Go for Windows7 - run: | - go_name="go-for-win7-windows-amd64" - go_file="${go_name}.zip" - - curl --fail --location --output "${go_file}" \ - "https://github.com/XTLS/go-win7/releases/download/patched-${GO_WIN7_VERSION}/${go_file}" - 7z x "${go_file}" "-o${go_name}" -y - - go_root="$(cygpath -w "$PWD/${go_name}")" - echo "GOROOT=${go_root}" >> $GITHUB_ENV - echo "CGO_ENABLED=1" >> $GITHUB_ENV - echo "${go_root}\\bin" >> $GITHUB_PATH - if: matrix.id == 'windows7-amd64' - - name: Set up Go for Windows10 - uses: actions/setup-go@v7 - with: - go-version: "1.26" - check-latest: true - cache: false - if: matrix.id == 'windows10-amd64' - - name: Build Windows AMD64 bindings from source - shell: pwsh - env: - CC: gcc - CXX: g++ - CGO_ENABLED: "1" - CMAKE_BUILD_PARALLEL_LEVEL: "2" - GOPROXY: https://proxy.golang.org,direct - run: | - $ErrorActionPreference = 'Stop' - $PSNativeCommandUseErrorActionPreference = $true - - Write-Output "Go executable: $((Get-Command go -ErrorAction Stop).Source)" - go version - go env GOROOT GOOS GOARCH CGO_ENABLED - Write-Output "GCC executable: $((Get-Command gcc -ErrorAction Stop).Source)" - gcc -dumpmachine - - $temporaryDirectory = Join-Path $env:RUNNER_TEMP 'bindings' - New-Item -ItemType Directory -Force -Path $temporaryDirectory | Out-Null - foreach ($name in 'TMPDIR', 'TEMP', 'TMP') { - Set-Item -Path "Env:$name" -Value $temporaryDirectory - } - - Write-Output "Configured temporary directory: $temporaryDirectory" - python -c "import tempfile; print(f'Python temporary directory: {tempfile.gettempdir()}')" - - python -m pip install ` - "setuptools>=68" ` - wheel ` - "cmake>=3.15" ` - "pybind11>=3.0.1,<3.1" - - $wheelDirectory = Join-Path $temporaryDirectory 'wheels' - New-Item -ItemType Directory -Force -Path $wheelDirectory | Out-Null - - $bindings = @( - 'Xray-core' - 'hysteria2' - 'tun2socks' - ) - - foreach ($binding in $bindings) { - python -m pip wheel ` - --no-cache-dir ` - --no-build-isolation ` - --no-deps ` - --no-binary=:all: ` - --wheel-dir $wheelDirectory ` - $binding - } - - $wheels = @( - Get-ChildItem -LiteralPath $wheelDirectory -Filter '*.whl' -File | - Sort-Object Name - ) - Write-Output 'Locally built wheels:' - - foreach ($wheel in $wheels) { - Write-Output " $($wheel.Name)" - } - - $unexpectedWheels = @($wheels | Where-Object Name -NotLike '*win_amd64.whl') - - if ($wheels.Count -ne 3 -or $unexpectedWheels.Count -ne 0) { - throw 'Expected exactly three locally built win_amd64 wheels' - } - - $wheelPaths = @($wheels.FullName) - python -m pip install ` - --no-index ` - --force-reinstall ` - --no-deps ` - $wheelPaths - if: matrix.id == 'windows7-amd64' || matrix.id == 'windows10-amd64' - - name: Install pre-built Hysteria1 wheel - run: | - python -m pip install \ - --only-binary=hysteria \ - --no-cache-dir \ - --no-deps \ - hysteria - if: matrix.id == 'windows7-amd64' || matrix.id == 'windows10-amd64' - - name: Install supported binding wheels - run: | - if [[ "$RUNNER_OS" == "Windows" ]]; then - binary_policy="--only-binary=Xray-core,hysteria,hysteria2,tun2socks" - else - binary_policy="" - fi - - python -m pip install \ - ${binary_policy} \ - --no-cache-dir \ - --no-deps \ - Xray-core \ - hysteria \ - hysteria2 \ - tun2socks - if: matrix.id != 'windows7-amd64' && matrix.id != 'windows10-amd64' - - name: Verify Windows native and UI imports - run: | - python - <<'PY' - import importlib - - for name in ('xray', 'hysteria', 'hysteria2', 'tun2socks'): - module = importlib.import_module(name) - print(f'{name}: {module.__file__}') - - # Native extension imports fail here if a wheel has the wrong machine type. - print('All native bindings imported successfully') - - # Some Windows PySide6 distributions intentionally omit Qt WebEngine. - # The application must still import and use its non-WebEngine map fallback. - from Furious.Widget.EndpointInfoWidget import EndpointInfoWidget - - print(f'Endpoint UI imported successfully: {EndpointInfoWidget.__name__}') - PY - if: runner.os == 'Windows' - - name: Verify Linux application imports without WebEngine - if: runner.os == 'Linux' - env: - QT_QPA_PLATFORM: offscreen - run: | - python - <<'PY' - import importlib - - import Furious - import Furious.Application - import Furious.Window - from PySide6 import QtCore - from PySide6.QtWidgets import QApplication - - endpointModule = importlib.import_module( - 'Furious.Widget.EndpointInfoWidget' - ) - - if endpointModule.QWebEngineView is not None: - raise SystemExit('Qt WebEngine must be unavailable in Linux releases') - - application = QApplication.instance() or QApplication([]) - mapWidget = endpointModule._createEndpointMapWidget(None) - - if not isinstance( - mapWidget, - endpointModule._UnavailableEndpointMapWidget, - ): - raise SystemExit('the endpoint map did not use its no-WebEngine fallback') - - mapWidget.close() - mapWidget.deleteLater() - application.sendPostedEvents( - None, - QtCore.QEvent.Type.DeferredDelete, - ) - application.processEvents() - - print(f'Furious imported successfully: {Furious.__name__}') - print('Application and window modules imported successfully') - print('Endpoint UI created its no-WebEngine fallback successfully') - PY - - name: Verify Qt WebEngine imports - if: runner.os == 'macOS' - run: | - python - <<'PY' - from PySide6 import QtWebChannel, QtWebEngineCore, QtWebEngineWidgets - - print(f'QtWebChannel imported successfully: {QtWebChannel.__name__}') - print(f'QtWebEngineCore imported successfully: {QtWebEngineCore.__name__}') - print(f'QtWebEngineWidgets imported successfully: {QtWebEngineWidgets.__name__}') - PY - - name: Download latest asset files - run: | - python Deploy.py --download - - name: Run deploy script - env: - APPIMAGE_EXTRACT_AND_RUN: "1" - run: | - if [[ -n "${{ matrix.windows-compatibility }}" ]]; then - export WIN_VER_COMPATIBLE="${{ matrix.windows-compatibility }}" - fi - python Deploy.py - - name: Verify Linux distribution excludes WebEngine - if: runner.os == 'Linux' - run: | - python - <<'PY' - from pathlib import Path - - root = Path('Furious-Deploy/Furious.dist') - - if not root.is_dir(): - raise SystemExit(f'Nuitka distribution is missing: {root}') - - forbidden = ('qtwebchannel', 'qtwebengine') - unexpected = [] - - for path in root.rglob('*'): - relative = path.relative_to(root).as_posix().casefold() - - if any(name in relative for name in forbidden): - unexpected.append(relative) - - if unexpected: - raise SystemExit( - 'WebEngine/Addons artifacts found in the Linux distribution:\n' - + '\n'.join(f' {path}' for path in unexpected) - ) - - print(f'No Qt WebEngine or Qt WebChannel artifacts found below {root}') - PY - - name: Verify Flatpak native dependencies - if: runner.os == 'Linux' - run: | - bundle="$(find . -maxdepth 1 -name 'Furious-*.flatpak' -print -quit)" - - if [[ -z "$bundle" ]]; then - echo 'Flatpak bundle was not generated' - exit 1 - fi - - flatpak install --user --noninteractive --reinstall -y "$bundle" - - cleanup() { - flatpak uninstall \ - --user \ - --noninteractive \ - --delete-data \ - -y \ - com.Furious.Furious || true - } - - trap cleanup EXIT - - flatpak run --user --command=bash com.Furious.Furious -c ' - set -uo pipefail - - application_root=/app/lib/Furious - dependency_report=/tmp/furious-flatpak-missing-dependencies - failures=0 - unsupported_qt_plugins=( - "$application_root/PySide6/qt-plugins/imageformats/libqpdf.so" - "$application_root/PySide6/qt-plugins/imageformats/libqtiff.so" - "$application_root/PySide6/qt-plugins/imageformats/libqwebp.so" - "$application_root/PySide6/qt-plugins/platforms/libqeglfs.so" - "$application_root/PySide6/qt-plugins/egldeviceintegrations" - ) - - : > "$dependency_report" - - if [[ ! -x "$application_root/Furious.bin" ]]; then - printf "Packaged application is missing or not executable: %s\n" \ - "$application_root/Furious.bin" >> "$dependency_report" - failures=1 - fi - - double_conversion="$( - find /app/lib -type f -name "libdouble-conversion.so.*" \ - -print -quit 2>> "$dependency_report" - )" - - if [[ -z "$double_conversion" ]]; then - printf "Bundled libdouble-conversion.so.3 was not found below /app/lib\n" \ - >> "$dependency_report" - failures=1 - else - printf "Bundled double-conversion library: %s\n" "$double_conversion" - fi - - for plugin in "${unsupported_qt_plugins[@]}"; do - if [[ -e "$plugin" ]]; then - printf "Unsupported Qt plugin was packaged: %s\n" "$plugin" \ - >> "$dependency_report" - failures=1 - fi - done - - if ! command -v ldd >/dev/null; then - printf "The Flatpak runtime does not provide ldd\n" \ - >> "$dependency_report" - failures=1 - else - while IFS= read -r binary; do - if ! dependencies="$(ldd "$binary" 2>&1)"; then - printf "%s\n%s\n\n" \ - "Failed to inspect $binary:" \ - "$dependencies" >> "$dependency_report" - failures=1 - elif printf "%s\n" "$dependencies" | grep -Fq "not found"; then - printf "%s\n%s\n\n" \ - "Missing dependencies for $binary:" \ - "$dependencies" >> "$dependency_report" - failures=1 - fi - done < <( - find "$application_root" \ - -type f \ - \( -name "*.bin" -o -name "*.so" -o -name "*.so.*" \) \ - -print - ) - fi - - if ((failures)); then - cat "$dependency_report" - exit 1 - fi - - echo "Flatpak native dependency check passed" - ' - - name: Verify macOS application bundle - if: runner.os == 'macOS' - shell: bash - run: | - app='app/Furious-GUI.app' - source_app='Furious-Deploy/Furious-GUI.app' - macos_root="${app}/Contents/MacOS" - qt_root="${macos_root}/PySide6/Qt" - webengine_framework="${qt_root}/lib/QtWebEngineCore.framework" - relocated_qt_root="${app}/Contents/Frameworks/PySide6/Qt" - relocated_webengine_framework="${relocated_qt_root}/lib/QtWebEngineCore.framework" - webengine_helper="${relocated_webengine_framework}/Helpers/QtWebEngineProcess.app/Contents/MacOS/QtWebEngineProcess" - failures=0 - - check() { - local description="$1" - local status - shift - - echo "::group::${description}" - - if "$@"; then - status=0 - echo "PASS: ${description} (exit code ${status})" - else - status=$? - echo "::error title=macOS bundle verification::FAIL: ${description} (exit code ${status})" - failures=$((failures + 1)) - fi - - echo '::endgroup::' - } - - echo "Application bundle: ${app}" - echo "Qt runtime root: ${qt_root}" - echo "Relocated Qt root: ${relocated_qt_root}" - echo "WebEngine framework link: ${webengine_framework}" - echo "Relocated WebEngine framework: ${relocated_webengine_framework}" - echo "WebEngine helper: ${webengine_helper}" - - check 'Application bundle exists' test -d "${app}" - check 'Nuitka source application bundle exists' test -d "${source_app}" - framework_paths=( - "${webengine_framework}" - "${relocated_webengine_framework}" - "${relocated_webengine_framework}/Versions/Current" - "${relocated_webengine_framework}/QtWebEngineCore" - ) - check 'Relocated framework paths can be inspected' ls -ld "${framework_paths[@]}" - check 'MacOS WebEngine framework entry is a symbolic link' test -L "${webengine_framework}" - check 'Framework Current version is a symbolic link' test -L "${relocated_webengine_framework}/Versions/Current" - check 'Framework top-level binary is a symbolic link' test -L "${relocated_webengine_framework}/QtWebEngineCore" - check 'WebEngine process helper is executable' test -x "${webengine_helper}" - resource_paths=( - "${macos_root}/icudtl.dat" - "${macos_root}/qtwebengine_resources.pak" - ) - check 'WebEngine resource metadata can be inspected' ls -l@ "${resource_paths[@]}" - check 'Chromium ICU data exists' test -f "${resource_paths[0]}" - check 'Chromium resource pack exists' test -f "${resource_paths[1]}" - check 'Nuitka source code signature is valid' codesign --verify --deep --strict --verbose=2 "${source_app}" - check 'Staged application code signature is valid' codesign --verify --deep --strict --verbose=2 "${app}" - size_paths=( - "${app}" - "${app}/Contents/Frameworks" - "${app}/Contents/MacOS" - ) - check 'Application bundle sizes can be measured' du -sh "${size_paths[@]}" - - if ((failures > 0)); then - echo "::error title=macOS bundle verification::${failures} check(s) failed" - exit 1 - fi - - name: Verify packaged Windows binaries - env: - EXPECTED_WINDOWS_ARCHITECTURE: ${{ matrix.windows-architecture }} - WINDOWS_COMPATIBILITY: ${{ matrix.windows-compatibility }} - run: | - python - <<'PY' - import os - import struct - from pathlib import Path - - machineCodes = { - 'amd64': 0x8664, - 'arm64': 0xAA64, - } - expected = os.environ['EXPECTED_WINDOWS_ARCHITECTURE'] - expectedMachine = machineCodes[expected] - root = Path('Furious-Deploy/Furious.dist') - compatibilityDll = root / 'api-ms-win-core-path-l1-1-0.dll' - - if ( - os.environ['WINDOWS_COMPATIBILITY'] == 'windows7' - and not compatibilityDll.is_file() - ): - raise SystemExit(f'Windows 7 compatibility DLL is missing: {compatibilityDll}') - - binaries = sorted( - { - *root.rglob('*.exe'), - *root.rglob('*.pyd'), - *([compatibilityDll] if compatibilityDll.is_file() else []), - } - ) - - if not binaries: - raise SystemExit(f'no packaged Windows binaries found below {root}') - - for binary in binaries: - with binary.open('rb') as stream: - if stream.read(2) != b'MZ': - raise SystemExit(f'{binary} is not a PE binary') - - stream.seek(0x3C) - peOffset = struct.unpack('