diff --git a/.github/workflows/deploy-pypi.yml b/.github/workflows/deploy-pypi.yml index 042e836..47fc1eb 100644 --- a/.github/workflows/deploy-pypi.yml +++ b/.github/workflows/deploy-pypi.yml @@ -85,25 +85,34 @@ jobs: matrix: include: - id: linux-amd64 - os: ubuntu-22.04 + os: ubuntu-24.04 python-version: "3.13" python-architecture: x64 pyside6-version: "6.8.3" + linux-appimage-builder: linuxdeploy windows-compatibility: "" windows-architecture: "" - id: linux-arm64 - os: ubuntu-22.04-arm + os: ubuntu-24.04-arm python-version: "3.11" python-architecture: arm64 - pyside6-version: "6.5.3" + pyside6-version: "6.8.3" + linux-appimage-builder: appimagetool windows-compatibility: "" windows-architecture: "" - - id: windows-win7-amd64 + - id: windows7-amd64 os: windows-2025 python-version: "3.13" python-architecture: x64 pyside6-version: "6.8.3" - windows-compatibility: windows + 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: windows-arm64 os: windows-11-vs2026-arm @@ -127,12 +136,7 @@ jobs: windows-compatibility: "" windows-architecture: "" env: - XRAY_CORE_VERSION: "1.8.26.9" - HYSTERIA_VERSION: "1.3.5.4" - HYSTERIA2_VERSION: "2.12.1.1" - TUN2SOCKS_VERSION: "2.7.0.1" GO_WIN7_VERSION: "1.26.7" - NUITKA_VERSION: "4.1.3" WIX_VERSION: "6.0.2" WIX_INSTALLER_SHA256: "A8A5CC7443353CEF3AB900C60CD7A3A5EE601746319D104AC7B12AD0CED2345C" steps: @@ -151,7 +155,26 @@ jobs: - name: Install Linux dependencies run: | sudo apt update - sudo apt install -y libxcb-cursor0 qt6-base-dev patchelf ccache zlib1g-dev + + if [ "$RUNNER_ARCH" == "ARM64" ]; then + if apt-cache show libminizip1t64 >/dev/null 2>&1; then + minizip_package='libminizip1t64' + else + minizip_package='libminizip1' + fi + + sudo apt install -y \ + libevent-2.1-7 \ + libwebp7 \ + "$minizip_package" + 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 @@ -159,20 +182,33 @@ jobs: flatpak install -y flathub org.kde.Platform//6.8 org.kde.Sdk//6.8 if [ "$RUNNER_ARCH" == "X64" ]; then - appimage_arch="x86_64" + echo "QMAKE=$(command -v qmake6)" >> "$GITHUB_ENV" + + linuxdeploy_arch="x86_64" + linuxdeploy_tools=".linuxdeploy-bin" + mkdir "$linuxdeploy_tools" + echo "${PWD}/${linuxdeploy_tools}" >> "$GITHUB_PATH" + + wget -P "$linuxdeploy_tools" \ + "https://github.com/linuxdeploy/linuxdeploy/releases/latest/download/linuxdeploy-${linuxdeploy_arch}.AppImage" + wget -P "$linuxdeploy_tools" \ + "https://github.com/linuxdeploy/linuxdeploy-plugin-qt/releases/latest/download/linuxdeploy-plugin-qt-${linuxdeploy_arch}.AppImage" + wget -P "$linuxdeploy_tools" \ + "https://github.com/AppImage/appimagetool/releases/latest/download/appimagetool-${linuxdeploy_arch}.AppImage" + chmod +x "$linuxdeploy_tools"/* elif [ "$RUNNER_ARCH" == "ARM64" ]; then appimage_arch="aarch64" + 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"/* else echo "Unknown runner architecture: $RUNNER_ARCH" exit 1 fi - echo "appimagetool architecture is $appimage_arch" - - 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 @@ -221,14 +257,14 @@ jobs: with: python-version: ${{ matrix.python-version }} check-latest: true - if: matrix.id == 'windows-win7-amd64' + 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 != 'windows-win7-amd64' + if: matrix.id != 'windows7-amd64' - name: Verify runner and Python architecture env: EXPECTED_ARCHITECTURE: ${{ matrix.python-architecture }} @@ -268,7 +304,7 @@ jobs: uses: LorenEteval/setup-pyside6-win7@v1 with: pyside6-version: ${{ matrix.pyside6-version }} - if: matrix.id == 'windows-win7-amd64' + if: matrix.id == 'windows7-amd64' - name: Install Python build and runtime dependencies run: | python -c "import sys; print(sys.version)" @@ -304,7 +340,7 @@ jobs: ) PY - if [[ "${{ matrix.id }}" != "windows-win7-amd64" ]]; then + if [[ "${{ matrix.id }}" != "windows7-amd64" ]]; then if [[ "$RUNNER_OS" == "Windows" ]]; then pyside_binary_policy="--only-binary=PySide6-Essentials,PySide6-Addons" else @@ -322,8 +358,7 @@ jobs: fi python -m pip install -r .binary-requirements.txt - - python -m pip install "Nuitka==${NUITKA_VERSION}" imageio requests + 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: Install patched Go for Windows7 @@ -339,87 +374,97 @@ jobs: echo "GOROOT=${go_root}" >> $GITHUB_ENV echo "CGO_ENABLED=1" >> $GITHUB_ENV echo "${go_root}\\bin" >> $GITHUB_PATH - if: matrix.id == 'windows-win7-amd64' - - name: Build Windows7 bindings from source with patched Go + 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: | - command -v go + $ErrorActionPreference = 'Stop' + $PSNativeCommandUseErrorActionPreference = $true + + Write-Output "Go executable: $((Get-Command go -ErrorAction Stop).Source)" go version go env GOROOT GOOS GOARCH CGO_ENABLED - command -v gcc + Write-Output "GCC executable: $((Get-Command gcc -ErrorAction Stop).Source)" gcc -dumpmachine - short_temp="$RUNNER_TEMP/w7" - mkdir -p "$short_temp" - short_temp="$(cygpath -w "$short_temp")" - export TMPDIR="$short_temp" - export TEMP="$short_temp" - export TMP="$short_temp" + $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 + } - python - <<'PY' - import os - import tempfile + Write-Output "Configured temporary directory: $temporaryDirectory" + python -c "import tempfile; print(f'Python temporary directory: {tempfile.gettempdir()}')" - print(f'Configured temporary directory: {os.environ["TEMP"]}') - print(f'Python temporary directory: {tempfile.gettempdir()}') - PY - - python -m pip install \ - "setuptools>=68" \ - wheel \ - "cmake>=3.15" \ + python -m pip install ` + "setuptools>=68" ` + wheel ` + "cmake>=3.15" ` "pybind11>=3.0.1,<3.1" - rm -rf win7-wheels - mkdir win7-wheels - bindings=( - "Xray-core==${XRAY_CORE_VERSION}" - "hysteria2==${HYSTERIA2_VERSION}" - "tun2socks==${TUN2SOCKS_VERSION}" + $wheelDirectory = Join-Path $temporaryDirectory 'wheels' + New-Item -ItemType Directory -Force -Path $wheelDirectory | Out-Null + + $bindings = @( + 'Xray-core' + 'hysteria2' + 'tun2socks' ) - for binding in "${bindings[@]}" - do - python -m pip wheel \ - --no-cache-dir \ - --no-build-isolation \ - --no-deps \ - --no-binary=:all: \ - --wheel-dir win7-wheels \ - "${binding}" - done + foreach ($binding in $bindings) { + python -m pip wheel ` + --no-cache-dir ` + --no-build-isolation ` + --no-deps ` + --no-binary=:all: ` + --wheel-dir $wheelDirectory ` + $binding + } - python - <<'PY' - from pathlib import Path + $wheels = @( + Get-ChildItem -LiteralPath $wheelDirectory -Filter '*.whl' -File | + Sort-Object Name + ) + Write-Output 'Locally built wheels:' - wheels = sorted(Path('win7-wheels').glob('*.whl')) - print('Locally built wheels:') + foreach ($wheel in $wheels) { + Write-Output " $($wheel.Name)" + } - for wheel in wheels: - print(f' {wheel.name}') + $unexpectedWheels = @($wheels | Where-Object Name -NotLike '*win_amd64.whl') - if len(wheels) != 3 or any('win_amd64.whl' not in wheel.name for wheel in wheels): - raise SystemExit('expected exactly three locally built win_amd64 wheels') - PY + if ($wheels.Count -ne 3 -or $unexpectedWheels.Count -ne 0) { + throw 'Expected exactly three locally built win_amd64 wheels' + } - python -m pip install \ - --no-index \ - --force-reinstall \ - --no-deps \ - win7-wheels/*.whl - if: matrix.id == 'windows-win7-amd64' - - name: Install Windows7-compatible Hysteria1 wheel + $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==${HYSTERIA_VERSION}" - if: matrix.id == 'windows-win7-amd64' + hysteria + if: matrix.id == 'windows7-amd64' || matrix.id == 'windows10-amd64' - name: Install supported binding wheels run: | if [[ "$RUNNER_OS" == "Windows" ]]; then @@ -432,12 +477,12 @@ jobs: ${binary_policy} \ --no-cache-dir \ --no-deps \ - "Xray-core==${XRAY_CORE_VERSION}" \ - "hysteria==${HYSTERIA_VERSION}" \ - "hysteria2==${HYSTERIA2_VERSION}" \ - "tun2socks==${TUN2SOCKS_VERSION}" - if: matrix.id != 'windows-win7-amd64' - - name: Verify Windows binding imports + 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 @@ -448,22 +493,113 @@ jobs: # 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 Qt WebEngine imports + if: runner.os != 'Windows' + 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" + LINUX_APPIMAGE_BUILDER: ${{ matrix.linux-appimage-builder }} run: | if [[ -n "${{ matrix.windows-compatibility }}" ]]; then export WIN_VER_COMPATIBLE="${{ matrix.windows-compatibility }}" fi python Deploy.py + - 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 @@ -477,10 +613,19 @@ jobs: 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 []), } ) @@ -568,6 +713,6 @@ jobs: with: identifier: LorenEteval.Furious release-tag: ${{ github.ref_name }} - installers-regex: '^Furious-.*-(?:windows-amd64|windows11-arm64)\.zip$' + installers-regex: '^Furious-.*-(?:windows10-amd64|windows11-arm64)\.zip$' max-versions-to-keep: 5 token: ${{ secrets.WINGET_TOKEN }} diff --git a/Deploy.py b/Deploy.py index 1c67deb..9b2e19d 100644 --- a/Deploy.py +++ b/Deploy.py @@ -52,6 +52,7 @@ USER_HOME = pathlib.Path.home() DEPLOY_DIR_NAME = f'{APPLICATION_NAME}-Deploy' HYSTERIA_DATA_DIR = DATA_DIR / 'hysteria' +WINDOWS_7_COMPATIBILITY_DLL_NAME = 'api-ms-win-core-path-l1-1-0.dll' NUITKA_BINARY_VERSION_OPTION = ( f'--company-name={APPLICATION_NAME} ' @@ -189,11 +190,34 @@ elif PLATFORM == 'Linux': ) LINUX_APPIMAGE_FILENAME = f'{ARTIFACT_NAME}.AppImage' - LINUX_CREATE_APPIMAGE_CMD = [ - f'appimagetool-{PLATFORM_MACHINE}.AppImage', - LINUX_APP_DIR, - ROOT_DIR / LINUX_APPIMAGE_FILENAME, - ] + LINUX_APPIMAGE_BUILDER = os.environ.get( + 'LINUX_APPIMAGE_BUILDER', 'appimagetool' + ).casefold() + + if LINUX_APPIMAGE_BUILDER == 'linuxdeploy': + LINUX_CREATE_APPIMAGE_CMD = [ + f'linuxdeploy-{PLATFORM_MACHINE}.AppImage', + '--appdir', + LINUX_APP_DIR, + '-d', + LINUX_APP_DIR / LINUX_DESKTOP_FILE, + '-i', + LINUX_APP_DIR / LINUX_ICON_FILE, + '--plugin', + 'qt', + '--output', + 'appimage', + ] + elif LINUX_APPIMAGE_BUILDER == 'appimagetool': + LINUX_CREATE_APPIMAGE_CMD = [ + f'appimagetool-{PLATFORM_MACHINE}.AppImage', + LINUX_APP_DIR, + ROOT_DIR / LINUX_APPIMAGE_FILENAME, + ] + else: + raise RuntimeError( + f'unsupported Linux AppImage builder: {LINUX_APPIMAGE_BUILDER!r}' + ) LINUX_DEBIAN_DIR = ROOT_DIR / 'debian' LINUX_DEB_FILENAME = f'{ARTIFACT_NAME}.deb' @@ -470,6 +494,22 @@ def main(): printStandardStream(result.stdout, result.stderr) if PLATFORM == 'Windows': + distributionPath = ROOT_DIR / DEPLOY_DIR_NAME / f'{APPLICATION_NAME}.dist' + + if winVerCompatible.casefold() == 'windows7': + compatibilityDll = pathlib.Path(sys.executable).with_name( + WINDOWS_7_COMPATIBILITY_DLL_NAME + ) + + if not compatibilityDll.is_file(): + raise FileNotFoundError( + f'Windows 7 compatibility DLL not found: {compatibilityDll}' + ) + + shutil.copy2(compatibilityDll, distributionPath) + + logger.info(f'included Windows 7 compatibility DLL: {compatibilityDll}') + try: shutil.rmtree(ROOT_DIR / DEPLOY_DIR_NAME / WIN_UNZIPPED) except FileNotFoundError: @@ -482,14 +522,14 @@ def main(): # Copy LICENSE to distribution folder shutil.copy( ROOT_DIR / 'LICENSE', - ROOT_DIR / DEPLOY_DIR_NAME / f'{APPLICATION_NAME}.dist', + distributionPath, ) shutil.copy( ROOT_DIR / 'LICENSE.rtf', ROOT_DIR / DEPLOY_DIR_NAME, ) shutil.copytree( - ROOT_DIR / DEPLOY_DIR_NAME / f'{APPLICATION_NAME}.dist', + distributionPath, ROOT_DIR / DEPLOY_DIR_NAME / WIN_UNZIPPED, ) shutil.make_archive( @@ -645,10 +685,32 @@ def main(): raise - shutil.copytree( - ROOT_DIR / DEPLOY_DIR_NAME / 'Furious-GUI.app', - MAC_APP_DIR / 'Furious-GUI.app', - ) + sourceApp = ROOT_DIR / DEPLOY_DIR_NAME / 'Furious-GUI.app' + stagedApp = MAC_APP_DIR / 'Furious-GUI.app' + + try: + result = runExternalCommand( + ( + 'ditto', + '--rsrc', + '--extattr', + str(sourceApp), + str(stagedApp), + ), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=True, + ) + except subprocess.CalledProcessError as err: + logger.error(f'stage macOS app failed with returncode {err.returncode}') + + printStandardStream(err.stdout, err.stderr) + + sys.exit(EXIT_FAILURE) + else: + logger.info('stage macOS app success') + + printStandardStream(result.stdout, result.stderr) logger.info('generating dmg') @@ -734,12 +796,16 @@ def main(): logger.info('generating AppImage') - # Nuitka's standalone directory already owns its complete Qt runtime. - # appimagetool packages it as-is instead of harvesting Qt a second time. - os.environ['ARCH'] = PLATFORM_MACHINE + if LINUX_APPIMAGE_BUILDER == 'linuxdeploy': + os.environ['LDAI_OUTPUT'] = LINUX_APPIMAGE_FILENAME + os.environ['LINUXDEPLOY_OUTPUT_VERSION'] = APPLICATION_VERSION + else: + # Nuitka's standalone directory already owns its complete Qt runtime. + # appimagetool packages it as-is instead of harvesting Qt a second time. + os.environ['ARCH'] = PLATFORM_MACHINE try: - runExternalCommand( + result = runExternalCommand( LINUX_CREATE_APPIMAGE_CMD, stdout=subprocess.PIPE, stderr=subprocess.PIPE, diff --git a/Furious/Widget/EndpointInfoWidget.py b/Furious/Widget/EndpointInfoWidget.py index 9a07783..dc2d6a7 100644 --- a/Furious/Widget/EndpointInfoWidget.py +++ b/Furious/Widget/EndpointInfoWidget.py @@ -26,13 +26,6 @@ from Furious.Service import EndpointInfo, EndpointInfoState from Furious.Widget.WaitingSpinner import WaitingSpinner from PySide6 import QtCore, QtGui -from PySide6.QtWebChannel import QWebChannel -from PySide6.QtWebEngineCore import ( - QWebEnginePage, - QWebEngineProfile, - QWebEngineSettings, -) -from PySide6.QtWebEngineWidgets import QWebEngineView from PySide6.QtWidgets import * import json @@ -42,8 +35,33 @@ __all__ = ['EndpointInfoWidget'] logger = logging.getLogger(__name__) +try: + from PySide6.QtWebChannel import QWebChannel + from PySide6.QtWebEngineCore import ( + QWebEnginePage, + QWebEngineProfile, + QWebEngineSettings, + ) + from PySide6.QtWebEngineWidgets import QWebEngineView +except ImportError: + ( + QWebChannel, + QWebEnginePage, + QWebEngineProfile, + QWebEngineSettings, + QWebEngineView, + ) = ( + None, + None, + None, + None, + None, + ) -class _EndpointWebView(QWebEngineView): +_EndpointWebViewBase = QWebEngineView if QWebEngineView is not None else QWidget + + +class _EndpointWebView(_EndpointWebViewBase): """Keep map wheel gestures from bubbling into the Metrics scroll area.""" def wheelEvent(self, event): @@ -204,10 +222,7 @@ class _EndpointMapWidget(QWidget): layout.addWidget(self.webView, 0, 0) layout.addWidget(self.statusOverlay, 0, 0) - if ( - PLATFORM == 'Windows' - and QApplication.platformName().casefold() != 'offscreen' - ): + if QApplication.platformName().casefold() != 'offscreen': # Qt WebEngine embeds a native surface. When that surface first # appears inside the Metrics QScrollArea, Qt otherwise promotes # the visible parent chain to native windows, remapping and @@ -234,6 +249,7 @@ class _EndpointMapWidget(QWidget): """Replace the displayed coordinate and status message.""" refreshCompleted = self._retainMapDuringRefresh and not loading previousLocation = self._location + previousCoordinate = ( ( previousLocation.latitude, @@ -367,7 +383,6 @@ class _EndpointMapWidget(QWidget): self._mapError = message or self._unavailableText self._updateOverlay() - @QtCore.Slot(QWebEnginePage.RenderProcessTerminationStatus, int) def _renderProcessTerminated(self, status, exitCode): """Convert an unexpected Chromium exit into the normal map fallback.""" if self._rendererFailureHandled: @@ -402,9 +417,12 @@ class _EndpointMapWidget(QWidget): return hasLocation = self._hasLocation() - location = self._location if hasLocation else None - accentColor = self.palette().color(QtGui.QPalette.ColorRole.Highlight).name() - font = QtGui.QFontInfo(self.font()) + + location, accentColor, font = ( + self._location if hasLocation else None, + self.palette().color(QtGui.QPalette.ColorRole.Highlight).name(), + QtGui.QFontInfo(self.font()), + ) state = { 'markerVisible': hasLocation, @@ -478,6 +496,103 @@ class _EndpointMapWidget(QWidget): self.loadingSpinner.stop() +class _UnavailableEndpointMapWidget(QWidget): + """Present endpoint-map state when Qt WebEngine is unavailable.""" + + def __init__(self, parent=None): + """Create one persistent, lightweight fallback owned by the map card.""" + super().__init__(parent) + + self._loading = False + self._active = False + self._message = '' + self._loadingText = '' + self._unavailableText = '' + + self.setObjectName('EndpointMapWidget') + self.setMinimumHeight(260) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + + self.statusLabel = AppQLabel(translatable=False, parent=self) + self.statusLabel.setObjectName('EndpointMapPlaceholder') + self.statusLabel.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + self.statusLabel.setWordWrap(False) + + self.loadingSpinner = WaitingSpinner( + self, + center_on_parent=False, + line_length=5, + line_width=2, + radius=4, + lines=12, + ) + self.loadingSpinner.setFixedSize(22, 22) + + layout = QHBoxLayout(self) + layout.setContentsMargins(12, 12, 12, 12) + layout.setSpacing(8) + layout.addStretch(1) + layout.addWidget(self.loadingSpinner) + layout.addWidget(self.statusLabel) + layout.addStretch(1) + + def setLocation(self, _location, message='', *, loading=False): + """Present the shared endpoint status without attempting map rendering.""" + self._message = str(message or '') + self._loading = bool(loading) + self._updatePresentation() + + def beginRefresh(self): + """Show refresh progress until the endpoint service publishes a result.""" + self._loading = True + self._message = self._loadingText + self._updatePresentation() + + def setActive(self, active): + """Animate the fallback only while its containing page is visible.""" + self._active = bool(active) + self._updatePresentation() + + def updateTheme(self, _theme=None): + """Refresh palette-derived spinner color after an application theme change.""" + self._updatePresentation() + + def setUnavailableText(self, text): + """Set the translated explanation shown in place of the map.""" + self._unavailableText = str(text or '') + self._updatePresentation() + + def setLoadingText(self, text): + """Set the translated refresh message.""" + self._loadingText = str(text or '') + self._updatePresentation() + + def _updatePresentation(self): + """Synchronize the persistent label and visibility-gated spinner.""" + if self._loading: + message = self._loadingText or self._message + else: + message = self._message or self._unavailableText + + self.statusLabel.setText(message) + self.loadingSpinner.color = self.palette().color(QtGui.QPalette.ColorRole.Text) + + if self._active and self._loading: + self.loadingSpinner.start() + else: + self.loadingSpinner.stop() + + +def _createEndpointMapWidget(parent): + """Create the rich renderer when available, otherwise its safe fallback.""" + if QWebEngineView is not None: + return _EndpointMapWidget(parent) + else: + logger.warning('Qt WebEngine is unavailable; using the endpoint map fallback') + + return _UnavailableEndpointMapWidget(parent) + + class _ValueRow(QtCore.QObject): """Align one endpoint label, selectable value, and optional copy action.""" @@ -683,7 +798,7 @@ class EndpointInfoWidget(Mixins.ThemeAware, Mixins.QTranslatable, QFrame): self.mapTitleLabel = AppQLabel(_('Approximate Location'), parent=self.mapCard) self.mapTitleLabel.setObjectName('MetricCardTitle') - self.mapWidget = _EndpointMapWidget(self.mapCard) + self.mapWidget = _createEndpointMapWidget(self.mapCard) mapLayout = QVBoxLayout(self.mapCard) mapLayout.setContentsMargins(14, 12, 14, 12) diff --git a/Furious/Window/MainWindow.py b/Furious/Window/MainWindow.py index ba5da0f..d781448 100644 --- a/Furious/Window/MainWindow.py +++ b/Furious/Window/MainWindow.py @@ -65,6 +65,8 @@ class MainWindow(AppQMainWindow): QtCore.QSize(1800, 960) if PLATFORM != 'Darwin' else DEFAULT_WINDOW_SIZE_DARWIN ) + QT_FALLBACK_WINDOW_SIZE = QtCore.QSize(640, 480) + def __init__(self, *args, **kwargs): """Create and register the built-in application pages.""" super().__init__(*args, **kwargs) @@ -251,7 +253,15 @@ class MainWindow(AppQMainWindow): restored = False - if restored: + if ( + restored + and PLATFORM == 'Darwin' + and self.size() == self.QT_FALLBACK_WINDOW_SIZE + ): + self._applyDefaultWindowSize( + 'saved macOS main-window geometry restored to the Qt fallback size' + ) + elif restored: logger.info( f'restored main-window geometry: {self.geometry().getRect()}' ) diff --git a/tests/test_main_window_geometry.py b/tests/test_main_window_geometry.py index d08a42f..4623ffc 100644 --- a/tests/test_main_window_geometry.py +++ b/tests/test_main_window_geometry.py @@ -82,6 +82,7 @@ class _GeometryWindow(AppQMainWindow): DEFAULT_WINDOW_SIZE = QtCore.QSize(700, 500) + QT_FALLBACK_WINDOW_SIZE = MainWindow.QT_FALLBACK_WINDOW_SIZE _applyDefaultWindowSize = MainWindow._applyDefaultWindowSize _restoreLegacyWindowSize = MainWindow._restoreLegacyWindowSize _restoreMainWindowState = MainWindow._restoreMainWindowState @@ -359,8 +360,8 @@ class MainWindowGeometryTest(unittest.TestCase): window.close() window.deleteLater() - def testValidIntentional640By480GeometryIsPreserved(self): - """Never reinterpret a successful small user geometry as Qt fallback.""" + def testDarwinRestoredQtFallbackSizeUsesCanonicalDefault(self): + """Replace macOS Qt fallback dimensions with the product default.""" with isolatedSettings(): expected = QtCore.QRect(40, 50, 640, 480) AppSettings.set( @@ -370,7 +371,31 @@ class MainWindowGeometryTest(unittest.TestCase): window = _GeometryWindow() - with patch('Furious.Qt.QtWidgets.moveToCenter') as moveToCenter: + with patch('Furious.Window.MainWindow.PLATFORM', 'Darwin'), patch( + 'Furious.Qt.QtWidgets.moveToCenter' + ) as moveToCenter: + window.show() + + self.assertEqual(window.size(), window.DEFAULT_WINDOW_SIZE) + moveToCenter.assert_not_called() + + window.close() + window.deleteLater() + + def testNonDarwinRestored640By480GeometryIsPreserved(self): + """Preserve a successful 640 by 480 restoration outside macOS.""" + with isolatedSettings(): + expected = QtCore.QRect(40, 50, 640, 480) + AppSettings.set( + 'AppMainWindowGeometry', + self._saveGeometry(expected), + ) + + window = _GeometryWindow() + + with patch('Furious.Window.MainWindow.PLATFORM', 'Windows'), patch( + 'Furious.Qt.QtWidgets.moveToCenter' + ) as moveToCenter: window.show() self.assertEqual(window.size(), expected.size())