Merge branch 'main' of hiddify-github:hiddify/hiddify-next

This commit is contained in:
Hiddify
2023-10-02 20:03:03 +02:00
26 changed files with 1362 additions and 260 deletions

View File

@@ -239,6 +239,7 @@
"profiles": { "profiles": {
"unexpected": "Unexpected Error", "unexpected": "Unexpected Error",
"notFound": "Profile Not Found", "notFound": "Profile Not Found",
"invalidUrl": "Invalid URL",
"invalidConfig": "Invalid Configs" "invalidConfig": "Invalid Configs"
} }
} }

File diff suppressed because one or more lines are too long

View File

@@ -4,20 +4,31 @@ import 'package:hiddify/domain/profiles/profiles.dart';
extension ProfileMapper on Profile { extension ProfileMapper on Profile {
ProfileEntriesCompanion toCompanion() { ProfileEntriesCompanion toCompanion() {
return ProfileEntriesCompanion.insert( return switch (this) {
id: id, RemoteProfile(:final url, :final options, :final subInfo) =>
active: active, ProfileEntriesCompanion.insert(
name: name, id: id,
url: url, type: ProfileType.remote,
lastUpdate: lastUpdate, active: active,
updateInterval: Value(options?.updateInterval), name: name,
upload: Value(subInfo?.upload), url: Value(url),
download: Value(subInfo?.download), lastUpdate: lastUpdate,
total: Value(subInfo?.total), updateInterval: Value(options?.updateInterval),
expire: Value(subInfo?.expire), upload: Value(subInfo?.upload),
webPageUrl: Value(extra?.webPageUrl), download: Value(subInfo?.download),
supportUrl: Value(extra?.supportUrl), total: Value(subInfo?.total),
); expire: Value(subInfo?.expire),
webPageUrl: Value(subInfo?.webPageUrl),
supportUrl: Value(subInfo?.supportUrl),
),
LocalProfile() => ProfileEntriesCompanion.insert(
id: id,
type: ProfileType.local,
active: active,
name: name,
lastUpdate: lastUpdate,
),
};
} }
static Profile fromEntry(ProfileEntry e) { static Profile fromEntry(ProfileEntry e) {
@@ -36,26 +47,27 @@ extension ProfileMapper on Profile {
download: e.download!, download: e.download!,
total: e.total!, total: e.total!,
expire: e.expire!, expire: e.expire!,
);
}
ProfileExtra? extra;
if (e.webPageUrl != null || e.supportUrl != null) {
extra = ProfileExtra(
webPageUrl: e.webPageUrl, webPageUrl: e.webPageUrl,
supportUrl: e.supportUrl, supportUrl: e.supportUrl,
); );
} }
return Profile( return switch (e.type) {
id: e.id, ProfileType.remote => RemoteProfile(
active: e.active, id: e.id,
name: e.name, active: e.active,
url: e.url, name: e.name,
lastUpdate: e.lastUpdate, url: e.url!,
options: options, lastUpdate: e.lastUpdate,
subInfo: subInfo, options: options,
extra: extra, subInfo: subInfo,
); ),
ProfileType.local => LocalProfile(
id: e.id,
active: e.active,
name: e.name,
lastUpdate: e.lastUpdate,
),
};
} }
} }

View File

@@ -3,8 +3,10 @@ import 'dart:io';
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:drift/native.dart'; import 'package:drift/native.dart';
import 'package:hiddify/data/local/dao/dao.dart'; import 'package:hiddify/data/local/dao/dao.dart';
import 'package:hiddify/data/local/schema_versions.dart';
import 'package:hiddify/data/local/tables.dart'; import 'package:hiddify/data/local/tables.dart';
import 'package:hiddify/data/local/type_converters.dart'; import 'package:hiddify/data/local/type_converters.dart';
import 'package:hiddify/domain/profiles/profiles.dart';
import 'package:hiddify/services/files_editor_service.dart'; import 'package:hiddify/services/files_editor_service.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
@@ -17,7 +19,31 @@ class AppDatabase extends _$AppDatabase {
AppDatabase.connect() : super(_openConnection()); AppDatabase.connect() : super(_openConnection());
@override @override
int get schemaVersion => 1; int get schemaVersion => 2;
@override
MigrationStrategy get migration {
return MigrationStrategy(
onCreate: (Migrator m) async {
await m.createAll();
},
onUpgrade: stepByStep(
// add type column to profile entries table
// make url column nullable
from1To2: (m, schema) async {
await m.alterTable(
TableMigration(
schema.profileEntries,
columnTransformer: {
schema.profileEntries.type: const Constant<String>("remote"),
},
newColumns: [schema.profileEntries.type],
),
);
},
),
);
}
} }
LazyDatabase _openConnection() { LazyDatabase _openConnection() {

View File

@@ -0,0 +1,136 @@
import 'package:drift/internal/versioned_schema.dart' as i0;
import 'package:drift/drift.dart' as i1;
import 'package:drift/drift.dart'; // ignore_for_file: type=lint,unused_import
// GENERATED BY drift_dev, DO NOT MODIFY.
final class _S2 extends i0.VersionedSchema {
_S2({required super.database}) : super(version: 2);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
profileEntries,
];
late final Shape0 profileEntries = Shape0(
source: i0.VersionedTable(
entityName: 'profile_entries',
withoutRowId: false,
isStrict: false,
tableConstraints: [
'PRIMARY KEY(id)',
],
columns: [
_column_0,
_column_1,
_column_2,
_column_3,
_column_4,
_column_5,
_column_6,
_column_7,
_column_8,
_column_9,
_column_10,
_column_11,
_column_12,
],
attachedDatabase: database,
),
alias: null);
}
class Shape0 extends i0.VersionedTable {
Shape0({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get id =>
columnsByName['id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get type =>
columnsByName['type']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<bool> get active =>
columnsByName['active']! as i1.GeneratedColumn<bool>;
i1.GeneratedColumn<String> get name =>
columnsByName['name']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get url =>
columnsByName['url']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<DateTime> get lastUpdate =>
columnsByName['last_update']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<int> get updateInterval =>
columnsByName['update_interval']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get upload =>
columnsByName['upload']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get download =>
columnsByName['download']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get total =>
columnsByName['total']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<DateTime> get expire =>
columnsByName['expire']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<String> get webPageUrl =>
columnsByName['web_page_url']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get supportUrl =>
columnsByName['support_url']! as i1.GeneratedColumn<String>;
}
i1.GeneratedColumn<String> _column_0(String aliasedName) =>
i1.GeneratedColumn<String>('id', aliasedName, false,
type: i1.DriftSqlType.string);
i1.GeneratedColumn<String> _column_1(String aliasedName) =>
i1.GeneratedColumn<String>('type', aliasedName, false,
type: i1.DriftSqlType.string);
i1.GeneratedColumn<bool> _column_2(String aliasedName) =>
i1.GeneratedColumn<bool>('active', aliasedName, false,
type: i1.DriftSqlType.bool,
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
'CHECK ("active" IN (0, 1))'));
i1.GeneratedColumn<String> _column_3(String aliasedName) =>
i1.GeneratedColumn<String>('name', aliasedName, false,
additionalChecks: i1.GeneratedColumn.checkTextLength(
minTextLength: 1,
),
type: i1.DriftSqlType.string);
i1.GeneratedColumn<String> _column_4(String aliasedName) =>
i1.GeneratedColumn<String>('url', aliasedName, true,
type: i1.DriftSqlType.string);
i1.GeneratedColumn<DateTime> _column_5(String aliasedName) =>
i1.GeneratedColumn<DateTime>('last_update', aliasedName, false,
type: i1.DriftSqlType.dateTime);
i1.GeneratedColumn<int> _column_6(String aliasedName) =>
i1.GeneratedColumn<int>('update_interval', aliasedName, true,
type: i1.DriftSqlType.int);
i1.GeneratedColumn<int> _column_7(String aliasedName) =>
i1.GeneratedColumn<int>('upload', aliasedName, true,
type: i1.DriftSqlType.int);
i1.GeneratedColumn<int> _column_8(String aliasedName) =>
i1.GeneratedColumn<int>('download', aliasedName, true,
type: i1.DriftSqlType.int);
i1.GeneratedColumn<int> _column_9(String aliasedName) =>
i1.GeneratedColumn<int>('total', aliasedName, true,
type: i1.DriftSqlType.int);
i1.GeneratedColumn<DateTime> _column_10(String aliasedName) =>
i1.GeneratedColumn<DateTime>('expire', aliasedName, true,
type: i1.DriftSqlType.dateTime);
i1.GeneratedColumn<String> _column_11(String aliasedName) =>
i1.GeneratedColumn<String>('web_page_url', aliasedName, true,
type: i1.DriftSqlType.string);
i1.GeneratedColumn<String> _column_12(String aliasedName) =>
i1.GeneratedColumn<String>('support_url', aliasedName, true,
type: i1.DriftSqlType.string);
i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, _S2 schema) from1To2,
}) {
return (currentVersion, database) async {
switch (currentVersion) {
case 1:
final schema = _S2(database: database);
final migrator = i1.Migrator(database, schema);
await from1To2(migrator, schema);
return 2;
default:
throw ArgumentError.value('Unknown migration from $currentVersion');
}
};
}
i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, _S2 schema) from1To2,
}) =>
i0.VersionedSchema.stepByStepHelper(
step: migrationSteps(
from1To2: from1To2,
));

