Files
FlClash/lib/models/profile.dart
T
chen08209 0bac50029a feat(profiles): manage external resources from the profile details
Changelog: Profile-detail resources and include-all in custom groups
2026-10-17 17:21:43 +08:00

218 lines
6.2 KiB
Dart

import 'dart:io';
import 'dart:typed_data';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'clash_config.dart';
part 'generated/profile.freezed.dart';
part 'generated/profile.g.dart';
typedef ValidateConfig =
Future<String> Function(String path, String ageSecretKey);
@freezed
abstract class SubscriptionInfo with _$SubscriptionInfo {
const factory SubscriptionInfo({
@Default(0) int upload,
@Default(0) int download,
@Default(0) int total,
@Default(0) int expire,
}) = _SubscriptionInfo;
factory SubscriptionInfo.fromJson(Map<String, Object?> json) =>
_$SubscriptionInfoFromJson(json);
factory SubscriptionInfo.formHString(String? info) {
if (info == null) return const SubscriptionInfo();
final Map<String, int> map = {};
for (final field in info.split(';')) {
final separator = field.indexOf('=');
if (separator == -1) continue;
final value = _parseCount(field.substring(separator + 1).trim());
if (value != null) {
map[field.substring(0, separator).trim().toLowerCase()] = value;
}
}
return SubscriptionInfo(
upload: map['upload'] ?? 0,
download: map['download'] ?? 0,
total: map['total'] ?? 0,
expire: map['expire'] ?? 0,
);
}
static int? _parseCount(String value) {
final count = int.tryParse(value);
if (count != null) return count;
final decimal = double.tryParse(value);
return decimal != null && decimal.isFinite ? decimal.truncate() : null;
}
}
extension SubscriptionInfoExt on SubscriptionInfo {
double? get usage =>
total > 0 ? ((upload + download) / total).clamp(0.0, 1.0) : null;
DateTime? get expireDate =>
expire > 0 ? DateTime.fromMillisecondsSinceEpoch(expire * 1000) : null;
}
@freezed
abstract class Profile with _$Profile {
const factory Profile({
required int id,
@Default(ProfileType.file) ProfileType type,
@Default('') String label,
String? currentGroupName,
@Default('') String url,
@Default('') String ageSecretKey,
DateTime? lastUpdateDate,
required Duration autoUpdateDuration,
SubscriptionInfo? subscriptionInfo,
@Default(true) bool autoUpdate,
@Default({}) Map<String, String> selectedMap,
@Default({}) Set<String> unfoldSet,
@Default(ExtendType.standard) ExtendType extendType,
int? scriptId,
String? matchTarget,
int? order,
@Default(ProfileOverrides())
@JsonKey(fromJson: ProfileOverrides.safeFromJson)
ProfileOverrides overrides,
}) = _Profile;
factory Profile.fromJson(Map<String, Object?> json) =>
_$ProfileFromJson(json);
factory Profile.normal({String? label, String url = ''}) {
final id = snowflake.id;
return Profile(
type: url.isEmpty ? ProfileType.file : ProfileType.url,
label: label ?? '',
url: url,
id: id,
autoUpdateDuration: defaultUpdateDuration,
);
}
factory Profile.custom({String? label}) {
return Profile(
id: snowflake.id,
type: ProfileType.custom,
label: label ?? '',
lastUpdateDate: DateTime.now(),
autoUpdate: false,
autoUpdateDuration: defaultUpdateDuration,
);
}
}
/// A profile leaving out one of the global rules its extension would add.
@freezed
abstract class DisabledRule with _$DisabledRule {
const factory DisabledRule({required int profileId, required int ruleId}) =
_DisabledRule;
}
extension ProfilesExt on List<Profile> {
Profile? getProfile(int? profileId) {
final index = indexWhere((profile) => profile.id == profileId);
return index == -1 ? null : this[index];
}
Profile optimizeLabel(Profile profile) {
return profile.copyWith(
label: uniqueLabelFor(
profile.label,
fallback: profile.defaultLabel,
taken: (label) =>
any((item) => item.label == label && item.id != profile.id),
),
);
}
}
extension ProfileExtension on Profile {
bool get realAutoUpdate => type == ProfileType.url && autoUpdate;
String get realLabel => label.takeFirstValid([defaultLabel]);
String get defaultLabel => url.webUri?.host ?? id.toString();
String get fileName => '$id.yaml';
String get exportFileName {
final label = realLabel;
final lower = label.toLowerCase();
if (lower.endsWith('.yaml') || lower.endsWith('.yml')) {
return label;
}
return '$label.yaml';
}
String get updatingKey => 'profile_$id';
Future<Profile?> checkAndUpdateAndCopy({
required ValidateConfig validate,
}) async {
if (type != ProfileType.url) {
return null;
}
final mFile = await _getFile(false);
if (await mFile.exists()) {
return null;
}
return update(validate: validate);
}
Future<File> _getFile([bool autoCreate = true]) async {
final path = await appPath.getProfilePath(id.toString());
final file = File(path);
final isExists = await file.exists();
if (!isExists && autoCreate) {
return file.create(recursive: true);
}
return file;
}
Future<File> get file async {
return _getFile();
}
Future<Profile> update({required ValidateConfig validate}) async {
final response = await request.getFileResponseForUrl(url);
final disposition = response.headers.value('content-disposition');
final userinfo = response.headers.value('subscription-userinfo');
return copyWith(
label: label.takeFirstValid([
getFileNameForDisposition(disposition),
defaultLabel,
]),
subscriptionInfo: SubscriptionInfo.formHString(userinfo),
).saveFile(
decompressUnlabeled(response.data ?? Uint8List(0)),
validate: validate,
);
}
Future<Profile> saveFile(
Uint8List bytes, {
required ValidateConfig validate,
}) async {
final path = await appPath.tempFilePath;
final tempFile = File(path);
await tempFile.safeWriteAsBytes(bytes);
final message = await validate(path, ageSecretKey);
if (message.isNotEmpty) {
throw MessageException(message);
}
final mFile = await file;
await tempFile.copy(mFile.path);
await tempFile.safeDelete();
return copyWith(lastUpdateDate: DateTime.now());
}
}