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;
|
||||
|
||||
Reference in New Issue
Block a user