fix(backup,database): stage restores and enforce foreign keys

Changelog: Restores never apply halfway
This commit is contained in:
chen08209
2026-09-25 03:56:58 +08:00
parent 8068e686a3
commit bee8f24269
25 changed files with 4242 additions and 524 deletions
+281
View File
@@ -0,0 +1,281 @@
import 'dart:convert';
import 'dart:io';
import 'package:archive/archive_io.dart';
import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/common/migration.dart';
import 'package:fl_clash/database/database.dart';
import 'package:fl_clash/models/models.dart';
import 'package:flutter/foundation.dart';
import 'package:path/path.dart';
enum BackupFailure { invalid, newerVersion }
final class BackupException implements Exception {
final BackupFailure failure;
const BackupException(this.failure);
@override
String toString() => 'BackupException(${failure.name})';
}
/// Archive entries mirror the data directory, so an entry name is also the
/// file's path relative to the home directory.
abstract final class BackupEntries {
static String profile(Object id) => '$profilesDirectoryName/$id.yaml';
static String script(Object id) => 'scripts/$id.js';
static String provider(ClashProvider provider) =>
'$providersDirectoryName/${providerCacheDirectoryName(provider.kind)}/'
'${provider.fileName}';
/// A remote provider's cache belongs to the Core and downloads again.
static List<String> of({
required Iterable<int> profileIds,
required Iterable<int> scriptIds,
required Iterable<ClashProvider> providers,
}) {
return [
...profileIds.map(profile),
...scriptIds.map(script),
...providers.where((item) => !item.isRemote).map(provider),
];
}
static List<String> ofData(MigrationData data) {
return of(
profileIds: data.profiles.map((item) => item.id),
scriptIds: data.scripts.map((item) => item.id),
providers: data.clashProviders,
);
}
static String resolve(String root, String entry) {
return joinAll([root, ...posix.split(entry)]);
}
}
typedef BackupArchiveRequest = ({
String archivePath,
Map<String, Object?> configMap,
String databaseSnapshotPath,
String homeDirPath,
List<String> entries,
});
typedef RestoreArchiveRequest = ({String archivePath, String stagingDirPath});
Future<void> backupTask(BackupArchiveRequest request) {
return compute(_backupTask, request);
}
Future<void> _backupTask(BackupArchiveRequest request) {
return writeBackupArchive(
archivePath: request.archivePath,
configMap: request.configMap,
databaseSnapshotPath: request.databaseSnapshotPath,
homeDirPath: request.homeDirPath,
entries: request.entries,
);
}
Future<MigrationData> restoreTask(RestoreArchiveRequest request) {
return compute(_restoreTask, request);
}
Future<MigrationData> _restoreTask(RestoreArchiveRequest request) {
return readBackupArchive(
archivePath: request.archivePath,
stagingDirPath: request.stagingDirPath,
);
}
@visibleForTesting
Future<void> writeBackupArchive({
required String archivePath,
required Map<String, Object?> configMap,
required String databaseSnapshotPath,
required String homeDirPath,
required Iterable<String> entries,
}) async {
final encoder = ZipFileEncoder()..create(archivePath);
try {
encoder.addArchiveFile(
ArchiveFile.string(configJsonName, json.encode(configMap)),
);
await encoder.addFile(File(databaseSnapshotPath), backupDatabaseName);
for (final entry in entries) {
final file = File(BackupEntries.resolve(homeDirPath, entry));
if (await file.exists()) {
await encoder.addFile(file, entry);
}
}
} finally {
await encoder.close();
}
}
/// Writes nothing outside [stagingDirPath]; the files the result names are left
/// there at their [BackupEntries] paths.
@visibleForTesting
Future<MigrationData> readBackupArchive({
required String archivePath,
required String stagingDirPath,
}) async {
await _extract(archivePath, stagingDirPath);
final configFile = File(join(stagingDirPath, configJsonName));
if (!await configFile.exists()) {
throw const BackupException(BackupFailure.invalid);
}
final configMap = _decodeConfig(await configFile.readAsString());
final version = configMap['version'] ?? 0;
if (version is! int) {
throw const BackupException(BackupFailure.invalid);
}
if (version > Migration.currentVersion) {
throw const BackupException(BackupFailure.newerVersion);
}
if (version == 0) {
return migrateLegacyConfig(
configMap: configMap,
sourcePath: stagingDirPath,
targetPath: stagingDirPath,
);
}
final databaseFile = File(join(stagingDirPath, backupDatabaseName));
if (!await databaseFile.exists()) {
return MigrationData(configMap: configMap);
}
final schemaVersion = await _readSchemaVersion(databaseFile);
final database = Database(
driftDatabase(
name: basenameWithoutExtension(backupDatabaseName),
// Left unset, drift asks path_provider for a temp directory, and this
// isolate has no platform channel to answer it.
native: DriftNativeOptions(
databaseDirectory: () async => Directory(stagingDirPath),
tempDirectoryPath: () async => stagingDirPath,
),
),
);
try {
if (schemaVersion > database.schemaVersion) {
throw const BackupException(BackupFailure.newerVersion);
}
final results = await Future.wait([
database.profilesDao.query().get(),
database.scriptsDao.query().get(),
database.rules.all().map((item) => item.toRule()).get(),
(database.select(database.profileRuleLinks)..orderBy([
(t) => OrderingTerm(expression: t.order, nulls: NullsOrder.last),
]))
.map((item) => item.toLink())
.get(),
(database.select(database.proxyGroups)..orderBy([
(t) => OrderingTerm(expression: t.order, nulls: NullsOrder.last),
]))
.map((item) => item.toProxyGroup())
.get(),
database.clashProvidersDao.queryAll().get(),
(database.select(database.customProxies)..orderBy([
(t) => OrderingTerm(expression: t.order, nulls: NullsOrder.last),
]))
.map((item) => item.toCustomProxy())
.get(),
]);
return MigrationData(
configMap: configMap,
profiles: results[0].cast<Profile>(),
scripts: results[1].cast<Script>(),
rules: results[2].cast<Rule>(),
links: results[3].cast<ProfileRuleLink>(),
proxyGroups: results[4].cast<ProxyGroup>(),
clashProviders: results[5].cast<ClashProvider>(),
customProxies: results[6].cast<CustomProxy>(),
);
} finally {
await database.close();
}
}
Future<void> _extract(String archivePath, String stagingDirPath) async {
await Directory(stagingDirPath).create(recursive: true);
final input = InputFileStream(archivePath);
try {
final Archive archive;
try {
archive = ZipDecoder().decodeStream(input);
} on Exception {
throw const BackupException(BackupFailure.invalid);
}
for (final file in archive.files) {
if (!file.isFile) {
continue;
}
final outPath = _stagedEntryPath(stagingDirPath, file.name);
if (outPath == null) {
continue;
}
await Directory(dirname(outPath)).create(recursive: true);
final output = OutputFileStream(outPath);
try {
file.writeContent(output);
} finally {
await output.close();
}
}
} finally {
await input.close();
}
}
/// `posix.normalize` collapses `a/../b`, but a name that starts with `../`
/// normalizes to itself and an absolute one stays absolute, so either would
/// write wherever the archive asks. A backup file is untrusted input.
String? _stagedEntryPath(String stagingDirPath, String name) {
final normalized = posix.normalize(name.replaceAll('\\', '/'));
if (normalized.isEmpty ||
posix.isAbsolute(normalized) ||
normalized == '..' ||
normalized.startsWith('../')) {
return null;
}
final outPath = normalize(BackupEntries.resolve(stagingDirPath, normalized));
if (!isWithin(stagingDirPath, outPath)) {
return null;
}
return outPath;
}
Map<String, Object?> _decodeConfig(String source) {
final Object? decoded;
try {
decoded = json.decode(source);
} on FormatException {
throw const BackupException(BackupFailure.invalid);
}
if (decoded is! Map<String, Object?>) {
throw const BackupException(BackupFailure.invalid);
}
return decoded;
}
/// Opening the database through drift would already run its migrations, so the
/// version is read from the file header: SQLite keeps `user_version` as a
/// big-endian integer at byte 60.
Future<int> _readSchemaVersion(File databaseFile) async {
final handle = await databaseFile.open();
try {
final header = await handle.read(64);
if (header.length < 64) {
throw const BackupException(BackupFailure.invalid);
}
return ByteData.sublistView(header).getInt32(60);
} finally {
await handle.close();
}
}
+1
View File
@@ -1,5 +1,6 @@
export 'app_localizations.dart';
export 'app_ports.dart';
export 'backup.dart';
export 'changelog.dart';
export 'color.dart';
export 'compute.dart';
+16 -16
View File
@@ -9,17 +9,20 @@ import 'package:flutter/foundation.dart';
typedef DAVClientFactory = DAVClient Function(DAVProps props);
class DAVClient {
late DAVTransport client;
late String fileName;
DAVClient(DAVProps dav)
: _transport = DAVTransport(
uri: dav.uri,
user: dav.user,
password: dav.password,
),
fileName = dav.fileName;
DAVClient(DAVProps dav) {
client = DAVTransport(uri: dav.uri, user: dav.user, password: dav.password);
fileName = dav.fileName;
}
final DAVTransport _transport;
final String fileName;
Future<bool> ping() async {
try {
await client.options('/');
await _transport.options('/');
return true;
} catch (e) {
commonPrint.log(
@@ -34,17 +37,14 @@ class DAVClient {
String get backupFile => '$root/$fileName';
Future<bool> backup(String localFilePath) async {
await client.mkcol(root);
await client.put(backupFile, await io.File(localFilePath).readAsBytes());
return true;
Future<void> upload(String localPath) async {
await _transport.mkcol(root);
await _transport.put(backupFile, await io.File(localPath).readAsBytes());
}
Future<bool> restore() async {
final backupFilePath = await appPath.backupFilePath;
final bytes = await client.get(backupFile);
await io.File(backupFilePath).safeWriteAsBytes(bytes);
return true;
Future<void> download(String localPath) async {
final bytes = await _transport.get(backupFile);
await io.File(localPath).safeWriteAsBytes(bytes);
}
}
+1
View File
@@ -51,6 +51,7 @@ class _AppMigrationStore implements MigrationStore {
data.rules,
data.links,
data.proxyGroups,
clashProviders: data.clashProviders,
);
}
+105
View File
@@ -0,0 +1,105 @@
part of 'database.dart';
@DataClassName('RawClashProvider')
class ClashProviders extends Table {
@override
String get tableName => 'clash_providers';
IntColumn get id => integer()();
TextColumn get kind => textEnum<ProviderKind>()();
TextColumn get label => text()();
TextColumn get url => text()();
TextColumn get behavior => textEnum<RuleProviderBehavior>().nullable()();
TextColumn get format => textEnum<RuleProviderFormat>().nullable()();
IntColumn get order => integer().nullable()();
@override
Set<Column> get primaryKey => {id};
}
@DriftAccessor(tables: [ClashProviders])
class ClashProvidersDao extends DatabaseAccessor<Database>
with _$ClashProvidersDaoMixin {
ClashProvidersDao(super.attachedDatabase);
SimpleSelectStatement<$ClashProvidersTable, RawClashProvider>
_orderedSelect() {
final stmt = clashProviders.select();
stmt.orderBy([
(t) => OrderingTerm(expression: t.order, nulls: NullsOrder.last),
(t) => OrderingTerm.asc(t.id),
]);
return stmt;
}
Selectable<ClashProvider> query(ProviderKind kind) {
final stmt = _orderedSelect()..where((t) => t.kind.equalsValue(kind));
return stmt.map((item) => item.toClashProvider());
}
Selectable<ClashProvider> queryAll() {
return _orderedSelect().map((item) => item.toClashProvider());
}
Selectable<String> fileNames() {
return clashProviders.select().map(
(item) => item.toClashProvider().fileName,
);
}
Future<void> putAll(Iterable<ClashProvidersCompanion> items) async {
await batch((b) {
b.insertAllOnConflictUpdate(clashProviders, items);
});
}
void putAllWithBatch(Batch batch, Iterable<ClashProvider> providers) {
batch.insertAllOnConflictUpdate(
clashProviders,
providers.map((item) => item.toCompanion()),
);
}
void setAllWithBatch(Batch batch, Iterable<ClashProvider> providers) {
clashProviders.setAll(
batch,
providers.map((provider) => provider.toCompanion()),
deleteFilter: (_) => const Constant(true),
preDelete: true,
);
}
}
extension RawClashProviderExt on RawClashProvider {
ClashProvider toClashProvider() {
return ClashProvider(
id: id,
kind: kind,
label: label,
url: url,
behavior: behavior,
format: format,
order: order,
);
}
}
extension ClashProvidersCompanionExt on ClashProvider {
ClashProvidersCompanion toCompanion([int? order]) {
return ClashProvidersCompanion.insert(
id: Value(id),
kind: kind,
label: label,
url: url,
behavior: Value(behavior),
format: Value(format),
order: Value(order ?? this.order),
);
}
}
+14
View File
@@ -41,3 +41,17 @@ class StringSetConverter extends TypeConverter<Set<String>, String> {
return json.encode(value.toList());
}
}
class JsonMapConverter extends TypeConverter<Map<String, dynamic>, String> {
const JsonMapConverter();
@override
Map<String, dynamic> fromSql(String fromDb) {
return Map<String, dynamic>.from(json.decode(fromDb));
}
@override
String toSql(Map<String, dynamic> value) {
return json.encode(value);
}
}
+110
View File
@@ -0,0 +1,110 @@
part of 'database.dart';
@DataClassName('RawCustomProxy')
@TableIndex(
name: 'idx_custom_proxies_profile_order',
columns: {#profileId, #order},
)
class CustomProxies extends Table {
@override
String get tableName => 'custom_proxies';
IntColumn get id => integer()();
IntColumn get profileId => integer().nullable().references(
Profiles,
#id,
onDelete: KeyAction.cascade,
)();
TextColumn get definition => text().map(const JsonMapConverter())();
TextColumn get order => text().nullable()();
@override
Set<Column> get primaryKey => {id};
}
@DriftAccessor(tables: [CustomProxies])
class CustomProxiesDao extends DatabaseAccessor<Database>
with _$CustomProxiesDaoMixin {
CustomProxiesDao(super.attachedDatabase);
Selectable<CustomProxy> query(int profileId) {
final stmt = customProxies.select();
stmt.where((row) => row.profileId.equals(profileId));
stmt.orderBy([
(t) => OrderingTerm(expression: t.order, nulls: NullsOrder.last),
]);
return stmt.map((item) => item.toCustomProxy());
}
Selectable<int> count(int profileId) {
final stmt = customProxies.select();
stmt.where((row) => row.profileId.equals(profileId));
return stmt.count;
}
Future<int> order(
int profileId, {
required CustomProxy proxy,
required String order,
}) {
return customProxies.insertOnConflictUpdate(
proxy.toCompanion(profileId, order),
);
}
void setAllWithBatch(
int? profileId,
Batch batch,
Iterable<CustomProxy> items,
) {
final keys = indexing.generateNKeys(items.length);
customProxies.setAll(
batch,
items.mapIndexed(
(index, item) => item.toCompanion(profileId, keys[index]),
),
deleteFilter: (row) => profileId == null
? const Constant(true)
: row.profileId.equals(profileId),
preDelete: true,
);
}
Future<void> delAll(Iterable<int> ids) {
return batch((b) {
customProxies.deleteInChunks(b, ids, (t, chunk) => t.id.isIn(chunk));
});
}
void putAllWithBatch(Batch batch, Iterable<CustomProxy> items) {
batch.insertAllOnConflictUpdate(
customProxies,
items.map((item) => item.toCompanion()),
);
}
}
extension RawCustomProxyExt on RawCustomProxy {
CustomProxy toCustomProxy() {
return CustomProxy(
profileId: profileId,
id: id,
definition: definition,
order: order,
);
}
}
extension CustomProxiesCompanionExt on CustomProxy {
CustomProxiesCompanion toCompanion([int? profileId, String? order]) {
return CustomProxiesCompanion.insert(
id: Value(id),
profileId: Value(this.profileId ?? profileId),
definition: definition,
order: Value(order ?? this.order),
);
}
}
+122 -18
View File
@@ -10,7 +10,9 @@ import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/models.dart';
import 'package:flutter/foundation.dart' show visibleForTesting;
part 'clash_providers.dart';
part 'converter.dart';
part 'custom_proxies.dart';
part 'generated/database.g.dart';
part 'groups.dart';
part 'icons.dart';
@@ -27,14 +29,24 @@ part 'scripts.dart';
ProfileRuleLinks,
ProxyGroups,
IconRecords,
ClashProviders,
CustomProxies,
],
daos: [
ProfilesDao,
ScriptsDao,
RulesDao,
ProxyGroupsDao,
IconRecordsDao,
ClashProvidersDao,
CustomProxiesDao,
],
daos: [ProfilesDao, ScriptsDao, RulesDao, ProxyGroupsDao, IconRecordsDao],
)
class Database extends _$Database {
Database([QueryExecutor? executor]) : super(executor ?? _openConnection());
@override
int get schemaVersion => 3;
int get schemaVersion => 10;
static LazyDatabase _openConnection() {
return LazyDatabase(() async {
@@ -56,10 +68,48 @@ class Database extends _$Database {
if (from < 3) {
await _addColumnIfMissing(m, profiles, profiles.matchTarget);
}
if (from < 4) {
await _addColumnIfMissing(m, scripts, scripts.url);
}
if (from < 5) {
await _addColumnIfMissing(m, scripts, scripts.order);
}
if (from < 6) {
await _createTableIfMissing(m, clashProviders);
}
if (from < 7) {
await _addColumnIfMissing(m, proxyGroups, proxyGroups.tolerance);
await _addColumnIfMissing(m, proxyGroups, proxyGroups.strategy);
}
if (from < 8) {
await m.alterTable(TableMigration(clashProviders));
}
// Ahead of the version 9 step, whose purge reaches every table.
if (from < 10) {
if (await _createTableIfMissing(m, customProxies)) {
await m.createIndex(idxCustomProxiesProfileOrder);
}
}
if (from < 9) {
await _purgeOrphans();
}
},
beforeOpen: (_) => customStatement('PRAGMA foreign_keys = ON'),
);
}
/// Drift rewinds user_version on downgrade but keeps the tables it added.
Future<bool> _createTableIfMissing(Migrator m, TableInfo table) async {
final tableInfo = await customSelect(
'PRAGMA table_info(${table.actualTableName})',
).get();
if (tableInfo.isNotEmpty) {
return false;
}
await m.createTable(table);
return true;
}
/// Drift rewinds user_version on downgrade but keeps the columns it added.
Future<void> _addColumnIfMissing(
Migrator m,
@@ -135,45 +185,89 @@ class Database extends _$Database {
List<Rule> rules,
List<ProfileRuleLink> links,
List<ProxyGroup> proxyGroups, {
List<ClashProvider> clashProviders = const [],
List<CustomProxy> customProxies = const [],
bool isOverride = false,
}) async {
if (profiles.isEmpty &&
scripts.isEmpty &&
rules.isEmpty &&
links.isEmpty &&
proxyGroups.isEmpty) {
proxyGroups.isEmpty &&
clashProviders.isEmpty &&
customProxies.isEmpty) {
return;
}
await batch((b) {
if (isOverride) {
profilesDao.setAllWithBatch(b, profiles);
scriptsDao.setAllWithBatch(b, scripts);
rulesDao.restoreWithBatch(b, rules, links);
proxyGroupsDao.setAllWithBatch(null, b, proxyGroups);
return;
}
profilesDao.putAllWithBatch(
b,
profiles.map((item) => item.toCompanion()),
);
scriptsDao.putAllWithBatch(b, scripts);
rulesDao.mergeWithBatch(b, rules, links);
proxyGroupsDao.putAllWithBatch(b, proxyGroups);
await transaction(() async {
// Backups taken while foreign keys were off may carry orphaned rows.
await customStatement('PRAGMA defer_foreign_keys = ON');
await batch((b) {
if (isOverride) {
profilesDao.setAllWithBatch(b, profiles);
scriptsDao.setAllWithBatch(b, scripts);
rulesDao.restoreWithBatch(b, rules, links);
proxyGroupsDao.setAllWithBatch(null, b, proxyGroups);
customProxiesDao.setAllWithBatch(null, b, customProxies);
clashProvidersDao.setAllWithBatch(b, clashProviders);
return;
}
profilesDao.putAllWithBatch(
b,
profiles.map((item) => item.toCompanion()),
);
scriptsDao.putAllWithBatch(b, scripts);
rulesDao.mergeWithBatch(b, rules, links);
proxyGroupsDao.putAllWithBatch(b, proxyGroups);
customProxiesDao.putAllWithBatch(b, customProxies);
clashProvidersDao.putAllWithBatch(b, clashProviders);
});
await _purgeOrphans();
});
}
Future<void> deleteProfile(int profileId) {
return transaction(() async {
await profiles.remove((t) => t.id.equals(profileId));
await rulesDao.delUnlinkedRules();
});
}
Future<void> _purgeOrphans() async {
final profileIds = selectOnly(profiles)..addColumns([profiles.id]);
final ruleIds = selectOnly(rules)..addColumns([rules.id]);
await profileRuleLinks.remove(
(t) =>
t.ruleId.isNotInQuery(ruleIds) |
(t.profileId.isNotNull() & t.profileId.isNotInQuery(profileIds)),
);
await proxyGroups.remove(
(t) => t.profileId.isNotNull() & t.profileId.isNotInQuery(profileIds),
);
await customProxies.remove(
(t) => t.profileId.isNotNull() & t.profileId.isNotInQuery(profileIds),
);
await rulesDao.delUnlinkedRules();
}
Future<void> setProfileCustomData(
int profileId,
List<CustomProxy>? proxies,
List<ProxyGroup> groups,
List<Rule> rules,
) async {
await batch((b) {
if (proxies != null) {
customProxiesDao.setAllWithBatch(profileId, b, proxies);
}
proxyGroupsDao.setAllWithBatch(profileId, b, groups);
rulesDao.setCustomRulesWithBatch(profileId, b, rules);
});
}
}
// SQLite builds before 3.32 cap a statement at 999 bound parameters.
const _maxBoundValues = 900;
extension TableInfoExt<Tbl extends Table, Row> on TableInfo<Tbl, Row> {
void setAll(
Batch batch,
@@ -190,6 +284,16 @@ extension TableInfoExt<Tbl extends Table, Row> on TableInfo<Tbl, Row> {
}
}
void deleteInChunks<V extends Object>(
Batch batch,
Iterable<V> values,
Expression<bool> Function(Tbl tbl, List<V> chunk) filter,
) {
for (final chunk in values.chunks(_maxBoundValues)) {
batch.deleteWhere(this, (tbl) => filter(tbl, chunk));
}
}
Selectable<int?> get count {
final countExp = countAll();
final query = select().addColumns([countExp]);
File diff suppressed because it is too large Load Diff
+26
View File
@@ -46,6 +46,10 @@ class ProxyGroups extends Table {
TextColumn get expectedStatus => text().nullable()();
IntColumn get tolerance => integer().nullable()();
TextColumn get strategy => text().nullable()();
BoolColumn get includeAll => boolean().nullable()();
BoolColumn get includeAllProxies => boolean().nullable()();
@@ -112,6 +116,18 @@ class ProxyGroupsDao extends DatabaseAccessor<Database>
);
}
Future<void> renameUse({required String oldName, required String newName}) {
return customUpdate(
'UPDATE ${proxyGroups.entityName} '
'SET ${proxyGroups.use.name} = REPLACE(${proxyGroups.use.name}, ?, ?) '
'WHERE ${proxyGroups.profileId.name} IS NOT NULL',
variables: [
Variable.withString('"$oldName"'),
Variable.withString('"$newName"'),
],
);
}
void setAllWithBatch(
int? profileId,
Batch batch,
@@ -130,6 +146,12 @@ class ProxyGroupsDao extends DatabaseAccessor<Database>
);
}
Future<void> delAll(Iterable<int> ids) {
return batch((b) {
proxyGroups.deleteInChunks(b, ids, (t, chunk) => t.id.isIn(chunk));
});
}
void putAllWithBatch(Batch batch, Iterable<ProxyGroup> proxyGroups) {
final keys = indexing.generateNKeys(proxyGroups.length);
batch.insertAllOnConflictUpdate(
@@ -160,6 +182,8 @@ extension RawProxyGroupExt on RawProxyGroup {
excludeFilter: excludeFilter,
excludeType: excludeType,
expectedStatus: expectedStatus,
tolerance: tolerance,
strategy: LoadBalanceStrategy.parse(strategy),
includeAll: includeAll,
includeAllProxies: includeAllProxies,
includeAllProviders: includeAllProviders,
@@ -189,6 +213,8 @@ extension ProxyGroupsCompanionExt on ProxyGroup {
excludeFilter: Value(excludeFilter),
excludeType: Value(excludeType),
expectedStatus: Value(expectedStatus),
tolerance: Value(tolerance),
strategy: Value(strategy?.value),
includeAll: Value(includeAll),
includeAllProxies: Value(includeAllProxies),
includeAllProviders: Value(includeAllProviders),
+7 -13
View File
@@ -56,19 +56,13 @@ class IconRecordsDao extends DatabaseAccessor<Database>
final count = await iconRecords.count.getSingle() ?? 0;
if (count > maxCapacity) {
final oldestRecords =
await (select(iconRecords)
..orderBy([
(t) => OrderingTerm(
expression: t.lastAccessed,
mode: OrderingMode.asc,
),
])
..limit(count - maxCapacity))
.get();
final oldestUrls = oldestRecords.map((e) => e.url).toList();
await (delete(iconRecords)..where((t) => t.url.isIn(oldestUrls))).go();
final oldestUrls = selectOnly(iconRecords)
..addColumns([iconRecords.url])
..orderBy([OrderingTerm.asc(iconRecords.lastAccessed)])
..limit(count - maxCapacity);
await (delete(
iconRecords,
)..where((t) => t.url.isInQuery(oldestUrls))).go();
}
});
}
+38 -13
View File
@@ -138,12 +138,9 @@ class RulesDao extends DatabaseAccessor<Database> with _$RulesDaoMixin {
Iterable<Rule> rules,
Iterable<ProfileRuleLink> links,
) {
_putRulesWithBatch(batch, rules);
final ruleIds = rules.map((item) => item.id);
batch.deleteWhere(this.rules, (t) => t.id.isNotIn(ruleIds));
_putLinksWithBatch(batch, links);
final linkKeys = links.map((item) => item.key);
batch.deleteWhere(profileRuleLinks, (t) => t.id.isNotIn(linkKeys));
batch.deleteAll(profileRuleLinks);
batch.deleteAll(this.rules);
mergeWithBatch(batch, rules, links);
}
Future<void> delRules(Iterable<int> ruleIds) {
@@ -243,6 +240,26 @@ class RulesDao extends DatabaseAccessor<Database> with _$RulesDaoMixin {
return stmt.write(RulesCompanion(ruleTarget: Value(newName)));
}
Future<int> renameCustomRuleProvider({
required String oldName,
required String newName,
}) {
final stmt = rules.update()
..where(
(t) =>
t.ruleAction.equalsValue(RuleAction.RULE_SET) &
t.ruleProvider.equals(oldName),
)
..where(
(t) => t.id.isInQuery(
selectOnly(profileRuleLinks)
..addColumns([profileRuleLinks.ruleId])
..where(profileRuleLinks.scene.equalsValue(RuleScene.custom)),
),
);
return stmt.write(RulesCompanion(ruleProvider: Value(newName)));
}
JoinedSelectStatement<HasResultSet, dynamic> _getSelectStatement({
int? profileId,
RuleScene? scene,
@@ -305,8 +322,20 @@ class RulesDao extends DatabaseAccessor<Database> with _$RulesDaoMixin {
});
}
Future<void> _delAll(Iterable<int> ruleIds) async {
await rules.deleteWhere((t) => t.id.isIn(ruleIds));
Future<void> _delAll(Iterable<int> ruleIds) {
return batch((b) {
rules.deleteInChunks(b, ruleIds, (t, chunk) => t.id.isIn(chunk));
});
}
Future<void> delUnlinkedRules() {
return rules.remove(_isUnlinked);
}
Expression<bool> _isUnlinked(Rules rule) {
final linkedIds = selectOnly(profileRuleLinks)
..addColumns([profileRuleLinks.ruleId]);
return rule.id.isNotInQuery(linkedIds);
}
void _setWithBatch(
@@ -342,11 +371,7 @@ class RulesDao extends DatabaseAccessor<Database> with _$RulesDaoMixin {
),
);
b.deleteWhere(this.rules, (r) {
final linkedIds = selectOnly(profileRuleLinks);
linkedIds.addColumns([profileRuleLinks.ruleId]);
return r.id.isNotInQuery(linkedIds);
});
b.deleteWhere(this.rules, _isUnlinked);
}
}
+26 -3
View File
@@ -11,6 +11,10 @@ class Scripts extends Table {
DateTimeColumn get lastUpdateTime => dateTime()();
TextColumn get url => text().nullable()();
IntColumn get order => integer().nullable()();
@override
Set<Column> get primaryKey => {id};
}
@@ -20,7 +24,18 @@ class ScriptsDao extends DatabaseAccessor<Database> with _$ScriptsDaoMixin {
ScriptsDao(super.attachedDatabase);
Selectable<Script> query() {
return scripts.select().map((item) => item.toScript());
final stmt = scripts.select();
stmt.orderBy([
(t) => OrderingTerm(expression: t.order, nulls: NullsOrder.last),
(t) => OrderingTerm.asc(t.id),
]);
return stmt.map((item) => item.toScript());
}
Future<void> putAll(Iterable<ScriptsCompanion> items) async {
await batch((b) async {
b.insertAllOnConflictUpdate(scripts, items);
});
}
Selectable<Script> get(int scriptId) {
@@ -60,16 +75,24 @@ class ScriptsDao extends DatabaseAccessor<Database> with _$ScriptsDaoMixin {
extension RawScriptExt on RawScript {
Script toScript() {
return Script(id: id, label: label, lastUpdateTime: lastUpdateTime);
return Script(
id: id,
label: label,
lastUpdateTime: lastUpdateTime,
url: url,
order: order,
);
}
}
extension ScriptsCompanionExt on Script {
ScriptsCompanion toCompanion() {
ScriptsCompanion toCompanion([int? order]) {
return ScriptsCompanion.insert(
id: Value(id),
label: label,
lastUpdateTime: lastUpdateTime,
url: Value(url),
order: Value(order ?? this.order),
);
}
}
+161 -55
View File
@@ -1,81 +1,187 @@
part of '../action.dart';
typedef BackupDelivery = Future<bool> Function(String archivePath);
/// Returns the archive to restore, or null when the user backed out. A source
/// that downloads writes to [downloadPath], which is removed afterwards.
typedef BackupFetch = Future<String?> Function(String downloadPath);
@Riverpod(keepAlive: true)
class BackupAction extends _$BackupAction {
@override
void build() {}
Future<bool> consumeBackup(Future<bool> Function(String path) send) async {
final path = await backup();
if (path.isEmpty) {
return false;
}
Future<bool> backup(BackupDelivery deliver) {
return _withWorkDir((workDir) async {
return deliver(await _createArchive(workDir));
});
}
Future<bool> restore(RestoreOption option, BackupFetch fetch) {
return _withWorkDir((workDir) async {
final archivePath = await fetch(join(workDir, 'download.zip'));
if (archivePath == null) {
return false;
}
final stagingDirPath = join(workDir, 'staging');
final MigrationData data;
try {
data = await restoreTask((
archivePath: archivePath,
stagingDirPath: stagingDirPath,
));
} on BackupException catch (e) {
throw MessageException(switch (e.failure) {
BackupFailure.invalid => currentAppLocalizations.invalidBackupFile,
BackupFailure.newerVersion =>
currentAppLocalizations.backupFromNewerVersion,
});
}
await applyRestore(data, option, stagingDirPath: stagingDirPath);
return true;
});
}
Future<bool> _withWorkDir(Future<bool> Function(String workDir) run) async {
final workDir = Directory(join(await appPath.tempPath, 'backup$uniqueId'));
await workDir.create(recursive: true);
try {
return await send(path);
return await run(workDir.path);
} finally {
await File(path).safeDelete();
await workDir.safeDelete(recursive: true);
await _discardLegacyStaging();
}
}
@visibleForTesting
Future<String> backup() async {
final res = await Future.wait([
database.profilesDao.fileNames().get(),
database.scriptsDao.fileNames().get(),
]);
final profileFileNames = res[0];
final scriptFileNames = res[1];
/// Earlier versions staged restores in the home directory and never removed
/// the downloaded archive.
Future<void> _discardLegacyStaging() async {
final homeDirPath = await appPath.homeDirPath;
await File(join(homeDirPath, 'backup.zip')).safeDelete();
await Directory(join(homeDirPath, 'restore')).safeDelete(recursive: true);
}
Future<String> _createArchive(String workDir) async {
final databaseSnapshotPath = join(workDir, backupDatabaseName);
await database.customStatement('VACUUM INTO ?', [databaseSnapshotPath]);
final (profileIds, scriptIds, providers) = await (
database.profilesDao.query().map((item) => item.id).get(),
database.scriptsDao.query().map((item) => item.id).get(),
database.clashProvidersDao.queryAll().get(),
).wait;
final configMap = ref.read(configProvider).toJson();
configMap['version'] = await preferences.getVersion();
return backupTask(configMap, [...profileFileNames, ...scriptFileNames]);
}
Future<void> restore(RestoreOption option) async {
final restoreDirPath = await appPath.restoreDirPath;
final restoreDir = Directory(restoreDirPath);
try {
final migrationData = await restoreTask();
if (!await restoreDir.exists()) {
throw MessageException(currentAppLocalizations.restoreException);
}
await applyRestore(migrationData, option);
} finally {
await restoreDir.safeDelete(recursive: true);
}
final archivePath = join(workDir, 'backup.zip');
await backupTask((
archivePath: archivePath,
configMap: configMap,
databaseSnapshotPath: databaseSnapshotPath,
homeDirPath: await appPath.homeDirPath,
entries: BackupEntries.of(
profileIds: profileIds,
scriptIds: scriptIds,
providers: providers,
),
));
return archivePath;
}
@visibleForTesting
Future<void> applyRestore(MigrationData data, RestoreOption option) async {
final restoreStrategy = ref.read(
appSettingProvider.select((state) => state.restoreStrategy),
);
final isOverride = restoreStrategy == RestoreStrategy.override;
Future<void> applyRestore(
MigrationData data,
RestoreOption option, {
String? stagingDirPath,
}) async {
final configMap = data.configMap;
final config = option == RestoreOption.onlyProfiles || configMap == null
? null
: Config.fromJson(configMap);
await database.restore(
data.profiles,
data.scripts,
data.rules,
data.links,
data.proxyGroups,
isOverride: isOverride,
);
final isOverride =
ref.read(appSettingProvider).restoreStrategy ==
RestoreStrategy.override;
final previousProviders = await database.clashProvidersDao.queryAll().get();
final putBack = stagingDirPath == null
? null
: await _copyStagedFiles(data, stagingDirPath);
try {
await database.restore(
data.profiles,
data.scripts,
data.rules,
data.links,
data.proxyGroups,
clashProviders: data.clashProviders,
customProxies: data.customProxies,
isOverride: isOverride,
);
} catch (_) {
await putBack?.call();
rethrow;
}
await _clearReplacedProviderCaches(previousProviders);
if (config == null) {
return;
}
ref.read(davSettingProvider.notifier).update((_) => config.davProps);
ref.read(patchClashConfigProvider.notifier).value = config.patchClashConfig;
ref.read(appSettingProvider.notifier).value = config.appSettingProps;
ref.read(currentProfileIdProvider.notifier).value = config.currentProfileId;
ref.read(themeSettingProvider.notifier).value = config.themeProps;
ref.read(windowSettingProvider.notifier).value = config.windowProps;
ref.read(vpnSettingProvider.notifier).value = config.vpnProps;
ref.read(proxiesStyleSettingProvider.notifier).value =
config.proxiesStyleProps;
ref.read(overrideDnsProvider.notifier).value = config.overrideDns;
ref.read(networkSettingProvider.notifier).value = config.networkProps;
ref.read(hotKeyActionsProvider.notifier).value = config.hotKeyActions;
writeConfig(
ref,
config.copyWith(
davProps: config.davProps ?? ref.read(davSettingProvider),
),
);
}
/// The files go in before the rows that name them, so the returned callback
/// is how a restore whose rows fail takes them back out.
Future<Future<void> Function()> _copyStagedFiles(
MigrationData data,
String stagingDirPath,
) async {
final homeDirPath = await appPath.homeDirPath;
final replacedDirPath = join(stagingDirPath, 'replaced');
final copied = <String, bool>{};
Future<void> putBack() async {
for (final MapEntry(key: entry, value: replaced) in copied.entries) {
final target = BackupEntries.resolve(homeDirPath, entry);
if (replaced) {
await File(
BackupEntries.resolve(replacedDirPath, entry),
).safeCopy(target);
} else {
await File(target).safeDelete();
}
}
}
try {
for (final entry in BackupEntries.ofData(data)) {
final staged = File(BackupEntries.resolve(stagingDirPath, entry));
if (!await staged.exists()) {
continue;
}
final target = File(BackupEntries.resolve(homeDirPath, entry));
final replaced = await target.exists();
if (replaced) {
await target.safeCopy(BackupEntries.resolve(replacedDirPath, entry));
}
copied[entry] = replaced;
await staged.safeCopy(target.path);
}
} catch (_) {
await putBack();
rethrow;
}
return putBack;
}
Future<void> _clearReplacedProviderCaches(
List<ClashProvider> previousProviders,
) async {
final kept = (await database.clashProvidersDao.fileNames().get()).toSet();
for (final provider in previousProviders) {
if (kept.contains(provider.fileName)) {
continue;
}
await File(await provider.path).safeDelete();
}
}
}
+220 -23
View File
@@ -1,7 +1,6 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/database/database.dart';
import 'package:fl_clash/enum/enum.dart';
@@ -91,13 +90,26 @@ Stream<int> proxyGroupsCount(Ref ref, int profileId) {
return database.proxyGroupsDao.count(profileId).watchSingle();
}
@riverpod
Stream<int> customProxiesCount(Ref ref, int profileId) {
return database.customProxiesDao.count(profileId).watchSingle();
}
@Riverpod(keepAlive: true)
class Profiles extends _$Profiles {
@override
List<Profile> build() {
ref.listen(clashProvidersProvider(ProviderKind.proxy), (_, _) {});
return ref.watch(profilesStreamProvider).value ?? [];
}
Set<String> get _reservedLabels => {
for (final provider
in ref.read(clashProvidersProvider(ProviderKind.proxy)).value ??
const <ClashProvider>[])
provider.label,
};
void _optimistic(List<Profile> next, FutureOr<void> Function() action) {
unawaited(
_optimisticAsync(next, action).catchError(_reportOptimisticFailure),
@@ -118,7 +130,7 @@ class Profiles extends _$Profiles {
}
void put(Profile profile) {
final newProfile = state.optimizeLabel(profile);
final newProfile = state.optimizeLabel(profile, reserved: _reservedLabels);
_optimistic(
state.copyAndPut(newProfile, (item) => item.id == newProfile.id),
() => database.profiles.put(newProfile.toCompanion()),
@@ -128,7 +140,7 @@ class Profiles extends _$Profiles {
Future<void> del(int id) {
return _optimisticAsync(
state.where((e) => e.id != id).toList(),
() => database.profiles.remove((t) => t.id.equals(id)),
() => database.deleteProfile(id),
);
}
@@ -194,16 +206,16 @@ class Scripts extends _$Scripts with AsyncNotifierMixin, OptimisticMixin {
optimistic(next, () => database.scripts.remove((t) => t.id.equals(id)));
}
void delAll(Iterable<int> ids) {
final scriptIds = ids.toSet();
optimistic(
value.where((item) => !scriptIds.contains(item.id)).toList(),
() => database.scripts.remove((t) => t.id.isIn(scriptIds)),
);
}
bool isExits(String label) {
return value.indexWhere((item) => item.label == label) != -1;
void order(int oldIndex, int newIndex) {
final next = value.copyAndReorder(oldIndex, newIndex);
final changed = <ScriptsCompanion>[];
next.forEachIndexed((index, item) {
if (item.order != index) {
next[index] = item.copyWith(order: index);
changed.add(item.toCompanion(index));
}
});
optimistic(next, () => database.scriptsDao.putAll(changed));
}
@override
@@ -215,6 +227,82 @@ class Scripts extends _$Scripts with AsyncNotifierMixin, OptimisticMixin {
}
}
@riverpod
class ClashProviders extends _$ClashProviders
with AsyncNotifierMixin, OptimisticMixin {
@override
Stream<List<ClashProvider>> build(ProviderKind kind) {
return database.clashProvidersDao.query(kind).watch();
}
@override
List<ClashProvider> get value => state.value ?? [];
void put(ClashProvider provider) {
final next = List<ClashProvider>.from(value);
final index = next.indexWhere((item) => item.id == provider.id);
final renamedFrom = index != -1 && next[index].label != provider.label
? next[index].label
: null;
if (index != -1) {
next[index] = provider;
} else {
next.add(provider);
}
optimistic(
next,
() => database.transaction(() async {
if (renamedFrom != null) {
switch (provider.kind) {
case ProviderKind.proxy:
await database.proxyGroupsDao.renameUse(
oldName: renamedFrom,
newName: provider.label,
);
case ProviderKind.rule:
await database.rulesDao.renameCustomRuleProvider(
oldName: renamedFrom,
newName: provider.label,
);
}
}
await database.clashProviders.put(provider.toCompanion());
}),
);
}
void del(int id) {
final next = List<ClashProvider>.from(value);
final index = next.indexWhere((item) => item.id == id);
if (index == -1) return;
next.removeAt(index);
optimistic(
next,
() => database.clashProviders.remove((t) => t.id.equals(id)),
);
}
void order(int oldIndex, int newIndex) {
final next = value.copyAndReorder(oldIndex, newIndex);
final changed = <ClashProvidersCompanion>[];
next.forEachIndexed((index, item) {
if (item.order != index) {
next[index] = item.copyWith(order: index);
changed.add(item.toCompanion(index));
}
});
optimistic(next, () => database.clashProvidersDao.putAll(changed));
}
@override
bool updateShouldNotify(
AsyncValue<List<ClashProvider>> previous,
AsyncValue<List<ClashProvider>> next,
) {
return !clashProviderListEquality.equals(previous.value, next.value);
}
}
@riverpod
Future<Script?> script(Ref ref, int? scriptId) async {
final script = ref.watch(
@@ -250,10 +338,34 @@ mixin RuleListMixin on OptimisticMixin<List<Rule>> {
);
}
void delAll(Iterable<int> ruleIds) {
void putAll(List<Rule> rules) {
if (rules.isEmpty) {
return;
}
final orders = indexing.generateNKeysBetween(
null,
value.firstOrNull?.order,
rules.length,
);
final newRules = [
for (final (index, rule) in rules.indexed)
rule.copyWith(order: orders[index]),
];
optimistic(
value.where((item) => !ruleIds.contains(item.id)).toList(),
() => database.rulesDao.delRules(ruleIds),
[...newRules, ...value],
() => database.transaction(() async {
for (final rule in newRules) {
await persistRule(rule);
}
}),
);
}
void delAll(Iterable<int> ruleIds) {
final ids = ruleIds.toSet();
optimistic(
value.where((item) => !ids.contains(item.id)).toList(),
() => database.rulesDao.delRules(ids),
);
}
@@ -342,20 +454,20 @@ class ProxyGroups extends _$ProxyGroups
return !proxyGroupsEquality.equals(previous.value, next.value);
}
void del(String name) {
void delAll(Iterable<int> proxyGroupIds) {
final ids = proxyGroupIds.toSet();
optimistic(
value.where((item) => item.name != name).toList(),
() => database.proxyGroups.remove(
(t) => t.profileId.equals(profileId) & t.name.equals(name),
),
value.where((item) => !ids.contains(item.id)).toList(),
() => database.proxyGroupsDao.delAll(ids),
);
}
bool put(ProxyGroup proxyGroup) {
final previous = value;
final index = previous.indexWhere((item) => item.id == proxyGroup.id);
if (index == -1 &&
previous.indexWhere((item) => item.name == proxyGroup.name) != -1) {
if (previous.any(
(item) => item.name == proxyGroup.name && item.id != proxyGroup.id,
)) {
return false;
}
final renamedFrom = index != -1 && previous[index].name != proxyGroup.name
@@ -419,6 +531,91 @@ class ProxyGroups extends _$ProxyGroups
List<ProxyGroup> get value => state.value ?? [];
}
@riverpod
class CustomProxies extends _$CustomProxies
with AsyncNotifierMixin, OptimisticMixin {
@override
Stream<List<CustomProxy>> build(int profileId) {
return database.customProxiesDao.query(profileId).watch();
}
@override
bool updateShouldNotify(
AsyncValue<List<CustomProxy>> previous,
AsyncValue<List<CustomProxy>> next,
) {
return !customProxiesEquality.equals(previous.value, next.value);
}
@override
List<CustomProxy> get value => state.value ?? [];
void delAll(Iterable<int> ids) {
final idSet = ids.toSet();
optimistic(
value.where((item) => !idSet.contains(item.id)).toList(),
() => database.customProxiesDao.delAll(idSet),
);
}
/// A rename carries over to the groups and rules that name the proxy, the
/// same way a group rename does.
void put(CustomProxy proxy) {
final previous = value;
final index = previous.indexWhere((item) => item.id == proxy.id);
final renamedFrom = index != -1 && previous[index].name != proxy.name
? previous[index].name
: null;
final next = List<CustomProxy>.from(previous);
final CustomProxy nextProxy;
if (index != -1) {
nextProxy = proxy;
next[index] = nextProxy;
} else {
final lastOrder = previous.map((item) => item.order).nonNulls.lastOrNull;
nextProxy = proxy.copyWith(
order: indexing.generateKeyBetween(lastOrder, null),
);
next.add(nextProxy);
}
optimistic(
next,
() => database.transaction(() async {
if (renamedFrom != null) {
await database.rulesDao.renameCustomRuleTarget(
profileId,
oldName: renamedFrom,
newName: nextProxy.name,
);
await database.proxyGroupsDao.renameProxies(
profileId,
oldName: renamedFrom,
newName: nextProxy.name,
);
}
await database.customProxies.put(nextProxy.toCompanion(profileId));
}),
);
}
void order(int oldIndex, int newIndex) {
final item = value[oldIndex];
final nextItems = value.copyAndReorder(oldIndex, newIndex);
final newOrder = indexing.generateKeyBetween(
nextItems.safeGet(newIndex - 1)?.order,
nextItems.safeGet(newIndex + 1)?.order,
)!;
optimistic(
nextItems,
() => database.customProxiesDao.order(
profileId,
proxy: item,
order: newOrder,
),
);
}
}
@riverpod
class ProfileDisabledRuleIds extends _$ProfileDisabledRuleIds
with AsyncNotifierMixin, OptimisticMixin {
+253 -3
View File
@@ -260,6 +260,76 @@ final class ProxyGroupsCountFamily extends $Family
String toString() => r'proxyGroupsCountProvider';
}
@ProviderFor(customProxiesCount)
final customProxiesCountProvider = CustomProxiesCountFamily._();
final class CustomProxiesCountProvider
extends $FunctionalProvider<AsyncValue<int>, int, Stream<int>>
with $FutureModifier<int>, $StreamProvider<int> {
CustomProxiesCountProvider._({
required CustomProxiesCountFamily super.from,
required int super.argument,
}) : super(
retry: null,
name: r'customProxiesCountProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$customProxiesCountHash();
@override
String toString() {
return r'customProxiesCountProvider'
''
'($argument)';
}
@$internal
@override
$StreamProviderElement<int> $createElement($ProviderPointer pointer) =>
$StreamProviderElement(pointer);
@override
Stream<int> create(Ref ref) {
final argument = this.argument as int;
return customProxiesCount(ref, argument);
}
@override
bool operator ==(Object other) {
return other is CustomProxiesCountProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$customProxiesCountHash() =>
r'648c6350b0b9c6e1ab8fd8c3760c2949f2072689';
final class CustomProxiesCountFamily extends $Family
with $FunctionalFamilyOverride<Stream<int>, int> {
CustomProxiesCountFamily._()
: super(
retry: null,
name: r'customProxiesCountProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
CustomProxiesCountProvider call(int profileId) =>
CustomProxiesCountProvider._(argument: profileId, from: this);
@override
String toString() => r'customProxiesCountProvider';
}
@ProviderFor(Profiles)
final profilesProvider = ProfilesProvider._();
@@ -292,7 +362,7 @@ final class ProfilesProvider
}
}
String _$profilesHash() => r'5d818c87da9278ae44c0298b73806aaf0155c96e';
String _$profilesHash() => r'e36801d7209492d633d0ef0114240088905ea5ae';
abstract class _$Profiles extends $Notifier<List<Profile>> {
List<Profile> build();
@@ -336,7 +406,7 @@ final class ScriptsProvider
Scripts create() => Scripts();
}
String _$scriptsHash() => r'5deb6254cd0fd99d9d179ee4576ec680928f182a';
String _$scriptsHash() => r'd972619681830814ed31751a17346bbfd0ca64a2';
abstract class _$Scripts extends $StreamNotifier<List<Script>> {
Stream<List<Script>> build();
@@ -356,6 +426,96 @@ abstract class _$Scripts extends $StreamNotifier<List<Script>> {
}
}
@ProviderFor(ClashProviders)
final clashProvidersProvider = ClashProvidersFamily._();
final class ClashProvidersProvider
extends $StreamNotifierProvider<ClashProviders, List<ClashProvider>> {
ClashProvidersProvider._({
required ClashProvidersFamily super.from,
required ProviderKind super.argument,
}) : super(
retry: null,
name: r'clashProvidersProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$clashProvidersHash();
@override
String toString() {
return r'clashProvidersProvider'
''
'($argument)';
}
@$internal
@override
ClashProviders create() => ClashProviders();
@override
bool operator ==(Object other) {
return other is ClashProvidersProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$clashProvidersHash() => r'3628f6bbed3b0e4dba71bcc126ea72f4192b860f';
final class ClashProvidersFamily extends $Family
with
$ClassFamilyOverride<
ClashProviders,
AsyncValue<List<ClashProvider>>,
List<ClashProvider>,
Stream<List<ClashProvider>>,
ProviderKind
> {
ClashProvidersFamily._()
: super(
retry: null,
name: r'clashProvidersProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
ClashProvidersProvider call(ProviderKind kind) =>
ClashProvidersProvider._(argument: kind, from: this);
@override
String toString() => r'clashProvidersProvider';
}
abstract class _$ClashProviders extends $StreamNotifier<List<ClashProvider>> {
late final _$args = ref.$arg as ProviderKind;
ProviderKind get kind => _$args;
Stream<List<ClashProvider>> build(ProviderKind kind);
@$mustCallSuper
@override
WhenComplete runBuild() {
final ref =
this.ref as $Ref<AsyncValue<List<ClashProvider>>, List<ClashProvider>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<List<ClashProvider>>, List<ClashProvider>>,
AsyncValue<List<ClashProvider>>,
Object?,
Object?
>;
return element.handleCreate(ref, () => build(_$args));
}
}
@ProviderFor(script)
final scriptProvider = ScriptFamily._();
@@ -689,7 +849,7 @@ final class ProxyGroupsProvider
}
}
String _$proxyGroupsHash() => r'f3a477527846f52cc957af6622d923514a89a66e';
String _$proxyGroupsHash() => r'caaf53c947ae2da656c284410860448353a209b0';
final class ProxyGroupsFamily extends $Family
with
@@ -738,6 +898,96 @@ abstract class _$ProxyGroups extends $StreamNotifier<List<ProxyGroup>> {
}
}
@ProviderFor(CustomProxies)
final customProxiesProvider = CustomProxiesFamily._();
final class CustomProxiesProvider
extends $StreamNotifierProvider<CustomProxies, List<CustomProxy>> {
CustomProxiesProvider._({
required CustomProxiesFamily super.from,
required int super.argument,
}) : super(
retry: null,
name: r'customProxiesProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$customProxiesHash();
@override
String toString() {
return r'customProxiesProvider'
''
'($argument)';
}
@$internal
@override
CustomProxies create() => CustomProxies();
@override
bool operator ==(Object other) {
return other is CustomProxiesProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$customProxiesHash() => r'bc9fbfcc728ae91a19c5e9f596926f32aa74a36a';
final class CustomProxiesFamily extends $Family
with
$ClassFamilyOverride<
CustomProxies,
AsyncValue<List<CustomProxy>>,
List<CustomProxy>,
Stream<List<CustomProxy>>,
int
> {
CustomProxiesFamily._()
: super(
retry: null,
name: r'customProxiesProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
CustomProxiesProvider call(int profileId) =>
CustomProxiesProvider._(argument: profileId, from: this);
@override
String toString() => r'customProxiesProvider';
}
abstract class _$CustomProxies extends $StreamNotifier<List<CustomProxy>> {
late final _$args = ref.$arg as int;
int get profileId => _$args;
Stream<List<CustomProxy>> build(int profileId);
@$mustCallSuper
@override
WhenComplete runBuild() {
final ref =
this.ref as $Ref<AsyncValue<List<CustomProxy>>, List<CustomProxy>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<List<CustomProxy>>, List<CustomProxy>>,
AsyncValue<List<CustomProxy>>,
Object?,
Object?
>;
return element.handleCreate(ref, () => build(_$args));
}
}
@ProviderFor(ProfileDisabledRuleIds)
final profileDisabledRuleIdsProvider = ProfileDisabledRuleIdsFamily._();
+167 -184
View File
@@ -1,15 +1,15 @@
import 'dart:async';
import 'dart:io';
import 'package:dynamic_color/dynamic_color.dart';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/common/dav_client.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/action.dart';
import 'package:fl_clash/providers/app.dart';
import 'package:fl_clash/providers/config.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/widgets/navigation_dock.dart';
import 'package:fl_clash/widgets/dialog.dart';
import 'package:fl_clash/widgets/fade_box.dart';
import 'package:fl_clash/widgets/input.dart';
@@ -55,118 +55,87 @@ class _BackupAndRestoreState extends ConsumerState<BackupAndRestore>
);
}
Future<void> _backupOnWebDAV() async {
final appLocalizations = context.appLocalizations;
Future<void> _run({
required String title,
required String successMessage,
required Future<bool> Function() task,
}) async {
final res = await globalState.loadingRun<bool>(
() async {
final client = _davConnection.client;
if (client == null) {
return false;
}
return ref
.read(backupActionProvider.notifier)
.consumeBackup(client.backup);
},
task,
tag: LoadingTag.backup_restore,
title: title,
);
if (res != true) return;
unawaited(
dialogs.showMessage(
title: title,
message: TextSpan(text: successMessage),
),
);
}
Future<void> _backup(BackupDelivery deliver) {
final appLocalizations = context.appLocalizations;
return _run(
title: appLocalizations.backup,
);
if (res != true) return;
unawaited(
dialogs.showMessage(
title: appLocalizations.backup,
message: TextSpan(text: appLocalizations.backupSuccess),
),
successMessage: appLocalizations.backupSuccess,
task: () => ref.read(backupActionProvider.notifier).backup(deliver),
);
}
Future<void> _restoreOnWebDAV(RestoreOption option) async {
Future<void> _restore(RestoreOption option, BackupFetch fetch) {
final appLocalizations = context.appLocalizations;
final res = await globalState.loadingRun<bool>(
() async {
final client = _davConnection.client;
if (client == null) {
return false;
}
await client.restore();
await ref.read(backupActionProvider.notifier).restore(option);
return true;
},
tag: LoadingTag.backup_restore,
return _run(
title: appLocalizations.restore,
);
if (res != true) return;
unawaited(
dialogs.showMessage(
title: appLocalizations.restore,
message: TextSpan(text: appLocalizations.restoreSuccess),
),
successMessage: appLocalizations.restoreSuccess,
task: () =>
ref.read(backupActionProvider.notifier).restore(option, fetch),
);
}
Future<void> _handleRestoreOnWebDAV() async {
final restoreOption = await dialogs.showCommonDialog<RestoreOption>(
Future<RestoreOption?> _pickRestoreOption() {
return dialogs.showCommonDialog<RestoreOption>(
child: const RestoreOptionsDialog(),
);
if (restoreOption == null || !context.mounted) return;
unawaited(_restoreOnWebDAV(restoreOption));
}
Future<void> _backupOnLocal() async {
final appLocalizations = context.appLocalizations;
final res = await globalState.loadingRun<bool>(
() async {
return ref.read(backupActionProvider.notifier).consumeBackup((
path,
) async {
final value = await picker.saveFileWithPath(
getBackupFileName(),
path,
);
return value != null;
});
},
title: appLocalizations.backup,
tag: LoadingTag.backup_restore,
);
if (res != true) return;
unawaited(
dialogs.showMessage(
title: appLocalizations.backup,
message: TextSpan(text: appLocalizations.backupSuccess),
),
);
Future<void> _backupOnWebDAV() async {
final client = _davConnection.client;
if (client == null) return;
await _backup((archivePath) async {
await client.upload(archivePath);
return true;
});
}
Future<void> _restoreOnLocal(RestoreOption option) async {
final backupAction = ref.read(backupActionProvider.notifier);
final appLocalizations = context.appLocalizations;
Future<void> _restoreOnWebDAV() async {
final client = _davConnection.client;
if (client == null) return;
final option = await _pickRestoreOption();
if (option == null || !mounted) return;
await _restore(option, (downloadPath) async {
await client.download(downloadPath);
return downloadPath;
});
}
Future<void> _backupOnLocal() {
return _backup((archivePath) async {
final savedPath = await picker.saveFileWithPath(
getBackupFileName(),
archivePath,
);
return savedPath != null;
});
}
Future<void> _restoreOnLocal() async {
final option = await _pickRestoreOption();
if (option == null || !mounted) return;
final file = await picker.pickerFile();
final path = file?.path;
if (path == null) return;
await File(path).safeCopy(await appPath.backupFilePath);
final res = await globalState.loadingRun<bool>(
() async {
await backupAction.restore(option);
return true;
},
tag: LoadingTag.backup_restore,
title: appLocalizations.restore,
);
if (res != true) return;
unawaited(
dialogs.showMessage(
title: appLocalizations.restore,
message: TextSpan(text: appLocalizations.restoreSuccess),
),
);
}
Future<void> _handleRestoreOnLocal() async {
final option = await dialogs.showCommonDialog<RestoreOption>(
child: const RestoreOptionsDialog(),
);
if (option == null || !mounted) return;
unawaited(_restoreOnLocal(option));
if (path == null || !mounted) return;
await _restore(option, (_) async => path);
}
void _handleChange(String? value, WidgetRef ref) {
@@ -207,91 +176,105 @@ class _BackupAndRestoreState extends ConsumerState<BackupAndRestore>
isLoading: isLoading,
title: appLocalizations.backupAndRestore,
body: ListView(
padding: const EdgeInsets.symmetric(
horizontal: 16,
).copyWith(top: context.appBarInset, bottom: 16),
children: [
ListHeader(title: appLocalizations.remote),
if (dav == null)
ListItem(
leading: const Icon(Icons.account_box),
title: Text(appLocalizations.noInfo),
subtitle: Text(appLocalizations.pleaseBindWebDAV),
trailing: FilledButton.tonal(
onPressed: () {
_showAddWebDAV(dav);
},
child: Text(appLocalizations.bind),
),
)
else ...[
ListItem(
leading: const Icon(Icons.account_box),
title: TooltipText(
text: Text(
dav.user,
maxLines: 1,
overflow: TextOverflow.ellipsis,
generateSectionV3(
title: appLocalizations.remote,
items: [
if (dav == null)
ListItem(
leading: const GlyphIcon(AppGlyphs.account),
title: Text(appLocalizations.noInfo),
subtitle: Text(appLocalizations.pleaseBindWebDAV),
trailing: ElasticButton(
child: FilledButton.tonal(
onPressed: () {
_showAddWebDAV(dav);
},
child: Text(appLocalizations.bind),
),
),
)
else ...[
ListItem(
leading: const GlyphIcon(AppGlyphs.account),
title: TooltipText(
text: Text(
dav.user,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
subtitle: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(appLocalizations.connectivity),
_DavConnectionIndicator(connection: _davConnection),
],
),
),
trailing: ElasticButton(
child: FilledButton.tonal(
onPressed: () {
_showAddWebDAV(dav);
},
child: Text(appLocalizations.edit),
),
),
),
),
subtitle: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(appLocalizations.connectivity),
_DavConnectionIndicator(connection: _davConnection),
],
ListItem.input(
title: Text(appLocalizations.file),
subtitle: Text(dav.fileName),
dialogTitle: appLocalizations.file,
value: dav.fileName,
resetValue: defaultDavFileName,
maxLength: TextInputLimits.fileName,
onChanged: (value) {
_handleChange(value, ref);
},
),
),
trailing: FilledButton.tonal(
onPressed: () {
_showAddWebDAV(dav);
ListItem(
onTap: () {
_backupOnWebDAV();
},
title: Text(appLocalizations.backup),
),
ListItem(
onTap: () {
_restoreOnWebDAV();
},
title: Text(appLocalizations.restore),
),
],
],
),
generateSectionV3(
title: appLocalizations.local,
items: [
ListItem(
onTap: () {
_backupOnLocal();
},
child: Text(appLocalizations.edit),
title: Text(appLocalizations.backup),
),
),
const SizedBox(height: 4),
ListItem.input(
title: Text(appLocalizations.file),
subtitle: Text(dav.fileName),
dialogTitle: appLocalizations.file,
value: dav.fileName,
resetValue: defaultDavFileName,
maxLength: TextInputLimits.fileName,
onChanged: (value) {
_handleChange(value, ref);
},
),
ListItem(
onTap: () {
_backupOnWebDAV();
},
title: Text(appLocalizations.backup),
subtitle: Text(appLocalizations.remoteBackupDesc),
),
ListItem(
onTap: () {
_handleRestoreOnWebDAV();
},
title: Text(appLocalizations.restore),
subtitle: Text(appLocalizations.restoreFromWebDAVDesc),
),
],
ListHeader(title: appLocalizations.local),
ListItem(
onTap: () {
_backupOnLocal();
},
title: Text(appLocalizations.backup),
subtitle: Text(appLocalizations.localBackupDesc),
ListItem(
onTap: () {
_restoreOnLocal();
},
title: Text(appLocalizations.restore),
),
],
),
ListItem(
onTap: () {
_handleRestoreOnLocal();
},
title: Text(appLocalizations.restore),
subtitle: Text(appLocalizations.restoreFromFileDesc),
generateSectionV3(
title: appLocalizations.options,
items: [
_RestoreStrategyItem(onPressed: _handleUpdateRestoreStrategy),
],
),
ListHeader(title: appLocalizations.options),
_RestoreStrategyItem(onPressed: _handleUpdateRestoreStrategy),
],
),
);
@@ -321,9 +304,7 @@ class _DavConnectionIndicator extends StatelessWidget {
shape: BoxShape.circle,
color: !isConnected
? context.colorScheme.error
: Colors.green.harmonizeWith(
context.colorScheme.primary,
),
: context.colorScheme.success,
),
width: 12,
height: 12,
@@ -348,9 +329,11 @@ class _RestoreStrategyItem extends ConsumerWidget {
return ListItem(
onTap: onPressed,
title: Text(context.appLocalizations.restoreStrategy),
trailing: FilledButton(
onPressed: onPressed,
child: Text(restoreStrategy.label),
trailing: ElasticButton(
child: FilledButton(
onPressed: onPressed,
child: Text(restoreStrategy.label),
),
),
);
}
@@ -470,7 +453,7 @@ class _WebDAVFormDialogState extends ConsumerState<WebDAVFormDialog> {
minLines: 1,
textInputAction: TextInputAction.next,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.link),
prefixIcon: const GlyphIcon(AppGlyphs.link),
labelText: appLocalizations.address,
helperText: appLocalizations.addressHelp,
),
@@ -486,7 +469,7 @@ class _WebDAVFormDialogState extends ConsumerState<WebDAVFormDialog> {
inputFormatters: TextInputLimits.limit(TextInputLimits.userName),
textInputAction: TextInputAction.next,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.account_circle),
prefixIcon: const GlyphIcon(AppGlyphs.account),
labelText: appLocalizations.account,
),
validator: (String? value) {
@@ -510,13 +493,13 @@ class _WebDAVFormDialogState extends ConsumerState<WebDAVFormDialog> {
_submit();
},
decoration: InputDecoration(
prefixIcon: const Icon(Icons.password),
prefixIcon: const GlyphIcon(AppGlyphs.password),
suffixIcon: IconButton(
tooltip: obscure
? context.appLocalizations.showPassword
: context.appLocalizations.hidePassword,
icon: Icon(
obscure ? Icons.visibility : Icons.visibility_off,
icon: GlyphIcon(
obscure ? AppGlyphs.eye : AppGlyphs.eyeOff,
),
onPressed: () {
_obscureController.value = !obscure;