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;
+171 -93
View File
@@ -1,15 +1,13 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:archive/archive_io.dart';
import 'package:drift/native.dart';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/common/migration.dart';
import 'package:fl_clash/database/database.dart' as db;
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/models/models.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart';
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
@@ -43,8 +41,6 @@ void main() {
Directory(join(root.path, 'tmp')).createSync(recursive: true);
Directory(join(root.path, 'archive')).createSync(recursive: true);
PathProviderPlatform.instance = _FakePathProvider(root.path);
// readBackupArchive reports an unusable archive through the localizations.
await AppLocalizations.load(const Locale('en'));
});
tearDownAll(() {
@@ -78,6 +74,8 @@ void main() {
profilesDirPath: profiles.path,
providersDirPath: providers.path,
scriptsDirPath: scripts.path,
providerFileNames: const [],
providerCacheDirPath: join(root.path, 'missing'),
);
expect(orphans.map(basename), unorderedEquals(['2.yaml', '20.js', '2']));
@@ -99,6 +97,8 @@ void main() {
profilesDirPath: join(root.path, 'missing'),
providersDirPath: providers.path,
scriptsDirPath: join(root.path, 'missing'),
providerFileNames: const [],
providerCacheDirPath: join(root.path, 'missing'),
);
expect(
@@ -120,6 +120,8 @@ void main() {
profilesDirPath: profiles.path,
providersDirPath: join(root.path, 'missing'),
scriptsDirPath: join(root.path, 'missing'),
providerFileNames: const [],
providerCacheDirPath: join(root.path, 'missing'),
);
expect(orphans.map(basename), ['stray.yaml']);
@@ -137,11 +139,38 @@ void main() {
profilesDirPath: profiles.path,
providersDirPath: join(root.path, 'missing'),
scriptsDirPath: join(root.path, 'missing'),
providerFileNames: const [],
providerCacheDirPath: join(root.path, 'missing'),
);
expect(orphans, isEmpty);
});
test('reports a provider cache file no row names any more', () {
final cache = makeDir('provider_cache');
writeFile(join(cache.path, 'proxies', 'live'), 'keep');
writeFile(join(cache.path, 'proxies', 'gone'), 'drop');
writeFile(join(cache.path, 'rules', 'stale'), 'drop');
final orphans = shakeOrphanFiles(
profileIds: const [],
scriptIds: const [],
providerFileNames: const ['live'],
profilesDirPath: join(root.path, 'missing'),
providersDirPath: join(root.path, 'missing'),
providerCacheDirPath: cache.path,
scriptsDirPath: join(root.path, 'missing'),
);
expect(
orphans,
unorderedEquals([
join(cache.path, 'proxies', 'gone'),
join(cache.path, 'rules', 'stale'),
]),
);
});
test('ignores directories that do not exist', () {
expect(
shakeOrphanFiles(
@@ -150,6 +179,8 @@ void main() {
profilesDirPath: join(root.path, 'nope'),
providersDirPath: join(root.path, 'nope'),
scriptsDirPath: join(root.path, 'nope'),
providerFileNames: const [],
providerCacheDirPath: join(root.path, 'missing'),
),
isEmpty,
);
@@ -416,13 +447,20 @@ void main() {
});
group('backup and restore', () {
const localProvider = ClashProvider(
id: 4,
kind: ProviderKind.rule,
label: 'Local rules',
behavior: RuleProviderBehavior.classical,
format: RuleProviderFormat.yaml,
);
late Directory home;
late Directory restore;
late Directory staging;
late String databasePath;
setUp(() async {
home = makeDir('home');
restore = makeDir('restore');
staging = makeDir('staging');
databasePath = join(home.path, 'database.sqlite');
});
@@ -442,25 +480,55 @@ void main() {
1,
const Rule(id: 3, content: 'example.com', order: 'a'),
);
await database.clashProvidersDao.putAll([localProvider.toCompanion()]);
await database.close();
}
Future<String> backup({
Map<String, dynamic> configMap = const {'version': 1},
Iterable<String> fileNames = const ['1.yaml', '2.js'],
}) {
return writeBackupArchive(
Map<String, Object?> configMap = const {'version': 1},
List<String>? entries,
}) async {
entries ??= BackupEntries.of(
profileIds: const [1],
scriptIds: const [2],
providers: const [localProvider],
);
final archivePath = join(root.path, 'archive', '$uniqueId.zip');
await writeBackupArchive(
archivePath: archivePath,
configMap: configMap,
fileNames: fileNames,
databasePath: databasePath,
profilesDirPath: join(home.path, 'profiles'),
scriptsDirPath: join(home.path, 'scripts'),
zipFilePath: join(root.path, 'archive', '$uniqueId.zip'),
tempDatabasePath: join(root.path, 'tmp', '$uniqueId.sqlite'),
tempConfigPath: join(root.path, 'tmp', '$uniqueId.json'),
databaseSnapshotPath: databasePath,
homeDirPath: home.path,
entries: entries,
);
return archivePath;
}
Future<String> zipOf(Map<String, List<int>> files) async {
final archivePath = join(root.path, 'archive', '$uniqueId.zip');
final encoder = ZipFileEncoder()..create(archivePath);
for (final MapEntry(:key, :value) in files.entries) {
encoder.addArchiveFile(ArchiveFile.bytes(key, value));
}
await encoder.close();
return archivePath;
}
Future<MigrationData> restore(String archivePath) {
return readBackupArchive(
archivePath: archivePath,
stagingDirPath: staging.path,
);
}
Matcher failsWith(BackupFailure failure) => throwsA(
isA<BackupException>().having(
(error) => error.failure,
'failure',
failure,
),
);
test('the archive carries the database, the config and only the listed '
'files', () async {
await seedDatabase();
@@ -473,51 +541,41 @@ void main() {
final names = ZipDecoder()
.decodeStream(InputFileStream(archivePath))
.files
.map((file) => basename(file.name))
.map((file) => file.name)
.toSet();
expect(names, containsAll([backupDatabaseName, configJsonName]));
expect(names, contains('1.yaml'));
expect(names, contains('2.js'));
expect(names, isNot(contains('9.yaml')));
expect(names, {
backupDatabaseName,
configJsonName,
'profiles/1.yaml',
'scripts/2.js',
});
});
test('the temporary database and config copies are cleaned up', () async {
await seedDatabase();
final before = Directory(join(root.path, 'tmp'));
await backup();
final leftovers = before.existsSync()
? before.listSync().map((entity) => basename(entity.path))
: <String>[];
expect(leftovers, isEmpty);
});
test('restoring returns the rows and copies the files home', () async {
test('restoring returns the rows and stages the files', () async {
await seedDatabase();
writeFile(join(home.path, 'profiles', '1.yaml'), 'proxies: []');
writeFile(join(home.path, 'scripts', '2.js'), 'body');
final archivePath = await backup();
final target = makeDir('restore_target');
final data = await readBackupArchive(
backupFilePath: archivePath,
restoreDirPath: restore.path,
homeDirPath: target.path,
writeFile(
join(home.path, 'providers', 'rules', localProvider.fileName),
'payload: []',
);
final archivePath = await backup();
final data = await restore(archivePath);
expect(data.profiles.single.label, 'Backed up');
expect(data.scripts.single.label, 'Script');
expect(data.rules.single.content, 'example.com');
expect(data.links.single.ruleId, 3);
expect(data.clashProviders.single.label, 'Local rules');
expect(data.configMap?['version'], 1);
expect(
File(join(target.path, 'profiles', '1.yaml')).readAsStringSync(),
'proxies: []',
);
expect(
File(join(target.path, 'scripts', '2.js')).readAsStringSync(),
'body',
BackupEntries.ofData(data).map(
(entry) => File(
BackupEntries.resolve(staging.path, entry),
).readAsStringSync(),
),
['proxies: []', 'body', 'payload: []'],
);
});
@@ -531,15 +589,10 @@ void main() {
{'id': 'legacy', 'label': 'Old', 'autoUpdateDuration': 0},
],
},
fileNames: const ['legacy.yaml'],
entries: const ['profiles/legacy.yaml'],
);
final target = makeDir('restore_target');
final data = await readBackupArchive(
backupFilePath: archivePath,
restoreDirPath: restore.path,
homeDirPath: target.path,
);
final data = await restore(archivePath);
final profile = data.profiles.single;
expect(profile.label, 'Old');
@@ -550,56 +603,81 @@ void main() {
);
expect(
File(
join(target.path, 'profiles', '${profile.id}.yaml'),
BackupEntries.resolve(
staging.path,
BackupEntries.profile(profile.id),
),
).readAsStringSync(),
'proxies: []',
);
});
test(
'an entry that climbs out of the staging directory is dropped',
() async {
final archivePath = await zipOf({
configJsonName: utf8.encode(json.encode({'version': 1})),
'../escaped.txt': utf8.encode('outside'),
});
await restore(archivePath);
expect(
File(join(staging.parent.path, 'escaped.txt')).existsSync(),
isFalse,
);
},
);
test('an archive without a config document is rejected', () async {
final archivePath = join(root.path, 'archive', '$uniqueId.zip');
final encoder = ZipFileEncoder();
encoder.create(archivePath);
await encoder.addFile(
writeFile(join(root.path, 'tmp', '$uniqueId.txt'), 'nothing'),
'unrelated.txt',
);
unawaited(encoder.close());
final archivePath = await zipOf({'unrelated.txt': utf8.encode('x')});
await expectLater(restore(archivePath), failsWith(BackupFailure.invalid));
});
test('a file that is not a zip archive is rejected', () async {
final archivePath = writeFile(
join(root.path, 'archive', '$uniqueId.zip'),
'not a zip',
).path;
await expectLater(restore(archivePath), failsWith(BackupFailure.invalid));
});
test('a config from a newer data version is rejected', () async {
final archivePath = await zipOf({
configJsonName: utf8.encode(
json.encode({'version': Migration.currentVersion + 1}),
),
});
await expectLater(
readBackupArchive(
backupFilePath: archivePath,
restoreDirPath: makeDir('restore').path,
homeDirPath: makeDir('home').path,
),
throwsA(
isA<MessageException>().having(
(error) => error.message,
'message',
currentAppLocalizations.invalidBackupFile,
),
),
restore(archivePath),
failsWith(BackupFailure.newerVersion),
);
});
test('a database from a newer schema is rejected', () async {
await seedDatabase();
final raw = db.Database(NativeDatabase(File(databasePath)));
await raw.customStatement(
'PRAGMA user_version = ${raw.schemaVersion + 1}',
);
await raw.close();
final archivePath = await backup();
await expectLater(
restore(archivePath),
failsWith(BackupFailure.newerVersion),
);
});
test('an archive without a database yields just the config', () async {
final archivePath = join(root.path, 'archive', '$uniqueId.zip');
final encoder = ZipFileEncoder();
encoder.create(archivePath);
await encoder.addFile(
writeFile(
join(root.path, 'tmp', '$uniqueId.json'),
json.encode({'version': 1}),
),
configJsonName,
);
unawaited(encoder.close());
final archivePath = await zipOf({
configJsonName: utf8.encode(json.encode({'version': 1})),
});
final data = await readBackupArchive(
backupFilePath: archivePath,
restoreDirPath: makeDir('restore').path,
homeDirPath: makeDir('home').path,
);
final data = await restore(archivePath);
expect(data.configMap?['version'], 1);
expect(data.profiles, isEmpty);
+12 -11
View File
@@ -269,10 +269,10 @@ void main() {
expect(await buildClient().ping(), isFalse);
});
test('backup creates the collection and uploads the archive', () async {
test('upload creates the collection and puts the archive', () async {
final archive = writeArchive(List<int>.generate(32, (index) => index));
expect(await buildClient().backup(archive.path), isTrue);
await buildClient().upload(archive.path);
expect(server.requests.map((item) => item.method), ['MKCOL', 'PUT']);
expect(server.requests.last.path, '/$appName/backup.zip');
@@ -289,23 +289,24 @@ void main() {
final archive = writeArchive(const [1, 2, 3]);
final client = buildClient();
await client.backup(archive.path);
await client.upload(archive.path);
expect(await client.backup(archive.path), isTrue);
await expectLater(client.upload(archive.path), completes);
});
test('restore writes the downloaded archive to the backup file', () async {
test('download writes the remote archive to the given path', () async {
final payload = List<int>.generate(64, (index) => index);
server.files['/$appName/backup.zip'] = payload;
final target = join(root.path, 'download.zip');
expect(await buildClient().restore(), isTrue);
await buildClient().download(target);
expect(File(join(root.path, 'backup.zip')).readAsBytesSync(), payload);
expect(File(target).readAsBytesSync(), payload);
});
test('restore reports the status the server sent', () async {
test('download reports the status the server sent', () async {
await expectLater(
buildClient().restore(),
buildClient().download(join(root.path, 'missing.zip')),
throwsA(
isA<DAVException>().having(
(error) => error.toString(),
@@ -325,10 +326,10 @@ void main() {
headers: const {'location': '/moved/backup.zip'},
));
expect(await buildClient().restore(), isTrue);
await buildClient().download(join(root.path, 'moved.zip'));
expect(server.requests.map((item) => item.method), ['GET', 'GET']);
expect(File(join(root.path, 'backup.zip')).readAsBytesSync(), payload);
expect(File(join(root.path, 'moved.zip')).readAsBytesSync(), payload);
});
test('credentials are sent as basic auth', () async {
+241
View File
@@ -242,6 +242,8 @@ void main() {
excludeFilter: 'exclude',
excludeType: 'Direct',
expectedStatus: '204',
tolerance: 50,
strategy: LoadBalanceStrategy.stickySessions,
includeAll: true,
includeAllProxies: false,
includeAllProviders: true,
@@ -414,6 +416,164 @@ void main() {
},
);
test('delRules deletes more rules than one statement can bind', () async {
const profile = Profile(id: 1, autoUpdateDuration: Duration.zero);
final rules = List.generate(
33000,
(index) => Rule(
id: index + 1,
ruleAction: RuleAction.DOMAIN,
content: 'rule$index.example',
ruleTarget: 'DIRECT',
),
);
await database.profiles.put(profile.toCompanion());
await database.batch((b) {
database.rulesDao.setCustomRulesWithBatch(profile.id, b, rules);
});
await database.rulesDao.delRules(rules.map((rule) => rule.id));
expect(
await database.rulesDao.profileCustomRulesCount(profile.id).getSingle(),
0,
);
});
test('deleting a profile takes its links, groups and own rules', () async {
const gone = Profile(id: 1, autoUpdateDuration: Duration.zero);
const kept = Profile(id: 2, autoUpdateDuration: Duration.zero);
const global = Rule(id: 10, content: 'global.example', ruleTarget: 'A');
const custom = Rule(id: 11, content: 'custom.example', ruleTarget: 'A');
const other = Rule(id: 12, content: 'other.example', ruleTarget: 'A');
await database.profilesDao.putAll([gone.toCompanion(), kept.toCompanion()]);
await database.rulesDao.putGlobalRule(global);
await database.rulesDao.putProfileCustomRule(gone.id, custom);
await database.rulesDao.putDisabledLink(gone.id, global.id);
await database.rulesDao.putProfileCustomRule(kept.id, other);
for (final profile in [gone, kept]) {
await database.proxyGroups.put(
ProxyGroup(
id: profile.id,
name: 'Group',
type: GroupType.Selector,
).toCompanion(profile.id),
);
await database.customProxies.put(
CustomProxy(
id: profile.id,
definition: const {'name': 'Node', 'type': 'socks5'},
).toCompanion(profile.id),
);
}
await database.deleteProfile(gone.id);
final ruleIds = await database.rules.all().map((row) => row.id).get();
expect(ruleIds, unorderedEquals([global.id, other.id]));
final links = await database.profileRuleLinks.all().get();
expect(links.map((link) => link.profileId), unorderedEquals([null, 2]));
final groups = await database.proxyGroups.all().get();
expect(groups.map((group) => group.profileId), [kept.id]);
final proxies = await database.customProxies.all().get();
expect(proxies.map((proxy) => proxy.profileId), [kept.id]);
});
test('deleting rules takes their links with them', () async {
const profile = Profile(id: 1, autoUpdateDuration: Duration.zero);
const rule = Rule(id: 10, content: 'custom.example', ruleTarget: 'A');
await database.profiles.put(profile.toCompanion());
await database.rulesDao.putProfileCustomRule(profile.id, rule);
await database.rulesDao.delRules([rule.id]);
expect(await database.profileRuleLinks.count.getSingle(), 0);
});
test('a restore drops the orphans an older backup carries', () async {
const profile = Profile(id: 1, autoUpdateDuration: Duration.zero);
const rule = Rule(id: 10, content: 'kept.example', ruleTarget: 'A');
const orphan = Rule(id: 11, content: 'orphan.example', ruleTarget: 'A');
for (final isOverride in [false, true]) {
await database.restore(
[profile],
const [],
[rule, orphan],
const [
ProfileRuleLink(profileId: 1, ruleId: 10, scene: RuleScene.custom),
ProfileRuleLink(profileId: 99, ruleId: 11, scene: RuleScene.custom),
ProfileRuleLink(ruleId: 404),
],
const [
ProxyGroup(id: 1, profileId: 1, name: 'A', type: GroupType.Selector),
ProxyGroup(id: 2, profileId: 99, name: 'B', type: GroupType.Selector),
],
isOverride: isOverride,
);
final ruleIds = await database.rules.all().map((row) => row.id).get();
expect(ruleIds, [rule.id], reason: 'isOverride: $isOverride');
expect(await database.profileRuleLinks.count.getSingle(), 1);
final groups = await database.proxyGroups.all().get();
expect(groups.map((group) => group.id), [1]);
}
});
test(
'an override restore replaces more rules than one statement can bind',
() async {
const profile = Profile(id: 1, autoUpdateDuration: Duration.zero);
const stale = Rule(id: 1, content: 'stale.example', ruleTarget: 'DIRECT');
await database.restore(
[profile],
const [],
[stale],
[
ProfileRuleLink(
profileId: profile.id,
ruleId: stale.id,
scene: RuleScene.custom,
),
],
const [],
isOverride: true,
);
final rules = List.generate(
33000,
(index) => Rule(
id: index + 2,
content: 'rule$index.example',
ruleTarget: 'DIRECT',
),
);
await database.restore(
[profile],
const [],
rules,
rules
.map(
(rule) => ProfileRuleLink(
profileId: profile.id,
ruleId: rule.id,
scene: RuleScene.custom,
),
)
.toList(),
const [],
isOverride: true,
);
expect(
await database.rulesDao.profileCustomRulesCount(profile.id).getSingle(),
rules.length,
);
expect(await database.rules.count.getSingle(), rules.length);
expect(await database.profileRuleLinks.count.getSingle(), rules.length);
},
);
test(
'a compatible restore keeps records the backup does not carry',
() async {
@@ -511,6 +671,68 @@ void main() {
},
);
test('clash providers DAO splits kinds, orders, and replaces', () async {
const proxies = ClashProvider(
id: 50,
kind: ProviderKind.proxy,
label: 'Proxies',
url: 'https://proxies.example',
order: 1,
);
const rules = ClashProvider(
id: 51,
kind: ProviderKind.rule,
label: 'Rules',
url: 'https://rules.example',
behavior: RuleProviderBehavior.domain,
format: RuleProviderFormat.mrs,
);
const earlier = ClashProvider(
id: 52,
kind: ProviderKind.proxy,
label: 'Earlier',
url: 'https://earlier.example',
order: 0,
);
const local = ClashProvider(
id: 53,
kind: ProviderKind.rule,
label: 'Local',
order: 2,
);
await database.clashProvidersDao.putAll(
[proxies, rules, earlier, local].map((item) => item.toCompanion()),
);
expect(await database.clashProvidersDao.query(ProviderKind.proxy).get(), [
earlier,
proxies,
]);
expect(await database.clashProvidersDao.query(ProviderKind.rule).get(), [
local,
rules,
]);
expect((await database.clashProvidersDao.queryAll().get()).length, 4);
expect(
await database.clashProvidersDao.fileNames().get(),
unorderedEquals(
[proxies, rules, earlier, local].map((item) => item.fileName),
),
);
await database.restore(
const [],
const [],
const [],
const [],
const [],
clashProviders: const [rules],
isOverride: true,
);
expect(await database.clashProvidersDao.queryAll().get(), [rules]);
});
test('database restore and custom data replace related records', () async {
const profile = Profile(id: 1, autoUpdateDuration: Duration.zero);
final script = Script(
@@ -561,11 +783,21 @@ void main() {
name: 'Replacement',
type: GroupType.Fallback,
);
const replacementProxy = CustomProxy(
id: 45,
definition: {'name': 'Node', 'type': 'socks5'},
);
await database.setProfileCustomData(
profile.id,
[replacementProxy],
[replacementGroup],
[replacementRule],
);
final customProxy =
(await database.customProxiesDao.query(profile.id).get()).single;
expect(customProxy.id, replacementProxy.id);
expect(customProxy.profileId, profile.id);
expect(customProxy.definition, replacementProxy.definition);
expect(
(await database.rulesDao.queryProfileCustomRules(profile.id).get())
.single
@@ -578,6 +810,15 @@ void main() {
);
});
test('icon records evict down to their capacity', () async {
final dao = database.iconRecordsDao;
for (var index = 0; index <= dao.maxCapacity; index++) {
await dao.put('https://example.com/$index.png');
}
expect(await database.iconRecords.count.getSingle(), dao.maxCapacity);
});
test(
'icon records update access time, avoid duplicates, and filter queries',
() async {
+11 -5
View File
@@ -262,6 +262,8 @@ void main() {
excludeFilter: 'exclude',
excludeType: 'Direct',
expectedStatus: '204',
tolerance: 50,
strategy: 'round-robin',
includeAll: true,
includeAllProxies: true,
includeAllProviders: true,
@@ -270,8 +272,8 @@ void main() {
order: 'a0',
);
expect(group.toColumns(true), hasLength(22));
expect(group.toCompanion(true).toColumns(true), hasLength(22));
expect(group.toColumns(true), hasLength(24));
expect(group.toCompanion(true).toColumns(true), hasLength(24));
expect(RawProxyGroup.fromJson(group.toJson()).toJson(), group.toJson());
expect(group.copyWith(name: 'Changed').name, 'Changed');
expect(
@@ -291,7 +293,7 @@ void main() {
const emptyGroup = RawProxyGroup(id: 21, name: 'Empty', type: 'select');
expect(emptyGroup.toColumns(true), hasLength(3));
expect(emptyGroup.toColumns(false), hasLength(22));
expect(emptyGroup.toColumns(false), hasLength(24));
final companion =
ProxyGroupsCompanion.insert(name: 'Inserted', type: 'select').copyWith(
@@ -309,6 +311,8 @@ void main() {
excludeFilter: const Value('exclude'),
excludeType: const Value('Direct'),
expectedStatus: const Value('204'),
tolerance: const Value(50),
strategy: const Value('round-robin'),
includeAll: const Value(true),
includeAllProxies: const Value(true),
includeAllProviders: const Value(true),
@@ -316,7 +320,7 @@ void main() {
icon: const Value('icon'),
order: const Value('a0'),
);
expect(companion.toColumns(true), hasLength(22));
expect(companion.toColumns(true), hasLength(24));
expect(companion.toString(), contains('Inserted'));
expect(
ProxyGroupsCompanion.custom(
@@ -336,6 +340,8 @@ void main() {
excludeFilter: const Variable('exclude'),
excludeType: const Variable('Direct'),
expectedStatus: const Variable('204'),
tolerance: const Variable(50),
strategy: const Variable('round-robin'),
includeAll: const Variable(true),
includeAllProxies: const Variable(true),
includeAllProviders: const Variable(true),
@@ -343,7 +349,7 @@ void main() {
icon: const Variable('icon'),
order: const Variable('a0'),
).toColumns(false),
hasLength(22),
hasLength(24),
);
const icon = IconRecord(
+223 -5
View File
@@ -28,10 +28,61 @@ void _downgradeToV1(Database raw) {
/// Schema version 2 had no `match_target` on `profiles`.
void _downgradeToV2(Database raw) {
_downgradeToV3(raw);
raw.execute('ALTER TABLE profiles DROP COLUMN match_target');
raw.execute('PRAGMA user_version = 2');
}
/// Schema version 3 had no `url` on `scripts`.
void _downgradeToV3(Database raw) {
_downgradeToV4(raw);
raw.execute('ALTER TABLE scripts DROP COLUMN url');
raw.execute('PRAGMA user_version = 3');
}
/// Schema version 4 had no `order` on `scripts`.
void _downgradeToV4(Database raw) {
_downgradeToV5(raw);
raw.execute('ALTER TABLE scripts DROP COLUMN "order"');
raw.execute('PRAGMA user_version = 4');
}
/// Schema version 5 had no `clash_providers` table.
void _downgradeToV5(Database raw) {
_downgradeToV6(raw);
raw.execute('DROP TABLE IF EXISTS clash_providers');
raw.execute('PRAGMA user_version = 5');
}
/// Schema version 6 had no `tolerance` or `strategy` on `proxy_groups`.
void _downgradeToV6(Database raw) {
_downgradeToV7(raw);
raw.execute('ALTER TABLE proxy_groups DROP COLUMN tolerance');
raw.execute('ALTER TABLE proxy_groups DROP COLUMN strategy');
raw.execute('PRAGMA user_version = 6');
}
/// Schema version 9 had no `custom_proxies` table.
void _downgradeToV9(Database raw) {
raw.execute('DROP TABLE IF EXISTS custom_proxies');
raw.execute('PRAGMA user_version = 9');
}
/// Schema version 8 ran with foreign keys off, so its deletes left orphans.
void _downgradeToV8(Database raw) {
_downgradeToV9(raw);
raw.execute('PRAGMA user_version = 8');
}
/// Schema version 7 still carried the per-use options on `clash_providers`.
void _downgradeToV7(Database raw) {
_downgradeToV8(raw);
raw.execute('ALTER TABLE clash_providers ADD COLUMN interval INTEGER');
raw.execute('ALTER TABLE clash_providers ADD COLUMN filter TEXT');
raw.execute('ALTER TABLE clash_providers ADD COLUMN exclude_filter TEXT');
raw.execute('PRAGMA user_version = 7');
}
Set<String> _columnsOf(Database raw, String table) => {
for (final row in raw.select('PRAGMA table_info($table)'))
row['name'] as String,
@@ -76,7 +127,7 @@ void main() {
await openAndMigrate();
expect(_userVersion(raw), 3);
expect(_userVersion(raw), 10);
});
test('the v3 upgrade adds match_target to profiles', () async {
@@ -86,7 +137,41 @@ void main() {
await openAndMigrate();
expect(_columnsOf(raw, 'profiles'), contains('match_target'));
expect(_userVersion(raw), 3);
expect(_userVersion(raw), 10);
});
test('the v4 upgrade adds url to scripts', () async {
_downgradeToV3(raw);
raw.execute(
'INSERT INTO scripts (id, label, last_update_time) '
"VALUES (1, 'Local', 0)",
);
expect(_columnsOf(raw, 'scripts'), isNot(contains('url')));
final database = await openAndMigrate();
expect(_columnsOf(raw, 'scripts'), contains('url'));
expect(_userVersion(raw), 10);
final scripts = await database.scriptsDao.query().get();
expect(scripts.single.label, 'Local');
expect(scripts.single.url, isNull);
});
test('the v5 upgrade adds order to scripts and keeps id order', () async {
_downgradeToV4(raw);
raw.execute(
'INSERT INTO scripts (id, label, last_update_time) '
"VALUES (2, 'Second', 0), (1, 'First', 0)",
);
expect(_columnsOf(raw, 'scripts'), isNot(contains('order')));
final database = await openAndMigrate();
expect(_columnsOf(raw, 'scripts'), contains('order'));
expect(_userVersion(raw), 10);
final scripts = await database.scriptsDao.query().get();
expect(scripts.map((item) => item.label), ['First', 'Second']);
expect(scripts.map((item) => item.order), [null, null]);
});
test(
@@ -98,7 +183,89 @@ void main() {
await openAndMigrate();
expect(_columnsOf(raw, 'profiles'), contains('match_target'));
expect(_userVersion(raw), 3);
expect(_userVersion(raw), 10);
},
);
test('the v6 upgrade creates clash_providers', () async {
_downgradeToV5(raw);
expect(_hasTable(raw, 'clash_providers'), isFalse);
final database = await openAndMigrate();
expect(_hasTable(raw, 'clash_providers'), isTrue);
expect(_userVersion(raw), 10);
expect(
await database.clashProvidersDao.query(ProviderKind.proxy).get(),
isEmpty,
);
});
test(
'a v5 user_version with clash_providers already present still opens',
() async {
raw.execute('PRAGMA user_version = 5');
expect(_hasTable(raw, 'clash_providers'), isTrue);
await openAndMigrate();
expect(_hasTable(raw, 'clash_providers'), isTrue);
expect(_userVersion(raw), 10);
},
);
test('the v7 upgrade adds tolerance and strategy to proxy_groups', () async {
_downgradeToV6(raw);
expect(_columnsOf(raw, 'proxy_groups'), isNot(contains('tolerance')));
await openAndMigrate();
expect(
_columnsOf(raw, 'proxy_groups'),
containsAll(['tolerance', 'strategy']),
);
expect(_userVersion(raw), 10);
});
test(
'a v6 user_version with the v7 columns already present still opens',
() async {
raw.execute('PRAGMA user_version = 6');
expect(_columnsOf(raw, 'proxy_groups'), contains('strategy'));
await openAndMigrate();
expect(_columnsOf(raw, 'proxy_groups'), contains('strategy'));
expect(_userVersion(raw), 10);
},
);
test(
'the v8 upgrade drops the per-use columns from clash_providers',
() async {
_downgradeToV7(raw);
raw.execute(
'INSERT INTO clash_providers '
'(id, kind, label, url, behavior, format, "order", interval, filter, '
'exclude_filter) '
"VALUES (1, 'rule', 'Kept', '', 'classical', 'yaml', 0, 300, 'a', 'b')",
);
expect(
_columnsOf(raw, 'clash_providers'),
containsAll(['interval', 'filter', 'exclude_filter']),
);
final database = await openAndMigrate();
expect(
_columnsOf(raw, 'clash_providers'),
isNot(anyOf(contains('interval'), contains('filter'))),
);
expect(_userVersion(raw), 10);
expect(
(await database.clashProvidersDao.queryAll().get()).single.label,
'Kept',
);
},
);
@@ -144,6 +311,10 @@ void main() {
'INSERT INTO rules (id, value) '
"VALUES (2, 'IP-CIDR,10.0.0.0/8,REJECT,no-resolve')",
);
raw.execute(
'INSERT INTO profile_rule_mapping (id, rule_id) '
"VALUES ('1', 1), ('2', 2)",
);
final database = await openAndMigrate();
final rows = await database
@@ -168,12 +339,59 @@ void main() {
);
});
test('the v9 upgrade drops rows orphaned by earlier deletes', () async {
_downgradeToV8(raw);
raw.execute('PRAGMA foreign_keys = OFF');
raw.execute(
"INSERT INTO rules (id, rule_action) VALUES (1, 'DOMAIN'), "
"(2, 'DOMAIN'), (3, 'DOMAIN')",
);
raw.execute(
'INSERT INTO profile_rule_mapping (id, profile_id, rule_id) '
"VALUES ('global', NULL, 1), ('gone_profile', 99, 2), "
"('gone_rule', NULL, 404)",
);
raw.execute(
'INSERT INTO proxy_groups (id, profile_id, name, type) '
"VALUES (1, NULL, 'Global', 'select'), (2, 99, 'Gone', 'select')",
);
await openAndMigrate();
List<Object?> ids(String table) => [
for (final row in raw.select('SELECT id FROM $table ORDER BY id'))
row['id'],
];
expect(ids('rules'), [1]);
expect(ids('profile_rule_mapping'), ['global']);
expect(ids('proxy_groups'), [1]);
expect(raw.select('PRAGMA foreign_keys').single['foreign_keys'], 1);
});
test('the v10 upgrade creates custom_proxies', () async {
_downgradeToV9(raw);
expect(_hasTable(raw, 'custom_proxies'), isFalse);
final database = await openAndMigrate();
expect(_hasTable(raw, 'custom_proxies'), isTrue);
expect(
raw.select(
"SELECT name FROM sqlite_master WHERE type='index' AND name=?",
['idx_custom_proxies_profile_order'],
),
isNotEmpty,
);
expect(_userVersion(raw), 10);
expect(await database.customProxiesDao.query(1).get(), isEmpty);
});
test('an empty v1 rules table still reaches v2', () async {
_downgradeToV1(raw);
final database = await openAndMigrate();
expect(_userVersion(raw), 3);
expect(_userVersion(raw), 10);
expect(await database.customSelect('SELECT * FROM rules').get(), isEmpty);
});
@@ -183,7 +401,7 @@ void main() {
await openAndMigrate();
expect(_columnsOf(raw, 'rules'), before);
expect(_userVersion(raw), 3);
expect(_userVersion(raw), 10);
expect(_hasTable(raw, 'proxy_groups'), isTrue);
});
}
+199 -47
View File
@@ -2,22 +2,18 @@ import 'dart:convert';
import 'dart:io';
import 'package:drift/native.dart';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/database/database.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/action.dart';
import 'package:fl_clash/providers/config.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:flutter/widgets.dart' show Locale;
import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart';
import 'package:riverpod/riverpod.dart';
class _StubbedArchiveBackupAction extends BackupAction {
_StubbedArchiveBackupAction(this.archivePath);
final String archivePath;
@override
Future<String> backup() async => archivePath;
}
import 'package:shared_preferences/shared_preferences.dart';
Profile _profile(int id, String label) => Profile(
id: id,
@@ -30,6 +26,21 @@ void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late Database testDatabase;
late Directory home;
setUpAll(() async {
SharedPreferences.setMockInitialValues({'version': 1});
await AppLocalizations.load(const Locale('en'));
home = Directory.systemTemp.createTempSync('flclash-backup-action-');
AppPath.supportDirectory = () async => home;
AppPath.temporaryDirectory = () async => home;
AppPath.cacheDirectory = () async => home;
AppPath.downloadDirectory = () async => home;
});
tearDownAll(() {
if (home.existsSync()) home.deleteSync(recursive: true);
});
setUp(() {
testDatabase = Database(NativeDatabase.memory());
@@ -53,54 +64,113 @@ void main() {
jsonDecode(jsonEncode(container.read(configProvider).toJson()))
as Map<String, Object?>;
group('consumeBackup owns the archive it produced', () {
File stubArchive() {
final directory = Directory.systemTemp.createTempSync('flclash_backup');
addTearDown(() => directory.deleteSync(recursive: true));
final file = File('${directory.path}/backup.zip')
..writeAsBytesSync(const [80, 75, 5, 6]);
return file;
}
group('backup and restore round trip', () {
late Directory kept;
ProviderContainer containerFor(String archivePath) {
final container = ProviderContainer(
overrides: [
backupActionProvider.overrideWith(
() => _StubbedArchiveBackupAction(archivePath),
),
],
);
addTearDown(container.dispose);
return container;
}
test('deletes the archive once it has been sent', () async {
final archive = stubArchive();
final container = containerFor(archive.path);
String? sent;
final result = await actionOf(container).consumeBackup((path) async {
sent = path;
return true;
});
expect(result, isTrue);
expect(sent, archive.path);
expect(archive.existsSync(), isFalse);
setUp(() {
kept = Directory.systemTemp.createTempSync('flclash-backup-kept-');
addTearDown(() => kept.deleteSync(recursive: true));
});
test('deletes the archive when sending it fails', () async {
final archive = stubArchive();
final container = containerFor(archive.path);
Set<String> workDirs() => home
.listSync()
.map((entity) => basename(entity.path))
.where((name) => name.startsWith('backup'))
.toSet();
Future<String> backupToKept(BackupAction action) async {
final keptPath = join(kept.path, 'kept.zip');
final delivered = await action.backup((archivePath) async {
File(archivePath).copySync(keptPath);
return true;
});
expect(delivered, isTrue);
return keptPath;
}
test('restores the rows, files and settings a backup carries', () async {
await testDatabase.profilesDao.putAll([
_profile(1, 'Backed up').toCompanion(0),
]);
final profileFile = File(await appPath.getProfilePath('1'))
..createSync(recursive: true)
..writeAsStringSync('proxies: []');
final source = buildContainer()..listen(configProvider, (_, _) {});
source.read(excludeSSIDsProvider.notifier).value = ['home-wifi'];
final archivePath = await backupToKept(actionOf(source));
await testDatabase.delete(testDatabase.profiles).go();
profileFile.deleteSync();
final target = buildContainer()..listen(configProvider, (_, _) {});
final restored = await actionOf(
target,
).restore(RestoreOption.all, (_) async => archivePath);
expect(restored, isTrue);
final stored = await testDatabase.profilesDao.query().get();
expect(stored.map((item) => item.label), ['Backed up']);
expect(profileFile.readAsStringSync(), 'proxies: []');
expect(target.read(excludeSSIDsProvider), ['home-wifi']);
expect(workDirs(), isEmpty);
});
test('removes the work directory when delivery fails', () async {
final container = buildContainer();
await expectLater(
actionOf(
container,
).consumeBackup((_) async => throw const SocketException('offline')),
).backup((_) async => throw const SocketException('offline')),
throwsA(isA<SocketException>()),
);
expect(archive.existsSync(), isFalse);
expect(workDirs(), isEmpty);
});
test('a cancelled fetch changes nothing', () async {
final container = buildContainer();
final restored = await actionOf(
container,
).restore(RestoreOption.all, (_) async => null);
expect(restored, isFalse);
});
test('an unreadable archive is reported and changes nothing', () async {
await testDatabase.profilesDao.putAll([
_profile(9, 'Pre-existing').toCompanion(0),
]);
final bogus = File(join(kept.path, 'bogus.zip'))
..writeAsStringSync('not a zip');
final container = buildContainer();
await expectLater(
actionOf(container).restore(RestoreOption.all, (_) async => bogus.path),
throwsA(
isA<MessageException>().having(
(error) => error.message,
'message',
currentAppLocalizations.invalidBackupFile,
),
),
);
final stored = await testDatabase.profilesDao.query().get();
expect(stored.map((item) => item.label), ['Pre-existing']);
expect(workDirs(), isEmpty);
});
test('removes the staging an earlier version left behind', () async {
final legacyArchive = File(join(home.path, 'backup.zip'))
..writeAsStringSync('stale');
final legacyStaging = Directory(join(home.path, 'restore'))..createSync();
final container = buildContainer();
await actionOf(container).restore(RestoreOption.all, (_) async => null);
expect(legacyArchive.existsSync(), isFalse);
expect(legacyStaging.existsSync(), isFalse);
});
});
@@ -162,7 +232,74 @@ void main() {
});
});
test('a restore drops the cache of a provider it replaced', () async {
const dropped = ClashProvider(
id: 5,
kind: ProviderKind.rule,
label: 'Dropped',
url: 'https://example.com/dropped.yaml',
);
const kept = ClashProvider(
id: 6,
kind: ProviderKind.proxy,
label: 'Kept',
);
await testDatabase.clashProvidersDao.putAll(
[dropped, kept].map((item) => item.toCompanion()),
);
await dropped.saveContent('payload: []'.codeUnits);
await kept.saveContent('proxies: []'.codeUnits);
final container = buildContainer();
container
.read(appSettingProvider.notifier)
.update(
(state) =>
state.copyWith(restoreStrategy: RestoreStrategy.override),
);
await actionOf(container).applyRestore(
const MigrationData(clashProviders: [kept]),
RestoreOption.onlyProfiles,
);
expect(File(await dropped.path).existsSync(), isFalse);
expect(File(await kept.path).existsSync(), isTrue);
});
test('rows that fail to restore take their files back out', () async {
final replaced = File(await appPath.getProfilePath('41'))
..createSync(recursive: true)
..writeAsStringSync('current');
final added = File(await appPath.getProfilePath('42'));
final staging = Directory.systemTemp.createTempSync('flclash-staging-');
addTearDown(() => staging.deleteSync(recursive: true));
for (final id in [41, 42]) {
File(BackupEntries.resolve(staging.path, BackupEntries.profile(id)))
..createSync(recursive: true)
..writeAsStringSync('from backup');
}
await testDatabase.customStatement(
'CREATE TRIGGER refuse_profiles BEFORE INSERT ON profiles '
"BEGIN SELECT RAISE(ABORT, 'refused'); END",
);
await expectLater(
actionOf(buildContainer()).applyRestore(
MigrationData(profiles: [_profile(41, 'One'), _profile(42, 'Two')]),
RestoreOption.onlyProfiles,
stagingDirPath: staging.path,
),
throwsA(anything),
);
expect(replaced.readAsStringSync(), 'current');
expect(added.existsSync(), isFalse);
});
test('a backup carrying only proxy groups still writes them', () async {
await testDatabase.profiles.put(
const Profile(id: 7, autoUpdateDuration: Duration.zero).toCompanion(),
);
final container = buildContainer();
await actionOf(container).applyRestore(
@@ -201,6 +338,7 @@ void main() {
.read(patchClashConfigProvider.notifier)
.update((state) => state.copyWith(mixedPort: 7899));
source.read(overrideDnsProvider.notifier).value = true;
source.read(overrideNtpProvider.notifier).value = true;
final configMap = configMapOf(source);
final target = buildContainer();
@@ -212,6 +350,20 @@ void main() {
expect(target.read(appSettingProvider).autoLaunch, isTrue);
expect(target.read(patchClashConfigProvider).mixedPort, 7899);
expect(target.read(overrideDnsProvider), isTrue);
expect(target.read(overrideNtpProvider), isTrue);
});
test('keeps the bound WebDAV account when the backup has none', () async {
final configMap = configMapOf(buildContainer());
const dav = DAVProps(uri: 'https://dav.example', user: 'me');
final target = buildContainer();
target.read(davSettingProvider.notifier).value = dav;
await actionOf(
target,
).applyRestore(MigrationData(configMap: configMap), RestoreOption.all);
expect(target.read(davSettingProvider), dav);
});
test('leaves the settings untouched for an onlyProfiles restore', () async {
+73 -11
View File
@@ -132,6 +132,23 @@ void main() {
},
);
test('put steps past a label an app-level proxy provider holds', () async {
await testDatabase.clashProvidersDao.putAll([
const ClashProvider(
id: 9,
kind: ProviderKind.proxy,
label: 'Shared nodes',
url: 'https://example.com/nodes.yaml',
).toCompanion(),
]);
await keepAlive(clashProvidersProvider(ProviderKind.proxy));
notifier.put(profile(1, label: 'Shared nodes'));
await pumpEventQueue();
expect(read().single.label, 'Shared nodes(1)');
});
test('put falls back to the id when the profile has no label', () async {
// Profile.normal() leaves the label empty when the download exposed no
// filename, and optimizeLabel is the only thing that names it.
@@ -296,6 +313,23 @@ void main() {
List<Script> read() => notifier.value;
test('order persists the new positions and lists by them', () async {
notifier.put(script(1, 'First'));
await pumpEventQueue();
notifier.put(script(2, 'Second'));
await pumpEventQueue();
notifier.put(script(3, 'Third'));
await pumpEventQueue();
notifier.order(2, 0);
await pumpEventQueue();
expect(read().map((item) => item.label), ['Third', 'First', 'Second']);
final rows = await testDatabase.scriptsDao.query().get();
expect(rows.map((item) => item.label), ['Third', 'First', 'Second']);
expect(rows.map((item) => item.order), [0, 1, 2]);
});
test('put appends a new script and replaces an existing one', () async {
notifier.put(script(1, 'First'));
await pumpEventQueue();
@@ -358,12 +392,17 @@ void main() {
expect(read().map((item) => item.id), [2], reason: 'rolled back');
});
test('isExits matches on label', () async {
notifier.put(script(1, 'Known'));
test('put persists the source url and clears it again', () async {
const url = 'https://example.com/override.js';
notifier.put(script(1, 'Remote').copyWith(url: url));
await pumpEventQueue();
expect(notifier.isExits('Known'), isTrue);
expect(notifier.isExits('Unknown'), isFalse);
expect((await testDatabase.scriptsDao.get(1).getSingle()).url, url);
notifier.put(script(1, 'Remote'));
await pumpEventQueue();
expect((await testDatabase.scriptsDao.get(1).getSingle()).url, isNull);
});
});
@@ -403,6 +442,24 @@ void main() {
expect(rows.map((item) => item.id), [2, 1]);
});
test(
'putAll puts the batch ahead of existing rules in its order',
() async {
notifier.put(const Rule(id: 1, content: 'existing'));
await pumpEventQueue();
notifier.putAll(const [
Rule(id: 2, content: 'second'),
Rule(id: 3, content: 'third'),
]);
expect(read().map((item) => item.id), [2, 3, 1], reason: 'optimistic');
await pumpEventQueue();
final rows = await testDatabase.rulesDao.queryGlobalAddedRules().get();
expect(rows.map((item) => item.id), [2, 3, 1]);
},
);
test('delAll removes every listed rule', () async {
notifier.put(const Rule(id: 1, content: 'first'));
await pumpEventQueue();
@@ -719,15 +776,20 @@ void main() {
);
});
test('del removes the group', () async {
test('delAll removes every listed group', () async {
expect(notifier.put(group(1, 'Gone')), isTrue);
await pumpEventQueue();
notifier.del('Gone');
expect(notifier.put(group(2, 'AlsoGone')), isTrue);
await pumpEventQueue();
expect(notifier.put(group(3, 'Kept')), isTrue);
await pumpEventQueue();
expect(read(), isEmpty);
expect(await testDatabase.proxyGroupsDao.query(profileId).get(), isEmpty);
notifier.delAll([1, 2]);
await pumpEventQueue();
expect(read().map((item) => item.id), [3]);
final rows = await testDatabase.proxyGroupsDao.query(profileId).get();
expect(rows.map((item) => item.id), [3]);
});
test('order moves a group and persists the new key', () async {
@@ -744,12 +806,12 @@ void main() {
expect(rows.map((item) => item.id), before.reversed);
});
test('del restores the previous list when the write fails', () async {
test('delAll restores the previous list when the write fails', () async {
expect(notifier.put(group(1, 'Kept')), isTrue);
await pumpEventQueue();
await breakTable('proxy_groups');
final failure = captureWriteFailure(() => notifier.del('Kept'));
final failure = captureWriteFailure(() => notifier.delAll([1]));
expect(read(), isEmpty, reason: 'optimistic');
await failure;
+13 -11
View File
@@ -1,4 +1,5 @@
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/app.dart';
import 'package:fl_clash/providers/config.dart';
@@ -9,6 +10,7 @@ import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import '../helpers/glyph_finders.dart';
import '../helpers/test_app.dart';
import '../helpers/test_profiles.dart';
@@ -124,7 +126,7 @@ void main() {
await pumpDialog(tester, const WebDAVFormDialog());
final toggle = find.descendant(
of: find.widgetWithIcon(TextFormField, Icons.password),
of: find.widgetWithGlyph(TextFormField, AppGlyphs.password),
matching: find.byType(IconButton),
);
expect(tester.widget<IconButton>(toggle).tooltip, 'Show password');
@@ -139,15 +141,15 @@ void main() {
await pumpDialog(tester, const WebDAVFormDialog());
await tester.enterText(
find.widgetWithIcon(TextFormField, Icons.link),
find.widgetWithGlyph(TextFormField, AppGlyphs.link),
'not-a-url',
);
await tester.enterText(
find.widgetWithIcon(TextFormField, Icons.account_circle),
find.widgetWithGlyph(TextFormField, AppGlyphs.account),
'alice',
);
await tester.enterText(
find.widgetWithIcon(TextFormField, Icons.password),
find.widgetWithGlyph(TextFormField, AppGlyphs.password),
'secret',
);
await tester.tap(find.widgetWithText(TextButton, 'Save'));
@@ -162,15 +164,15 @@ void main() {
await pumpDialog(tester, const WebDAVFormDialog());
await tester.enterText(
find.widgetWithIcon(TextFormField, Icons.link),
find.widgetWithGlyph(TextFormField, AppGlyphs.link),
'https://dav.example.com/remote',
);
await tester.enterText(
find.widgetWithIcon(TextFormField, Icons.account_circle),
find.widgetWithGlyph(TextFormField, AppGlyphs.account),
'alice',
);
await tester.enterText(
find.widgetWithIcon(TextFormField, Icons.password),
find.widgetWithGlyph(TextFormField, AppGlyphs.password),
'secret',
);
await tester.tap(find.widgetWithText(TextButton, 'Save'));
@@ -190,7 +192,7 @@ void main() {
await pumpDialog(tester, const WebDAVFormDialog(dav: _existing));
await tester.enterText(
find.widgetWithIcon(TextFormField, Icons.account_circle),
find.widgetWithGlyph(TextFormField, AppGlyphs.account),
'bob',
);
await tester.tap(find.widgetWithText(TextButton, 'Save'));
@@ -224,10 +226,10 @@ void main() {
testWidgets('toggles password visibility', (tester) async {
await pumpDialog(tester, const WebDAVFormDialog(dav: _existing));
expect(find.byIcon(Icons.visibility), findsOneWidget);
await tester.tap(find.byIcon(Icons.visibility));
expect(find.byGlyph(AppGlyphs.eye), findsOneWidget);
await tester.tap(find.byGlyph(AppGlyphs.eye));
await tester.pumpAndSettle();
expect(find.byIcon(Icons.visibility_off), findsOneWidget);
expect(find.byGlyph(AppGlyphs.eyeOff), findsOneWidget);
});
});
}