Add dedicated subscription management page

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2026-08-12 12:23:08 +08:00
parent 5b30c837c5
commit fdcbf36aa8
8 changed files with 750 additions and 279 deletions
+173 -79
View File
@@ -6,6 +6,7 @@ TRANSLATION = {
"Furious.Qt.QtWidgets",
"Furious.Widget.ServerTableView",
"Furious.Widget.SubscriptionTableView",
"Furious.Window.SubscriptionPage",
"Furious.Window.SubscriptionWindow"
],
"RU": "Удалить",
@@ -86,7 +87,8 @@ TRANSLATION = {
},
"Invalid data": {
"source": [
"Furious.Actions.Import"
"Furious.Actions.Import",
"Furious.Window.SubscriptionPage"
],
"RU": "Неверные данные",
"ZH": "无效的数据",
@@ -225,6 +227,7 @@ TRANSLATION = {
"Furious.Window.IndentDialog",
"Furious.Window.NetworkTestDialog",
"Furious.Window.ProxyBypassDialog",
"Furious.Window.SubscriptionPage",
"Furious.Window.SubscriptionWindow",
"Furious.Window.TextEditorWindow"
],
@@ -361,6 +364,7 @@ TRANSLATION = {
"Edit": {
"source": [
"Furious.Window.LogPage",
"Furious.Window.SubscriptionPage",
"Furious.Window.TextEditorWindow"
],
"RU": "Редактировать",
@@ -448,7 +452,8 @@ TRANSLATION = {
"Furious.Backends.Xray.VlessEditor",
"Furious.Backends.Xray.VmessEditor",
"Furious.Widget.ServerTableView",
"Furious.Widget.SubscriptionTableView"
"Furious.Widget.SubscriptionTableView",
"Furious.Window.SubscriptionPage"
],
"RU": "Примечание",
"ZH": "别名",
@@ -509,7 +514,8 @@ TRANSLATION = {
"Subscription": {
"source": [
"Furious.Widget.ServerTableView",
"Furious.Window.HomePage"
"Furious.Window.MainWindow",
"Furious.Window.SubscriptionPage"
],
"RU": "Подписка",
"ZH": "订阅",
@@ -532,14 +538,6 @@ TRANSLATION = {
"ZH": "速度",
"isReviewed": "True"
},
"Edit Configuration...": {
"source": [
"Furious.Actions.Configuration"
],
"RU": "Изменить конфигурацию...",
"ZH": "编辑配置...",
"isReviewed": "True"
},
"Move Up": {
"source": [
"Furious.Widget.ServerTableView"
@@ -664,6 +662,7 @@ TRANSLATION = {
},
"Add Subscription": {
"source": [
"Furious.Window.SubscriptionPage",
"Furious.Window.SubscriptionWindow"
],
"RU": "Добавить подписку",
@@ -688,6 +687,7 @@ TRANSLATION = {
},
"Edit Subscription": {
"source": [
"Furious.Window.SubscriptionPage",
"Furious.Window.SubscriptionWindow"
],
"RU": "Редактировать подписку",
@@ -705,6 +705,7 @@ TRANSLATION = {
"Add": {
"source": [
"Furious.Backends.Xray.RoutingWindow",
"Furious.Window.SubscriptionPage",
"Furious.Window.SubscriptionWindow"
],
"RU": "Добавить",
@@ -843,54 +844,6 @@ TRANSLATION = {
"ZH": "折叠导航",
"isReviewed": "True"
},
"Update Subscription (Use Current Proxy)": {
"source": [
"Furious.Window.HomePage"
],
"RU": "Обновить подписки (использовать текущий прокси-сервер)",
"ZH": "更新订阅(使用当前代理)",
"isReviewed": "True"
},
"Update Subscription (Force Proxy)": {
"source": [
"Furious.Window.HomePage"
],
"RU": "Обновить подписки (обязательно через прокси-сервер)",
"ZH": "更新订阅(强制代理)",
"isReviewed": "True"
},
"Update Subscription (No Proxy)": {
"source": [
"Furious.Window.HomePage"
],
"RU": "Обновить подписки (не использовать прокси)",
"ZH": "更新订阅(无代理)",
"isReviewed": "True"
},
"Edit Subscription...": {
"source": [
"Furious.Window.HomePage"
],
"RU": "Редактировать подписки...",
"ZH": "编辑订阅...",
"isReviewed": "True"
},
"Plugins": {
"source": [
"Furious.Window.HomePage"
],
"RU": "Плагины",
"ZH": "插件",
"isReviewed": "True"
},
"Core": {
"source": [
"Furious.Window.HomePage"
],
"RU": "Core",
"ZH": "Core",
"isReviewed": "True"
},
"Open Application Folder": {
"source": [
"Furious.Window.SettingsPage"
@@ -1042,7 +995,6 @@ TRANSLATION = {
},
"Settings": {
"source": [
"Furious.Actions.Settings",
"Furious.Window.MainWindow",
"Furious.Window.SettingsPage"
],
@@ -1356,6 +1308,7 @@ TRANSLATION = {
"Furious.Window.IndentDialog",
"Furious.Window.NetworkTestDialog",
"Furious.Window.ProxyBypassDialog",
"Furious.Window.SubscriptionPage",
"Furious.Window.SubscriptionWindow"
],
"RU": "OK",
@@ -1412,7 +1365,8 @@ TRANSLATION = {
},
"Auto Update": {
"source": [
"Furious.Widget.SubscriptionTableView"
"Furious.Widget.SubscriptionTableView",
"Furious.Window.SubscriptionPage"
],
"RU": "Автообновление",
"ZH": "自动更新",
@@ -1420,7 +1374,8 @@ TRANSLATION = {
},
"Auto Update Use Proxy": {
"source": [
"Furious.Widget.SubscriptionTableView"
"Furious.Widget.SubscriptionTableView",
"Furious.Window.SubscriptionPage"
],
"RU": "Автообновление (используя прокси)",
"ZH": "自动更新使用代理",
@@ -2014,7 +1969,9 @@ TRANSLATION = {
},
"Enabled": {
"source": [
"Furious.Backends.Xray.RoutingWindow"
"Furious.Backends.Xray.RoutingWindow",
"Furious.Widget.SubscriptionTableView",
"Furious.Window.SubscriptionPage"
],
"RU": "Включено",
"ZH": "已启用",
@@ -2022,7 +1979,8 @@ TRANSLATION = {
},
"Disabled": {
"source": [
"Furious.Backends.Xray.RoutingWindow"
"Furious.Backends.Xray.RoutingWindow",
"Furious.Widget.SubscriptionTableView"
],
"RU": "Отключено",
"ZH": "已禁用",
@@ -2265,14 +2223,6 @@ TRANSLATION = {
"ZH": "自定义Hysteria2 TUN设置...",
"isReviewed": "True"
},
"Extensions": {
"source": [
"Furious.Window.HomePage"
],
"RU": "Расширения",
"ZH": "扩展功能",
"isReviewed": "True"
},
"Log Type": {
"source": [
"Furious.Window.LogPage"
@@ -2496,7 +2446,7 @@ TRANSLATION = {
},
"Hysteria2 Traffic Statistics": {
"source": [
"Furious.Window.SettingsPage"
"Furious.Backends.Hysteria2.Plugin"
],
"RU": "Статистика трафика Hysteria2",
"ZH": "Hysteria2流量统计",
@@ -2624,7 +2574,7 @@ TRANSLATION = {
},
"Traffic Stats API URL": {
"source": [
"Furious.Window.SettingsPage"
"Furious.Backends.Hysteria2.Plugin"
],
"RU": "URL API статистики трафика",
"ZH": "流量统计API URL",
@@ -2632,7 +2582,7 @@ TRANSLATION = {
},
"Hysteria2 server API address; /traffic is appended automatically.": {
"source": [
"Furious.Window.SettingsPage"
"Furious.Backends.Hysteria2.Plugin"
],
"RU": "Адрес API сервера Hysteria2; /traffic добавляется автоматически.",
"ZH": "Hysteria2服务器API地址;将自动附加 /traffic。",
@@ -2640,7 +2590,7 @@ TRANSLATION = {
},
"Traffic Stats Client ID": {
"source": [
"Furious.Window.SettingsPage"
"Furious.Backends.Hysteria2.Plugin"
],
"RU": "Идентификатор клиента статистики",
"ZH": "流量统计客户端ID",
@@ -2648,7 +2598,7 @@ TRANSLATION = {
},
"Traffic Stats API Secret": {
"source": [
"Furious.Window.SettingsPage"
"Furious.Backends.Hysteria2.Plugin"
],
"RU": "Секрет API статистики трафика",
"ZH": "流量统计API密钥",
@@ -2656,7 +2606,7 @@ TRANSLATION = {
},
"Authorization value configured by the Hysteria2 server.": {
"source": [
"Furious.Window.SettingsPage"
"Furious.Backends.Hysteria2.Plugin"
],
"RU": "Значение авторизации, настроенное на сервере Hysteria2.",
"ZH": "Hysteria2服务器配置的Authorization值。",
@@ -2704,7 +2654,7 @@ TRANSLATION = {
},
"Authentication ID reported by the Hysteria2 server.": {
"source": [
"Furious.Window.SettingsPage"
"Furious.Backends.Hysteria2.Plugin"
],
"RU": "Идентификатор аутентификации, указанный сервером Hysteria2.",
"ZH": "Hysteria2服务器报告的身份验证 ID。",
@@ -2725,5 +2675,149 @@ TRANSLATION = {
"ZH": "重新连接后,将累计上传和下载流量从零开始计算。",
"isReviewed": "True",
"RU": "После переподключения начинать подсчёт накопленного исходящего и входящего трафика с нуля."
},
"Show Home Page...": {
"source": [
"Furious.Actions.Configuration"
],
"ZH": "显示主页...",
"isReviewed": "True",
"RU": "Показать главную страницу..."
},
"Show the server management Home page": {
"source": [
"Furious.Actions.Configuration"
],
"ZH": "显示服务器管理主页",
"isReviewed": "True",
"RU": "Показать главную страницу управления серверами"
},
"Last Updated": {
"source": [
"Furious.Widget.SubscriptionTableView"
],
"ZH": "上次更新",
"isReviewed": "True",
"RU": "Последнее обновление"
},
"Profiles": {
"source": [
"Furious.Widget.SubscriptionTableView"
],
"ZH": "配置文件",
"isReviewed": "True",
"RU": "Профили"
},
"Plugin Settings": {
"source": [
"Furious.Window.SettingsPage"
],
"ZH": "插件设置",
"isReviewed": "True",
"RU": "Настройки плагинов"
},
"Enable Metrics Collection": {
"source": [
"Furious.Window.SettingsPage"
],
"ZH": "启用指标收集",
"isReviewed": "True",
"RU": "Включить сбор метрик"
},
"Collect network speed and traffic history while connected.": {
"source": [
"Furious.Window.SettingsPage"
],
"ZH": "连接时收集网络速度和流量历史记录。",
"isReviewed": "True",
"RU": "Собирать историю скорости сети и трафика при подключении."
},
"URL": {
"source": [
"Furious.Window.SubscriptionPage"
],
"ZH": "URL",
"isReviewed": "True",
"RU": "URL"
},
"User Agent": {
"source": [
"Furious.Window.SubscriptionPage"
],
"ZH": "用户代理",
"isReviewed": "True",
"RU": "User-Agent"
},
"Profile Filter (Regex)": {
"source": [
"Furious.Window.SubscriptionPage"
],
"ZH": "配置文件筛选器(正则表达式)",
"isReviewed": "True",
"RU": "Фильтр профилей (регулярное выражение)"
},
"Please enter a subscription remark.": {
"source": [
"Furious.Window.SubscriptionPage"
],
"ZH": "请输入订阅备注。",
"isReviewed": "True",
"RU": "Введите название подписки."
},
"Please enter a valid subscription URL.": {
"source": [
"Furious.Window.SubscriptionPage"
],
"ZH": "请输入有效的订阅 URL。",
"isReviewed": "True",
"RU": "Введите корректный URL подписки."
},
"Subscriptions": {
"source": [
"Furious.Window.SubscriptionPage"
],
"ZH": "订阅管理",
"isReviewed": "True",
"RU": "Подписки"
},
"Update Using": {
"source": [
"Furious.Window.SubscriptionPage"
],
"ZH": "更新方式",
"isReviewed": "True",
"RU": "Способ обновления"
},
"Add From Clipboard": {
"source": [
"Furious.Window.SubscriptionPage"
],
"ZH": "从剪贴板添加",
"isReviewed": "True",
"RU": "Добавить из буфера обмена"
},
"Copy URL": {
"source": [
"Furious.Window.SubscriptionPage"
],
"ZH": "复制 URL",
"isReviewed": "True",
"RU": "Копировать URL"
},
"Update Selected": {
"source": [
"Furious.Window.SubscriptionPage"
],
"ZH": "更新所选项",
"isReviewed": "True",
"RU": "Обновить выбранные"
},
"Update All": {
"source": [
"Furious.Window.SubscriptionPage"
],
"ZH": "全部更新",
"isReviewed": "True",
"RU": "Обновить все"
}
}
+4
View File
@@ -33,8 +33,12 @@ class UserSubEntry:
remark: str
webURL: str
enabled: bool
autoupdate: str
proxy: str
userAgent: str
filter: str
lastUpdated: str
class UserSub:
+51 -3
View File
@@ -51,6 +51,7 @@ import logging
import icmplib
import functools
import collections
import datetime
__all__ = ['ServerTableView']
@@ -151,6 +152,8 @@ class MBoxUpdateSubsInfo(AppQMessageBox):
class SubscriptionManager(WebGETManager):
"""Coordinate subscription operations."""
subscriptionsChanged = QtCore.Signal()
def __init__(self, parent, **kwargs):
"""Initialize the SubscriptionManager."""
actionMessage = kwargs.pop('actionMessage', 'update subs')
@@ -229,6 +232,8 @@ class SubscriptionManager(WebGETManager):
# Show the MessageBox asynchronously
mbox.open()
self.subscriptionsChanged.emit()
def mustCall(self, **kwargs):
"""Perform the required completion hook."""
depthMap = kwargs.get('depthMap', {})
@@ -253,6 +258,27 @@ class SubscriptionManager(WebGETManager):
)
result = self.importer.importPayload(data, source)
profileFilter = str(kwargs.get('filter', '')).strip()
if result is not None and profileFilter:
try:
pattern = re.compile(profileFilter, re.IGNORECASE)
except re.error as ex:
logger.error(
f'invalid subscription filter for {remark!r}: {ex}. '
f'Importing all profiles'
)
else:
result = type(result)(
result.decoderId,
tuple(
profile
for profile in result.profiles
if pattern.search(str(getattr(profile, 'itemRemark', '')))
),
result.rejectedItems,
)
if result is None or not result.profiles:
failureArgs.append({'error': 'UnsupportedSubscriptionFormat', **kwargs})
else:
@@ -264,6 +290,13 @@ class SubscriptionManager(WebGETManager):
successArgs.append({'profiles': result.profiles, **kwargs})
unique = kwargs.get('unique', '')
if unique in Storage.UserSubs():
Storage.UserSubs()[unique]['lastUpdated'] = (
datetime.datetime.now().astimezone().isoformat(timespec='seconds')
)
def failureCallback(self, networkReply, **kwargs):
"""Handle a failed network operation."""
remark = kwargs.get('remark', '')
@@ -290,15 +323,21 @@ class SubscriptionManager(WebGETManager):
# Some providers reject the default Qt User-Agent (e.g. with 503),
# so identify ourselves explicitly.
request = QNetworkRequest(QtCore.QUrl(url))
userAgent = str(kwargs.get('userAgent', '')).strip()
request.setRawHeader(
b'User-Agent',
f'{APPLICATION_NAME}/{APPLICATION_VERSION}'.encode(),
(userAgent or f'{APPLICATION_NAME}/{APPLICATION_VERSION}').encode(),
)
self.webGET(request, logActionMessage=logActionMessage, **kwargs)
def updateSubsByUnique(self, unique: str, **kwargs):
"""Update subs by unique."""
subscription = Storage.UserSubs().get(unique)
if not subscription or not subscription.get('enabled', True):
return
depthMap = kwargs.get('depthMap', {'depth': 1})
successArgs = kwargs.get('successArgs', list())
failureArgs = kwargs.get('failureArgs', list())
@@ -316,11 +355,20 @@ class SubscriptionManager(WebGETManager):
def updateSubs(self, **kwargs):
"""Update subs."""
depthMap = {'depth': len(Storage.UserSubs())}
enabledKeys = tuple(
key
for key, subscription in Storage.UserSubs().items()
if subscription.get('enabled', True) and subscription.get('webURL')
)
if not enabledKeys:
return
depthMap = {'depth': len(enabledKeys)}
successArgs = list()
failureArgs = list()
for key in Storage.UserSubs().keys():
for key in enabledKeys:
self.updateSubsByUnique(
key,
depthMap=depthMap,
+85 -9
View File
@@ -132,7 +132,13 @@ class UserSubsTableModel(QtCore.QAbstractTableModel):
flags = QtCore.Qt.ItemFlag.ItemIsEnabled | QtCore.Qt.ItemFlag.ItemIsSelectable
if self.itemKey[index.column()] not in ['autoupdate', 'proxy']:
if self.itemKey[index.column()] not in [
'autoupdate',
'proxy',
'enabled',
'lastUpdated',
'profiles',
]:
flags |= QtCore.Qt.ItemFlag.ItemIsEditable
return flags
@@ -153,9 +159,20 @@ class UserSubsTableModel(QtCore.QAbstractTableModel):
subsob = self.subsObjectByRow(row)
text = self.headers[column](subsob)
mapped = self.itemKey[column]
if mapped == 'profiles':
unique = self.uniqueByRow(row)
text = str(
sum(
1
for profile in Storage.UserServers()
if profile.itemSubscription == unique
)
)
if role == QtCore.Qt.ItemDataRole.DisplayRole:
if self.itemKey[column] in ['autoupdate', 'proxy']:
if mapped in ['autoupdate', 'proxy', 'enabled']:
return _(text)
return text
@@ -184,7 +201,13 @@ class UserSubsTableModel(QtCore.QAbstractTableModel):
mapped = self.itemKey[column]
if mapped in ['autoupdate', 'proxy']:
if mapped in [
'autoupdate',
'proxy',
'enabled',
'lastUpdated',
'profiles',
]:
return False
self.subsObjectByRow(row)[mapped] = str(value)
@@ -268,6 +291,10 @@ _TRANSLATABLE_HEADERS = [
_('Remark'),
_('Auto Update'),
_('Auto Update Use Proxy'),
_('Enabled'),
_('Disabled'),
_('Last Updated'),
_('Profiles'),
]
@@ -304,14 +331,31 @@ class SubscriptionTableView(Mixins.QTranslatable, AppQTableView):
Headers = [
SubscriptionTableColumn('Remark', lambda item: item.get('remark', '')),
SubscriptionTableColumn('URL', lambda item: item.get('webURL', '')),
SubscriptionTableColumn(
'Enabled',
lambda item: 'Enabled' if item.get('enabled', True) else 'Disabled',
),
SubscriptionTableColumn('Auto Update', lambda item: item.get('autoupdate', '')),
SubscriptionTableColumn(
'Auto Update Use Proxy', lambda item: item.get('proxy', '')
),
SubscriptionTableColumn(
'Last Updated',
lambda item: item.get('lastUpdated', ''),
),
SubscriptionTableColumn('Profiles'),
]
# Corresponds to 'Headers'
ItemKey = ['remark', 'webURL', 'autoupdate', 'proxy']
ItemKey = [
'remark',
'webURL',
'enabled',
'autoupdate',
'proxy',
'lastUpdated',
'profiles',
]
def __init__(self, *args, **kwargs):
"""Initialize the subscription table view."""
@@ -319,7 +363,9 @@ class SubscriptionTableView(Mixins.QTranslatable, AppQTableView):
super().__init__(*args, **kwargs)
self.timers = list(QtCore.QTimer() for i in range(len(Storage.UserSubs())))
self.timers = list(
QtCore.QTimer(self) for _index in range(len(Storage.UserSubs()))
)
self.timerConnected = list(False for i in range(len(Storage.UserSubs())))
self.sourceModel = UserSubsTableModel(self.Headers, self.ItemKey, parent=self)
self.setModel(self.sourceModel)
@@ -373,6 +419,13 @@ class SubscriptionTableView(Mixins.QTranslatable, AppQTableView):
list(set(index.row() for index in self.selectionModel().selectedRows()))
)
@property
def selectedUniques(self):
"""Return stable subscription IDs for the selected rows."""
keys = tuple(Storage.UserSubs())
return tuple(keys[row] for row in self.selectedIndex if row < len(keys))
@QtCore.Slot(QtCore.QPoint)
def handleCustomContextMenuRequested(self, point):
"""Handle custom context menu requested."""
@@ -481,7 +534,7 @@ class SubscriptionTableView(Mixins.QTranslatable, AppQTableView):
assert isinstance(qtimer, QtCore.QTimer)
if timems is not None:
if timems is not None and subsob.get('enabled', True):
logger.info(
f'start auto update job for subscription ({remark}, {webURL}). '
f'Interval is {timems // (60 * 1000)} mins'
@@ -626,6 +679,12 @@ class SubscriptionTableView(Mixins.QTranslatable, AppQTableView):
def flushAll(self):
"""Refresh all."""
for item in Storage.UserSubs().values():
item.setdefault('enabled', True)
item.setdefault('userAgent', '')
item.setdefault('filter', '')
item.setdefault('lastUpdated', '')
self.sourceModel.emitAllChanged()
for index, key in enumerate(Storage.UserSubs()):
@@ -633,13 +692,26 @@ class SubscriptionTableView(Mixins.QTranslatable, AppQTableView):
def appendNewItem(self, **kwargs):
"""Append new item."""
unique, remark, webURL, autoupdate, proxy = (
(
unique,
remark,
webURL,
enabled,
autoupdate,
proxy,
userAgent,
profileFilter,
lastUpdated,
) = (
kwargs.pop('unique', ''),
kwargs.pop('remark', ''),
kwargs.pop('webURL', ''),
# Below values are not filled when adding item via Gui
kwargs.pop('enabled', True),
kwargs.pop('autoupdate', ''),
kwargs.pop('proxy', ''),
kwargs.pop('userAgent', ''),
kwargs.pop('filter', ''),
kwargs.pop('lastUpdated', ''),
)
# The internal subs object
@@ -647,8 +719,12 @@ class SubscriptionTableView(Mixins.QTranslatable, AppQTableView):
unique: {
'remark': remark,
'webURL': webURL,
'enabled': bool(enabled),
'autoupdate': autoupdate,
'proxy': proxy,
'userAgent': userAgent,
'filter': profileFilter,
'lastUpdated': lastUpdated,
}
}
@@ -660,7 +736,7 @@ class SubscriptionTableView(Mixins.QTranslatable, AppQTableView):
row = self.sourceModel.rowCount()
# Add timer
self.timers.append(QtCore.QTimer())
self.timers.append(QtCore.QTimer(self))
self.timerConnected.append(False)
self.sourceModel.beginInsertRows(QtCore.QModelIndex(), row, row)
+33 -184
View File
@@ -23,7 +23,7 @@ from Furious.Frozenlib import *
from Furious.Interface import *
from Furious.Models import *
from Furious.Repository import *
from Furious.Plugins import CapabilityKind, getPluginRegistry
from Furious.Plugins import getPluginRegistry
from Furious.Qt import *
from Furious.Qt import gettext as _
from Furious.Service import (
@@ -43,7 +43,6 @@ from Furious.Widget.ServerTableView import *
from Furious.Window.NetworkTestDialog import *
from Furious.Window.ProxyBypassDialog import *
from Furious.Window.QRCodeWindow import QRCodeWindow
from Furious.Window.SubscriptionWindow import *
from Furious.Window.TextEditorWindow import TextEditorWindow
from Furious.Window.TunSettingsDialog import *
@@ -62,20 +61,6 @@ __all__ = ['HomePage']
logger = logging.getLogger(__name__)
def _isCoreActive(coreType) -> bool:
"""Return whether the tray's active connection owns *coreType*."""
try:
connectAction = APP().systemTray.ConnectAction
return connectAction.isConnected() and any(
isinstance(process, coreType)
for process in connectAction.coreManager.processesPool
)
except (AttributeError, RuntimeError):
# The home page is built before the system tray is attached.
return False
class AppConnectivityManager(ConnectivityManager):
"""Coordinate app network connectivity operations."""
@@ -254,7 +239,7 @@ class NetworkStateBadge(Mixins.QTranslatable, Mixins.ThemeAware, QWidget):
class TrafficStatsBadge(Mixins.QTranslatable, Mixins.ThemeAware, QWidget):
"""Display independently updated traffic speeds and usage."""
"""Display compact Fluent direction groups for live network statistics."""
UploadIconFileName = 'cloud-upload.svg'
DownloadIconFileName = 'cloud-download.svg'
@@ -274,19 +259,38 @@ class TrafficStatsBadge(Mixins.QTranslatable, Mixins.ThemeAware, QWidget):
self.uploadTextLabel = AppQLabel(translatable=False, parent=self)
self.uploadUsageLabel = AppQLabel(translatable=False, parent=self)
self.downloadTextLabel.setObjectName('TrafficSpeedLabel')
self.uploadTextLabel.setObjectName('TrafficSpeedLabel')
self.uploadUsageLabel.setObjectName('TrafficUsageLabel')
self.downloadUsageLabel.setObjectName('TrafficUsageLabel')
self.downloadBadge = QFrame(parent=self)
self.downloadBadge.setObjectName('TrafficDirectionBadge')
self.downloadBadge.setProperty('direction', 'download')
self.uploadBadge = QFrame(parent=self)
self.uploadBadge.setObjectName('TrafficDirectionBadge')
self.uploadBadge.setProperty('direction', 'upload')
downloadLayout = QHBoxLayout(self.downloadBadge)
downloadLayout.setContentsMargins(8, 2, 8, 2)
downloadLayout.setSpacing(5)
downloadLayout.addWidget(self.downloadIconLabel)
downloadLayout.addWidget(self.downloadTextLabel)
downloadLayout.addWidget(self.downloadUsageLabel)
uploadLayout = QHBoxLayout(self.uploadBadge)
uploadLayout.setContentsMargins(8, 2, 8, 2)
uploadLayout.setSpacing(5)
uploadLayout.addWidget(self.uploadIconLabel)
uploadLayout.addWidget(self.uploadTextLabel)
uploadLayout.addWidget(self.uploadUsageLabel)
self._layout = QHBoxLayout(self)
self._layout.setContentsMargins(8, 3, 8, 3)
self._layout.setSpacing(6)
self._layout.addWidget(self.downloadIconLabel)
self._layout.addWidget(self.downloadTextLabel)
self._layout.addWidget(self.downloadUsageLabel)
self._layout.addSpacing(4)
self._layout.addWidget(self.uploadIconLabel)
self._layout.addWidget(self.uploadTextLabel)
self._layout.addWidget(self.uploadUsageLabel)
self._layout.setContentsMargins(0, 1, 0, 1)
self._layout.setSpacing(5)
self._layout.addWidget(self.downloadBadge)
self._layout.addWidget(self.uploadBadge)
self.setIconByTheme(APP().theme())
self.updateToolTips()
@@ -330,8 +334,8 @@ class TrafficStatsBadge(Mixins.QTranslatable, Mixins.ThemeAware, QWidget):
@QtCore.Slot(object, object)
def setUsage(self, upload: int, download: int):
"""Display formatted cumulative upload and download usage."""
self.uploadUsageLabel.setText(f'({formatTrafficUsage(upload)})')
self.downloadUsageLabel.setText(f'({formatTrafficUsage(download)})')
self.uploadUsageLabel.setText(f'• {formatTrafficUsage(upload)}')
self.downloadUsageLabel.setText(f'• {formatTrafficUsage(download)}')
self.setVisible(True)
@QtCore.Slot()
@@ -429,17 +433,6 @@ class HomePage(Mixins.QTranslatable, QMainWindow):
qrCodeWindowFactory=QRCodeWindow,
importActionsFactory=self.serverImportActions,
)
self.userSubsWindow = SubscriptionWindow(
parent=self,
deleteUniqueCallback=lambda unique: self.userServersQTableWidget.deleteItemByIndex(
list(
index
for index, server in enumerate(Storage.UserServers())
if server.itemSubscription == unique
),
showProgress=False,
),
)
pluginRegistry = getPluginRegistry()
self.customizeProxyBypassDialog = ProxyBypassDialog(parent=self)
self.customizeNetworkTestDialog = NetworkTestDialog(parent=self)
@@ -481,124 +474,6 @@ class HomePage(Mixins.QTranslatable, QMainWindow):
)
)
subsActions = [
AppQAction(
_('Update Subscription (Use Current Proxy)'),
callback=lambda: self.userServersQTableWidget.updateSubs(
Storage.Extras.UserHttpProxy(),
parent=self,
),
),
AppQAction(
_('Update Subscription (Force Proxy)'),
callback=lambda: self.userServersQTableWidget.updateSubs(
'127.0.0.1:10809',
parent=self,
),
),
AppQAction(
_('Update Subscription (No Proxy)'),
callback=lambda: self.userServersQTableWidget.updateSubs(
None,
parent=self,
),
),
AppQSeperator(),
AppQAction(
_('Edit Subscription...'),
icon=bootstrapIcon('star.svg'),
callback=lambda: self.userSubsWindow.show(),
),
]
corePluginActions = []
for plugin in pluginRegistry.pluginsWithCapability(
CapabilityKind.KernelFactory
):
pluginMetadata = pluginRegistry.metadataFor(plugin)
managementActions = pluginRegistry.managementActions(
plugin,
parent=self,
isCoreActive=_isCoreActive,
)
if managementActions:
corePluginActions.append(
AppQAction(
pluginMetadata.displayName,
menu=AppQMenu(*managementActions),
useActionGroup=False,
checkable=False,
)
)
else:
corePluginActions.append(
AppQAction(
pluginMetadata.displayName,
checkable=False,
)
)
extensionPluginActions = []
corePlugins = set(
pluginRegistry.pluginsWithCapability(CapabilityKind.KernelFactory)
)
for plugin in pluginRegistry.pluginsWithCapability(
CapabilityKind.ActionProvider
):
if plugin in corePlugins:
continue
managementActions = pluginRegistry.managementActions(
plugin,
parent=self,
isCoreActive=_isCoreActive,
)
if managementActions:
pluginMetadata = pluginRegistry.metadataFor(plugin)
extensionPluginActions.append(
AppQAction(
pluginMetadata.displayName,
menu=AppQMenu(*managementActions),
useActionGroup=False,
checkable=False,
)
)
pluginActions = [
AppQAction(
_('Core'),
menu=AppQMenu(*corePluginActions),
useActionGroup=False,
checkable=False,
),
]
if extensionPluginActions:
pluginActions.append(
AppQAction(
_('Extensions'),
menu=AppQMenu(*extensionPluginActions),
useActionGroup=False,
checkable=False,
)
)
pluginsToolbarActions = [
AppQSeperator(),
AppQAction(
_('Plugins'),
icon=bootstrapIcon('plugin.svg'),
menu=AppQMenu(*pluginActions),
useSetMenu=False,
useActionGroup=False,
checkable=False,
),
]
if hasattr(AppQAction, 'setMenu'):
self.toolbar = AppQToolBar(
AppQAction(
@@ -609,16 +484,6 @@ class HomePage(Mixins.QTranslatable, QMainWindow):
useActionGroup=False,
checkable=False,
),
AppQSeperator(),
AppQAction(
_('Subscription'),
icon=bootstrapIcon('collection.svg'),
menu=AppQMenu(*subsActions),
useSetMenu=False,
useActionGroup=False,
checkable=False,
),
*pluginsToolbarActions,
)
self.toolbar.setObjectName('HomePageToolBar')
self.toolbar.setMovable(False)
@@ -635,29 +500,13 @@ class HomePage(Mixins.QTranslatable, QMainWindow):
'actions': [*serverActions],
}
subsMenu = {
'name': 'Subscription',
'actions': [*subsActions],
}
pluginsMenu = {
'name': 'Plugins',
'actions': [*pluginActions],
}
# Corresponds to menus defined above
_TRANSLATABLE_MENU_NAME = [
_('Server'),
_('Subscription'),
_('Plugins'),
]
# Menus
for menuDict in (
serverMenu,
subsMenu,
pluginsMenu,
):
for menuDict in (serverMenu,):
menuName = menuDict['name']
menuObjName = f'_{menuName}Menu'
menu = AppQMenu(
+23 -4
View File
@@ -23,12 +23,13 @@ from Furious.Frozenlib import *
from Furious.Models import ConfigFactory, ServerProfile
from Furious.Qt import *
from Furious.Qt import gettext as _
from Furious.Service import MetricsDataManager
from Furious.Service import MetricsDataManager, PluginNavigationManager
from Furious.Widget.NavigationView import NavigationView
from Furious.Window.HomePage import HomePage
from Furious.Window.LogPage import LogPage
from Furious.Window.MetricsPage import MetricsPage
from Furious.Window.SettingsPage import SettingsPage
from Furious.Window.SubscriptionPage import SubscriptionPage
from PySide6 import QtCore
@@ -50,6 +51,7 @@ registerAppSettings('AppMainWindowNavigationExpanded', isBinary=True)
_TRANSLATABLE_NAVIGATION_LABELS = (
_('Home'),
_('Log'),
_('Subscription'),
_('Metrics'),
_('Settings'),
)
@@ -72,6 +74,10 @@ class MainWindow(AppQMainWindow):
self.navigationView = NavigationView(parent=self)
self.homePage = HomePage(parent=self.navigationView)
self.subscriptionPage = SubscriptionPage(
self.homePage.userServersQTableWidget,
parent=self.navigationView,
)
self.metricsDataManager = MetricsDataManager(parent=self)
self.homePage.trafficStatsManager.sampleChanged.connect(
self.metricsDataManager.recordTrafficSample
@@ -110,6 +116,14 @@ class MainWindow(AppQMainWindow):
'Log',
'pin-angle.svg',
)
self.navigationView.addPage(
'subscription',
self.subscriptionPage,
'Subscription',
'collection.svg',
)
self.pluginNavigationManager = PluginNavigationManager()
self.pluginNavigationManager.registerPages(self.navigationView)
self.navigationView.addPage(
'metrics',
self.metricsPage,
@@ -137,7 +151,7 @@ class MainWindow(AppQMainWindow):
# Preserve the established application-facing server-management API.
self.userServersQTableWidget = self.homePage.userServersQTableWidget
self.userSubsWindow = self.homePage.userSubsWindow
self.userSubsWindow = self.subscriptionPage
self.networkConnectivityManager = self.homePage.networkConnectivityManager
self.networkState = self.homePage.networkState
self.trafficStats = self.homePage.trafficStats
@@ -171,9 +185,14 @@ class MainWindow(AppQMainWindow):
self.show()
self.showPage('settings')
def showSubscriptionPage(self):
"""Navigate directly to subscription management."""
self.show()
self.showPage('subscription')
def updateSubsByUnique(self, unique: str, httpProxy: Union[str, None], **kwargs):
"""Forward a subscription update to the home page."""
self.homePage.updateSubsByUnique(unique, httpProxy, **kwargs)
"""Forward a subscription update to the dedicated page controller."""
self.subscriptionPage.updateSubsByUnique(unique, httpProxy, **kwargs)
def appendNewItemByFactory(self, factory: ConfigFactory | ServerProfile):
"""Forward a new server profile to the home page."""
+379
View File
@@ -0,0 +1,379 @@
# 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.
"""Present subscription definitions and synchronization as a dedicated page."""
from __future__ import annotations
from Furious.Frozenlib import APP, Mixins, PySide6Legacy
from Furious.Qt import *
from Furious.Qt import gettext as _
from Furious.Repository import Storage
from Furious.Widget.SubscriptionTableView import SubscriptionTableView
from PySide6 import QtCore
from PySide6.QtWidgets import *
from urllib.parse import urlsplit
import uuid
__all__ = ['SubscriptionPage']
class _SubscriptionEditorDialog(AppQDialog):
"""Edit one complete subscription definition with validation."""
def __init__(self, subscription=None, parent=None):
"""Initialize fields from an existing definition or defaults."""
super().__init__(parent)
subscription = dict(subscription or {})
self.remarkEdit = QLineEdit(subscription.get('remark', ''))
self.urlEdit = QLineEdit(subscription.get('webURL', ''))
self.enabledCheckBox = QCheckBox()
self.enabledCheckBox.setChecked(subscription.get('enabled', True))
self.autoUpdateComboBox = QComboBox()
self.proxyComboBox = QComboBox()
self.userAgentEdit = QLineEdit(subscription.get('userAgent', ''))
self.filterEdit = QLineEdit(subscription.get('filter', ''))
self.remarkEdit.setMaximumWidth(520)
self.urlEdit.setMaximumWidth(520)
self.userAgentEdit.setMaximumWidth(520)
self.filterEdit.setMaximumWidth(520)
for value in SubscriptionTableView.AutoUpdateOptions:
self.autoUpdateComboBox.addItem(_(value), value)
for value in SubscriptionTableView.ProxyOptions:
self.proxyComboBox.addItem(_(value), value)
autoIndex = self.autoUpdateComboBox.findData(subscription.get('autoupdate', ''))
proxyIndex = self.proxyComboBox.findData(subscription.get('proxy', ''))
self.autoUpdateComboBox.setCurrentIndex(max(autoIndex, 0))
self.proxyComboBox.setCurrentIndex(max(proxyIndex, 0))
self.buttons = AppQDialogButtonBox(QtCore.Qt.Orientation.Horizontal)
self.buttons.addButton(_('OK'), AppQDialogButtonBox.ButtonRole.AcceptRole)
self.buttons.addButton(
_('Cancel'),
AppQDialogButtonBox.ButtonRole.RejectRole,
)
self.buttons.accepted.connect(self.accept)
self.buttons.rejected.connect(self.reject)
form = QFormLayout()
form.setContentsMargins(18, 18, 18, 18)
form.setSpacing(12)
form.addRow(_('Remark'), self.remarkEdit)
form.addRow(_('URL'), self.urlEdit)
form.addRow(_('Enabled'), self.enabledCheckBox)
form.addRow(_('Auto Update'), self.autoUpdateComboBox)
form.addRow(_('Auto Update Use Proxy'), self.proxyComboBox)
form.addRow(_('User Agent'), self.userAgentEdit)
form.addRow(_('Profile Filter (Regex)'), self.filterEdit)
form.addRow(self.buttons)
self.setLayout(form)
self.setWindowTitle(
_('Edit Subscription') if subscription else _('Add Subscription')
)
self.resize(700, 360)
def _showValidationError(self, message: str):
"""Show one non-blocking validation message owned by this dialog."""
messageBox = AppQMessageBox(
icon=AppQMessageBox.Icon.Warning,
parent=self,
)
messageBox.setWindowTitle(_('Invalid data'))
messageBox.setText(message)
messageBox.open()
def accept(self):
"""Validate required fields before accepting the definition."""
remark = self.remarkEdit.text().strip()
url = self.urlEdit.text().strip()
parsed = urlsplit(url)
if not remark:
self._showValidationError(_('Please enter a subscription remark.'))
return
if parsed.scheme.casefold() not in ('http', 'https') or not parsed.netloc:
self._showValidationError(_('Please enter a valid subscription URL.'))
return
super().accept()
def subscription(self):
"""Return the normalized values entered by the user."""
return {
'remark': self.remarkEdit.text().strip(),
'webURL': self.urlEdit.text().strip(),
'enabled': self.enabledCheckBox.isChecked(),
'autoupdate': self.autoUpdateComboBox.currentData() or '',
'proxy': self.proxyComboBox.currentData() or '',
'userAgent': self.userAgentEdit.text().strip(),
'filter': self.filterEdit.text().strip(),
}
class SubscriptionPage(Mixins.QTranslatable, Mixins.ThemeAware, QMainWindow):
"""Own subscription editing, grouping, and synchronization workflows."""
def __init__(self, serverTable, parent=None):
"""Initialize around the existing profile synchronization backend."""
super().__init__(parent)
self.setObjectName('SubscriptionPage')
self.serverTable = serverTable
self.pageTitleLabel = AppQLabel(translatable=False)
self.pageTitleLabel.setObjectName('SubscriptionPageTitle')
self.proxyLabel = AppQLabel(translatable=False)
self.proxyComboBox = QComboBox()
self.proxyComboBox.setMinimumWidth(160)
self.addButton = QPushButton()
self.addFromClipboardButton = QPushButton()
self.editButton = QPushButton()
self.deleteButton = QPushButton()
self.copyURLButton = QPushButton()
self.updateSelectedButton = QPushButton()
self.updateAllButton = QPushButton()
self.table = SubscriptionTableView(
deleteUniqueCallback=self._deleteProfilesForSubscription,
parent=self,
)
self.table.doubleClicked.connect(lambda _index: self.editSelected())
self.serverTable.subsManager.subscriptionsChanged.connect(self.table.flushAll)
self.addButton.clicked.connect(self.addSubscription)
self.addFromClipboardButton.clicked.connect(self.addFromClipboard)
self.editButton.clicked.connect(self.editSelected)
self.deleteButton.clicked.connect(self.table.deleteSelectedItem)
self.copyURLButton.clicked.connect(self.copySelectedURL)
self.updateSelectedButton.clicked.connect(self.updateSelected)
self.updateAllButton.clicked.connect(self.updateAll)
for key in SubscriptionTableView.ProxyOptions:
self.proxyComboBox.addItem(_(key), key)
controls = QHBoxLayout()
controls.setContentsMargins(0, 0, 0, 0)
controls.setSpacing(8)
controls.addWidget(self.pageTitleLabel)
controls.addStretch(1)
controls.addWidget(self.proxyLabel)
controls.addWidget(self.proxyComboBox)
controls.addWidget(self.updateSelectedButton)
controls.addWidget(self.updateAllButton)
actions = QHBoxLayout()
actions.setContentsMargins(0, 0, 0, 0)
actions.setSpacing(8)
actions.addWidget(self.addButton)
actions.addWidget(self.addFromClipboardButton)
actions.addWidget(self.editButton)
actions.addWidget(self.deleteButton)
actions.addWidget(self.copyURLButton)
actions.addStretch(1)
content = QWidget()
content.setObjectName('SubscriptionPageContent')
layout = QVBoxLayout(content)
layout.setContentsMargins(20, 18, 20, 20)
layout.setSpacing(12)
layout.addLayout(controls)
layout.addLayout(actions)
layout.addWidget(self.table, 1)
self.setCentralWidget(content)
self.setIconsByTheme(APP().theme())
self.retranslate()
def _deleteProfilesForSubscription(self, unique: str):
"""Remove profiles belonging to a deleted subscription group."""
indexes = [
index
for index, server in enumerate(Storage.UserServers())
if server.itemSubscription == unique
]
self.serverTable.deleteItemByIndex(indexes, showProgress=False)
def _selectedUnique(self):
"""Return the first selected subscription ID, if any."""
selected = self.table.selectedUniques
return selected[0] if selected else None
def _openEditor(self, unique=None, initial=None):
"""Open and retain an asynchronous add/edit dialog."""
source = Storage.UserSubs().get(unique, initial or {})
dialog = _SubscriptionEditorDialog(source, parent=self)
dialog.setWindowTitle(
_('Edit Subscription') if unique else _('Add Subscription')
)
dialog.setWindowModality(QtCore.Qt.WindowModality.WindowModal)
def finished(code):
"""Persist accepted values through the existing table model."""
if code != PySide6Legacy.enumValueWrapper(AppQDialog.DialogCode.Accepted):
return
subscriptionUnique = unique or str(uuid.uuid4())
existing = Storage.UserSubs().get(subscriptionUnique, {})
self.table.appendNewItem(
unique=subscriptionUnique,
lastUpdated=existing.get('lastUpdated', ''),
**dialog.subscription(),
)
self.table.selectRow(list(Storage.UserSubs()).index(subscriptionUnique))
dialog.finished.connect(finished)
dialog.open()
@QtCore.Slot()
def addSubscription(self):
"""Open the full subscription editor for a new group."""
self._openEditor()
@QtCore.Slot()
def addFromClipboard(self):
"""Seed a subscription from an HTTP(S) URL on the clipboard."""
url = QApplication.clipboard().text().strip()
parsed = urlsplit(url)
if parsed.scheme.casefold() not in ('http', 'https') or not parsed.netloc:
self._openEditor()
return
self._openEditor(
initial={
'remark': parsed.hostname or _('Subscription'),
'webURL': url,
'enabled': True,
}
)
@QtCore.Slot()
def editSelected(self):
"""Edit the selected subscription without relying on inline cells."""
unique = self._selectedUnique()
if unique is not None:
self._openEditor(unique)
@QtCore.Slot()
def copySelectedURL(self):
"""Copy the selected subscription URL for sharing/export."""
unique = self._selectedUnique()
if unique is not None:
QApplication.clipboard().setText(
str(Storage.UserSubs()[unique].get('webURL', ''))
)
def _selectedProxy(self):
"""Resolve the proxy policy selected for manual synchronization."""
key = self.proxyComboBox.currentData() or ''
resolver = SubscriptionTableView.ProxyOptions.get(key)
return resolver() if callable(resolver) else None
@QtCore.Slot()
def updateSelected(self):
"""Synchronize selected enabled subscription groups together."""
keys = tuple(
key
for key in self.table.selectedUniques
if Storage.UserSubs().get(key, {}).get('enabled', True)
and Storage.UserSubs().get(key, {}).get('webURL')
)
if not keys:
return
depthMap = {'depth': len(keys)}
successArgs = []
failureArgs = []
httpProxy = self._selectedProxy()
for key in keys:
self.serverTable.updateSubsByUnique(
key,
httpProxy,
depthMap=depthMap,
successArgs=successArgs,
failureArgs=failureArgs,
showMessageBox=True,
parent=self,
)
@QtCore.Slot()
def updateAll(self):
"""Synchronize every enabled subscription with one proxy policy."""
self.serverTable.updateSubs(self._selectedProxy(), parent=self)
def updateSubsByUnique(self, unique: str, httpProxy, **kwargs):
"""Preserve the established application subscription-update API."""
self.serverTable.updateSubsByUnique(unique, httpProxy, **kwargs)
def setIconsByTheme(self, theme: str):
"""Apply theme-aware Fluent icons to subscription commands."""
iconFactory = (
bootstrapIconWhite if theme == AppStyleSheet.Dark else bootstrapIcon
)
for button, iconName in (
(self.addButton, 'plus-lg.svg'),
(self.addFromClipboardButton, 'clipboard-plus.svg'),
(self.editButton, 'pencil-square.svg'),
(self.deleteButton, 'trash.svg'),
(self.copyURLButton, 'link-45deg.svg'),
(self.updateSelectedButton, 'arrow-repeat.svg'),
(self.updateAllButton, 'cloud-arrow-down.svg'),
):
button.setIcon(iconFactory(iconName))
def themeChangedCallback(self, theme: str):
"""Refresh command icons after a theme change."""
self.setIconsByTheme(theme)
def showEvent(self, event):
"""Refresh persisted subscription data whenever the page is shown."""
super().showEvent(event)
self.table.flushAll()
def retranslate(self):
"""Refresh page labels, commands, and proxy choices."""
selectedProxy = self.proxyComboBox.currentData()
with Mixins.QBlockSignalContext(self.proxyComboBox):
self.proxyComboBox.clear()
for key in SubscriptionTableView.ProxyOptions:
self.proxyComboBox.addItem(_(key), key)
index = self.proxyComboBox.findData(selectedProxy)
self.proxyComboBox.setCurrentIndex(max(index, 0))
self.pageTitleLabel.setText(_('Subscriptions'))
self.proxyLabel.setText(_('Update Using'))
self.addButton.setText(_('Add'))
self.addFromClipboardButton.setText(_('Add From Clipboard'))
self.editButton.setText(_('Edit'))
self.deleteButton.setText(_('Delete'))
self.copyURLButton.setText(_('Copy URL'))
self.updateSelectedButton.setText(_('Update Selected'))
self.updateAllButton.setText(_('Update All'))
+2
View File
@@ -25,6 +25,7 @@ from .LogPage import LogPage
from .MetricsPage import MetricsPage
from .MainWindow import MainWindow
from .SettingsPage import SettingsPage
from .SubscriptionPage import SubscriptionPage
from .NetworkTestDialog import NetworkTestDialog
from .ProxyBypassDialog import ProxyBypassDialog
from .QRCodeWindow import QRCodeWindow
@@ -39,6 +40,7 @@ __all__ = [
'MetricsPage',
'MainWindow',
'SettingsPage',
'SubscriptionPage',
'NetworkTestDialog',
'ProxyBypassDialog',
'QRCodeWindow',