fix(backup,database): stage restores and enforce foreign keys
Changelog: Restores never apply halfway
This commit is contained in:
@@ -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,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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ class _AppMigrationStore implements MigrationStore {
|
||||
data.rules,
|
||||
data.links,
|
||||
data.proxyGroups,
|
||||
clashProviders: data.clashProviders,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user