Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2026-08-24 00:39:42 +08:00
parent 9fdbcf1bde
commit db7d2dd055
5 changed files with 484 additions and 123 deletions
+232 -87
View File
@@ -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 }}
+81 -15
View File
@@ -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,
+132 -17
View File
@@ -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)
+11 -1
View File
@@ -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()}'
)
+28 -3
View File
@@ -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())