Files
FlClash/lib/providers/actions/backup.dart
T
chen08209 bee8f24269 fix(backup,database): stage restores and enforce foreign keys
Changelog: Restores never apply halfway
2026-10-02 02:51:32 +08:00

188 lines
6.0 KiB
Dart

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> 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 run(workDir.path);
} finally {
await workDir.safeDelete(recursive: true);
await _discardLegacyStaging();
}
}
/// 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();
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, {
String? stagingDirPath,
}) async {
final configMap = data.configMap;
final config = option == RestoreOption.onlyProfiles || configMap == null
? null
: Config.fromJson(configMap);
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;
}
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();
}
}
}