Show subscription usage and expiry

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2026-09-08 10:54:14 +08:00
parent bd24ff3085
commit 3dcd6ab930
7 changed files with 344 additions and 24 deletions
+8 -18
View File
@@ -4,7 +4,6 @@ TRANSLATION = {
"Furious.Backends.Xray.AssetWindow",
"Furious.Backends.Xray.RoutingWindow",
"Furious.Widget.ServerTableView",
"Furious.Widget.SubscriptionTableView",
"Furious.Window.SubscriptionPage"
],
"RU": "Удалить",
@@ -394,7 +393,6 @@ TRANSLATION = {
},
"Invalid server configuration": {
"source": [
"Furious.Controllers.ConnectionController",
"Furious.Window.TextEditorWindow"
],
"RU": "Неверная конфигурация сервера",
@@ -846,22 +844,6 @@ TRANSLATION = {
"ZH": "未知错误",
"isReviewed": "True"
},
"Failed to start core": {
"source": [
"Furious.Controllers.ConnectionController"
],
"RU": "Сбой при запуске ядра",
"ZH": "内核启动失败",
"isReviewed": "True"
},
"Core terminated unexpectedly": {
"source": [
"Furious.Controllers.ConnectionController"
],
"RU": "Неожиданное завершение работы ядра",
"ZH": "内核意外终止",
"isReviewed": "True"
},
"Disconnected": {
"source": [
"Furious.Controllers.ConnectionController",
@@ -3317,5 +3299,13 @@ TRANSLATION = {
"RU": "Ошибка обновления",
"ZH": "更新失败",
"isReviewed": "True"
},
"Usage / Expiry": {
"source": [
"Furious.Widget.SubscriptionTableView"
],
"RU": "Трафик / Срок",
"ZH": "用量 / 到期",
"isReviewed": "True"
}
}
+23
View File
@@ -54,6 +54,10 @@ class SubscriptionGroup:
lastSyncStatus: str = ''
lastSyncError: str = ''
profileCount: int = 0
subscriptionUpload: int = 0
subscriptionDownload: int = 0
subscriptionTotal: int = 0
subscriptionExpire: int = 0
extras: dict[str, Any] = field(default_factory=dict, repr=False)
@classmethod
@@ -77,6 +81,10 @@ class SubscriptionGroup:
('lastSyncStatus', ''),
('lastSyncError', ''),
('profileCount', 0),
('subscriptionUpload', 0),
('subscriptionDownload', 0),
('subscriptionTotal', 0),
('subscriptionExpire', 0),
)
}
@@ -110,6 +118,17 @@ class SubscriptionGroup:
except (TypeError, ValueError):
known[name] = 0
for name in (
'subscriptionUpload',
'subscriptionDownload',
'subscriptionTotal',
'subscriptionExpire',
):
try:
known[name] = min(max(0, int(known[name])), (1 << 63) - 1)
except (TypeError, ValueError):
known[name] = 0
extras = dict(nestedExtras) if isinstance(nestedExtras, Mapping) else {}
extras.update(data)
@@ -133,6 +152,10 @@ class SubscriptionGroup:
'lastSyncStatus': self.lastSyncStatus,
'lastSyncError': self.lastSyncError,
'profileCount': self.profileCount,
'subscriptionUpload': self.subscriptionUpload,
'subscriptionDownload': self.subscriptionDownload,
'subscriptionTotal': self.subscriptionTotal,
'subscriptionExpire': self.subscriptionExpire,
}
)
+3
View File
@@ -34,6 +34,9 @@ and temporary resources, never durable collections, shared transition authority,
data. Workers never read live repositories or Qt models; the GUI thread verifies the full source signature and group
revision, commits while preserving live profile identity/local metadata, then publishes coalesced status/structure.
Post-commit reconnect/test invalidation failure is reported without undoing the committed profiles.
- Provider-reported subscription usage/expiry metadata is untrusted advisory input. Parse it with strict bounds at the
network boundary and commit or clear it only alongside a successful current synchronization; failed synchronization
preserves the last successful metadata.
- Log transport, traffic collection, and metric history remain bounded and independent of page visibility. Rendering may
be lazy; collection/draining ownership is not.
- Logging accepts concurrent producers through one bounded ordered model; runtime-only clearing and retention cannot
+76 -2
View File
@@ -85,6 +85,57 @@ SUBSCRIPTION_PROXY_OPTIONS = (
'No proxy',
)
SUBSCRIPTION_USERINFO_HEADER = b'Subscription-Userinfo'
SUBSCRIPTION_USERINFO_KEYS = ('upload', 'download', 'total', 'expire')
MAXIMUM_SUBSCRIPTION_USERINFO_LENGTH = 4096
MAXIMUM_SUBSCRIPTION_USERINFO_VALUE = (1 << 63) - 1
def _parseSubscriptionUserInfo(value) -> dict[str, int] | None:
"""Parse bounded non-negative values from a subscription response header."""
if value is None:
return None
if isinstance(value, QtCore.QByteArray):
value = bytes(value)
elif isinstance(value, (bytearray, memoryview)):
value = bytes(value)
if isinstance(value, bytes):
try:
text = value[:MAXIMUM_SUBSCRIPTION_USERINFO_LENGTH].decode('ascii')
except UnicodeDecodeError:
return None
elif isinstance(value, str):
text = value[:MAXIMUM_SUBSCRIPTION_USERINFO_LENGTH]
else:
return None
if not text.strip():
return None
result = {key: 0 for key in SUBSCRIPTION_USERINFO_KEYS}
for part in text.split(';'):
key, separator, rawValue = part.partition('=')
key = key.strip().casefold()
rawValue = rawValue.strip()
if (
not separator
or key not in result
or not rawValue.isascii()
or not rawValue.isdecimal()
):
continue
parsed = int(rawValue)
if parsed <= MAXIMUM_SUBSCRIPTION_USERINFO_VALUE:
result[key] = parsed
return result
def resolveSubscriptionProxy(option: str):
"""Resolve one persisted subscription proxy policy."""
@@ -838,6 +889,14 @@ class SubscriptionManager(HttpGetManager):
group.lastSyncError = ''
group.profileCount = len(result.profileIds)
if 'subscriptionInfo' in param:
subscriptionInfo = param.get('subscriptionInfo') or {}
group.subscriptionUpload = subscriptionInfo.get('upload', 0)
group.subscriptionDownload = subscriptionInfo.get('download', 0)
group.subscriptionTotal = subscriptionInfo.get('total', 0)
group.subscriptionExpire = subscriptionInfo.get('expire', 0)
Storage.upsertSubscriptionGroup(group)
except Exception:
# Any non-exit exceptions
@@ -955,7 +1014,14 @@ class SubscriptionManager(HttpGetManager):
if 'batchId' in kwargs:
data = bytes(networkReply.readAll().data())
decoderId = kwargs.get('decoderId') or kwargs.get('lastDecoderId')
context = {**kwargs, 'decoderId': decoderId}
subscriptionInfo = _parseSubscriptionUserInfo(
networkReply.rawHeader(SUBSCRIPTION_USERINFO_HEADER)
)
context = {
**kwargs,
'decoderId': decoderId,
'subscriptionInfo': subscriptionInfo,
}
if self.importer.registry.subscriptionDecoderWorkerSafe(decoderId):
self._startImportPreparation(data, context)
@@ -971,6 +1037,9 @@ class SubscriptionManager(HttpGetManager):
failureArgs = kwargs.get('failureArgs', list())
data = bytes(networkReply.readAll().data())
subscriptionInfo = _parseSubscriptionUserInfo(
networkReply.rawHeader(SUBSCRIPTION_USERINFO_HEADER)
)
source = SubscriptionSource(
kwargs.get('unique', ''),
@@ -1013,7 +1082,12 @@ class SubscriptionManager(HttpGetManager):
)
successArgs.append(
{**kwargs, 'profiles': result.profiles, 'decoderId': result.decoderId}
{
**kwargs,
'profiles': result.profiles,
'decoderId': result.decoderId,
'subscriptionInfo': subscriptionInfo,
}
)
def failureCallback(self, networkReply, **kwargs):
+45 -2
View File
@@ -33,6 +33,7 @@ from Furious.Repository import Storage, SubscriptionGroup
from Furious.Service import (
SUBSCRIPTION_AUTO_UPDATE_OPTIONS,
SUBSCRIPTION_PROXY_OPTIONS,
formatTrafficUsage,
resolveSubscriptionProxy,
)
from Furious.Qt import (
@@ -51,8 +52,9 @@ from PySide6.QtWidgets import QAbstractItemView, QHeaderView, QTableView
from typing import Union, Callable
import logging
import datetime
import functools
import logging
logger = logging.getLogger(__name__)
@@ -76,9 +78,10 @@ class SubscriptionTableHorizontalHeader(AppQHeaderView):
'enabled',
'lastSyncStatus',
'lastUpdated',
'subscriptionInfo',
'profiles',
)
DefaultSectionSizes = (260, 520, 120, 150, 220, 140)
DefaultSectionSizes = (260, 520, 120, 150, 220, 220, 140)
LegacyColumnKeys = ('remark', 'webURL', 'autoupdate', 'proxy')
# Format discriminator for the semantic JSON stored under
@@ -296,6 +299,41 @@ def subscriptionSyncStatusText(item: dict) -> str:
}.get(str(item.get('lastSyncStatus', '')), _('Never'))
def _nonnegativeInteger(value) -> int:
"""Return a safe non-negative integer from persisted presentation input."""
try:
return max(int(value), 0)
except (TypeError, ValueError, OverflowError):
return 0
def subscriptionInfoText(item: dict) -> str:
"""Return compact provider-reported traffic usage and expiry metadata."""
parts = []
total = _nonnegativeInteger(item.get('subscriptionTotal', 0))
if total:
used = _nonnegativeInteger(
item.get('subscriptionUpload', 0)
) + _nonnegativeInteger(item.get('subscriptionDownload', 0))
parts.append(f'{formatTrafficUsage(used)} / {formatTrafficUsage(total)}')
expiresAt = _nonnegativeInteger(item.get('subscriptionExpire', 0))
if expiresAt:
try:
expires = datetime.datetime.fromtimestamp(
expiresAt, tz=datetime.timezone.utc
).date()
except (OverflowError, OSError, ValueError):
pass
else:
parts.append(expires.isoformat())
return ' · '.join(parts)
class UserSubsTableModel(QtCore.QAbstractTableModel):
"""Expose user subs table data through a Qt item model."""
@@ -338,6 +376,7 @@ class UserSubsTableModel(QtCore.QAbstractTableModel):
'enabled',
'lastSyncStatus',
'lastUpdated',
'subscriptionInfo',
'profiles',
]:
flags |= QtCore.Qt.ItemFlag.ItemIsEditable
@@ -409,6 +448,7 @@ class UserSubsTableModel(QtCore.QAbstractTableModel):
'enabled',
'lastSyncStatus',
'lastUpdated',
'subscriptionInfo',
'profiles',
]:
return False
@@ -501,6 +541,7 @@ _TRANSLATABLE_HEADERS = [
_('Updated'),
_('Update Failed'),
_('Last Updated'),
_('Usage / Expiry'),
_('Profiles'),
]
@@ -529,6 +570,7 @@ class SubscriptionTableView(Mixins.QTranslatable, AppQTableView):
'Last Updated',
lambda item: item.get('lastUpdated', ''),
),
SubscriptionTableColumn('Usage / Expiry', subscriptionInfoText),
SubscriptionTableColumn('Profiles'),
]
@@ -539,6 +581,7 @@ class SubscriptionTableView(Mixins.QTranslatable, AppQTableView):
'enabled',
'lastSyncStatus',
'lastUpdated',
'subscriptionInfo',
'profiles',
]
+19
View File
@@ -200,6 +200,25 @@ class RepositoryContractTest(unittest.TestCase):
self.assertEqual(tuple(repository.data()), ('A', 'B', 'C', 'D', 'E'))
self.assertFalse(repository.moveGroups(('A',), 'up'))
def testSubscriptionProviderMetadataRoundTripsWithBoundedIntegers(self):
"""Preserve current quota metadata while normalizing persisted input."""
group = SubscriptionGroup.fromMapping(
'group-a',
{
'subscriptionUpload': '1024',
'subscriptionDownload': 2048,
'subscriptionTotal': -1,
'subscriptionExpire': 999999999999999999999999,
'futureField': 'preserved',
},
)
self.assertEqual(group.subscriptionUpload, 1024)
self.assertEqual(group.subscriptionDownload, 2048)
self.assertEqual(group.subscriptionTotal, 0)
self.assertEqual(group.subscriptionExpire, (1 << 63) - 1)
self.assertEqual(group.toMapping()['futureField'], 'preserved')
def testMovingBetweenSubscriptionGroupsDetachesSyncOwnership(self):
"""Keep no-op ownership but make cross-group moves locally managed."""
with isolatedSettings():
+170 -2
View File
@@ -29,6 +29,7 @@ from Furious.Qt import gettext
from Furious.Service.SubscriptionManager import (
SubscriptionManager,
_SubscriptionBatchState,
_parseSubscriptionUserInfo,
)
from Furious.Window.SubscriptionPage import SubscriptionPage
from Furious.Widget.SubscriptionTableView import SubscriptionTableView
@@ -56,9 +57,12 @@ class _Payload:
class _Reply:
"""Provide deterministic response data and failure diagnostics."""
def __init__(self, value=b'', error='request failed'):
def __init__(self, value=b'', error='request failed', headers=None):
self._value = value
self._error = error
self._headers = {
bytes(name).lower(): value for name, value in (headers or {}).items()
}
def readAll(self):
return _Payload(self._value)
@@ -66,6 +70,9 @@ class _Reply:
def errorString(self):
return self._error
def rawHeader(self, name):
return self._headers.get(bytes(name).lower(), b'')
class _AbortableReply:
"""Record cancellation of one exact service-owned network reply."""
@@ -127,7 +134,15 @@ class SubscriptionManagerTest(TestCase):
return_value=None,
):
manager.successCallback(
_Reply(b'payload'),
_Reply(
b'payload',
headers={
b'Subscription-Userinfo': (
b'upload=1024; download=2048; total=8192; '
b'expire=1893456000'
)
},
),
unique='group-a',
remark='Group A',
webURL='https://invalid.test/subscription',
@@ -138,6 +153,15 @@ class SubscriptionManagerTest(TestCase):
self.assertEqual(len(successful), 1)
self.assertEqual(successful[0]['profiles'], (profile,))
self.assertEqual(successful[0]['decoderId'], 'decoder')
self.assertEqual(
successful[0]['subscriptionInfo'],
{
'upload': 1024,
'download': 2048,
'total': 8192,
'expire': 1893456000,
},
)
self.assertEqual(failed, [])
manager.importer = SimpleNamespace(importPayload=mock.Mock(return_value=None))
@@ -156,6 +180,115 @@ class SubscriptionManagerTest(TestCase):
self.assertEqual(failed[-1]['error'], 'UnsupportedSubscriptionFormat')
manager.deleteLater()
def testSubscriptionUserInfoParserRejectsInvalidAndUnboundedValues(self):
"""Treat provider quota headers as bounded advisory network input."""
self.assertIsNone(_parseSubscriptionUserInfo(b''))
self.assertIsNone(_parseSubscriptionUserInfo(b'upload=\xff'))
self.assertEqual(
_parseSubscriptionUserInfo(QtCore.QByteArray(b'Upload=1')),
{'upload': 1, 'download': 0, 'total': 0, 'expire': 0},
)
self.assertEqual(
_parseSubscriptionUserInfo(
b'upload=1024; download=2048; total=8192; expire=1893456000; '
b'ignored=value; upload=-1; total=999999999999999999999999'
),
{
'upload': 1024,
'download': 2048,
'total': 8192,
'expire': 1893456000,
},
)
def testBatchResponseCarriesSubscriptionInfoIntoWorkerContext(self):
"""Keep response metadata attached to the exact asynchronous request."""
manager = self._manager()
manager._isCurrentRequest = mock.Mock(return_value=True)
manager.importer = SimpleNamespace(
registry=SimpleNamespace(
subscriptionDecoderWorkerSafe=mock.Mock(return_value=True)
)
)
manager._startImportPreparation = mock.Mock()
manager.successCallback(
_Reply(
b'payload',
headers={
b'Subscription-Userinfo': (
b'upload=1024; download=2048; total=8192; ' b'expire=1893456000'
)
},
),
unique='group-a',
batchId=7,
lastDecoderId='decoder',
)
payload, context = manager._startImportPreparation.call_args.args
self.assertEqual(payload, b'payload')
self.assertEqual(
context['subscriptionInfo'],
{
'upload': 1024,
'download': 2048,
'total': 8192,
'expire': 1893456000,
},
)
manager.deleteLater()
def testSuccessfulCommitReplacesSubscriptionInfoButFailurePreservesIt(self):
"""Tie advisory metadata to the same successful group commit boundary."""
group = SubscriptionGroup(
id='group-a',
subscriptionUpload=1,
subscriptionDownload=2,
subscriptionTotal=3,
subscriptionExpire=4,
)
result = SimpleNamespace(profileIds=('profile-a', 'profile-b'))
with (
mock.patch.object(Storage, 'SubscriptionGroup', return_value=group),
mock.patch.object(Storage, 'upsertSubscriptionGroup') as upsert,
):
SubscriptionManager._recordGroupSuccess(
{
'unique': 'group-a',
'decoderId': 'decoder',
'subscriptionInfo': {
'upload': 1024,
'download': 2048,
'total': 8192,
'expire': 1893456000,
},
},
result,
)
SubscriptionManager._recordGroupFailure(
{'unique': 'group-a', 'error': 'offline'}
)
metadataAfterFailure = (
group.subscriptionUpload,
group.subscriptionDownload,
group.subscriptionTotal,
group.subscriptionExpire,
)
SubscriptionManager._recordGroupSuccess(
{'unique': 'group-a', 'subscriptionInfo': None},
result,
)
self.assertEqual(metadataAfterFailure, (1024, 2048, 8192, 1893456000))
self.assertEqual(group.subscriptionUpload, 0)
self.assertEqual(group.subscriptionDownload, 0)
self.assertEqual(group.subscriptionTotal, 0)
self.assertEqual(group.subscriptionExpire, 0)
self.assertEqual(group.lastSyncStatus, 'success')
self.assertEqual(upsert.call_count, 3)
def testRequestFailureIsDataForPresentationNotAWidgetSideEffect(self):
manager = self._manager()
failed = []
@@ -701,6 +834,41 @@ class SubscriptionManagerTest(TestCase):
self.assertEqual(gettext('Updating...', 'RU'), 'Обновление...')
self.assertEqual(gettext('Updated', 'ZH'), '已更新')
self.assertEqual(gettext('Update Failed', 'ZH'), '更新失败')
self.assertEqual(gettext('Usage / Expiry', 'RU'), 'Трафик / Срок')
self.assertEqual(gettext('Usage / Expiry', 'ZH'), '用量 / 到期')
def testSubscriptionTableShowsOptionalUsageAndExpiryMetadata(self):
"""Keep provider metadata compact and blank when it is unavailable."""
subscriptions = {
'group-a': self._subscription(
subscriptionUpload=1024,
subscriptionDownload=2048,
subscriptionTotal=8192,
subscriptionExpire=1893456000,
),
'group-b': self._subscription(
remark='Group B',
webURL='https://invalid.test/b',
),
}
with mock.patch.object(Storage, 'UserSubs', return_value=subscriptions):
table = SubscriptionTableView()
model = table.sourceModel
column = table.ItemKey.index('subscriptionInfo')
self.assertEqual(
model.data(model.index(0, column), QtCore.Qt.ItemDataRole.DisplayRole),
'3 KiB / 8 KiB · 2030-01-01',
)
self.assertEqual(
model.data(model.index(1, column), QtCore.Qt.ItemDataRole.DisplayRole),
'',
)
self.assertFalse(
model.flags(model.index(0, column)) & QtCore.Qt.ItemFlag.ItemIsEditable
)
table.deleteLater()
def testSubscriptionStateNotificationRepaintsOnlyAffectedMetadataRow(self):
"""Resolve stable IDs at delivery and avoid a whole-table refresh."""