View File

@@ -0,0 +1,160 @@
{
"_meta": {
"description": "This file contains a serialized version of schema entities for drift.",
"version": "1.1.0"
},
"options": {
"store_date_time_values_as_text": true
},
"entities": [
{
"id": 0,
"references": [],
"type": "table",
"data": {
"name": "profile_entries",
"was_declared_in_moor": false,
"columns": [
{
"name": "id",
"getter_name": "id",
"moor_type": "string",
"nullable": false,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "active",
"getter_name": "active",
"moor_type": "bool",
"nullable": false,
"customConstraints": null,
"defaultConstraints": "CHECK (\"active\" IN (0, 1))",
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "name",
"getter_name": "name",
"moor_type": "string",
"nullable": false,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": [
{
"allowed-lengths": {
"min": 1,
"max": null
}
}
]
},
{
"name": "url",
"getter_name": "url",
"moor_type": "string",
"nullable": false,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "last_update",
"getter_name": "lastUpdate",
"moor_type": "dateTime",
"nullable": false,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "update_interval",
"getter_name": "updateInterval",
"moor_type": "int",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": [],
"type_converter": {
"dart_expr": "DurationTypeConverter()",
"dart_type_name": "Duration"
}
},
{
"name": "upload",
"getter_name": "upload",
"moor_type": "int",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "download",
"getter_name": "download",
"moor_type": "int",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "total",
"getter_name": "total",
"moor_type": "int",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "expire",
"getter_name": "expire",
"moor_type": "dateTime",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "web_page_url",
"getter_name": "webPageUrl",
"moor_type": "string",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "support_url",
"getter_name": "supportUrl",
"moor_type": "string",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
}
],
"is_virtual": false,
"without_rowid": false,
"constraints": [],
"explicit_pk": [
"id"
]
}
}
]
}

View File

@@ -0,0 +1,174 @@
{
"_meta": {
"description": "This file contains a serialized version of schema entities for drift.",
"version": "1.1.0"
},
"options": {
"store_date_time_values_as_text": true
},
"entities": [
{
"id": 0,
"references": [],
"type": "table",
"data": {
"name": "profile_entries",
"was_declared_in_moor": false,
"columns": [
{
"name": "id",
"getter_name": "id",
"moor_type": "string",
"nullable": false,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "type",
"getter_name": "type",
"moor_type": "string",
"nullable": false,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": [],
"type_converter": {
"dart_expr": "const EnumNameConverter<ProfileType>(ProfileType.values)",
"dart_type_name": "ProfileType"
}
},
{
"name": "active",
"getter_name": "active",
"moor_type": "bool",
"nullable": false,
"customConstraints": null,
"defaultConstraints": "CHECK (\"active\" IN (0, 1))",
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "name",
"getter_name": "name",
"moor_type": "string",
"nullable": false,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": [
{
"allowed-lengths": {
"min": 1,
"max": null
}
}
]
},
{
"name": "url",
"getter_name": "url",
"moor_type": "string",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "last_update",
"getter_name": "lastUpdate",
"moor_type": "dateTime",
"nullable": false,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "update_interval",
"getter_name": "updateInterval",
"moor_type": "int",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": [],
"type_converter": {
"dart_expr": "DurationTypeConverter()",
"dart_type_name": "Duration"
}
},
{
"name": "upload",
"getter_name": "upload",
"moor_type": "int",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "download",
"getter_name": "download",
"moor_type": "int",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "total",
"getter_name": "total",
"moor_type": "int",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "expire",
"getter_name": "expire",
"moor_type": "dateTime",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "web_page_url",
"getter_name": "webPageUrl",
"moor_type": "string",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
},
{
"name": "support_url",
"getter_name": "supportUrl",
"moor_type": "string",
"nullable": true,
"customConstraints": null,
"default_dart": null,
"default_client_dart": null,
"dsl_features": []
}
],
"is_virtual": false,
"without_rowid": false,
"constraints": [],
"explicit_pk": [
"id"
]
}
}
]
}

View File

