Files
LorenEteval_Furious/Furious/Models/Profile.py
T
2026-08-13 23:55:48 +08:00

365 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/>.
"""Define server profiles as metadata composed with connection documents."""
from __future__ import annotations
from collections.abc import Iterator, Mapping, MutableMapping
from dataclasses import dataclass, field
from typing import Any
import copy
import hashlib
import json
import uuid
from .Configuration import ConfigFactory
__all__ = [
'ProfileMetadata',
'ServerProfile',
'connectionOf',
'ensureProfile',
'profileConnectionFingerprint',
]
@dataclass
class ProfileMetadata:
"""Store user and subscription metadata outside a connection document."""
displayName: str = ''
group: str = ''
tags: tuple[str, ...] = tuple()
subscriptionSource: str = ''
updatedAt: str = ''
annotations: str = ''
favorite: bool = False
latency: str = ''
speed: str = ''
extras: dict[str, Any] = field(default_factory=dict)
profileId: str = field(default_factory=lambda: str(uuid.uuid4()))
subscriptionManaged: bool = False
subscriptionProfileKey: str = ''
@classmethod
def fromMapping(cls, value: Mapping[str, Any] | None = None, **kwargs):
"""Construct metadata from current or legacy persisted field names."""
data = dict(value or {})
data.update(kwargs)
nestedExtras = data.pop('extras', {})
tags = data.pop('tags', tuple()) or tuple()
if isinstance(tags, str):
tags = tuple(value.strip() for value in tags.split(',') if value.strip())
else:
tags = tuple(tags)
favorite = data.pop('favorite', False)
if isinstance(favorite, str):
favorite = favorite.strip().casefold() in ('1', 'true', 'yes', 'on')
subscriptionSource = str(
data.pop('subscriptionSource', data.pop('subsId', '')) or ''
)
subscriptionManaged = data.pop('subscriptionManaged', None)
if subscriptionManaged is None:
# Before managed ownership was explicit, a non-empty subsId was
# written only for profiles imported from that subscription.
subscriptionManaged = bool(subscriptionSource)
elif isinstance(subscriptionManaged, str):
subscriptionManaged = subscriptionManaged.strip().casefold() in (
'1',
'true',
'yes',
'on',
)
known = {
'profileId': str(data.pop('profileId', '') or uuid.uuid4()),
'displayName': data.pop('displayName', data.pop('remark', '')),
'group': data.pop('group', ''),
'tags': tags,
'subscriptionSource': subscriptionSource,
'subscriptionManaged': bool(subscriptionManaged),
'subscriptionProfileKey': str(data.pop('subscriptionProfileKey', '') or ''),
'updatedAt': data.pop('updatedAt', ''),
'annotations': data.pop('annotations', ''),
'favorite': bool(favorite),
'latency': data.pop('latency', data.pop('delayResult', '')),
'speed': data.pop('speed', data.pop('speedResult', '')),
}
extras = dict(nestedExtras) if isinstance(nestedExtras, Mapping) else {}
extras.update(data)
return cls(**known, extras=extras)
def toMapping(self) -> dict[str, Any]:
"""Return the normalized persisted metadata mapping."""
return {
'profileId': self.profileId,
'displayName': self.displayName,
'group': self.group,
'tags': list(self.tags),
'subscriptionSource': self.subscriptionSource,
'subscriptionManaged': self.subscriptionManaged,
'subscriptionProfileKey': self.subscriptionProfileKey,
'updatedAt': self.updatedAt,
'annotations': self.annotations,
'favorite': self.favorite,
'latency': self.latency,
'speed': self.speed,
'extras': dict(self.extras),
}
def set(self, name: str, value):
"""Set a current or legacy metadata field."""
aliases = {
'remark': 'displayName',
'subsId': 'subscriptionSource',
'delayResult': 'latency',
'speedResult': 'speed',
}
attribute = aliases.get(name, name)
if attribute == 'tags':
self.tags = (
tuple(item.strip() for item in value.split(',') if item.strip())
if isinstance(value, str)
else tuple(value or tuple())
)
elif attribute == 'favorite' and isinstance(value, str):
self.favorite = value.strip().casefold() in ('1', 'true', 'yes', 'on')
elif attribute == 'subscriptionSource':
self.subscriptionSource = str(value or '')
if not self.subscriptionSource:
self.subscriptionManaged = False
self.subscriptionProfileKey = ''
elif attribute in self.__dataclass_fields__ and attribute != 'extras':
setattr(self, attribute, value)
else:
self.extras[name] = value
@dataclass
class ServerProfile(MutableMapping[str, Any]):
"""Compose profile metadata with a core-neutral connection document."""
connection: ConfigFactory
metadata: ProfileMetadata = field(default_factory=ProfileMetadata)
index: int = 0
deleted: bool = False
@classmethod
def fromConfiguration(
cls,
configuration: ConfigFactory,
metadata: ProfileMetadata | Mapping[str, Any] | None = None,
*,
index: int = 0,
deleted: bool = False,
):
"""Move transient parser metadata into a separate profile object."""
if isinstance(configuration, ServerProfile):
return configuration
if not isinstance(configuration, ConfigFactory):
raise TypeError('profile connection must be a ConfigFactory')
if isinstance(metadata, ProfileMetadata):
profileMetadata = copy.deepcopy(metadata)
else:
profileMetadata = ProfileMetadata.fromMapping(metadata)
connection = configuration.deepcopy()
return cls(connection, profileMetadata, index, deleted)
def __getitem__(self, key: str):
"""Return a connection-document value."""
return self.connection[key]
def __setitem__(self, key: str, value):
"""Set a connection-document value."""
self.connection[key] = value
def __delitem__(self, key: str):
"""Delete a connection-document value."""
del self.connection[key]
def __iter__(self) -> Iterator[str]:
"""Iterate over connection-document keys."""
return iter(self.connection)
def __len__(self) -> int:
"""Return the connection-document size."""
return len(self.connection)
def deepcopy(self):
"""Return an independent profile copy."""
return copy.deepcopy(self)
def independentCopy(self):
"""Return a manual copy with a new identity and no source owner."""
profile = self.deepcopy()
profile.metadata.profileId = str(uuid.uuid4())
profile.metadata.subscriptionSource = ''
profile.metadata.subscriptionManaged = False
profile.metadata.subscriptionProfileKey = ''
return profile
def replaceConnection(self, connection: ConfigFactory):
"""Return this profile's metadata composed with a new connection."""
return ServerProfile.fromConfiguration(
connection,
self.metadata,
index=self.index,
deleted=self.deleted,
)
def coreName(self) -> str:
"""Return the runtime implementation name."""
return self.connection.coreName()
def isValid(self) -> bool:
"""Return whether the connection document is valid."""
return self.connection.isValid()
def toJSONString(self, **kwargs) -> str:
"""Serialize only the connection document as JSON."""
return self.connection.toJSONString(**kwargs)
def toURI(self, remark: str = '') -> str:
"""Serialize the connection document as a share URI."""
exportOptions = (
{'profileMetadata': self.metadata}
if self.itemProtocol.casefold() == 'shadowsocks'
else {}
)
return self.connection.toURI(
remark or self.metadata.displayName, **exportOptions
)
def httpProxy(self) -> str:
"""Return the connection's HTTP proxy endpoint."""
return self.connection.httpProxy()
def socksProxy(self) -> str:
"""Return the connection's SOCKS proxy endpoint."""
return self.connection.socksProxy()
def setHttpProxy(self, endpoint: str) -> bool:
"""Set the connection's HTTP proxy endpoint."""
return self.connection.setHttpProxy(endpoint)
def setSocksProxy(self, endpoint: str) -> bool:
"""Set the connection's SOCKS proxy endpoint."""
return self.connection.setSocksProxy(endpoint)
@property
def itemRemark(self) -> str:
"""Return the profile display name."""
return self.metadata.displayName
@property
def itemProtocol(self) -> str:
"""Return the connection protocol display value."""
return str(getattr(self.connection, 'itemProtocol', ''))
@property
def itemAddress(self) -> str:
"""Return the connection address display value."""
return str(getattr(self.connection, 'itemAddress', ''))
@property
def itemPort(self) -> str:
"""Return the connection port display value."""
return str(getattr(self.connection, 'itemPort', ''))
@property
def itemTransport(self) -> str:
"""Return the connection transport display value."""
return str(getattr(self.connection, 'itemTransport', ''))
@property
def itemTLS(self) -> str:
"""Return the connection TLS display value."""
return str(getattr(self.connection, 'itemTLS', ''))
@property
def itemSubscription(self) -> str:
"""Return the subscription source identifier."""
return self.metadata.subscriptionSource
@property
def itemSubscriptionManaged(self) -> bool:
"""Return whether a subscription synchronizer owns this profile."""
return self.metadata.subscriptionManaged
@property
def itemLatency(self) -> str:
"""Return the last latency result."""
return self.metadata.latency
@property
def itemSpeed(self) -> str:
"""Return the last speed result."""
return self.metadata.speed
def connectionOf(value):
"""Return a server profile's connection document or *value* itself."""
return value.connection if isinstance(value, ServerProfile) else value
def ensureProfile(value, **metadata) -> ServerProfile:
"""Return *value* as a profile, merging optional metadata fields."""
if isinstance(value, ServerProfile):
for name, item in metadata.items():
value.metadata.set(name, item)
return value
return ServerProfile.fromConfiguration(value, metadata)
def profileConnectionFingerprint(value) -> str:
"""Return a deterministic identity for a profile connection document."""
connection = connectionOf(value)
try:
serialized = json.dumps(
connection,
ensure_ascii=False,
separators=(',', ':'),
sort_keys=True,
)
except Exception:
# Any non-exit exceptions
serializer = getattr(connection, 'toJSONString', None)
serialized = serializer(indent=0) if callable(serializer) else str(connection)
return hashlib.sha256(serialized.encode('utf-8')).hexdigest()