Add proxy endpoint information

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2026-08-19 11:02:53 +08:00
parent fc462bb6ea
commit 936a5b3664
19 changed files with 3145 additions and 10 deletions
+12
View File
@@ -26,6 +26,7 @@ from Furious.Service.TrafficStatsManager import (
CLEAR_TRAFFIC_USAGE_ON_RECONNECT_SETTING,
METRICS_COLLECTION_SETTING,
)
from Furious.Service.EndpointInfoService import PROXY_ENDPOINT_INFO_SETTING
from PySide6 import QtCore
@@ -33,6 +34,7 @@ __all__ = [
'APPLICATION_THEME_SETTING',
'LOG_AUTO_SCROLL_DOWN_SETTING',
'LOG_AUTO_CLEAR_SETTING',
'PROXY_ENDPOINT_INFO_SETTING',
'SettingsController',
]
@@ -292,6 +294,16 @@ class SettingsController:
except (AttributeError, RuntimeError):
pass
@classmethod
def setProxyEndpointInfoEnabled(cls, enabled: bool):
"""Persist and immediately apply privacy-sensitive endpoint inspection."""
cls._setBinary(PROXY_ENDPOINT_INFO_SETTING, enabled)
try:
AppEndpointInfoService().setEnabled(enabled)
except (AttributeError, RuntimeError):
pass
@classmethod
def setEditorWhitespaceVisible(cls, enabled: bool):
"""Apply and persist editor whitespace visibility."""
+2
View File
@@ -27,6 +27,7 @@ from .SettingsController import (
APPLICATION_THEME_SETTING,
LOG_AUTO_CLEAR_SETTING,
LOG_AUTO_SCROLL_DOWN_SETTING,
PROXY_ENDPOINT_INFO_SETTING,
SettingsController,
)
@@ -34,6 +35,7 @@ __all__ = [
'APPLICATION_THEME_SETTING',
'LOG_AUTO_CLEAR_SETTING',
'LOG_AUTO_SCROLL_DOWN_SETTING',
'PROXY_ENDPOINT_INFO_SETTING',
'ConnectionController',
'ConnectionError',
'ConnectionState',
+72
View File
@@ -0,0 +1,72 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta
http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src 'self' qrc: blob:; style-src 'self' 'unsafe-inline'; connect-src https://tiles.openfreemap.org; img-src data: blob: https://tiles.openfreemap.org; font-src data: https://tiles.openfreemap.org; worker-src blob:; child-src blob:"
>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="maplibre-gl.css">
<style>
html, body, #map {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
background: transparent;
}
:root {
--endpoint-attribution-background: rgb(255 255 255 / 92%);
--endpoint-attribution-foreground: #202020;
}
:root[data-theme="dark"] {
--endpoint-attribution-background: rgb(24 30 39 / 92%);
--endpoint-attribution-foreground: #f5f5f5;
}
.endpoint-marker {
width: 20px;
height: 20px;
box-sizing: border-box;
border: 3px solid white;
border-radius: 50%;
background: var(--endpoint-accent, #0f7bff);
box-shadow: 0 1px 5px rgb(0 0 0 / 45%);
}
.maplibregl-canvas:focus,
.maplibregl-canvas:focus-visible {
outline: none;
}
.maplibregl-ctrl-attrib.maplibregl-compact {
max-width: calc(100% - 20px);
color: var(--endpoint-attribution-foreground) !important;
background-color: var(--endpoint-attribution-background) !important;
box-shadow: 0 1px 4px rgb(0 0 0 / 24%);
}
.maplibregl-ctrl-attrib a {
color: inherit !important;
}
.maplibregl-ctrl-attrib.maplibregl-compact-show
.maplibregl-ctrl-attrib-button {
background-color: transparent !important;
}
:root[data-theme="dark"] .maplibregl-ctrl-attrib-button {
filter: invert(1) brightness(1.7);
}
</style>
<script src="qrc:///qtwebchannel/qwebchannel.js"></script>
<script src="maplibre-gl.js"></script>
</head>
<body>
<div id="map" aria-label="Approximate location map"></div>
<script src="EndpointMap.js"></script>
</body>
</html>
+130
View File
@@ -0,0 +1,130 @@
(() => {
'use strict';
let bridge = null;
let map = null;
let marker = null;
let state = null;
let appliedRevision = -1;
let activeStyle = '';
let initialReadyReported = false;
const reportError = (event) => {
const message = event && event.error && event.error.message
? event.error.message
: 'The map provider could not be loaded.';
if (bridge) {
bridge.failed(String(message));
}
};
const reportReady = () => {
if (!initialReadyReported && bridge) {
initialReadyReported = true;
bridge.ready();
}
};
const ensureMarker = () => {
if (!map || !state || !state.markerVisible) {
if (marker) {
marker.remove();
marker = null;
}
return;
}
if (!marker) {
const element = document.createElement('div');
element.className = 'endpoint-marker';
marker = new maplibregl.Marker({element, anchor: 'center'});
}
marker
.setLngLat([state.markerLongitude, state.markerLatitude])
.addTo(map);
};
const applyState = () => {
if (!map || !state) {
return;
}
document.documentElement.dataset.theme = state.darkMode
? 'dark'
: 'light';
document.documentElement.style.setProperty(
'--endpoint-accent',
state.accentColor
);
const nextStyle = state.darkMode
? state.darkStyleUrl
: state.lightStyleUrl;
if (nextStyle !== activeStyle) {
activeStyle = nextStyle;
map.setStyle(nextStyle);
}
if (state.viewRevision !== appliedRevision) {
appliedRevision = state.viewRevision;
map.jumpTo({
center: [state.markerLongitude, state.markerLatitude],
zoom: state.defaultGeographicZoom,
});
}
ensureMarker();
map.resize();
};
const createMap = () => {
if (map || !state) {
return;
}
activeStyle = state.darkMode
? state.darkStyleUrl
: state.lightStyleUrl;
map = new maplibregl.Map({
container: 'map',
style: activeStyle,
center: [state.markerLongitude, state.markerLatitude],
zoom: state.defaultGeographicZoom,
minZoom: 2,
maxZoom: 18,
attributionControl: true,
cooperativeGestures: false,
fadeDuration: 0,
});
map.on('error', reportError);
map.once('load', () => {
ensureMarker();
reportReady();
});
applyState();
};
window.furiousEndpointMap = {
setState(nextState) {
state = nextState;
createMap();
applyState();
},
};
document.addEventListener('click', (event) => {
const anchor = event.target.closest('a[href]');
if (!anchor || !bridge) {
return;
}
event.preventDefault();
bridge.openExternal(anchor.href);
}, true);
new QWebChannel(qt.webChannelTransport, (channel) => {
bridge = channel.objects.endpointMapBridge;
createMap();
});
})();
+116
View File
@@ -0,0 +1,116 @@
Copyright (c) 2023, MapLibre contributors
All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name of MapLibre GL JS nor the names of its contributors
may be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-------------------------------------------------------------------------------
Contains code from mapbox-gl-js v1.13 and earlier
Version v1.13 of mapbox-gl-js and earlier are licensed under a BSD-3-Clause license
Copyright (c) 2020, Mapbox
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name of Mapbox GL JS nor the names of its contributors
may be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE,
EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-------------------------------------------------------------------------------
Contains code from glfx.js
Copyright (C) 2011 by Evan Wallace
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
--------------------------------------------------------------------------------
Contains a portion of d3-color https://github.com/d3/d3-color
Copyright 2010-2016 Mike Bostock
All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name of the author nor the names of contributors may be used to
endorse or promote products derived from this software without specific prior
written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+171 -1
View File
@@ -328,6 +328,7 @@ TRANSLATION = {
},
"Copy": {
"source": [
"Furious.Widget.EndpointInfoWidget",
"Furious.Window.LogPage",
"Furious.Window.TextEditorWindow"
],
@@ -756,7 +757,8 @@ TRANSLATION = {
},
"Refresh": {
"source": [
"Furious.Backends.Xray.AssetWindow"
"Furious.Backends.Xray.AssetWindow",
"Furious.Widget.EndpointInfoWidget"
],
"RU": "Обновить",
"ZH": "刷新",
@@ -3061,5 +3063,173 @@ TRANSLATION = {
"RU": "Обработка...",
"ZH": "处理中...",
"isReviewed": "True"
},
"Detecting...": {
"source": [
"Furious.Widget.EndpointInfoWidget"
],
"RU": "Определение...",
"ZH": "正在检测...",
"isReviewed": "True"
},
"Connect to a proxy to view endpoint information.": {
"source": [
"Furious.Widget.EndpointInfoWidget"
],
"RU": "Подключитесь к прокси, чтобы просмотреть информацию о выходном узле.",
"ZH": "连接代理后可查看出口信息。",
"isReviewed": "True"
},
"Connecting...": {
"source": [
"Furious.Widget.EndpointInfoWidget"
],
"RU": "Подключение...",
"ZH": "正在连接...",
"isReviewed": "True"
},
"Proxy endpoint information is unavailable.": {
"source": [
"Furious.Widget.EndpointInfoWidget"
],
"RU": "Информация о выходном узле прокси недоступна.",
"ZH": "代理出口信息不可用。",
"isReviewed": "True"
},
"Approximate location unavailable": {
"source": [
"Furious.Widget.EndpointInfoWidget"
],
"RU": "Приблизительное местоположение недоступно",
"ZH": "大致位置信息不可用",
"isReviewed": "True"
},
"Proxy Endpoint Information": {
"source": [
"Furious.Widget.EndpointInfoWidget"
],
"RU": "Информация о выходном узле прокси",
"ZH": "代理出口信息",
"isReviewed": "True"
},
"Country": {
"source": [
"Furious.Widget.EndpointInfoWidget"
],
"RU": "Страна",
"ZH": "国家",
"isReviewed": "True"
},
"Approximate Location": {
"source": [
"Furious.Widget.EndpointInfoWidget"
],
"RU": "Приблизительное местоположение",
"ZH": "大致位置",
"isReviewed": "True"
},
"Organization": {
"source": [
"Furious.Widget.EndpointInfoWidget"
],
"RU": "Организация",
"ZH": "组织",
"isReviewed": "True"
},
"Location is estimated from the public IP and may be inaccurate.": {
"source": [
"Furious.Widget.EndpointInfoWidget"
],
"RU": "Местоположение приблизительно определено по публичному IP и может быть неточным.",
"ZH": "位置信息根据公网 IP 估算,可能不准确。",
"isReviewed": "True"
},
"Not available": {
"source": [
"Furious.Widget.EndpointInfoWidget"
],
"RU": "Недоступно",
"ZH": "不可用",
"isReviewed": "True"
},
"Unknown": {
"source": [
"Furious.Widget.EndpointInfoWidget"
],
"RU": "Неизвестно",
"ZH": "未知",
"isReviewed": "True"
},
"Endpoint inspection is disabled.": {
"source": [
"Furious.Widget.EndpointInfoWidget"
],
"RU": "Проверка конечной точки отключена.",
"ZH": "代理端点检测已禁用。",
"isReviewed": "True"
},
"Enable Proxy Endpoint Information": {
"source": [
"Furious.Window.SettingsPage"
],
"RU": "Включить информацию о конечной точке прокси",
"ZH": "启用代理端点信息",
"isReviewed": "True"
},
"Inspect the active proxy public address and approximate location.": {
"source": [
"Furious.Window.SettingsPage"
],
"RU": "Определять публичный адрес активного прокси и его примерное местоположение.",
"ZH": "检测当前代理的公网地址和大致位置。",
"isReviewed": "True"
},
"Proxy Endpoint Information & Privacy": {
"source": [
"Furious.Window.SettingsPage"
],
"RU": "Информация о выходном узле прокси и конфиденциальность",
"ZH": "代理出口信息与隐私",
"isReviewed": "True"
},
"TUN remote address is required when application tun2socks is enabled": {
"source": [
"Furious.Backends.ExternalCore.Editor"
],
"RU": "При включённом Tun2socks приложения необходимо указать удалённый адрес TUN",
"ZH": "启用应用程序的Tun2socks时必须填写TUN远程地址",
"isReviewed": "True"
},
"Data usage": {
"source": [
"Furious.Window.SettingsPage"
],
"RU": "Использование данных",
"ZH": "数据使用",
"isReviewed": "True"
},
"<b>Public IP</b><br>\nYour proxy's public IPv4 and IPv6 addresses are checked<br>\nthrough the active proxy connection using Cloudflare,<br>\nwith ipify as a fallback.<br>\nThese services can observe the proxy's public IP.": {
"source": [
"Furious.Window.SettingsPage"
],
"RU": "<b>Публичный IP-адрес</b><br>\nПубличные IPv4- и IPv6-адреса прокси проверяются<br>\nчерез активное прокси-соединение с помощью Cloudflare,<br>\nа ipify используется как резервный сервис.<br>\nЭти сервисы могут видеть публичный IP-адрес прокси.",
"ZH": "<b>公网 IP</b><br>\n系统通过当前代理连接<br>\n使用 Cloudflare 检查代理的公网 IPv4 和 IPv6 地址,<br>\n并以 ipify 作为备用服务。<br>\n这些服务可以看到代理的公网 IP。",
"isReviewed": "True"
},
"<b>Approximate Location</b><br>\nThe detected public IP is sent to ipapi.co<br>\nto estimate country, city, region, and network organization.<br>\nIP-based location can be inaccurate.": {
"source": [
"Furious.Window.SettingsPage"
],
"RU": "<b>Приблизительное местоположение</b><br>\nОбнаруженный публичный IP-адрес отправляется в ipapi.co<br>\nдля определения страны, города, региона и сетевой организации.<br>\nГеолокация по IP может быть неточной.",
"ZH": "<b>大致位置</b><br>\n系统将检测到的公网 IP 发送至 ipapi.co<br>\n用于估算国家、城市、地区和网络运营组织。<br>\n基于 IP 的位置可能不准确。",
"isReviewed": "True"
},
"<b>Map</b><br>\nMap styles and tiles for the approximate area are loaded<br>\nfrom OpenFreeMap, using OpenStreetMap data.<br>\nOpenFreeMap receives these map requests.": {
"source": [
"Furious.Window.SettingsPage"
],
"RU": "<b>Карта</b><br>\nСтили и тайлы карты приблизительной области<br>\nзагружаются из OpenFreeMap с использованием данных OpenStreetMap.<br>\nOpenFreeMap получает эти запросы карты.",
"ZH": "<b>地图</b><br>\n大致区域的地图样式和图块从 OpenFreeMap 加载,<br>\n并使用 OpenStreetMap 数据。<br>\nOpenFreeMap 会接收这些地图请求。",
"isReviewed": "True"
}
}
+47
View File
@@ -479,6 +479,18 @@ class AppStyleSheet:
min-width: 82px;
}}
QPushButton#SettingsLinkButton {{
min-height: 20px;
padding: 0;
border: none;
background-color: transparent;
color: {palette['accent']};
}}
QPushButton#SettingsLinkButton:hover {{
color: {palette['text_strong']};
}}
QCheckBox#SettingsToggle::indicator {{
width: 34px;
height: 18px;
@@ -528,6 +540,41 @@ class AppStyleSheet:
font-weight: 600;
}}
QLabel#EndpointStatusLabel {{
color: {palette['text_strong']};
font-weight: 600;
}}
QWidget#EndpointStatusWidget {{
background-color: transparent;
}}
QLabel#EndpointFieldName {{
color: {palette['muted']};
}}
QLabel#EndpointFieldValue {{
color: {palette['text_strong']};
}}
QWidget#EndpointFieldValueContainer {{
background-color: transparent;
}}
QLabel#EndpointNoteLabel {{
color: {palette['muted']};
}}
QPushButton#EndpointCopyButton {{
min-width: 34px;
padding: 0;
}}
QWidget#EndpointMapWidget {{
border: none;
background-color: transparent;
}}
QWidget#MetricsGraphWidget {{
border: none;
background-color: transparent;
+589
View File
@@ -0,0 +1,589 @@
# Copyright (C) 2024present Loren Eteval & contributors <loren.eteval@proton.me>
#
# 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 <https://www.gnu.org/licenses/>.
"""Discover proxy egress IP and approximate location without direct fallback."""
from __future__ import annotations
from Furious.Frozenlib import (
AppBinarySettings,
AppConnectionController,
AppSettings,
registerAppSettings,
)
from Furious.Qt.QtNetwork import AppQNetworkAccessManager
from Furious.Repository import Storage
from PySide6 import QtCore
from PySide6.QtNetwork import QNetworkReply, QNetworkRequest
from enum import Enum
from dataclasses import dataclass, replace
from ipaddress import IPv4Address, IPv6Address, ip_address
from typing import Callable
from urllib.parse import quote
import json
import logging
__all__ = [
'PROXY_ENDPOINT_INFO_SETTING',
'EndpointInfo',
'EndpointInfoService',
'EndpointInfoState',
'EndpointLocation',
'ProxyEndpointHttpClient',
]
logger = logging.getLogger(__name__)
PROXY_ENDPOINT_INFO_SETTING = 'ProxyEndpointInformationEnabled'
registerAppSettings(
PROXY_ENDPOINT_INFO_SETTING,
isBinary=True,
default=AppBinarySettings.OFF,
)
class EndpointInfoState(Enum):
"""Describe the connection-aware endpoint lookup state."""
Disabled = 'disabled'
Disconnected = 'disconnected'
Connecting = 'connecting'
Loading = 'loading'
Ready = 'ready'
Failed = 'failed'
@dataclass(frozen=True)
class EndpointLocation:
"""Describe approximate public-IP geolocation returned by a provider."""
countryCode: str = ''
countryName: str = ''
region: str = ''
city: str = ''
latitude: float | None = None
longitude: float | None = None
organization: str = ''
@property
def displayName(self) -> str:
"""Return the compact city/region/country presentation."""
values = []
for value in (self.city, self.region, self.countryName):
value = str(value or '').strip()
if value and value not in values:
values.append(value)
return ', '.join(values)
@dataclass(frozen=True)
class EndpointInfo:
"""Store one active connection's observed egress information."""
ipv4: str = ''
ipv6: str = ''
location: EndpointLocation = EndpointLocation()
ipv4Resolved: bool = False
ipv6Resolved: bool = False
locationResolved: bool = False
@property
def primaryAddress(self) -> str:
"""Return the preferred address used for geolocation."""
return self.ipv4 or self.ipv6
class ProxyEndpointHttpClient(AppQNetworkAccessManager):
"""Issue bounded HTTPS GET requests through one explicitly configured proxy."""
completed = QtCore.Signal(object, object, str)
TimeoutMilliseconds = 5000
def __init__(self, parent=None):
"""Initialize reply tracking under the service-owned network manager."""
super().__init__(parent)
self._pendingRequests = {}
def request(self, url: str, context):
"""Start one bounded request associated with *context*."""
request = QNetworkRequest(QtCore.QUrl(url))
request.setTransferTimeout(self.TimeoutMilliseconds)
request.setAttribute(
QNetworkRequest.Attribute.RedirectPolicyAttribute,
QNetworkRequest.RedirectPolicy.NoLessSafeRedirectPolicy,
)
request.setRawHeader(b'Accept', b'text/plain, application/json')
request.setRawHeader(b'User-Agent', b'Furious endpoint information')
reply = self.get(request)
self._pendingRequests[reply] = context
reply.finished.connect(self._replyFinished)
@QtCore.Slot()
def _replyFinished(self):
"""Consume, publish, and release one completed reply."""
reply = self.sender()
if not isinstance(reply, QNetworkReply):
return
context = self._pendingRequests.pop(reply, None)
try:
if reply.error() == QNetworkReply.NetworkError.NoError:
data, error = bytes(reply.readAll()), ''
else:
data, error = None, reply.errorString()
self.completed.emit(context, data, error)
finally:
reply.deleteLater()
def cancelAll(self):
"""Abort all connection-specific requests without retaining replies."""
pendingReplies = tuple(self._pendingRequests)
self._pendingRequests.clear()
for reply in pendingReplies:
try:
reply.finished.disconnect(self._replyFinished)
except (RuntimeError, TypeError):
pass
reply.abort()
reply.deleteLater()
@dataclass(frozen=True)
class _IPProvider:
name: str
url: str
parser: Callable[[bytes, int], tuple[str, str]]
def _validatedAddress(value, version: int) -> str:
"""Return a canonical address of *version* or raise ``ValueError``."""
address = ip_address(str(value).strip())
if address.version != version:
raise ValueError(f'expected IPv{version}, got IPv{address.version}')
return str(address)
def _parseCloudflareTrace(data: bytes, version: int) -> tuple[str, str]:
"""Parse Cloudflare's documented key/value trace response."""
values = {}
for line in data.decode('utf-8', 'replace').splitlines():
key, separator, value = line.partition('=')
if separator:
values[key.strip()] = value.strip()
return (
_validatedAddress(values.get('ip', ''), version),
str(values.get('loc', '')).upper(),
)
def _parsePlainAddress(data: bytes, version: int) -> tuple[str, str]:
"""Parse a provider response containing only one public address."""
return _validatedAddress(data.decode('ascii', 'strict').strip(), version), ''
IPV4_PROVIDERS = (
_IPProvider(
'Cloudflare',
'https://1.1.1.1/cdn-cgi/trace',
_parseCloudflareTrace,
),
_IPProvider('ipify', 'https://api4.ipify.org', _parsePlainAddress),
)
IPV6_PROVIDERS = (
_IPProvider(
'Cloudflare',
'https://[2606:4700:4700::1111]/cdn-cgi/trace',
_parseCloudflareTrace,
),
_IPProvider('ipify', 'https://api6.ipify.org', _parsePlainAddress),
)
class EndpointInfoService(QtCore.QObject):
"""Own lazy, per-connection endpoint discovery independently from the page."""
stateChanged, resultChanged, enabledChanged = (
QtCore.Signal(object),
QtCore.Signal(object),
QtCore.Signal(bool),
)
def __init__(
self,
parent=None,
*,
controller=None,
httpClient=None,
proxyResolver=None,
enabled=None,
):
"""Initialize the connection observer and injectable HTTP transport."""
super().__init__(parent)
self.controller = controller or AppConnectionController()
self.httpClient = httpClient or ProxyEndpointHttpClient(self)
self.proxyResolver = proxyResolver or Storage.Extras.UserHttpProxy
self._enabled = (
AppSettings.isStateON_(PROXY_ENDPOINT_INFO_SETTING)
if enabled is None
else bool(enabled)
)
self.state = EndpointInfoState.Disabled
self.result = EndpointInfo()
self._generation = 0
self._pageVisible = False
self._cached = False
self._requestInFlight = False
self._family = 4
self._providerIndex = 0
self._countryHint = ''
self.httpClient.completed.connect(self._requestCompleted)
stateChanged = getattr(self.controller, 'stateChanged', None)
activeConfigurationChanged = getattr(
self.controller, 'activeConfigurationChanged', None
)
if stateChanged is not None:
stateChanged.connect(self._connectionStateChanged)
if activeConfigurationChanged is not None:
activeConfigurationChanged.connect(self._activeConfigurationChanged)
self._syncConnectionState()
@property
def enabled(self) -> bool:
"""Return whether privacy-sensitive endpoint inspection is allowed."""
return self._enabled
@QtCore.Slot(bool)
def setEnabled(self, enabled: bool):
"""Apply endpoint inspection immediately and invalidate disabled work."""
enabled = bool(enabled)
if enabled == self._enabled:
return
self._enabled = enabled
self.enabledChanged.emit(enabled)
if not enabled:
self._invalidate()
self._setState(EndpointInfoState.Disabled)
return
self._syncConnectionState()
def _setState(self, state: EndpointInfoState):
"""Publish state only when it changes."""
if state is self.state:
return
self.state = state
self.stateChanged.emit(state)
def _publishResult(self, result: EndpointInfo):
"""Publish immutable endpoint data."""
self.result = result
self.resultChanged.emit(result)
def _invalidate(self):
"""Invalidate the old connection's cache and pending work."""
self._generation += 1
self._cached = False
self._requestInFlight = False
self._countryHint = ''
self.httpClient.cancelAll()
self._publishResult(EndpointInfo())
def _syncConnectionState(self):
"""Reflect the controller and begin lazy work only while visible."""
if not self._enabled:
self._setState(EndpointInfoState.Disabled)
return
if self.controller.isConnected():
self._setState(EndpointInfoState.Loading)
if self._pageVisible:
self.requestIfNeeded()
elif (
getattr(getattr(self.controller, 'state', None), 'name', '') == 'Connecting'
):
self._setState(EndpointInfoState.Connecting)
else:
self._setState(EndpointInfoState.Disconnected)
@QtCore.Slot(object)
def _connectionStateChanged(self, _state):
"""Invalidate results whenever the runtime connection changes state."""
self._invalidate()
self._syncConnectionState()
@QtCore.Slot(object)
def _activeConfigurationChanged(self, _configuration):
"""Reject late data when the active profile identity changes."""
self._invalidate()
self._syncConnectionState()
def setPageVisible(self, visible: bool):
"""Enable network work only while the owning page can present it."""
self._pageVisible = bool(visible)
if self._pageVisible:
self.requestIfNeeded()
@QtCore.Slot()
def refresh(self):
"""Explicitly replace the current connection's cached observation."""
if not self._enabled or not self.controller.isConnected():
return
self._generation += 1
self._cached = False
self._requestInFlight = False
self._countryHint = ''
self.httpClient.cancelAll()
self._publishResult(EndpointInfo())
self._setState(EndpointInfoState.Loading)
self._startLookup()
def requestIfNeeded(self):
"""Start a lookup only for an uncached visible connection session."""
if (
not self._pageVisible
or not self._enabled
or not self.controller.isConnected()
or self._cached
or self._requestInFlight
):
return
self._startLookup()
def _startLookup(self):
"""Configure the active local HTTP proxy before issuing any request."""
proxy = self.proxyResolver()
if not proxy or not self.httpClient.configureHttpProxy(proxy):
logger.error('endpoint lookup refused because no active HTTP proxy exists')
self._setState(EndpointInfoState.Failed)
return
self._requestInFlight = True
self._family = 4
self._providerIndex = 0
self._countryHint = ''
self._setState(EndpointInfoState.Loading)
self._requestIPProvider()
def _providers(self):
"""Return the provider chain for the current address family."""
return IPV4_PROVIDERS if self._family == 4 else IPV6_PROVIDERS
def _requestIPProvider(self):
"""Request the current provider in the sequential fallback chain."""
providers = self._providers()
if self._providerIndex >= len(providers):
self._finishFamily()
return
provider = providers[self._providerIndex]
self.httpClient.request(
provider.url,
{
'generation': self._generation,
'kind': 'ip',
'family': self._family,
'providerIndex': self._providerIndex,
},
)
@QtCore.Slot(object, object, str)
def _requestCompleted(self, context, data, error):
"""Apply only the response that belongs to the current connection."""
if (
not self._enabled
or not isinstance(context, dict)
or context.get('generation') != self._generation
):
return
if context.get('kind') == 'location':
self._locationCompleted(data, error)
else:
self._ipCompleted(context, data, error)
def _ipCompleted(self, context, data, error):
"""Validate one IP provider response or advance to its fallback."""
family = int(context.get('family', 0))
providerIndex = int(context.get('providerIndex', 0))
providers = IPV4_PROVIDERS if family == 4 else IPV6_PROVIDERS
if family != self._family or providerIndex != self._providerIndex:
return
provider = providers[providerIndex]
try:
if error or not isinstance(data, bytes):
raise ValueError(error or 'empty response')
address, countryCode = provider.parser(data, family)
except Exception as ex:
# Any non-exit exceptions
logger.debug(f'{provider.name} IPv{family} endpoint lookup failed: {ex}')
self._providerIndex += 1
self._requestIPProvider()
return
if countryCode and not self._countryHint:
self._countryHint = countryCode
if family == 4:
self._publishResult(replace(self.result, ipv4=address, ipv4Resolved=True))
else:
self._publishResult(replace(self.result, ipv6=address, ipv6Resolved=True))
self._finishFamily()
def _finishFamily(self):
"""Advance from IPv4 to IPv6, then enrich the observed result."""
if self._family == 4:
if not self.result.ipv4Resolved:
self._publishResult(replace(self.result, ipv4Resolved=True))
self._family = 6
self._providerIndex = 0
self._requestIPProvider()
return
if not self.result.ipv6Resolved:
self._publishResult(replace(self.result, ipv6Resolved=True))
address = self.result.primaryAddress
if not address:
self._finishLookup(EndpointInfoState.Failed)
return
encodedAddress = quote(address, safe='')
self.httpClient.request(
f'https://ipapi.co/{encodedAddress}/json/',
{
'generation': self._generation,
'kind': 'location',
'address': address,
},
)
def _locationCompleted(self, data, error):
"""Validate approximate geolocation without discarding valid IP data."""
location = EndpointLocation(countryCode=self._countryHint)
try:
if error or not isinstance(data, bytes):
raise ValueError(error or 'empty response')
payload = json.loads(data.decode('utf-8'))
if payload.get('error'):
raise ValueError(payload.get('reason') or 'provider error')
observedAddress = _validatedAddress(
payload.get('ip', ''), ip_address(self.result.primaryAddress).version
)
if observedAddress != self.result.primaryAddress:
raise ValueError('geolocation response address does not match request')
latitude, longitude = (
float(payload['latitude']),
float(payload['longitude']),
)
if not -90 <= latitude <= 90 or not -180 <= longitude <= 180:
raise ValueError('geolocation coordinates out of range')
countryCode = (
str(payload.get('country_code') or payload.get('country') or '')
.strip()
.upper()
)
location = EndpointLocation(
countryCode=countryCode or self._countryHint,
countryName=str(payload.get('country_name') or '').strip(),
region=str(payload.get('region') or '').strip(),
city=str(payload.get('city') or '').strip(),
latitude=latitude,
longitude=longitude,
organization=str(payload.get('org') or '').strip(),
)
except Exception as ex:
logger.warning(f'approximate endpoint geolocation failed: {ex}')
self._publishResult(
replace(self.result, location=location, locationResolved=True)
)
self._finishLookup(EndpointInfoState.Ready)
def _finishLookup(self, state: EndpointInfoState):
"""Cache the completed connection result and publish final state."""
if not self.result.locationResolved:
self._publishResult(replace(self.result, locationResolved=True))
self._requestInFlight = False
self._cached = True
self._setState(state)
+14
View File
@@ -22,6 +22,14 @@ from __future__ import annotations
from .ConnectionManager import ConnectionManager
from .ConnectivityManager import ConnectivityManager
from .DnsResolver import DnsResolver
from .EndpointInfoService import (
PROXY_ENDPOINT_INFO_SETTING,
EndpointInfo,
EndpointInfoService,
EndpointInfoState,
EndpointLocation,
ProxyEndpointHttpClient,
)
from .LogManager import (
ALL_LOGS_FILTER,
APPLICATION_LOG_CATEGORY,
@@ -60,6 +68,12 @@ __all__ = [
'ConnectionManager',
'ConnectivityManager',
'DnsResolver',
'EndpointInfo',
'EndpointInfoService',
'EndpointInfoState',
'PROXY_ENDPOINT_INFO_SETTING',
'EndpointLocation',
'ProxyEndpointHttpClient',
'ALL_LOGS_FILTER',
'APPLICATION_LOG_CATEGORY',
'CORE_LOG_CATEGORY',
+840
View File
@@ -0,0 +1,840 @@
# Copyright (C) 2024present Loren Eteval & contributors <loren.eteval@proton.me>
#
# 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 <https://www.gnu.org/licenses/>.
"""Present connection-scoped proxy endpoint information on the metrics page."""
from __future__ import annotations
from Furious.Frozenlib import APP, DATA_DIR, Mixins
from Furious.Qt import AppQLabel, AppQPushButton, AppStyleSheet, bootstrapIcon
from Furious.Qt import gettext as _
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 enum
import json
import logging
__all__ = ['EndpointInfoWidget']
logger = logging.getLogger(__name__)
class _EndpointWebView(QWebEngineView):
"""Keep map wheel gestures from bubbling into the Metrics scroll area."""
def wheelEvent(self, event):
"""Let the map process the gesture, then consume it at this boundary."""
super().wheelEvent(event)
event.accept()
class _EndpointMapBridge(QtCore.QObject):
"""Expose the narrow, trusted callback surface used by the local map page."""
mapReady, mapFailed, externalLinkRequested = (
QtCore.Signal(),
QtCore.Signal(str),
QtCore.Signal(str),
)
@QtCore.Slot()
def ready(self):
"""Publish that the initial vector style is ready for presentation."""
self.mapReady.emit()
@QtCore.Slot(str)
def failed(self, message):
"""Publish an error reported by the embedded map renderer."""
self.mapFailed.emit(str(message or ''))
@QtCore.Slot(str)
def openExternal(self, link):
"""Request external navigation without allowing in-view navigation."""
self.externalLinkRequested.emit(str(link or ''))
class _EndpointMapWidget(QWidget):
"""Host one lazily loaded MapLibre vector map for the page lifetime."""
class Style(enum.StrEnum):
"""OpenFreeMap styles available to the embedded endpoint map."""
Bright, Liberty, Positron, Dark, Fiord = (
'https://tiles.openfreemap.org/styles/bright',
'https://tiles.openfreemap.org/styles/liberty',
'https://tiles.openfreemap.org/styles/positron',
'https://tiles.openfreemap.org/styles/dark',
'https://tiles.openfreemap.org/styles/fiord',
)
HtmlPath = DATA_DIR / 'maplibre' / 'EndpointMap.html'
LightStyle = Style.Liberty
DarkStyle = Style.Fiord
TrustedAttributionHosts = frozenset(
{
'openfreemap.org',
'www.openfreemap.org',
'openmaptiles.org',
'www.openmaptiles.org',
'openstreetmap.org',
'www.openstreetmap.org',
}
)
DefaultGeographicZoom = 6.0
UserAgent = 'Mozilla/5.0'
def __init__(self, parent=None):
"""Initialize an inert map whose lifetime follows its containing card."""
super().__init__(parent)
self._location = None
self._message = ''
self._unavailableText = ''
self._loadingText = ''
self._loading = False
self._active = False
self._sourceLoaded = False
self._documentLoaded = False
self._mapReady = False
self._mapError = ''
self._viewRevision = 0
self._lastWebState = {}
self._theme = self._resolvedTheme()
self.setObjectName('EndpointMapWidget')
self.setMinimumHeight(260)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self.placeholderWidget = QWidget(self)
self.placeholderWidget.setAutoFillBackground(True)
self.placeholderLabel = AppQLabel(
translatable=False,
parent=self.placeholderWidget,
)
self.placeholderLabel.setObjectName('EndpointMapPlaceholder')
self.placeholderLabel.setWordWrap(True)
self.loadingSpinner = WaitingSpinner(
self.placeholderWidget,
center_on_parent=False,
line_length=5,
line_width=2,
radius=4,
lines=12,
)
self.loadingSpinner.setFixedSize(22, 22)
placeholderLayout = QHBoxLayout(self.placeholderWidget)
placeholderLayout.setContentsMargins(12, 12, 12, 12)
placeholderLayout.setSpacing(8)
placeholderLayout.addStretch(1)
placeholderLayout.addWidget(self.loadingSpinner)
placeholderLayout.addWidget(self.placeholderLabel)
placeholderLayout.addStretch(1)
# The off-the-record profile, page, channel, bridge, and WebEngine view
# are all persistent children of this page-lifetime map widget. This
# keeps vector-tile caching in memory and gives Chromium one bounded,
# explicit teardown path with the containing Metrics page.
self.webProfile = QWebEngineProfile(self)
self.webProfile.setHttpCacheType(
QWebEngineProfile.HttpCacheType.MemoryHttpCache
)
self.webProfile.setPersistentCookiesPolicy(
QWebEngineProfile.PersistentCookiesPolicy.NoPersistentCookies
)
self.webProfile.setHttpUserAgent(self.UserAgent)
self.webView = _EndpointWebView(self.webProfile, self)
self.webView.setObjectName('EndpointLocationMap')
self.webView.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.NoContextMenu)
self.webView.page().setBackgroundColor(QtCore.Qt.GlobalColor.transparent)
webSettings = self.webView.settings()
webSettings.setAttribute(
QWebEngineSettings.WebAttribute.LocalContentCanAccessRemoteUrls,
True,
)
webSettings.setAttribute(
QWebEngineSettings.WebAttribute.JavascriptCanOpenWindows,
False,
)
webSettings.setAttribute(
QWebEngineSettings.WebAttribute.WebGLEnabled,
True,
)
self.webChannel = QWebChannel(self.webView.page())
self.webBridge = _EndpointMapBridge(self.webChannel)
self.webChannel.registerObject('endpointMapBridge', self.webBridge)
self.webView.page().setWebChannel(self.webChannel)
self.webBridge.mapReady.connect(self._mapBecameReady)
self.webBridge.mapFailed.connect(self._mapFailed)
self.webBridge.externalLinkRequested.connect(self._openExternalLink)
self.webView.loadFinished.connect(self._loadFinished)
self.webView.page().renderProcessTerminated.connect(
self._renderProcessTerminated
)
self.overlayLayout = QGridLayout(self)
self.overlayLayout.setContentsMargins(0, 0, 0, 0)
self.overlayLayout.addWidget(self.webView, 0, 0)
self.overlayLayout.addWidget(self.placeholderWidget, 0, 0)
self._presentedWidget = self.placeholderWidget
self.placeholderWidget.raise_()
def _resolvedTheme(self, theme=None):
"""Resolve the map theme from the authoritative application setting."""
if theme is not None:
return AppStyleSheet.normalizeTheme(theme)
app = APP()
if app is not None and hasattr(app, 'theme'):
return AppStyleSheet.normalizeTheme(app.theme())
else:
base = self.palette().color(QtGui.QPalette.ColorRole.Base)
return AppStyleSheet.Dark if base.lightness() < 128 else AppStyleSheet.Light
def setLocation(self, location, message='', *, loading=False):
"""Replace the displayed coordinate and status message."""
previousLocation = self._location
previousCoordinate = (
(
previousLocation.latitude,
previousLocation.longitude,
)
if previousLocation is not None
and previousLocation.latitude is not None
and previousLocation.longitude is not None
else None
)
self._location = location
self._message = str(message or '')
self._loading = bool(loading)
coordinate = (
(location.latitude, location.longitude) if self._hasLocation() else None
)
# A newly observed endpoint is a data transition, not a repaint. Only
# that transition resets user pan/zoom to the canonical location view.
if coordinate is not None and coordinate != previousCoordinate:
self._viewRevision += 1
if self._active and self._hasLocation():
self._ensureSourceLoaded()
self._syncWebState()
self._updateVisibleWidget()
def setActive(self, active):
"""Allow map initialization and rendering only while its page is visible."""
self._active = bool(active)
if self._active and self._hasLocation():
self._ensureSourceLoaded()
self._syncWebState()
self._updateVisibleWidget()
def updateTheme(self, theme=None):
"""Switch vector styles without replacing the persistent map page."""
self._theme = self._resolvedTheme(theme)
self._syncWebState()
def setUnavailableText(self, text):
"""Set the translated fallback shown if the map provider fails."""
self._unavailableText = str(text or '')
self._updateVisibleWidget()
def setLoadingText(self, text):
"""Set the translated message used while map data or HTML is pending."""
self._loadingText = str(text or '')
self._updateVisibleWidget()
def _hasLocation(self):
"""Return whether the current result has a usable coordinate."""
return (
self._location is not None
and self._location.latitude is not None
and self._location.longitude is not None
)
def _ensureSourceLoaded(self):
"""Load the local MapLibre document once, on first visible coordinate."""
if self._sourceLoaded:
return
self._sourceLoaded = True
self.webView.setUrl(QtCore.QUrl.fromLocalFile(str(self.HtmlPath)))
@QtCore.Slot(bool)
def _loadFinished(self, successful):
"""Publish local-document failures and send the first map state."""
self._documentLoaded = bool(successful)
if not successful:
self._mapError = self._unavailableText or 'Endpoint map unavailable'
logger.error('failed to load the local endpoint map document')
self._syncWebState()
self._updateVisibleWidget()
@QtCore.Slot()
def _mapBecameReady(self):
"""Show the persistent map after its initial vector style is ready."""
self._mapReady = True
self._mapError = ''
self._updateVisibleWidget()
@QtCore.Slot(str)
def _mapFailed(self, message):
"""Show a fallback if the initial vector map cannot become ready."""
logger.error(f'endpoint map provider error: {message}')
if not self._mapReady:
self._mapError = message or self._unavailableText
self._updateVisibleWidget()
@QtCore.Slot(QWebEnginePage.RenderProcessTerminationStatus, int)
def _renderProcessTerminated(self, status, exitCode):
"""Convert an unexpected Chromium exit into the normal map fallback."""
self._mapReady = False
self._mapError = self._unavailableText or 'Endpoint map unavailable'
logger.error(
f'endpoint map render process terminated '
f'({status}, exit code {exitCode})',
)
self._updateVisibleWidget()
@QtCore.Slot(str)
def _openExternalLink(self, link):
"""Open only the static attribution providers used by the map style."""
url = QtCore.QUrl(link)
if (
url.scheme().casefold() == 'https'
and url.host().casefold() in self.TrustedAttributionHosts
):
QtGui.QDesktopServices.openUrl(url)
def _syncWebState(self):
"""Synchronize endpoint and theme state with the persistent web map."""
if not self._documentLoaded or not self._hasLocation():
return
location = self._location
accentColor = self.palette().color(QtGui.QPalette.ColorRole.Highlight).name()
state = {
'markerVisible': True,
'markerLatitude': float(location.latitude),
'markerLongitude': float(location.longitude),
'defaultGeographicZoom': self.DefaultGeographicZoom,
'viewRevision': self._viewRevision,
'darkMode': self._theme == AppStyleSheet.Dark,
'lightStyleUrl': self.LightStyle.value,
'darkStyleUrl': self.DarkStyle.value,
'accentColor': accentColor,
}
self._lastWebState = state
script = (
f'window.furiousEndpointMap && '
f'window.furiousEndpointMap.setState({json.dumps(state)});'
)
self._runJavaScript(script)
def _runJavaScript(self, script):
"""Run a state update on the one persistent local map document."""
self.webView.page().runJavaScript(script)
def _updateVisibleWidget(self):
"""Show the live map only after its initial vector style is ready."""
initializingMap = (
self._active
and self._hasLocation()
and not self._mapReady
and not self._mapError
)
showMap = (
self._active
and not self._loading
and self._hasLocation()
and self._documentLoaded
and self._mapReady
)
visibleWidget = self.webView if showMap else self.placeholderWidget
# Keep the persistent Chromium surface in the widget hierarchy and
# raise the opaque placeholder above it for loading/fallback states.
# QStackedLayout changes QWebEngineView visibility even in StackAll;
# that recreates its Windows compositor surface and can momentarily
# flash the containing top-level window.
if self._presentedWidget is not visibleWidget:
visibleWidget.raise_()
self._presentedWidget = visibleWidget
loading = self._loading or initializingMap
if loading:
self.placeholderLabel.setText(self._loadingText or self._message)
elif self._hasLocation() and self._mapError:
self.placeholderLabel.setText(self._unavailableText)
else:
self.placeholderLabel.setText(self._message)
self.loadingSpinner.color = self.palette().color(QtGui.QPalette.ColorRole.Text)
if self._active and loading and not showMap:
self.loadingSpinner.start()
else:
self.loadingSpinner.stop()
class _ValueRow(QtCore.QObject):
"""Align one endpoint label, selectable value, and optional copy action."""
copied = QtCore.Signal(str)
def __init__(self, name, parent=None, *, copyable=False):
"""Initialize one compact information row."""
super().__init__(parent)
self.nameLabel = AppQLabel(name, parent=parent)
self.nameLabel.setObjectName('EndpointFieldName')
self.valueLabel = AppQLabel(translatable=False, parent=parent)
self.valueLabel.setObjectName('EndpointFieldValue')
self.valueLabel.setTextInteractionFlags(
QtCore.Qt.TextInteractionFlag.TextSelectableByMouse
)
self.valueLabel.setWordWrap(True)
self.valueLabel.setSizePolicy(
QSizePolicy.Policy.Expanding,
QSizePolicy.Policy.Preferred,
)
self.copyButton = None
self._loading = False
self._animationsEnabled = False
self.valueContainer = QWidget(parent)
self.valueContainer.setObjectName('EndpointFieldValueContainer')
self.loadingSpinner = WaitingSpinner(
self.valueContainer,
center_on_parent=False,
line_length=3,
line_width=2,
radius=3,
lines=10,
)
self.loadingSpinner.setFixedSize(16, 16)
valueLayout = QHBoxLayout(self.valueContainer)
valueLayout.setContentsMargins(0, 0, 0, 0)
valueLayout.setSpacing(6)
valueLayout.addWidget(
self.loadingSpinner,
0,
QtCore.Qt.AlignmentFlag.AlignVCenter,
)
valueLayout.addWidget(self.valueLabel, 1)
if copyable:
self.copyButton = AppQPushButton(
icon=bootstrapIcon('files.svg'),
toolTip=_('Copy'),
parent=parent,
)
self.copyButton.setObjectName('EndpointCopyButton')
self.copyButton.setFixedSize(34, 30)
self.copyButton.clicked.connect(self._copy)
def addToLayout(self, layout: QGridLayout, row: int):
"""Insert this row into the card's shared label/value grid."""
if self.copyButton is not None:
nameAlignment = QtCore.Qt.AlignmentFlag.AlignVCenter
else:
nameAlignment = QtCore.Qt.AlignmentFlag.AlignTop
layout.addWidget(
self.nameLabel,
row,
0,
nameAlignment,
)
layout.addWidget(self.valueContainer, row, 1)
if self.copyButton is not None:
layout.addWidget(
self.copyButton,
row,
2,
QtCore.Qt.AlignmentFlag.AlignTop,
)
@QtCore.Slot()
def _copy(self):
"""Copy the currently displayed value through the application clipboard."""
value = self.valueLabel.text().strip()
if value and value != '':
QApplication.clipboard().setText(value)
self.copied.emit(value)
def setValue(self, value, emptyText='', *, loading=False):
"""Set the row value and copy availability."""
available = bool(value)
self._loading = bool(loading)
self.valueLabel.setText(str(value or emptyText))
self._syncLoadingAnimation()
if self.copyButton is not None:
self.copyButton.setEnabled(available and not self._loading)
def setAnimationsEnabled(self, enabled):
"""Run the persistent row spinner only while its page is visible."""
self._animationsEnabled = bool(enabled)
self._syncLoadingAnimation()
def _syncLoadingAnimation(self):
"""Apply the current loading and page-visibility state."""
self.loadingSpinner.color = self.valueContainer.palette().color(
QtGui.QPalette.ColorRole.Text
)
if self._loading and self._animationsEnabled:
self.loadingSpinner.start()
else:
self.loadingSpinner.stop()
class EndpointInfoWidget(Mixins.ThemeAware, Mixins.QTranslatable, QFrame):
"""Display observed public addresses and approximate egress location."""
def __init__(self, service, parent=None):
"""Build a long-lived view over one independently owned lookup service."""
super().__init__(parent)
self.service = service
self.setObjectName('MetricsSection')
self.setMinimumHeight(360)
self.titleLabel = AppQLabel(_('Proxy Endpoint Information'), parent=self)
self.titleLabel.setObjectName('MetricsSectionTitle')
self.refreshButton = AppQPushButton(
_('Refresh'),
icon=bootstrapIcon('arrow-clockwise.svg'),
toolTip=_('Refresh'),
parent=self,
)
self.refreshButton.clicked.connect(self.service.refresh)
titleLayout = QHBoxLayout()
titleLayout.setContentsMargins(0, 0, 0, 0)
titleLayout.setSpacing(8)
titleLayout.addWidget(self.titleLabel)
titleLayout.addStretch(1)
titleLayout.addWidget(self.refreshButton)
self.infoCard = QFrame(self)
self.infoCard.setObjectName('MetricCard')
self._unboundedInfoCardMaximumWidth = self.infoCard.maximumWidth()
self.infoCard.setMinimumWidth(380)
self.infoCard.setMaximumWidth(560)
self.infoCard.setSizePolicy(
QSizePolicy.Policy.Preferred,
QSizePolicy.Policy.Expanding,
)
self.statusWidget = QWidget(self.infoCard)
self.statusWidget.setObjectName('EndpointStatusWidget')
self.statusLabel = AppQLabel(translatable=False, parent=self.statusWidget)
self.statusLabel.setObjectName('EndpointStatusLabel')
self.statusLabel.setWordWrap(True)
self.spinner = WaitingSpinner(
self.statusWidget,
center_on_parent=False,
line_length=5,
line_width=2,
radius=4,
lines=12,
)
self.spinner.setFixedSize(22, 22)
statusLayout = QHBoxLayout(self.statusWidget)
statusLayout.setContentsMargins(0, 0, 0, 0)
statusLayout.setSpacing(8)
statusLayout.addWidget(self.spinner)
statusLayout.addWidget(self.statusLabel, 1)
self.ipv4Row = _ValueRow('IPv4', self.infoCard, copyable=True)
self.ipv6Row = _ValueRow('IPv6', self.infoCard, copyable=True)
self.countryRow = _ValueRow(_('Country'), self.infoCard)
self.locationRow = _ValueRow(_('Approximate Location'), self.infoCard)
self.organizationRow = _ValueRow(_('Organization'), self.infoCard)
self.noteLabel = AppQLabel(
_('Location is estimated from the public IP and may be inaccurate.'),
parent=self.infoCard,
)
self.noteLabel.setObjectName('EndpointNoteLabel')
self.noteLabel.setWordWrap(True)
informationLayout = QGridLayout()
informationLayout.setContentsMargins(0, 0, 0, 0)
informationLayout.setHorizontalSpacing(14)
informationLayout.setVerticalSpacing(10)
informationLayout.setColumnStretch(1, 1)
for rowIndex, row in enumerate(
(
self.ipv4Row,
self.ipv6Row,
self.countryRow,
self.locationRow,
self.organizationRow,
)
):
row.addToLayout(informationLayout, rowIndex)
infoLayout = QVBoxLayout(self.infoCard)
infoLayout.setContentsMargins(16, 14, 16, 14)
infoLayout.setSpacing(14)
infoLayout.addWidget(self.statusWidget)
infoLayout.addLayout(informationLayout)
infoLayout.addStretch(1)
infoLayout.addWidget(self.noteLabel)
self.mapCard = QFrame(self)
self.mapCard.setObjectName('MetricCard')
self.mapTitleLabel = AppQLabel(_('Approximate Location'), parent=self.mapCard)
self.mapTitleLabel.setObjectName('MetricCardTitle')
self.mapWidget = _EndpointMapWidget(self.mapCard)
mapLayout = QVBoxLayout(self.mapCard)
mapLayout.setContentsMargins(14, 12, 14, 12)
mapLayout.setSpacing(8)
mapLayout.addWidget(self.mapTitleLabel)
mapLayout.addWidget(self.mapWidget, 1)
self.bodyLayout = QBoxLayout(QBoxLayout.Direction.LeftToRight)
self.bodyLayout.setContentsMargins(0, 0, 0, 0)
self.bodyLayout.setSpacing(12)
self.bodyLayout.addWidget(self.infoCard, 1)
self.bodyLayout.addWidget(self.mapCard, 2)
layout = QVBoxLayout(self)
layout.setContentsMargins(16, 14, 16, 16)
layout.setSpacing(10)
layout.addLayout(titleLayout)
layout.addLayout(self.bodyLayout)
self.service.stateChanged.connect(self._updatePresentation)
self.service.resultChanged.connect(self._updatePresentation)
self.retranslate()
@QtCore.Slot()
@QtCore.Slot(object)
def _updatePresentation(self, _value=None):
"""Render one immutable service snapshot without starting network work."""
state = self.service.state
result: EndpointInfo = self.service.result
location = result.location
self.refreshButton.setEnabled(
state in (EndpointInfoState.Ready, EndpointInfoState.Failed)
)
if state is EndpointInfoState.Loading:
self.statusWidget.setVisible(True)
self.statusLabel.setText(_('Detecting...'))
self.spinner.color = self.palette().color(QtGui.QPalette.ColorRole.Text)
if self.isVisible():
self.spinner.start()
else:
self.spinner.stop()
else:
self.spinner.stop()
self.statusWidget.setVisible(state is not EndpointInfoState.Ready)
if state is EndpointInfoState.Disconnected:
self.statusLabel.setText(
_('Connect to a proxy to view endpoint information.')
)
elif state is EndpointInfoState.Disabled:
self.statusLabel.setText(_('Endpoint inspection is disabled.'))
elif state is EndpointInfoState.Connecting:
self.statusLabel.setText(_('Connecting...'))
elif state is EndpointInfoState.Failed:
self.statusLabel.setText(
_('Proxy endpoint information is unavailable.')
)
else:
self.statusLabel.clear()
ipv4Loading, ipv6Loading, mapLoading = (
state is EndpointInfoState.Loading and not result.ipv4Resolved,
state is EndpointInfoState.Loading and not result.ipv6Resolved,
state is EndpointInfoState.Loading,
)
locationLoading = mapLoading and not result.locationResolved
self.ipv4Row.setValue(
result.ipv4,
_('Detecting...') if ipv4Loading else _('Not available'),
loading=ipv4Loading,
)
self.ipv6Row.setValue(
result.ipv6,
_('Detecting...') if ipv6Loading else _('Not available'),
loading=ipv6Loading,
)
country = location.countryName
if country and location.countryCode:
country = f'{country} ({location.countryCode})'
elif location.countryCode:
country = location.countryCode
self.countryRow.setValue(
country,
_('Detecting...') if locationLoading else _('Unknown'),
loading=locationLoading,
)
self.locationRow.setValue(
location.displayName,
_('Detecting...') if locationLoading else _('Not available'),
loading=locationLoading,
)
self.organizationRow.setValue(
location.organization,
_('Detecting...') if locationLoading else _('Not available'),
loading=locationLoading,
)
if mapLoading:
mapMessage = _('Detecting...')
elif location.latitude is None or location.longitude is None:
mapMessage = _('Approximate location unavailable')
else:
mapMessage = ''
self.mapWidget.setLocation(location, mapMessage, loading=mapLoading)
self._setLoadingAnimationsEnabled(self.isVisible())
def _setLoadingAnimationsEnabled(self, enabled):
"""Start only the persistent loading indicators currently on screen."""
for row in (
self.ipv4Row,
self.ipv6Row,
self.countryRow,
self.locationRow,
self.organizationRow,
):
row.setAnimationsEnabled(enabled)
def themeChangedCallback(self, theme=None):
"""Switch the embedded map to the resolved application theme."""
self.mapWidget.updateTheme(theme)
def resizeEvent(self, event):
"""Stack cards only when side-by-side fields would become unreadable."""
direction = (
QBoxLayout.Direction.TopToBottom
if event.size().width() < 760
else QBoxLayout.Direction.LeftToRight
)
if self.bodyLayout.direction() is not direction:
self.bodyLayout.setDirection(direction)
self.infoCard.setMaximumWidth(
self._unboundedInfoCardMaximumWidth
if direction is QBoxLayout.Direction.TopToBottom
else 560
)
super().resizeEvent(event)
def showEvent(self, event):
"""Resume the lightweight activity indicator only while visible."""
super().showEvent(event)
self.mapWidget.setActive(True)
self._setLoadingAnimationsEnabled(True)
if self.service.state is EndpointInfoState.Loading:
self.spinner.start()
def hideEvent(self, event):
"""Avoid animating a spinner on a hidden Metrics page."""
self.mapWidget.setActive(False)
self.spinner.stop()
self._setLoadingAnimationsEnabled(False)
super().hideEvent(event)
def retranslate(self):
"""Refresh state-dependent endpoint presentation copy."""
self.mapWidget.setLoadingText(_('Detecting...'))
self.mapWidget.setUnavailableText(_('Approximate location unavailable'))
self._updatePresentation()
+2
View File
@@ -20,6 +20,7 @@
from __future__ import annotations
from .ConnectionProgressWidget import ConnectionProgressWidget
from .EndpointInfoWidget import EndpointInfoWidget
from .MetricsGraph import MetricsGraphWidget
from .NavigationView import NavigationView
from .RoutingSelector import RoutingSelector
@@ -29,6 +30,7 @@ from .WaitingSpinner import WaitingSpinner
__all__ = [
'ConnectionProgressWidget',
'EndpointInfoWidget',
'MetricsGraphWidget',
'NavigationView',
'RoutingSelector',
+40 -1
View File
@@ -27,10 +27,12 @@ from Furious.Service import (
DOWNLOAD_USAGE_METRIC,
UPLOAD_SPEED_METRIC,
UPLOAD_USAGE_METRIC,
EndpointInfoService,
MetricsDataManager,
formatTrafficSpeed,
formatTrafficUsage,
)
from Furious.Widget.EndpointInfoWidget import EndpointInfoWidget
from Furious.Widget.MetricsGraph import MetricsGraphWidget
from PySide6 import QtCore
@@ -106,7 +108,13 @@ class MetricsPage(Mixins.QTranslatable, Mixins.ThemeAware, QMainWindow):
DefaultTimeRange = 15 * 60
DefaultGranularity = 0
def __init__(self, manager: MetricsDataManager, parent=None):
def __init__(
self,
manager: MetricsDataManager,
parent=None,
*,
endpointInfoService=None,
):
"""Initialize the network metrics page around a data-only manager."""
super().__init__(parent)
@@ -115,6 +123,12 @@ class MetricsPage(Mixins.QTranslatable, Mixins.ThemeAware, QMainWindow):
self.setObjectName('MetricsPage')
self.manager = manager
if endpointInfoService is None:
self.endpointInfoService = EndpointInfoService(parent=self)
else:
self.endpointInfoService = endpointInfoService
self._dirty = True
self._renderRevision = 0
@@ -153,6 +167,16 @@ class MetricsPage(Mixins.QTranslatable, Mixins.ThemeAware, QMainWindow):
self.uploadUsageGraph,
)
self.endpointInfoWidget = EndpointInfoWidget(
self.endpointInfoService,
parent=self,
)
self.endpointInfoWidget.setVisible(self.endpointInfoService.enabled)
self.endpointInfoService.enabledChanged.connect(
self._endpointInfoEnabledChanged
)
controlsLayout = QHBoxLayout()
controlsLayout.setContentsMargins(0, 0, 0, 0)
controlsLayout.setSpacing(8)
@@ -172,6 +196,7 @@ class MetricsPage(Mixins.QTranslatable, Mixins.ThemeAware, QMainWindow):
contentLayout.addLayout(controlsLayout)
contentLayout.addWidget(self.downloadSection, 1)
contentLayout.addWidget(self.uploadSection, 1)
contentLayout.addWidget(self.endpointInfoWidget)
self.scrollArea = QScrollArea()
self.scrollArea.setObjectName('MetricsScrollArea')
@@ -254,6 +279,16 @@ class MetricsPage(Mixins.QTranslatable, Mixins.ThemeAware, QMainWindow):
self._dirty = True
self._scheduleRender()
@QtCore.Slot(bool)
def setEndpointInfoEnabled(self, enabled: bool):
"""Apply the persisted endpoint-inspection preference to the page service."""
self.endpointInfoService.setEnabled(enabled)
@QtCore.Slot(bool)
def _endpointInfoEnabledChanged(self, enabled: bool):
"""Remove the privacy-sensitive section entirely while it is disabled."""
self.endpointInfoWidget.setVisible(enabled)
@QtCore.Slot()
def _selectionChanged(self):
"""Refresh visible graphs after range or granularity selection."""
@@ -312,6 +347,7 @@ class MetricsPage(Mixins.QTranslatable, Mixins.ThemeAware, QMainWindow):
self._dirty = True
self._timelineTimer.start()
self.endpointInfoService.setPageVisible(True)
self._scheduleRender()
def hideEvent(self, event):
@@ -319,12 +355,14 @@ class MetricsPage(Mixins.QTranslatable, Mixins.ThemeAware, QMainWindow):
self._renderTimer.stop()
self._timelineTimer.stop()
self._dirty = True
self.endpointInfoService.setPageVisible(False)
super().hideEvent(event)
def themeChangedCallback(self, theme: str):
"""Repaint custom graphs for a theme change only when visible."""
self._dirty = True
self.endpointInfoWidget.themeChangedCallback(theme)
self._scheduleRender()
def retranslate(self):
@@ -349,6 +387,7 @@ class MetricsPage(Mixins.QTranslatable, Mixins.ThemeAware, QMainWindow):
_('Upload Speed'),
_('Upload Traffic Usage'),
)
self.endpointInfoWidget.retranslate()
self._populateComboBox(
self.timeRangeComboBox,
+91 -7
View File
@@ -30,7 +30,7 @@ from Furious.Plugins import (
)
from Furious.Qt import *
from Furious.Qt import gettext as _
from Furious.Service import isCoreActive
from Furious.Service import PROXY_ENDPOINT_INFO_SETTING, isCoreActive
from Furious.Service.TrafficStatsManager import (
CLEAR_TRAFFIC_USAGE_ON_RECONNECT_SETTING,
METRICS_COLLECTION_SETTING,
@@ -49,6 +49,48 @@ __all__ = ['SettingsPage']
logger = logging.getLogger(__name__)
def _endpointPrivacyParagraphs():
"""Return concise translated disclosure text for the current providers."""
return (
_(
'<b>Public IP</b><br>\n'
"Your proxy's public IPv4 and IPv6 addresses are checked<br>\n"
'through the active proxy connection using Cloudflare,<br>\n'
'with ipify as a fallback.<br>\nThese services '
"can observe the proxy's public IP."
),
_(
'<b>Approximate Location</b><br>\n'
'The detected public IP is sent to ipapi.co<br>\n'
'to estimate country, city, region, and network organization.<br>\n'
'IP-based location can be inaccurate.'
),
_(
'<b>Map</b><br>\n'
'Map styles and tiles for the approximate area are loaded<br>\n'
'from OpenFreeMap, using OpenStreetMap data.<br>\n'
'OpenFreeMap receives these map requests.'
),
)
def _endpointPrivacyMessageBox(parent=None):
"""Build one transient Fluent data-usage disclosure."""
title = _('Proxy Endpoint Information & Privacy')
messageBox = AppQMessageBox(
icon=AppQMessageBox.Icon.Information,
parent=parent,
title=title,
text=title,
buttons=AppQMessageBox.StandardButton.Ok,
)
messageBox.informativeLabel.setTextFormat(QtCore.Qt.TextFormat.RichText)
messageBox.setInformativeText('<br><br>\n'.join(_endpointPrivacyParagraphs()))
return messageBox
def _tunModeTitle() -> str:
"""Return the platform-appropriate translated TUN setting title."""
if PLATFORM == 'Linux' or SystemRuntime.isAdmin():
@@ -107,17 +149,17 @@ class _SettingsCard(Mixins.ThemeAware, QFrame):
self.control = control
self.control.setParent(self)
textLayout = QVBoxLayout()
textLayout.setContentsMargins(0, 0, 0, 0)
textLayout.setSpacing(2)
textLayout.addWidget(self.titleLabel)
textLayout.addWidget(self.descriptionLabel)
self.textLayout = QVBoxLayout()
self.textLayout.setContentsMargins(0, 0, 0, 0)
self.textLayout.setSpacing(2)
self.textLayout.addWidget(self.titleLabel)
self.textLayout.addWidget(self.descriptionLabel)
layout = QHBoxLayout(self)
layout.setContentsMargins(16, 12, 16, 12)
layout.setSpacing(14)
layout.addWidget(self.iconLabel, 0, QtCore.Qt.AlignmentFlag.AlignTop)
layout.addLayout(textLayout, 1)
layout.addLayout(self.textLayout, 1)
layout.addSpacing(16)
layout.addWidget(self.control, 0, QtCore.Qt.AlignmentFlag.AlignVCenter)
@@ -192,6 +234,38 @@ class _ToggleSettingsCard(_SettingsCard):
self.checkBox.syncChecked(AppSettings.isStateON_(self.settingName))
class _EndpointInfoSettingsCard(_ToggleSettingsCard):
"""Pair the opt-in endpoint switch with an in-app privacy explanation."""
def __init__(self, callback, privacyCallback, parent=None):
"""Initialize one translated privacy-sensitive settings card."""
super().__init__(
'geo-alt.svg',
PROXY_ENDPOINT_INFO_SETTING,
callback,
_('Enable Proxy Endpoint Information'),
_('Inspect the active proxy public address and approximate location.'),
parent=parent,
)
self.privacyButton = AppQPushButton(_('Data usage'), parent=self)
self.privacyButton.setObjectName('SettingsLinkButton')
self.privacyButton.setFlat(True)
linkFont = self.privacyButton.font()
linkFont.setUnderline(True)
self.privacyButton.setFont(linkFont)
self.privacyButton.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
self.privacyButton.clicked.connect(privacyCallback)
self.textLayout.addWidget(
self.privacyButton,
0,
QtCore.Qt.AlignmentFlag.AlignLeft,
)
class _ActionToggleSettingsCard(_SettingsCard):
"""Present a plugin-provided checkable action as a Fluent switch."""
@@ -640,6 +714,7 @@ class SettingsPage(Mixins.QTranslatable, QMainWindow):
self.forceLocalhostCard,
self.connectionProgressCard,
self.metricsCollectionCard,
self.endpointInfoCard,
self.clearTrafficUsageCard,
self.editorWhitespaceCard,
) = (
@@ -691,6 +766,10 @@ class SettingsPage(Mixins.QTranslatable, QMainWindow):
_('Enable Metrics Collection'),
_('Collect network speed and traffic history while connected.'),
),
_EndpointInfoSettingsCard(
AppSettingsController().setProxyEndpointInfoEnabled,
self._showEndpointPrivacy,
),
_ToggleSettingsCard(
'arrow-repeat.svg',
CLEAR_TRAFFIC_USAGE_ON_RECONNECT_SETTING,
@@ -718,6 +797,7 @@ class SettingsPage(Mixins.QTranslatable, QMainWindow):
self.connectionSection.addCard(self.forceLocalhostCard)
self.connectionSection.addCard(self.connectionProgressCard)
self.connectionSection.addCard(self.metricsCollectionCard)
self.connectionSection.addCard(self.endpointInfoCard)
self.connectionSection.addCard(self.clearTrafficUsageCard)
self.connectionSection.addCard(self.editorWhitespaceCard)
@@ -1022,6 +1102,10 @@ class SettingsPage(Mixins.QTranslatable, QMainWindow):
"""Create and retain the existing Tun2socks settings dialog."""
self._tunSettingsDialogFactory(parent=self).open()
def _showEndpointPrivacy(self):
"""Show the end-user disclosure for the active endpoint providers."""
_endpointPrivacyMessageBox(self).open()
def setConnectionControlsEnabled(self, enabled: bool):
"""Disable connection-sensitive settings during a transition."""
self.tunModeCard.checkBox.setEnabled(bool(enabled) and self._tunModeAvailable)
+2 -1
View File
@@ -14,6 +14,7 @@ authors = [
]
dependencies = [
"PySide6-Essentials",
"PySide6-Addons",
"ujson",
"pybase64",
"pyqrcode",
@@ -65,7 +66,7 @@ version = { attr = "Furious.Version.__version__" }
Furious = ["Data/**"]
[tool.uv.extra-build-dependencies]
Furious-GUI = ["PySide6-Essentials"]
Furious-GUI = ["PySide6-Essentials", "PySide6-Addons"]
[tool.black]
skip-string-normalization = true
+1
View File
@@ -1,4 +1,5 @@
PySide6-Essentials
PySide6-Addons
ujson
pybase64
pyqrcode
+1
View File
@@ -18,6 +18,7 @@ system proxy, TUN, routing, update network clients, or real proxy cores.
| External process launch, output, shutdown, threads, TUN metadata | `test_external_core.py` |
| Xray/Hysteria2 native-TUN ownership and proxy-only stripping | `test_native_tun_semantics.py` |
| Rolling metrics, stable buckets, lazy rendering, and hover | `test_metrics_behavior.py` |
| Proxy-only endpoint discovery, caching, and presentation | `test_endpoint_info.py` |
| Settings sandbox and navigation overlay behavior | `test_isolation_and_navigation.py` |
| Editor mappings, lazy log rendering, routing/message-box/connection UI | `test_ui_behavior.py` |
| Direct Qt ownership and destruction across independent UI families | `test_qt_lifetime.py` |
+955
View File
@@ -0,0 +1,955 @@
# Copyright (C) 2024present Loren Eteval & contributors <loren.eteval@proton.me>
#
# 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 <https://www.gnu.org/licenses/>.
"""Verify proxy-only endpoint discovery, cache invalidation, and presentation."""
from __future__ import annotations
from Furious.Controllers import ConnectionState, SettingsController
from Furious.Frozenlib import AppSettings
from Furious.Qt import AppQSwitch, AppStyleSheet
from Furious.Qt import gettext as _
from Furious.Service.EndpointInfoService import (
PROXY_ENDPOINT_INFO_SETTING,
EndpointInfo,
EndpointInfoService,
EndpointInfoState,
EndpointLocation,
ProxyEndpointHttpClient,
)
from Furious.Widget.EndpointInfoWidget import EndpointInfoWidget
from Furious.Window.SettingsPage import (
_EndpointInfoSettingsCard,
_endpointPrivacyMessageBox,
_endpointPrivacyParagraphs,
)
from PySide6 import QtCore, QtGui
from PySide6.QtNetwork import QNetworkReply
from PySide6.QtWebEngineWidgets import QWebEngineView
from PySide6.QtWidgets import QFrame, QWidget
from shiboken6 import isValid
from tests.support import (
application,
collectAtBoundary,
isolatedSettings,
processQtEvents,
)
import json
from pathlib import Path
import unittest
from unittest.mock import patch
import weakref
class _Controller(QtCore.QObject):
"""Expose the connection signals consumed by the endpoint service."""
stateChanged = QtCore.Signal(object)
activeConfigurationChanged = QtCore.Signal(object)
def __init__(self, state=ConnectionState.Connected):
"""Initialize a deterministic state-only controller."""
super().__init__()
self.state = state
def isConnected(self):
"""Return whether the fixture represents an active connection."""
return self.state is ConnectionState.Connected
def setState(self, state):
"""Publish one connection transition."""
self.state = state
self.stateChanged.emit(state)
class _HttpClient(QtCore.QObject):
"""Record requests and allow tests to complete them without network I/O."""
completed = QtCore.Signal(object, object, str)
def __init__(self, proxyAccepted=True):
"""Initialize request and proxy observations."""
super().__init__()
self.proxyAccepted = proxyAccepted
self.configuredProxies = []
self.requests = []
self.cancelCount = 0
def configureHttpProxy(self, proxy):
"""Record the only permitted transport path."""
self.configuredProxies.append(proxy)
return self.proxyAccepted
def request(self, url, context):
"""Record one provider request and its completion context."""
self.requests.append((url, context))
def completeLatest(self, data=None, error=''):
"""Complete the newest recorded request synchronously."""
_url, context = self.requests[-1]
self.completed.emit(context, data, error)
def cancelAll(self):
"""Record connection-scope cancellation."""
self.cancelCount += 1
class _PendingReply(QNetworkReply):
"""Provide a controllable reply for HTTP-client cancellation tests."""
def __init__(self, parent=None):
"""Initialize an unfinished readable reply."""
super().__init__(parent)
self.abortCount = 0
self.open(QtCore.QIODevice.OpenModeFlag.ReadOnly)
def abort(self):
"""Record cancellation and emit the normal Qt completion signal."""
self.abortCount += 1
self.setError(
QNetworkReply.NetworkError.OperationCanceledError,
'cancelled by test',
)
self.setFinished(True)
self.finished.emit()
def finishSuccessfully(self):
"""Complete normally without publishing response bytes."""
self.setFinished(True)
self.finished.emit()
def readData(self, maximumLength):
"""Return EOF because the fixture never publishes response data."""
return bytes()
class _EventRecorder(QtCore.QObject):
"""Record one event type without consuming the watched object's event."""
def __init__(self, eventType, parent=None):
"""Initialize an empty event observation list."""
super().__init__(parent)
self.eventType = eventType
self.events = []
def eventFilter(self, watched, event):
"""Record matching events and preserve normal delivery."""
if event.type() is self.eventType:
self.events.append(watched)
return False
class _PresentationService(QtCore.QObject):
"""Provide the minimal immutable API used by EndpointInfoWidget."""
stateChanged, resultChanged, enabledChanged = (
QtCore.Signal(object),
QtCore.Signal(object),
QtCore.Signal(bool),
)
def __init__(self):
"""Initialize one ready presentation snapshot."""
super().__init__()
self.state = EndpointInfoState.Ready
self.enabled = True
self.result = EndpointInfo(
ipv4='192.0.2.1',
ipv4Resolved=True,
ipv6Resolved=True,
locationResolved=True,
location=EndpointLocation(
countryCode='US',
countryName='United States',
city='Los Angeles',
region='California',
latitude=34.05,
longitude=-118.24,
organization='Example Network',
),
)
self.refreshCount = 0
@QtCore.Slot()
def refresh(self):
"""Record an explicit UI refresh request."""
self.refreshCount += 1
self.state = EndpointInfoState.Loading
self.result = EndpointInfo()
self.stateChanged.emit(self.state)
self.resultChanged.emit(self.result)
def completeRefresh(self):
"""Publish a deterministic refreshed endpoint at the same coordinate."""
self.state = EndpointInfoState.Ready
self.result = EndpointInfo(
ipv4='192.0.2.1',
ipv4Resolved=True,
ipv6Resolved=True,
locationResolved=True,
location=EndpointLocation(
countryCode='US',
countryName='United States',
city='Los Angeles',
region='California',
latitude=34.05,
longitude=-118.24,
organization='Example Network',
),
)
self.resultChanged.emit(self.result)
self.stateChanged.emit(self.state)
class EndpointInfoServiceTest(unittest.TestCase):
"""Exercise provider fallback and per-connection caching deterministically."""
@classmethod
def setUpClass(cls):
"""Create the process-wide headless QApplication."""
application()
def tearDown(self):
"""Drain deferred QObject destruction after each case."""
collectAtBoundary()
def testHttpCancellationDropsReplyContextWithoutCompletion(self):
"""Cancel deterministically without object-ID bookkeeping or late data."""
client = ProxyEndpointHttpClient()
reply = _PendingReply(client)
completions = []
client.completed.connect(lambda *values: completions.append(values))
with patch.object(client, 'get', return_value=reply):
client.request('https://example.invalid', {'generation': 7})
self.assertEqual(
client._pendingRequests,
{reply: {'generation': 7}},
)
client.cancelAll()
self.assertEqual(reply.abortCount, 1)
self.assertEqual(client._pendingRequests, {})
self.assertEqual(completions, [])
client.deleteLater()
def testHttpCompletionReleasesPendingRequestEntry(self):
"""Release the explicit reply/context pair on normal completion."""
client = ProxyEndpointHttpClient()
reply = _PendingReply(client)
completions = []
client.completed.connect(lambda *values: completions.append(values))
with patch.object(client, 'get', return_value=reply):
client.request('https://example.invalid', {'generation': 9})
reply.finishSuccessfully()
self.assertEqual(client._pendingRequests, {})
self.assertEqual(completions, [({'generation': 9}, b'', '')])
client.deleteLater()
@staticmethod
def _service(
proxy='127.0.0.1:10809',
*,
proxyAccepted=True,
enabled=True,
):
controller = _Controller()
client = _HttpClient(proxyAccepted=proxyAccepted)
service = EndpointInfoService(
controller=controller,
httpClient=client,
proxyResolver=lambda: proxy,
enabled=enabled,
)
return service, controller, client
def testInspectionDefaultsOffAndNeverIssuesProviderRequests(self):
"""Require an explicit opt-in before any endpoint provider is contacted."""
settings = QtCore.QSettings()
settings.remove(PROXY_ENDPOINT_INFO_SETTING)
controller = _Controller()
client = _HttpClient()
service = EndpointInfoService(
controller=controller,
httpClient=client,
proxyResolver=lambda: '127.0.0.1:10809',
)
service.setPageVisible(True)
service.refresh()
self.assertFalse(service.enabled)
self.assertEqual(service.state, EndpointInfoState.Disabled)
self.assertEqual(client.requests, [])
self.assertEqual(client.configuredProxies, [])
controller.deleteLater()
client.deleteLater()
service.deleteLater()
def testEnableDisableInvalidatesPendingLookupAndRejectsLateResults(self):
"""Apply opt-in immediately and prevent disabled replies repopulating data."""
service, controller, client = self._service(enabled=False)
service.setPageVisible(True)
self.assertEqual(client.requests, [])
service.setEnabled(True)
self.assertEqual(len(client.requests), 1)
_url, context = client.requests[-1]
service.setEnabled(False)
service.refresh()
client.completed.emit(context, b'ip=192.0.2.20\nloc=US\n', '')
self.assertEqual(service.state, EndpointInfoState.Disabled)
self.assertEqual(service.result, EndpointInfo())
self.assertEqual(len(client.requests), 1)
self.assertGreaterEqual(client.cancelCount, 1)
controller.deleteLater()
client.deleteLater()
service.deleteLater()
def testSettingsCardDefaultsOffPersistsAndExposesInAppPrivacyAction(self):
"""Bind one Fluent switch and one local privacy action to the preference."""
privacyRequests = []
with isolatedSettings():
card = _EndpointInfoSettingsCard(
SettingsController.setProxyEndpointInfoEnabled,
lambda: privacyRequests.append(True),
)
self.assertIsInstance(card.checkBox, AppQSwitch)
self.assertFalse(card.checkBox.isChecked())
self.assertEqual(card.privacyButton.objectName(), 'SettingsLinkButton')
self.assertEqual(card.privacyButton.text(), _('Data usage'))
with patch('Furious.Window.SettingsPage._', side_effect=lambda text: text):
privacyParagraphs = _endpointPrivacyParagraphs()
privacyText = '\n'.join(privacyParagraphs)
for paragraph in privacyParagraphs:
self.assertGreaterEqual(paragraph.count('<br>\n'), 3)
for provider in (
'Cloudflare',
'ipify',
'ipapi.co',
'OpenFreeMap',
'OpenStreetMap',
):
self.assertIn(provider, privacyText)
self.assertNotIn(f'<i>{provider}</i>', privacyText)
self.assertNotIn('<a ', privacyText)
self.assertNotIn('href=', privacyText)
self.assertNotIn('<i>', privacyText)
self.assertIn('\n', privacyText)
self.assertNotIn('<b>Privacy</b>', privacyText)
self.assertNotIn('proxy credentials', privacyText)
self.assertNotIn('subscription URLs', privacyText)
self.assertNotIn('Furious', privacyText)
self.assertNotIn('Qt Location', privacyText)
self.assertNotIn('first time Network Statistics', privacyText)
card.checkBox.setChecked(True)
self.assertTrue(AppSettings.isStateON_(PROXY_ENDPOINT_INFO_SETTING))
card.privacyButton.click()
self.assertEqual(privacyRequests, [True])
card.checkBox.setChecked(False)
self.assertFalse(AppSettings.isStateON_(PROXY_ENDPOINT_INFO_SETTING))
card.close()
card.deleteLater()
def testLookupIsLazyProxyOnlyAndCachedForConnection(self):
"""Never issue a direct request and reuse one completed session result."""
service, controller, client = self._service()
self.assertEqual(service.state, EndpointInfoState.Loading)
self.assertEqual(client.requests, [])
service.setPageVisible(True)
self.assertEqual(client.configuredProxies, ['127.0.0.1:10809'])
self.assertIn('1.1.1.1', client.requests[-1][0])
client.completeLatest(b'ip=192.0.2.10\nloc=US\n')
self.assertEqual(service.result.ipv4, '192.0.2.10')
self.assertTrue(service.result.ipv4Resolved)
self.assertFalse(service.result.ipv6Resolved)
self.assertIn('2606:4700:4700::1111', client.requests[-1][0])
client.completeLatest(error='IPv6 unavailable')
self.assertEqual(client.requests[-1][0], 'https://api6.ipify.org')
client.completeLatest(b'2001:db8::10')
self.assertEqual(service.result.ipv6, '2001:db8::10')
self.assertTrue(service.result.ipv6Resolved)
self.assertFalse(service.result.locationResolved)
self.assertIn('192.0.2.10', client.requests[-1][0])
location = {
'ip': '192.0.2.10',
'country_code': 'US',
'country_name': 'United States',
'region': 'California',
'city': 'Los Angeles',
'latitude': 34.05,
'longitude': -118.24,
'org': 'Example Network',
}
client.completeLatest(json.dumps(location).encode())
self.assertEqual(service.state, EndpointInfoState.Ready)
self.assertEqual(service.result.location.city, 'Los Angeles')
self.assertTrue(service.result.locationResolved)
requestCount = len(client.requests)
service.setPageVisible(False)
service.setPageVisible(True)
service.requestIfNeeded()
self.assertEqual(len(client.requests), requestCount)
controller.activeConfigurationChanged.emit(object())
self.assertEqual(service.state, EndpointInfoState.Loading)
self.assertEqual(service.result, EndpointInfo())
self.assertEqual(len(client.requests), requestCount + 1)
controller.deleteLater()
client.deleteLater()
service.deleteLater()
def testMissingOrRejectedProxyNeverFallsBackToDirectAccess(self):
"""Fail closed when the active local HTTP proxy cannot be configured."""
for proxy, proxyAccepted in ((None, True), ('127.0.0.1:10809', False)):
with self.subTest(proxy=proxy, proxyAccepted=proxyAccepted):
service, controller, client = self._service(
proxy,
proxyAccepted=proxyAccepted,
)
service.setPageVisible(True)
self.assertEqual(service.state, EndpointInfoState.Failed)
self.assertEqual(client.requests, [])
controller.deleteLater()
client.deleteLater()
service.deleteLater()
def testInvalidResponsesFallbackAndLateOldSessionDataIsIgnored(self):
"""Validate addresses and reject results from a disconnected session."""
service, controller, client = self._service()
service.setPageVisible(True)
staleRequest = client.requests[-1]
client.completeLatest(b'ip=not-an-address\nloc=US\n')
self.assertEqual(client.requests[-1][0], 'https://api4.ipify.org')
controller.setState(ConnectionState.Disconnected)
_url, context = staleRequest
client.completed.emit(context, b'ip=192.0.2.20\nloc=US\n', '')
self.assertEqual(service.state, EndpointInfoState.Disconnected)
self.assertEqual(service.result, EndpointInfo())
self.assertGreaterEqual(client.cancelCount, 1)
controller.deleteLater()
client.deleteLater()
service.deleteLater()
def testIPv6FailureKeepsIPv4AndCompletesLocation(self):
"""Treat IPv6 absence as a partial result rather than total failure."""
service, controller, client = self._service()
service.setPageVisible(True)
client.completeLatest(b'ip=8.8.8.8\nloc=US\n')
client.completeLatest(error='no IPv6 route')
client.completeLatest(error='no IPv6 fallback')
self.assertEqual(service.result.ipv4, '8.8.8.8')
self.assertEqual(service.result.ipv6, '')
self.assertTrue(service.result.ipv4Resolved)
self.assertTrue(service.result.ipv6Resolved)
self.assertFalse(service.result.locationResolved)
self.assertIn('8.8.8.8', client.requests[-1][0])
location = {
'ip': '8.8.8.8',
'country_code': 'US',
'country_name': 'United States',
'region': 'California',
'city': 'Mountain View',
'latitude': 37.4,
'longitude': -122.1,
}
client.completeLatest(json.dumps(location).encode())
self.assertEqual(service.state, EndpointInfoState.Ready)
self.assertEqual(service.result.location.countryCode, 'US')
self.assertTrue(service.result.locationResolved)
controller.deleteLater()
client.deleteLater()
service.deleteLater()
def testCountryIsPlainTextAndFlagResourcesAreAbsent(self):
"""Keep country presentation while removing the complete flag subsystem."""
repositoryRoot = Path(__file__).resolve().parents[1]
resourceManifest = (repositoryRoot / 'Resources.qrc').read_text(
encoding='utf-8'
)
self.assertNotIn('Icons/flags', resourceManifest)
self.assertNotIn('flag-icons', resourceManifest)
self.assertFalse((repositoryRoot / 'Icons' / 'flags').exists())
service = _PresentationService()
widget = EndpointInfoWidget(service)
self.assertIs(type(widget.infoCard), QFrame)
self.assertEqual(widget.countryRow.valueLabel.text(), 'United States (US)')
self.assertFalse(hasattr(widget.countryRow, 'flagLabel'))
widget.close()
widget.deleteLater()
service.deleteLater()
def testDataUsageDialogHasStaticProviderTextAndTransientLifetime(self):
"""Render provider names without links and destroy each closed disclosure."""
parent = QWidget()
references = []
for _index in range(5):
with patch('Furious.Window.SettingsPage._', side_effect=lambda text: text):
messageBox = _endpointPrivacyMessageBox(parent)
body = messageBox.informativeText()
references.append(weakref.ref(messageBox))
self.assertIn('Cloudflare', body)
self.assertIn('OpenFreeMap', body)
self.assertIn('OpenStreetMap', body)
self.assertNotIn('CARTO', body)
self.assertNotIn('<a ', body)
self.assertNotIn('href=', body)
self.assertIn('<br><br>\n', body)
self.assertNotIn('<br><br><b>', body)
self.assertNotIn('<b>Privacy</b>', body)
self.assertNotIn('proxy configuration', body)
self.assertNotIn('Furious', body)
self.assertNotIn('Qt Location', body)
self.assertEqual(len(messageBox.buttons()), 1)
messageBox.show()
processQtEvents()
messageBox.close()
processQtEvents()
del messageBox
collectAtBoundary()
self.assertTrue(all(reference() is None for reference in references))
parent.close()
parent.deleteLater()
def testFieldAndMapSpinnersTrackPartialCompletionIndependently(self):
"""Keep each unresolved field animated while preserving partial results."""
service = _PresentationService()
widget = EndpointInfoWidget(service)
widget.mapWidget._ensureSourceLoaded = lambda: None
widget.show()
processQtEvents()
service.refresh()
processQtEvents()
rows = (
widget.ipv4Row,
widget.ipv6Row,
widget.countryRow,
widget.locationRow,
widget.organizationRow,
)
self.assertTrue(all(row.loadingSpinner.is_spinning for row in rows))
self.assertTrue(widget.mapWidget.loadingSpinner.is_spinning)
service.result = EndpointInfo(ipv4='192.0.2.1', ipv4Resolved=True)
service.resultChanged.emit(service.result)
processQtEvents()
self.assertEqual(widget.ipv4Row.valueLabel.text(), '192.0.2.1')
self.assertFalse(widget.ipv4Row.loadingSpinner.is_spinning)
self.assertTrue(widget.ipv6Row.loadingSpinner.is_spinning)
self.assertTrue(widget.countryRow.loadingSpinner.is_spinning)
self.assertTrue(widget.mapWidget.loadingSpinner.is_spinning)
service.result = EndpointInfo(
ipv4='192.0.2.1',
ipv4Resolved=True,
ipv6Resolved=True,
)
service.resultChanged.emit(service.result)
processQtEvents()
self.assertEqual(widget.ipv6Row.valueLabel.text(), _('Not available'))
self.assertFalse(widget.ipv6Row.loadingSpinner.is_spinning)
self.assertTrue(widget.locationRow.loadingSpinner.is_spinning)
service.state = EndpointInfoState.Ready
service.result = EndpointInfo(
ipv4='192.0.2.1',
ipv4Resolved=True,
ipv6Resolved=True,
locationResolved=True,
location=EndpointLocation(
countryCode='US',
countryName='United States',
city='Los Angeles',
region='California',
latitude=34.05,
longitude=-118.24,
organization='Example Network',
),
)
service.resultChanged.emit(service.result)
service.stateChanged.emit(service.state)
widget.mapWidget._mapBecameReady()
processQtEvents()
self.assertTrue(all(not row.loadingSpinner.is_spinning for row in rows))
self.assertFalse(widget.mapWidget.loadingSpinner.is_spinning)
widget.close()
widget.deleteLater()
service.deleteLater()
def testPresentationShowsValidatedValuesAndApproximateLocation(self):
"""Render selectable IP data and expose one reusable refresh action."""
service = _PresentationService()
widget = EndpointInfoWidget(service)
widget.resize(900, 320)
scripts = []
widget.mapWidget._documentLoaded = True
widget.mapWidget._runJavaScript = scripts.append
widget.mapWidget._syncWebState()
self.assertEqual(widget.ipv4Row.valueLabel.text(), '192.0.2.1')
self.assertEqual(widget.refreshButton.toolTip(), _('Refresh'))
self.assertEqual(widget.ipv4Row.copyButton.toolTip(), _('Copy'))
self.assertEqual(widget.countryRow.valueLabel.text(), 'United States (US)')
self.assertEqual(
widget.locationRow.valueLabel.text(),
'Los Angeles, California, United States',
)
self.assertFalse(hasattr(widget.countryRow, 'flagLabel'))
self.assertFalse(widget.statusWidget.isVisible())
self.assertEqual(widget.statusLabel.text(), '')
self.assertEqual(widget.bodyLayout.stretch(0), 1)
self.assertEqual(widget.bodyLayout.stretch(1), 2)
self.assertGreaterEqual(widget.minimumHeight(), 360)
self.assertIsInstance(widget.mapWidget.webView, QWebEngineView)
self.assertEqual(widget.mapWidget.webProfile.httpUserAgent(), 'Mozilla/5.0')
self.assertNotIn('Furious', widget.mapWidget.webProfile.httpUserAgent())
self.assertTrue(widget.mapWidget._lastWebState['markerVisible'])
self.assertAlmostEqual(widget.mapWidget._lastWebState['markerLatitude'], 34.05)
self.assertAlmostEqual(
widget.mapWidget._lastWebState['markerLongitude'], -118.24
)
self.assertTrue(scripts)
self.assertEqual(
widget.ipv4Row.valueContainer.objectName(),
'EndpointFieldValueContainer',
)
self.assertEqual(widget.statusWidget.objectName(), 'EndpointStatusWidget')
self.assertIn(
'QWidget#EndpointFieldValueContainer',
AppStyleSheet.forTheme(AppStyleSheet.Light),
)
self.assertIn(
'QWidget#EndpointStatusWidget',
AppStyleSheet.forTheme(AppStyleSheet.Light),
)
wheelEvent = QtGui.QWheelEvent(
QtCore.QPointF(20, 20),
QtCore.QPointF(20, 20),
QtCore.QPoint(),
QtCore.QPoint(0, 60),
QtCore.Qt.MouseButton.NoButton,
QtCore.Qt.KeyboardModifier.NoModifier,
QtCore.Qt.ScrollPhase.ScrollUpdate,
False,
)
QtCore.QCoreApplication.sendEvent(widget.mapWidget.webView, wheelEvent)
self.assertTrue(wheelEvent.isAccepted())
html = widget.mapWidget.HtmlPath.read_text(encoding='utf-8')
mapScriptPath = widget.mapWidget.HtmlPath.with_name('EndpointMap.js')
mapScript = mapScriptPath.read_text(encoding='utf-8')
self.assertIn('EndpointMap.js', html)
self.assertIn('map.setStyle(nextStyle)', mapScript)
self.assertIn("document.documentElement.dataset.theme", mapScript)
self.assertIn('--endpoint-attribution-background', html)
self.assertIn('--endpoint-attribution-foreground', html)
self.assertIn('.maplibregl-canvas:focus', html)
self.assertIn('outline: none', html)
self.assertNotIn('color-mix(', html)
self.assertEqual(
{style.name: style.value for style in widget.mapWidget.Style},
{
'Bright': 'https://tiles.openfreemap.org/styles/bright',
'Liberty': 'https://tiles.openfreemap.org/styles/liberty',
'Positron': 'https://tiles.openfreemap.org/styles/positron',
'Dark': 'https://tiles.openfreemap.org/styles/dark',
'Fiord': 'https://tiles.openfreemap.org/styles/fiord',
},
)
self.assertEqual(
widget.mapWidget._lastWebState['lightStyleUrl'],
widget.mapWidget.LightStyle.value,
)
self.assertIs(widget.mapWidget.LightStyle, widget.mapWidget.Style.Liberty)
self.assertEqual(
widget.mapWidget._lastWebState['darkStyleUrl'],
widget.mapWidget.DarkStyle.value,
)
self.assertIs(widget.mapWidget.DarkStyle, widget.mapWidget.Style.Fiord)
self.assertNotIn('tile.openstreetmap.org', html)
self.assertNotIn('opacity: 0.60', html)
self.assertNotIn('opacity: 0.60', mapScript)
self.assertTrue(mapScriptPath.is_file())
self.assertTrue((widget.mapWidget.HtmlPath.parent / 'maplibre-gl.js').is_file())
self.assertTrue(
(widget.mapWidget.HtmlPath.parent / 'maplibre-gl.css').is_file()
)
self.assertTrue((widget.mapWidget.HtmlPath.parent / 'LICENSE').is_file())
widget.mapWidget.setActive(True)
self.assertFalse(widget.mapWidget.webView.isHidden())
self.assertFalse(widget.mapWidget.placeholderWidget.isHidden())
hideRecorder = _EventRecorder(QtCore.QEvent.Type.Hide, widget)
widget.mapWidget.webView.installEventFilter(hideRecorder)
widget.mapWidget._mapBecameReady()
processQtEvents()
self.assertEqual(hideRecorder.events, [])
self.assertIs(widget.mapWidget._presentedWidget, widget.mapWidget.webView)
self.assertFalse(widget.mapWidget.webView.isHidden())
self.assertFalse(widget.mapWidget.placeholderWidget.isHidden())
service.state = EndpointInfoState.Loading
service.stateChanged.emit(service.state)
self.assertIs(
widget.mapWidget._presentedWidget,
widget.mapWidget.placeholderWidget,
)
self.assertEqual(widget.mapWidget.placeholderLabel.text(), _('Detecting...'))
self.assertFalse(widget.mapWidget.webView.isHidden())
self.assertFalse(widget.mapWidget.placeholderWidget.isHidden())
self.assertTrue(widget.mapWidget.loadingSpinner.is_spinning)
service.state = EndpointInfoState.Ready
service.stateChanged.emit(service.state)
self.assertIs(widget.mapWidget._presentedWidget, widget.mapWidget.webView)
self.assertFalse(widget.mapWidget.webView.isHidden())
self.assertFalse(widget.mapWidget.placeholderWidget.isHidden())
widget.refreshButton.click()
self.assertEqual(service.refreshCount, 1)
self.assertIs(
widget.mapWidget._presentedWidget,
widget.mapWidget.placeholderWidget,
)
self.assertEqual(widget.mapWidget.placeholderLabel.text(), _('Detecting...'))
previousRevision = widget.mapWidget._viewRevision
service.completeRefresh()
processQtEvents()
self.assertGreater(widget.mapWidget._viewRevision, previousRevision)
self.assertAlmostEqual(widget.mapWidget._lastWebState['markerLatitude'], 34.05)
self.assertAlmostEqual(
widget.mapWidget._lastWebState['markerLongitude'], -118.24
)
widget.close()
widget.deleteLater()
service.deleteLater()
def testMapUsesForcedApplicationThemeFromItsFirstState(self):
"""Initialize map styling from the app preference, not the host palette."""
service = _PresentationService()
app = application()
with patch.object(type(app), 'theme', return_value=AppStyleSheet.Light):
widget = EndpointInfoWidget(service)
scripts = []
widget.mapWidget._documentLoaded = True
widget.mapWidget._runJavaScript = scripts.append
widget.mapWidget._syncWebState()
self.assertEqual(widget.mapWidget._theme, AppStyleSheet.Light)
self.assertFalse(widget.mapWidget._lastWebState['darkMode'])
self.assertEqual(
widget.mapWidget._lastWebState['lightStyleUrl'],
widget.mapWidget.LightStyle.value,
)
self.assertTrue(scripts)
widget.close()
widget.deleteLater()
service.deleteLater()
def testMapReadyAndRefreshDoNotToggleWidgetVisibility(self):
"""Raise persistent map layers without recreating the native surface."""
service = _PresentationService()
widget = EndpointInfoWidget(service)
widget.mapWidget._documentLoaded = True
with patch.object(widget.mapWidget, '_ensureSourceLoaded'):
widget.show()
processQtEvents()
windowHideRecorder = _EventRecorder(QtCore.QEvent.Type.Hide, widget)
mapHideRecorder = _EventRecorder(QtCore.QEvent.Type.Hide, widget)
mapShowRecorder = _EventRecorder(QtCore.QEvent.Type.Show, widget)
widget.installEventFilter(windowHideRecorder)
widget.mapWidget.webView.installEventFilter(mapHideRecorder)
widget.mapWidget.webView.installEventFilter(mapShowRecorder)
widget.mapWidget._mapBecameReady()
processQtEvents()
service.refresh()
processQtEvents()
self.assertEqual(windowHideRecorder.events, [])
self.assertEqual(mapHideRecorder.events, [])
self.assertEqual(mapShowRecorder.events, [])
self.assertIs(
widget.mapWidget._presentedWidget,
widget.mapWidget.placeholderWidget,
)
widget.close()
widget.deleteLater()
service.deleteLater()
def testMapThemeSwitchPreservesViewMarkerAndPersistentScene(self):
"""Switch real map styles without recreating the persistent renderer."""
service = _PresentationService()
widget = EndpointInfoWidget(service)
scripts = []
widget.mapWidget._documentLoaded = True
widget.mapWidget._runJavaScript = scripts.append
widget.mapWidget._syncWebState()
webView = widget.mapWidget.webView
webPage = webView.page()
webProfile = widget.mapWidget.webProfile
webBridge = widget.mapWidget.webBridge
initialRevision = widget.mapWidget._viewRevision
marker = (
widget.mapWidget._lastWebState['markerLatitude'],
widget.mapWidget._lastWebState['markerLongitude'],
)
for index in range(40):
theme = AppStyleSheet.Dark if index % 2 == 0 else AppStyleSheet.Light
widget.themeChangedCallback(theme)
processQtEvents()
self.assertIs(widget.mapWidget.webView, webView)
self.assertIs(widget.mapWidget.webView.page(), webPage)
self.assertIs(widget.mapWidget.webProfile, webProfile)
self.assertIs(widget.mapWidget.webBridge, webBridge)
self.assertEqual(widget.mapWidget._viewRevision, initialRevision)
self.assertEqual(
widget.mapWidget._lastWebState['darkMode'],
theme == AppStyleSheet.Dark,
)
self.assertEqual(
(
widget.mapWidget._lastWebState['markerLatitude'],
widget.mapWidget._lastWebState['markerLongitude'],
),
marker,
)
self.assertEqual(len(widget.findChildren(QWebEngineView)), 1)
self.assertEqual(len(scripts), 41)
widgetReference = weakref.ref(widget)
webViewReference = weakref.ref(webView)
webPageReference = weakref.ref(webPage)
webProfileReference = weakref.ref(webProfile)
webBridgeReference = weakref.ref(webBridge)
widget.close()
widget.deleteLater()
service.deleteLater()
del webBridge
del webProfile
del webPage
del webView
del widget
collectAtBoundary()
self.assertIsNone(widgetReference())
for reference in (
webViewReference,
webPageReference,
webProfileReference,
webBridgeReference,
):
wrapper = reference()
self.assertTrue(wrapper is None or not isValid(wrapper))
if __name__ == '__main__':
unittest.main()