@@ -1,12 +1,14 @@
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:hiddify/data/local/type_converters.dart'; import 'package:hiddify/data/local/type_converters.dart';
import 'package:hiddify/domain/profiles/profiles.dart';
@DataClassName('ProfileEntry') @DataClassName('ProfileEntry')
class ProfileEntries extends Table { class ProfileEntries extends Table {
TextColumn get id => text()(); TextColumn get id => text()();
TextColumn get type => textEnum<ProfileType>()();
BoolColumn get active => boolean()(); BoolColumn get active => boolean()();
TextColumn get name => text().withLength(min: 1)(); TextColumn get name => text().withLength(min: 1)();
TextColumn get url => text()(); TextColumn get url => text().nullable()();
DateTimeColumn get lastUpdate => dateTime()(); DateTimeColumn get lastUpdate => dateTime()();
IntColumn get updateInterval => IntColumn get updateInterval =>
integer().nullable().map(DurationTypeConverter())(); integer().nullable().map(DurationTypeConverter())();

View File

@@ -72,7 +72,7 @@ class ProfilesRepositoryImpl
return exceptionHandler( return exceptionHandler(
() async { () async {
final existingProfile = await profilesDao.getProfileByUrl(url); final existingProfile = await profilesDao.getProfileByUrl(url);
if (existingProfile != null) { if (existingProfile case RemoteProfile()) {
loggy.info("profile with url[$url] already exists, updating"); loggy.info("profile with url[$url] already exists, updating");
final baseProfile = markAsActive final baseProfile = markAsActive
? existingProfile.copyWith(active: true) ? existingProfile.copyWith(active: true)
@@ -105,7 +105,49 @@ class ProfilesRepositoryImpl
} }
@override @override
TaskEither<ProfileFailure, Unit> add(Profile baseProfile) { TaskEither<ProfileFailure, Unit> addByContent(
String content, {
required String name,
bool markAsActive = false,
}) {
return exceptionHandler(
() async {
final profileId = const Uuid().v4();
final tempPath = filesEditor.tempConfigPath(profileId);
final path = filesEditor.configPath(profileId);
try {
await File(tempPath).writeAsString(content);
final parseResult =
await singbox.parseConfig(path, tempPath, false).run();
return parseResult.fold(
(l) async {
loggy.warning("error parsing config: $l");
return left(ProfileFailure.invalidConfig(l.msg));
},
(_) async {
final profile = LocalProfile(
id: profileId,
active: markAsActive,
name: name,
lastUpdate: DateTime.now(),
);
await profilesDao.create(profile);
return right(unit);
},
);
} finally {
if (await File(tempPath).exists()) await File(tempPath).delete();
}
},
(error, stackTrace) {
loggy.warning("error adding profile by content", error, stackTrace);
return ProfileUnexpectedFailure(error, stackTrace);
},
);
}
@override
TaskEither<ProfileFailure, Unit> add(RemoteProfile baseProfile) {
return exceptionHandler( return exceptionHandler(
() async { () async {
return fetch(baseProfile.url, baseProfile.id) return fetch(baseProfile.url, baseProfile.id)
@@ -114,7 +156,6 @@ class ProfilesRepositoryImpl
await profilesDao.create( await profilesDao.create(
baseProfile.copyWith( baseProfile.copyWith(
subInfo: remoteProfile.subInfo, subInfo: remoteProfile.subInfo,
extra: remoteProfile.extra,
lastUpdate: DateTime.now(), lastUpdate: DateTime.now(),
), ),
); );
@@ -131,7 +172,7 @@ class ProfilesRepositoryImpl
} }
@override @override
TaskEither<ProfileFailure, Unit> update(Profile baseProfile) { TaskEither<ProfileFailure, Unit> update(RemoteProfile baseProfile) {
return exceptionHandler( return exceptionHandler(
() async { () async {
loggy.debug( loggy.debug(
@@ -143,7 +184,6 @@ class ProfilesRepositoryImpl
await profilesDao.edit( await profilesDao.edit(
baseProfile.copyWith( baseProfile.copyWith(
subInfo: remoteProfile.subInfo, subInfo: remoteProfile.subInfo,
extra: remoteProfile.extra,
lastUpdate: DateTime.now(), lastUpdate: DateTime.now(),
), ),
); );
@@ -209,13 +249,13 @@ class ProfilesRepositoryImpl
]; ];
@visibleForTesting @visibleForTesting
TaskEither<ProfileFailure, Profile> fetch( TaskEither<ProfileFailure, RemoteProfile> fetch(
String url, String url,
String fileName, String fileName,
) { ) {
return TaskEither( return TaskEither(
() async { () async {
final tempPath = filesEditor.configPath("temp_$fileName"); final tempPath = filesEditor.tempConfigPath(fileName);
final path = filesEditor.configPath(fileName); final path = filesEditor.configPath(fileName);
try { try {
final response = await dio.download(url.trim(), tempPath); final response = await dio.download(url.trim(), tempPath);

View File

@@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:dartx/dartx.dart'; import 'package:dartx/dartx.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hiddify/utils/utils.dart';
import 'package:loggy/loggy.dart'; import 'package:loggy/loggy.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
@@ -10,11 +11,13 @@ part 'profile.g.dart';
final _loggy = Loggy('Profile'); final _loggy = Loggy('Profile');
enum ProfileType { remote, local }
@freezed @freezed
class Profile with _$Profile { sealed class Profile with _$Profile {
const Profile._(); const Profile._();
const factory Profile({ const factory Profile.remote({
required String id, required String id,
required bool active, required bool active,
required String name, required String name,
@@ -22,10 +25,19 @@ class Profile with _$Profile {
required DateTime lastUpdate, required DateTime lastUpdate,
ProfileOptions? options, ProfileOptions? options,
SubscriptionInfo? subInfo, SubscriptionInfo? subInfo,
ProfileExtra? extra, }) = RemoteProfile;
}) = _Profile;
factory Profile.fromResponse(String url, Map<String, List<String>> headers) { const factory Profile.local({
required String id,
required bool active,
required String name,
required DateTime lastUpdate,
}) = LocalProfile;
static RemoteProfile fromResponse(
String url,
Map<String, List<String>> headers,
) {
_loggy.debug("Profile Headers: $headers"); _loggy.debug("Profile Headers: $headers");
final titleHeader = headers['profile-title']?.single; final titleHeader = headers['profile-title']?.single;
@@ -59,7 +71,7 @@ class Profile with _$Profile {
if (title.isEmpty) { if (title.isEmpty) {
final part = url.split("/").lastOrNull; final part = url.split("/").lastOrNull;
if (part != null) { if (part != null) {
final pattern = RegExp(r"\.(yaml|yml|txt)[\s\S]*"); final pattern = RegExp(r"\.(json|yaml|yml|txt)[\s\S]*");
title = part.replaceFirst(pattern, ""); title = part.replaceFirst(pattern, "");
} }
} }
@@ -79,15 +91,14 @@ class Profile with _$Profile {
final webPageUrlHeader = headers['profile-web-page-url']?.single; final webPageUrlHeader = headers['profile-web-page-url']?.single;
final supportUrlHeader = headers['support-url']?.single; final supportUrlHeader = headers['support-url']?.single;
ProfileExtra? extra; if (subInfo != null) {
if (webPageUrlHeader != null || supportUrlHeader != null) { subInfo = subInfo.copyWith(
extra = ProfileExtra( webPageUrl: isUrl(webPageUrlHeader ?? "") ? webPageUrlHeader : null,
webPageUrl: webPageUrlHeader, supportUrl: isUrl(supportUrlHeader ?? "") ? supportUrlHeader : null,
supportUrl: supportUrlHeader,
); );
} }
return Profile( return RemoteProfile(
id: const Uuid().v4(), id: const Uuid().v4(),
active: false, active: false,
name: title.isBlank ? "Remote Profile" : title, name: title.isBlank ? "Remote Profile" : title,
@@ -95,7 +106,6 @@ class Profile with _$Profile {
lastUpdate: DateTime.now(), lastUpdate: DateTime.now(),
options: options, options: options,
subInfo: subInfo, subInfo: subInfo,
extra: extra,
); );
} }
@@ -113,17 +123,6 @@ class ProfileOptions with _$ProfileOptions {
_$ProfileOptionsFromJson(json); _$ProfileOptionsFromJson(json);
} }
@freezed
class ProfileExtra with _$ProfileExtra {
const factory ProfileExtra({
String? webPageUrl,
String? supportUrl,
}) = _ProfileExtra;
factory ProfileExtra.fromJson(Map<String, dynamic> json) =>
_$ProfileExtraFromJson(json);
}
@freezed @freezed
class SubscriptionInfo with _$SubscriptionInfo { class SubscriptionInfo with _$SubscriptionInfo {
const SubscriptionInfo._(); const SubscriptionInfo._();
@@ -134,6 +133,8 @@ class SubscriptionInfo with _$SubscriptionInfo {
@JsonKey(fromJson: _fromJsonTotal, defaultValue: 9223372036854775807) @JsonKey(fromJson: _fromJsonTotal, defaultValue: 9223372036854775807)
required int total, required int total,
@JsonKey(fromJson: _dateTimeFromSecondsSinceEpoch) required DateTime expire, @JsonKey(fromJson: _dateTimeFromSecondsSinceEpoch) required DateTime expire,
String? webPageUrl,
String? supportUrl,
}) = _SubscriptionInfo; }) = _SubscriptionInfo;
bool get isExpired => expire <= DateTime.now(); bool get isExpired => expire <= DateTime.now();

View File

@@ -15,6 +15,9 @@ sealed class ProfileFailure with _$ProfileFailure, Failure {
const factory ProfileFailure.notFound() = ProfileNotFoundFailure; const factory ProfileFailure.notFound() = ProfileNotFoundFailure;
@With<ExpectedException>()
const factory ProfileFailure.invalidUrl() = ProfileInvalidUrlFailure;
const factory ProfileFailure.invalidConfig([String? message]) = const factory ProfileFailure.invalidConfig([String? message]) =
ProfileInvalidConfigFailure; ProfileInvalidConfigFailure;
@@ -29,6 +32,10 @@ sealed class ProfileFailure with _$ProfileFailure, Failure {
type: t.failure.profiles.notFound, type: t.failure.profiles.notFound,
message: null message: null
), ),
ProfileInvalidUrlFailure() => (
type: t.failure.profiles.invalidUrl,
message: null,
),
ProfileInvalidConfigFailure(:final message) => ( ProfileInvalidConfigFailure(:final message) => (
type: t.failure.profiles.invalidConfig, type: t.failure.profiles.invalidConfig,
message: message message: message

View File

@@ -19,9 +19,15 @@ abstract class ProfilesRepository {
bool markAsActive = false, bool markAsActive = false,
}); });
TaskEither<ProfileFailure, Unit> add(Profile baseProfile); TaskEither<ProfileFailure, Unit> addByContent(
String content, {
required String name,
bool markAsActive = false,
});
TaskEither<ProfileFailure, Unit> update(Profile baseProfile); TaskEither<ProfileFailure, Unit> add(RemoteProfile baseProfile);
TaskEither<ProfileFailure, Unit> update(RemoteProfile baseProfile);
TaskEither<ProfileFailure, Unit> edit(Profile profile); TaskEither<ProfileFailure, Unit> edit(Profile profile);

View File

@@ -1,4 +1,3 @@
import 'package:fpdart/fpdart.dart';
import 'package:hiddify/data/data_providers.dart'; import 'package:hiddify/data/data_providers.dart';
import 'package:hiddify/domain/profiles/profiles.dart'; import 'package:hiddify/domain/profiles/profiles.dart';
import 'package:hiddify/utils/utils.dart'; import 'package:hiddify/utils/utils.dart';
@@ -16,16 +15,4 @@ class ActiveProfile extends _$ActiveProfile with AppLogger {
.watchActiveProfile() .watchActiveProfile()
.map((event) => event.getOrElse((l) => throw l)); .map((event) => event.getOrElse((l) => throw l));
} }
Future<Unit?> updateProfile() async {
if (state case AsyncData(value: final profile?)) {
loggy.debug("updating active profile");
return ref
.read(profilesRepositoryProvider)
.update(profile)
.getOrElse((l) => throw l)
.run();
}
return null;
}
} }

View File

@@ -39,7 +39,10 @@ class ProfileTile extends HookConsumerWidget {
}, },
); );
final subInfo = profile.subInfo; final subInfo = switch (profile) {
RemoteProfile(:final subInfo) => subInfo,
_ => null,
};
final effectiveMargin = isMain final effectiveMargin = isMain
? const EdgeInsets.symmetric(horizontal: 16, vertical: 8) ? const EdgeInsets.symmetric(horizontal: 16, vertical: 8)
@@ -60,17 +63,19 @@ class ProfileTile extends HookConsumerWidget {
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
SizedBox( if (profile is RemoteProfile || !isMain) ...[
width: 48, SizedBox(
child: Semantics( width: 48,
sortKey: const OrdinalSortKey(1), child: Semantics(
child: ProfileActionButton(profile, !isMain), sortKey: const OrdinalSortKey(1),
child: ProfileActionButton(profile, !isMain),
),
), ),
), VerticalDivider(
VerticalDivider( width: 1,
width: 1, color: effectiveOutlineColor,
color: effectiveOutlineColor, ),
), ],
Expanded( Expanded(
child: Semantics( child: Semantics(
button: true, button: true,
@@ -177,7 +182,7 @@ class ProfileActionButton extends HookConsumerWidget {
CustomToast.success(t.profile.update.successMsg).show(context), CustomToast.success(t.profile.update.successMsg).show(context),
); );
if (!showAllActions) { if (profile case RemoteProfile() when !showAllActions) {
return Semantics( return Semantics(
button: true, button: true,
enabled: !updateProfileMutation.state.isInProgress, enabled: !updateProfileMutation.state.isInProgress,
@@ -191,7 +196,7 @@ class ProfileActionButton extends HookConsumerWidget {
updateProfileMutation.setFuture( updateProfileMutation.setFuture(
ref ref
.read(profilesNotifierProvider.notifier) .read(profilesNotifierProvider.notifier)
.updateProfile(profile), .updateProfile(profile as RemoteProfile),
); );
}, },
child: const Icon(Icons.update), child: const Icon(Icons.update),
@@ -250,20 +255,21 @@ class ProfileActionsMenu extends HookConsumerWidget {
return MenuAnchor( return MenuAnchor(
builder: builder, builder: builder,
menuChildren: [ menuChildren: [
MenuItemButton( if (profile case RemoteProfile())
leadingIcon: const Icon(Icons.update), MenuItemButton(
child: Text(t.profile.update.buttonTxt), leadingIcon: const Icon(Icons.update),
onPressed: () { child: Text(t.profile.update.buttonTxt),
if (updateProfileMutation.state.isInProgress) { onPressed: () {
return; if (updateProfileMutation.state.isInProgress) {
} return;
updateProfileMutation.setFuture( }
ref updateProfileMutation.setFuture(
.read(profilesNotifierProvider.notifier) ref
.updateProfile(profile), .read(profilesNotifierProvider.notifier)
); .updateProfile(profile as RemoteProfile),
}, );
), },
),
MenuItemButton( MenuItemButton(
leadingIcon: const Icon(Icons.edit), leadingIcon: const Icon(Icons.edit),
child: Text(t.profile.edit.buttonTxt), child: Text(t.profile.edit.buttonTxt),

View File

@@ -19,7 +19,7 @@ class ProfileDetailNotifier extends _$ProfileDetailNotifier with AppLogger {
}) async { }) async {
if (id == 'new') { if (id == 'new') {
return ProfileDetailState( return ProfileDetailState(
profile: Profile( profile: RemoteProfile(
id: const Uuid().v4(), id: const Uuid().v4(),
active: true, active: true,
name: profileName ?? "", name: profileName ?? "",
@@ -52,15 +52,20 @@ class ProfileDetailNotifier extends _$ProfileDetailNotifier with AppLogger {
if (state case AsyncData(:final value)) { if (state case AsyncData(:final value)) {
state = AsyncData( state = AsyncData(
value.copyWith( value.copyWith(
profile: value.profile.copyWith( profile: value.profile.map(
name: name ?? value.profile.name, remote: (rp) => rp.copyWith(
url: url ?? value.profile.url, name: name ?? rp.name,
options: updateInterval == null url: url ?? rp.url,
? value.profile.options options: updateInterval == null
: updateInterval.fold( ? rp.options
() => null, : updateInterval.fold(
(t) => ProfileOptions(updateInterval: Duration(hours: t)), () => null,
), (t) => ProfileOptions(
updateInterval: Duration(hours: t),
),
),
),
local: (lp) => lp.copyWith(name: name ?? lp.name),
), ),
), ),
); );
@@ -71,24 +76,33 @@ class ProfileDetailNotifier extends _$ProfileDetailNotifier with AppLogger {
if (state case AsyncData(:final value)) { if (state case AsyncData(:final value)) {
if (value.save.isInProgress) return; if (value.save.isInProgress) return;
final profile = value.profile; final profile = value.profile;
loggy.debug(
'saving profile, url: [${profile.url}], name: [${profile.name}]',
);
state = AsyncData(value.copyWith(save: const MutationInProgress()));
Either<ProfileFailure, Unit>? failureOrSuccess; Either<ProfileFailure, Unit>? failureOrSuccess;
if (profile.name.isBlank || profile.url.isBlank) { state = AsyncData(value.copyWith(save: const MutationInProgress()));
loggy.debug('profile save: invalid arguments'); switch (profile) {
} else if (value.isEditing) { case RemoteProfile():
if (_originalProfile?.url == profile.url) { loggy.debug(
'saving profile, url: [${profile.url}], name: [${profile.name}]',
);
if (profile.name.isBlank || profile.url.isBlank) {
loggy.debug('profile save: invalid arguments');
} else if (value.isEditing) {
if (_originalProfile case RemoteProfile(:final url)
when url == profile.url) {
loggy.debug('editing profile');
failureOrSuccess = await _profilesRepo.edit(profile).run();
} else {
loggy.debug('updating profile');
failureOrSuccess = await _profilesRepo.update(profile).run();
}
} else {
loggy.debug('adding profile, url: [${profile.url}]');
failureOrSuccess = await _profilesRepo.add(profile).run();
}
case LocalProfile() when value.isEditing:
loggy.debug('editing profile'); loggy.debug('editing profile');
failureOrSuccess = await _profilesRepo.edit(profile).run(); failureOrSuccess = await _profilesRepo.edit(profile).run();
} else { default:
loggy.debug('updating profile'); loggy.warning("local profile can't be added manually");
failureOrSuccess = await _profilesRepo.update(profile).run();
}
} else {
loggy.debug('adding profile, url: [${profile.url}]');
failureOrSuccess = await _profilesRepo.add(profile).run();
} }
state = AsyncData( state = AsyncData(
value.copyWith( value.copyWith(
@@ -105,12 +119,17 @@ class ProfileDetailNotifier extends _$ProfileDetailNotifier with AppLogger {
Future<void> updateProfile() async { Future<void> updateProfile() async {
if (state case AsyncData(:final value)) { if (state case AsyncData(:final value)) {
loggy.debug('updating profile');
if (value.profile case LocalProfile()) {
loggy.warning("local profile can't be updated");
return;
}
if (value.update.isInProgress || !value.isEditing) return; if (value.update.isInProgress || !value.isEditing) return;
final profile = value.profile; final profile = value.profile;
loggy.debug('updating profile'); loggy.debug('updating profile');
state = AsyncData(value.copyWith(update: const MutationInProgress())); state = AsyncData(value.copyWith(update: const MutationInProgress()));
final failureOrUpdatedProfile = await _profilesRepo final failureOrUpdatedProfile = await _profilesRepo
.update(profile) .update(profile as RemoteProfile)
.flatMap((_) => _profilesRepo.get(id)) .flatMap((_) => _profilesRepo.get(id))
.run(); .run();
state = AsyncData( state = AsyncData(

View File

@@ -3,6 +3,7 @@ import 'package:fpdart/fpdart.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hiddify/core/core_providers.dart'; import 'package:hiddify/core/core_providers.dart';
import 'package:hiddify/domain/failures.dart'; import 'package:hiddify/domain/failures.dart';
import 'package:hiddify/domain/profiles/profiles.dart';
import 'package:hiddify/features/common/confirmation_dialogs.dart'; import 'package:hiddify/features/common/confirmation_dialogs.dart';
import 'package:hiddify/features/profile_detail/notifier/notifier.dart'; import 'package:hiddify/features/profile_detail/notifier/notifier.dart';
import 'package:hiddify/features/settings/widgets/widgets.dart'; import 'package:hiddify/features/settings/widgets/widgets.dart';
@@ -93,12 +94,13 @@ class ProfileDetailPage extends HookConsumerWidget with PresLogger {
PopupMenuButton( PopupMenuButton(
itemBuilder: (context) { itemBuilder: (context) {
return [ return [
PopupMenuItem( if (state.profile case RemoteProfile())
child: Text(t.profile.update.buttonTxt), PopupMenuItem(
onTap: () async { child: Text(t.profile.update.buttonTxt),
await notifier.updateProfile(); onTap: () async {
}, await notifier.updateProfile();
), },
),
PopupMenuItem( PopupMenuItem(
child: Text(t.profile.delete.buttonTxt), child: Text(t.profile.delete.buttonTxt),
onTap: () async { onTap: () async {
@@ -140,52 +142,55 @@ class ProfileDetailPage extends HookConsumerWidget with PresLogger {
hint: t.profile.detailsForm.nameHint, hint: t.profile.detailsForm.nameHint,
), ),
), ),
Padding( if (state.profile
padding: const EdgeInsets.symmetric( case RemoteProfile(:final url, :final options)) ...[
horizontal: 16, Padding(
vertical: 8, padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: CustomTextFormField(
initialValue: url,
onChanged: (value) =>
notifier.setField(url: value),
validator: (value) =>
(value != null && !isUrl(value))
? t.profile.detailsForm.invalidUrlMsg
: null,
label: t.profile.detailsForm.urlLabel,
hint: t.profile.detailsForm.urlHint,
),
), ),
child: CustomTextFormField( ListTile(
initialValue: state.profile.url, title: Text(t.profile.detailsForm.updateInterval),
onChanged: (value) => notifier.setField(url: value), subtitle: Text(
validator: (value) => options?.updateInterval.toApproximateTime(
(value != null && !isUrl(value)) isRelativeToNow: false,
? t.profile.detailsForm.invalidUrlMsg ) ??
: null, t.general.toggle.disabled,
label: t.profile.detailsForm.urlLabel, ),
hint: t.profile.detailsForm.urlHint, leading: const Icon(Icons.update),
onTap: () async {
final intervalInHours = await SettingsInputDialog(
title: t.profile.detailsForm
.updateIntervalDialogTitle,
initialValue: options?.updateInterval.inHours,
optionalAction: (
t.general.state.disable,
() =>
notifier.setField(updateInterval: none()),
),
validator: isPort,
mapTo: int.tryParse,
digitsOnly: true,
).show(context);
if (intervalInHours == null) return;
notifier.setField(
updateInterval: optionOf(intervalInHours),
);
},
), ),
), ],
ListTile(
title: Text(t.profile.detailsForm.updateInterval),
subtitle: Text(
state.profile.options?.updateInterval
.toApproximateTime(
isRelativeToNow: false,
) ??
t.general.toggle.disabled,
),
leading: const Icon(Icons.update),
onTap: () async {
final intervalInHours = await SettingsInputDialog(
title: t.profile.detailsForm
.updateIntervalDialogTitle,
initialValue:
state.profile.options?.updateInterval.inHours,
optionalAction: (
t.general.state.disable,
() => notifier.setField(updateInterval: none()),
),
validator: isPort,
mapTo: int.tryParse,
digitsOnly: true,
).show(context);
if (intervalInHours == null) return;
notifier.setField(
updateInterval: optionOf(intervalInHours),
);
},
),
if (state.isEditing) if (state.isEditing)
ListTile( ListTile(
title: Text(t.profile.detailsForm.lastUpdate), title: Text(t.profile.detailsForm.lastUpdate),

View File

@@ -49,21 +49,39 @@ class ProfilesNotifier extends _$ProfilesNotifier with AppLogger {
}).run(); }).run();
} }
Future<Unit> addProfile(String url) async { Future<Unit> addProfile(String rawInput) async {
final activeProfile = await ref.read(activeProfileProvider.future); final activeProfile = await ref.read(activeProfileProvider.future);
final markAsActive = final markAsActive =
activeProfile == null || ref.read(markNewProfileActiveProvider); activeProfile == null || ref.read(markNewProfileActiveProvider);
loggy.debug("adding profile, url: [$url]"); if (LinkParser.parse(rawInput) case (final link)?) {
return ref loggy.debug("adding profile, url: [${link.url}]");
.read(profilesRepositoryProvider) return ref
.addByUrl(url, markAsActive: markAsActive) .read(profilesRepositoryProvider)
.getOrElse((l) { .addByUrl(link.url, markAsActive: markAsActive)
loggy.warning("failed to add profile: $l"); .getOrElse((l) {
throw l; loggy.warning("failed to add profile: $l");
}).run(); throw l;
}).run();
} else if (LinkParser.protocol(rawInput) case (final parsed)?) {
loggy.debug("adding profile, content");
return ref
.read(profilesRepositoryProvider)
.addByContent(
parsed.content,
name: parsed.name,
markAsActive: markAsActive,
)
.getOrElse((l) {
loggy.warning("failed to add profile: $l");
throw l;
}).run();
} else {
loggy.debug("invalid content");
throw const ProfileInvalidUrlFailure();
}
} }
Future<Unit?> updateProfile(Profile profile) async { Future<Unit?> updateProfile(RemoteProfile profile) async {
loggy.debug("updating profile"); loggy.debug("updating profile");
return ref return ref
.read(profilesRepositoryProvider) .read(profilesRepositoryProvider)

View File

@@ -50,20 +50,22 @@ class ProfilesUpdateNotifier extends _$ProfilesUpdateNotifier with AppLogger {
await ref.read(profilesRepositoryProvider).watchAll().first; await ref.read(profilesRepositoryProvider).watchAll().first;
if (failureOrProfiles case Right(value: final profiles)) { if (failureOrProfiles case Right(value: final profiles)) {
for (final profile in profiles) { for (final profile in profiles) {
loggy.debug("checking profile: [${profile.name}]"); if (profile case RemoteProfile()) {
final updateInterval = profile.options?.updateInterval; loggy.debug("checking profile: [${profile.name}]");
if (updateInterval != null && final updateInterval = profile.options?.updateInterval;
updateInterval <= if (updateInterval != null &&
DateTime.now().difference(profile.lastUpdate)) { updateInterval <=
final failureOrSuccess = await ref DateTime.now().difference(profile.lastUpdate)) {
.read(profilesRepositoryProvider) final failureOrSuccess = await ref
.update(profile) .read(profilesRepositoryProvider)
.run(); .update(profile)
state = AsyncData( .run();
(name: profile.name, failureOrSuccess: failureOrSuccess), state = AsyncData(
); (name: profile.name, failureOrSuccess: failureOrSuccess),
} else { );
loggy.debug("skipping profile: [${profile.name}]"); } else {
loggy.debug("skipping profile: [${profile.name}]");
}
} }
} }
} }

View File

@@ -6,6 +6,7 @@ import 'package:go_router/go_router.dart';
import 'package:hiddify/core/core_providers.dart'; import 'package:hiddify/core/core_providers.dart';
import 'package:hiddify/core/router/router.dart'; import 'package:hiddify/core/router/router.dart';
import 'package:hiddify/domain/failures.dart'; import 'package:hiddify/domain/failures.dart';
import 'package:hiddify/domain/profiles/profiles.dart';
import 'package:hiddify/features/common/qr_code_scanner_screen.dart'; import 'package:hiddify/features/common/qr_code_scanner_screen.dart';
import 'package:hiddify/features/profiles/notifier/notifier.dart'; import 'package:hiddify/features/profiles/notifier/notifier.dart';
import 'package:hiddify/utils/utils.dart'; import 'package:hiddify/utils/utils.dart';
@@ -29,8 +30,13 @@ class AddProfileModal extends HookConsumerWidget {
final addProfileMutation = useMutation( final addProfileMutation = useMutation(
initialOnFailure: (err) { initialOnFailure: (err) {
mutationTriggered.value = false; mutationTriggered.value = false;
// CustomToast.error(t.presentError(err)).show(context); if (err case ProfileInvalidUrlFailure()) {
CustomAlertDialog.fromErr(t.presentError(err)).show(context); CustomToast.error(
t.profile.add.invalidUrlMsg,
).show(context);
} else {
CustomAlertDialog.fromErr(t.presentError(err)).show(context);
}
}, },
initialOnSuccess: () { initialOnSuccess: () {
CustomToast.success(t.profile.save.successMsg).show(context); CustomToast.success(t.profile.save.successMsg).show(context);
@@ -102,24 +108,15 @@ class AddProfileModal extends HookConsumerWidget {
size: buttonWidth, size: buttonWidth,
onTap: () async { onTap: () async {
final captureResult = final captureResult =
await Clipboard.getData(Clipboard.kTextPlain); await Clipboard.getData(Clipboard.kTextPlain)
final link = .then((value) => value?.text ?? '');
LinkParser.parse(captureResult?.text ?? ''); if (addProfileMutation.state.isInProgress) return;
if (link != null && context.mounted) { mutationTriggered.value = true;
if (addProfileMutation.state.isInProgress) return; addProfileMutation.setFuture(
mutationTriggered.value = true; ref
addProfileMutation.setFuture( .read(profilesNotifierProvider.notifier)
ref .addProfile(captureResult),
.read(profilesNotifierProvider.notifier) );
.addProfile(link.url),
);
} else {
if (context.mounted) {
CustomToast.error(
t.profile.add.invalidUrlMsg,
).show(context);
}
}
}, },
), ),
const Gap(buttonsGap), const Gap(buttonsGap),
@@ -134,24 +131,15 @@ class AddProfileModal extends HookConsumerWidget {
await const QRCodeScannerScreen() await const QRCodeScannerScreen()
.open(context); .open(context);
if (captureResult == null) return; if (captureResult == null) return;
final link = LinkParser.simple(captureResult); if (addProfileMutation.state.isInProgress) {
if (link != null && context.mounted) { return;
if (addProfileMutation.state.isInProgress) {
return;
}
mutationTriggered.value = true;
addProfileMutation.setFuture(
ref
.read(profilesNotifierProvider.notifier)
.addProfile(link.url),
);
} else {
if (context.mounted) {
CustomToast.error(
t.profile.add.invalidUrlMsg,
).show(context);
}
} }
mutationTriggered.value = true;
addProfileMutation.setFuture(
ref
.read(profilesNotifierProvider.notifier)
.addProfile(captureResult),
);
}, },
) )
else else

View File

@@ -77,6 +77,8 @@ class FilesEditorService with InfraLogger {
return p.join(_configsDir.path, "$fileName.json"); return p.join(_configsDir.path, "$fileName.json");
} }
String tempConfigPath(String fileName) => configPath("temp_$fileName");
Future<void> deleteConfig(String fileName) { Future<void> deleteConfig(String fileName) {
return File(configPath(fileName)).delete(); return File(configPath(fileName)).delete();
} }

View File

@@ -1,10 +1,15 @@
import 'dart:convert';
import 'package:hiddify/domain/clash/clash.dart';
import 'package:hiddify/utils/validators.dart'; import 'package:hiddify/utils/validators.dart';
typedef ProfileLink = ({String url, String name}); typedef ProfileLink = ({String url, String name});
// TODO: test and improve // TODO: test and improve
abstract class LinkParser { abstract class LinkParser {
static const protocols = ['clash', 'clashmeta', 'sing-box', 'hiddify']; // protocols schemas
static const protocols = {'clash', 'clashmeta', 'sing-box', 'hiddify'};
static const rawProtocols = {'vmess', 'vless', 'trojan', 'ss', 'tuic'};
static ProfileLink? parse(String link) { static ProfileLink? parse(String link) {
return simple(link) ?? deep(link); return simple(link) ?? deep(link);
@@ -13,24 +18,39 @@ abstract class LinkParser {
static ProfileLink? simple(String link) { static ProfileLink? simple(String link) {
if (!isUrl(link)) return null; if (!isUrl(link)) return null;
final uri = Uri.parse(link.trim()); final uri = Uri.parse(link.trim());
final params = uri.queryParameters;
return ( return (
url: uri.toString(), url: uri.toString(),
// .replace(queryParameters: {}) name: uri.queryParameters['name'] ?? '',
// .toString()
// .removeSuffix('?')
// .split('&')
// .first,
name: params['name'] ?? '',
); );
} }
static ({String content, String name})? protocol(String content) {
final lines = safeDecodeBase64(content).split('\n');
for (final line in lines) {
final uri = Uri.tryParse(line);
if (uri == null) continue;
final fragment =
uri.hasFragment ? Uri.decodeComponent(uri.fragment) : null;
final name = switch (uri.scheme) {
'ss' => fragment ?? ProxyType.shadowSocks.label,
'vless' => fragment ?? ProxyType.vless.label,
'tuic' => fragment ?? ProxyType.tuic.label,
'vmess' => ProxyType.vmess.label,
_ => null,
};
if (name != null) {
return (content: content, name: name);
}
}
return null;
}
static ProfileLink? deep(String link) { static ProfileLink? deep(String link) {
final uri = Uri.tryParse(link.trim()); final uri = Uri.tryParse(link.trim());
if (uri == null || !uri.hasScheme || !uri.hasAuthority) return null; if (uri == null || !uri.hasScheme || !uri.hasAuthority) return null;
final queryParams = uri.queryParameters; final queryParams = uri.queryParameters;
switch (uri.scheme) { switch (uri.scheme) {
case 'clash' || 'clashmeta': case 'clash' || 'clashmeta' when uri.authority == 'install-config':
if (uri.authority != 'install-config' || if (uri.authority != 'install-config' ||
!queryParams.containsKey('url')) return null; !queryParams.containsKey('url')) return null;
return (url: queryParams['url']!, name: queryParams['name'] ?? ''); return (url: queryParams['url']!, name: queryParams['name'] ?? '');
@@ -48,3 +68,11 @@ abstract class LinkParser {
} }
} }
} }
String safeDecodeBase64(String str) {
try {
return utf8.decode(base64Decode(str));
} catch (e) {
return str;
}
}

View File

@@ -0,0 +1,21 @@
// GENERATED CODE, DO NOT EDIT BY HAND.
// ignore_for_file: type=lint
//@dart=2.12
import 'package:drift/drift.dart';
import 'package:drift/internal/migrations.dart';
import 'schema_v1.dart' as v1;
import 'schema_v2.dart' as v2;
class GeneratedHelper implements SchemaInstantiationHelper {
@override
GeneratedDatabase databaseForVersion(QueryExecutor db, int version) {
switch (version) {
case 1:
return v1.DatabaseAtV1(db);
case 2:
return v2.DatabaseAtV2(db);
default:
throw MissingSchemaException(version, const {1, 2});
}
}
}

View File

@@ -0,0 +1,100 @@
// GENERATED CODE, DO NOT EDIT BY HAND.
// ignore_for_file: type=lint
//@dart=2.12
import 'package:drift/drift.dart';
class ProfileEntries extends Table with TableInfo {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
ProfileEntries(this.attachedDatabase, [this._alias]);
late final GeneratedColumn<String> id = GeneratedColumn<String>(
'id', aliasedName, false,
type: DriftSqlType.string, requiredDuringInsert: true);
late final GeneratedColumn<bool> active = GeneratedColumn<bool>(
'active', aliasedName, false,
type: DriftSqlType.bool,
requiredDuringInsert: true,
defaultConstraints:
GeneratedColumn.constraintIsAlways('CHECK ("active" IN (0, 1))'));
late final GeneratedColumn<String> name =
GeneratedColumn<String>('name', aliasedName, false,
additionalChecks: GeneratedColumn.checkTextLength(
minTextLength: 1,
),
type: DriftSqlType.string,
requiredDuringInsert: true);
late final GeneratedColumn<String> url = GeneratedColumn<String>(
'url', aliasedName, false,
type: DriftSqlType.string, requiredDuringInsert: true);
late final GeneratedColumn<DateTime> lastUpdate = GeneratedColumn<DateTime>(
'last_update', aliasedName, false,
type: DriftSqlType.dateTime, requiredDuringInsert: true);
late final GeneratedColumn<int> updateInterval = GeneratedColumn<int>(
'update_interval', aliasedName, true,
type: DriftSqlType.int, requiredDuringInsert: false);
late final GeneratedColumn<int> upload = GeneratedColumn<int>(
'upload', aliasedName, true,
type: DriftSqlType.int, requiredDuringInsert: false);
late final GeneratedColumn<int> download = GeneratedColumn<int>(
'download', aliasedName, true,
type: DriftSqlType.int, requiredDuringInsert: false);
late final GeneratedColumn<int> total = GeneratedColumn<int>(
'total', aliasedName, true,
type: DriftSqlType.int, requiredDuringInsert: false);
late final GeneratedColumn<DateTime> expire = GeneratedColumn<DateTime>(
'expire', aliasedName, true,
type: DriftSqlType.dateTime, requiredDuringInsert: false);
late final GeneratedColumn<String> webPageUrl = GeneratedColumn<String>(
'web_page_url', aliasedName, true,
type: DriftSqlType.string, requiredDuringInsert: false);
late final GeneratedColumn<String> supportUrl = GeneratedColumn<String>(
'support_url', aliasedName, true,
type: DriftSqlType.string, requiredDuringInsert: false);
@override
List<GeneratedColumn> get $columns => [
id,
active,
name,
url,
lastUpdate,
updateInterval,
upload,
download,
total,
expire,
webPageUrl,
supportUrl
];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'profile_entries';
@override
Set<GeneratedColumn> get $primaryKey => {id};
@override
Never map(Map<String, dynamic> data, {String? tablePrefix}) {
throw UnsupportedError('TableInfo.map in schema verification code');
}
@override
ProfileEntries createAlias(String alias) {
return ProfileEntries(attachedDatabase, alias);
}
}
class DatabaseAtV1 extends GeneratedDatabase {
DatabaseAtV1(QueryExecutor e) : super(e);
late final ProfileEntries profileEntries = ProfileEntries(this);
@override
Iterable<TableInfo<Table, Object?>> get allTables =>
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
@override
List<DatabaseSchemaEntity> get allSchemaEntities => [profileEntries];
@override
int get schemaVersion => 1;
@override
DriftDatabaseOptions get options =>
const DriftDatabaseOptions(storeDateTimeAsText: true);
}

View File

@@ -0,0 +1,104 @@
// GENERATED CODE, DO NOT EDIT BY HAND.
// ignore_for_file: type=lint
//@dart=2.12
import 'package:drift/drift.dart';
class ProfileEntries extends Table with TableInfo {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
ProfileEntries(this.attachedDatabase, [this._alias]);
late final GeneratedColumn<String> id = GeneratedColumn<String>(
'id', aliasedName, false,
type: DriftSqlType.string, requiredDuringInsert: true);
late final GeneratedColumn<String> type = GeneratedColumn<String>(
'type', aliasedName, false,
type: DriftSqlType.string, requiredDuringInsert: true);
late final GeneratedColumn<bool> active = GeneratedColumn<bool>(
'active', aliasedName, false,
type: DriftSqlType.bool,
requiredDuringInsert: true,
defaultConstraints:
GeneratedColumn.constraintIsAlways('CHECK ("active" IN (0, 1))'));
late final GeneratedColumn<String> name =
GeneratedColumn<String>('name', aliasedName, false,
additionalChecks: GeneratedColumn.checkTextLength(
minTextLength: 1,
),
type: DriftSqlType.string,
requiredDuringInsert: true);
late final GeneratedColumn<String> url = GeneratedColumn<String>(
'url', aliasedName, true,
type: DriftSqlType.string, requiredDuringInsert: false);
late final GeneratedColumn<DateTime> lastUpdate = GeneratedColumn<DateTime>(
'last_update', aliasedName, false,
type: DriftSqlType.dateTime, requiredDuringInsert: true);
late final GeneratedColumn<int> updateInterval = GeneratedColumn<int>(
'update_interval', aliasedName, true,
type: DriftSqlType.int, requiredDuringInsert: false);
late final GeneratedColumn<int> upload = GeneratedColumn<int>(
'upload', aliasedName, true,
type: DriftSqlType.int, requiredDuringInsert: false);
late final GeneratedColumn<int> download = GeneratedColumn<int>(
'download', aliasedName, true,
type: DriftSqlType.int, requiredDuringInsert: false);
late final GeneratedColumn<int> total = GeneratedColumn<int>(
'total', aliasedName, true,
type: DriftSqlType.int, requiredDuringInsert: false);
late final GeneratedColumn<DateTime> expire = GeneratedColumn<DateTime>(
'expire', aliasedName, true,
type: DriftSqlType.dateTime, requiredDuringInsert: false);
late final GeneratedColumn<String> webPageUrl = GeneratedColumn<String>(
'web_page_url', aliasedName, true,
type: DriftSqlType.string, requiredDuringInsert: false);
late final GeneratedColumn<String> supportUrl = GeneratedColumn<String>(
'support_url', aliasedName, true,
type: DriftSqlType.string, requiredDuringInsert: false);
@override
List<GeneratedColumn> get $columns => [
id,
type,
active,
name,
url,
lastUpdate,
updateInterval,
upload,
download,
total,
expire,
webPageUrl,
supportUrl
];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'profile_entries';
@override
Set<GeneratedColumn> get $primaryKey => {id};
@override
Never map(Map<String, dynamic> data, {String? tablePrefix}) {
throw UnsupportedError('TableInfo.map in schema verification code');
}
@override
ProfileEntries createAlias(String alias) {
return ProfileEntries(attachedDatabase, alias);
}
}
class DatabaseAtV2 extends GeneratedDatabase {
DatabaseAtV2(QueryExecutor e) : super(e);
late final ProfileEntries profileEntries = ProfileEntries(this);
@override
Iterable<TableInfo<Table, Object?>> get allTables =>
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
@override
List<DatabaseSchemaEntity> get allSchemaEntities => [profileEntries];
@override
int get schemaVersion => 2;
@override
DriftDatabaseOptions get options =>
const DriftDatabaseOptions(storeDateTimeAsText: true);
}

View File

@@ -0,0 +1,20 @@
import 'package:drift_dev/api/migrations.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hiddify/data/local/database.dart';
import 'generated_migrations/schema.dart';
void main() {
late SchemaVerifier verifier;
setUpAll(() {
verifier = SchemaVerifier(GeneratedHelper());
});
test('upgrade from v1 to v2', () async {
final connection = await verifier.startAt(1);
final db = AppDatabase(connection: connection);
await verifier.migrateAndValidate(db, 2);
});
}

View File

@@ -19,7 +19,6 @@ void main() {
expect(profile.url, equals(validExtendedUrl)); expect(profile.url, equals(validExtendedUrl));
expect(profile.options, isNull); expect(profile.options, isNull);
expect(profile.subInfo, isNull); expect(profile.subInfo, isNull);
expect(profile.extra, isNull);
}, },
); );
@@ -53,13 +52,6 @@ void main() {
download: 1024, download: 1024,
total: 10240, total: 10240,
expire: DateTime(2024), expire: DateTime(2024),
),
),
);
expect(
profile.extra,
equals(
const ProfileExtra(
webPageUrl: validBaseUrl, webPageUrl: validBaseUrl,
supportUrl: validSupportUrl, supportUrl: validSupportUrl,
), ),