mirror of
https://github.com/LorenEteval/Furious.git
synced 2026-09-22 23:08:08 +03:00
Add proxy endpoint information
Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
@@ -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."""
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
})();
|
||||
@@ -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
Vendored
+171
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,589 @@
|
||||
# Copyright (C) 2024–present 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)
|
||||
@@ -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',
|
||||
|
||||
@@ -0,0 +1,840 @@
|
||||
# Copyright (C) 2024–present 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()
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,4 +1,5 @@
|
||||
PySide6-Essentials
|
||||
PySide6-Addons
|
||||
ujson
|
||||
pybase64
|
||||
pyqrcode
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -0,0 +1,955 @@
|
||||
# Copyright (C) 2024–present 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()
|
||||
Reference in New Issue
Block a user