opt.: split webdav & other settings (#569)

This commit is contained in:
lollipopkit🏳️‍⚧️
2024-08-31 21:45:09 +08:00
committed by GitHub
parent 7f0dc656b8
commit edb49ead67
10 changed files with 122 additions and 405 deletions

40
lib/core/sync.dart Normal file
View File

@@ -0,0 +1,40 @@
import 'dart:io';
import 'package:fl_lib/fl_lib.dart';
import 'package:server_box/data/model/app/backup.dart';
import 'package:server_box/data/store/no_backup.dart';
const sync = Sync._();
final class Sync extends SyncCfg {
const Sync._() : super();
@override
Future<void> saveToFile() => Backup.backup();
@override
Future<Mergeable> fromFile(String path) async {
final content = await File(path).readAsString();
return Backup.fromJsonString(content);
}
@override
Future<RemoteStorage?> get remoteStorage async {
if (isMacOS || isIOS) await icloud.init('iCloud.tech.lolli.serverbox');
final settings = NoBackupStore.instance;
await webdav.init(WebdavInitArgs(
url: settings.webdavUrl.fetch(),
user: settings.webdavUser.fetch(),
pwd: settings.webdavPwd.fetch(),
prefix: 'serverbox/',
));
final icloudEnabled = settings.icloudSync.fetch();
if (icloudEnabled) return icloud;
final webdavEnabled = settings.webdavSync.fetch();
if (webdavEnabled) return webdav;
return null;
}
}

View File

@@ -1,224 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'package:computer/computer.dart';
import 'package:fl_lib/fl_lib.dart';
import 'package:icloud_storage/icloud_storage.dart';
import 'package:logging/logging.dart';
import 'package:server_box/data/model/app/backup.dart';
import 'package:server_box/data/model/app/sync.dart';
import 'package:server_box/data/res/misc.dart';
import 'package:server_box/data/model/app/error.dart';
abstract final class ICloud {
static const _containerId = 'iCloud.tech.lolli.serverbox';
static final _logger = Logger('iCloud');
/// Upload file to iCloud
///
/// - [relativePath] is the path relative to [Paths.doc],
/// must not starts with `/`
/// - [localPath] has higher priority than [relativePath], but only apply
/// to the local path instead of iCloud path
///
/// Return [null] if upload success, [ICloudErr] otherwise
static Future<ICloudErr?> upload({
required String relativePath,
String? localPath,
}) async {
final completer = Completer<ICloudErr?>();
try {
await ICloudStorage.upload(
containerId: _containerId,
filePath: localPath ?? '${Paths.doc}/$relativePath',
destinationRelativePath: relativePath,
onProgress: (stream) {
stream.listen(
null,
onDone: () => completer.complete(null),
onError: (e) => completer.complete(
ICloudErr(type: ICloudErrType.generic, message: '$e'),
),
);
},
);
} catch (e, s) {
_logger.warning('Upload $relativePath failed', e, s);
completer.complete(ICloudErr(type: ICloudErrType.generic, message: '$e'));
}
return completer.future;
}
static Future<List<ICloudFile>> getAll() async {
return await ICloudStorage.gather(
containerId: _containerId,
);
}
static Future<void> delete(String relativePath) async {
try {
await ICloudStorage.delete(
containerId: _containerId,
relativePath: relativePath,
);
} catch (e, s) {
_logger.warning('Delete $relativePath failed', e, s);
}
}
/// Download file from iCloud
///
/// - [relativePath] is the path relative to [Paths.doc],
/// must not starts with `/`
/// - [localPath] has higher priority than [relativePath], but only apply
/// to the local path instead of iCloud path
///
/// Return `null` if upload success, [ICloudErr] otherwise
static Future<ICloudErr?> download({
required String relativePath,
String? localPath,
}) async {
final completer = Completer<ICloudErr?>();
try {
await ICloudStorage.download(
containerId: _containerId,
relativePath: relativePath,
destinationFilePath: localPath ?? '${Paths.doc}/$relativePath',
onProgress: (stream) {
stream.listen(
null,
onDone: () => completer.complete(null),
onError: (e) => completer.complete(
ICloudErr(type: ICloudErrType.generic, message: '$e'),
),
);
},
);
} catch (e, s) {
_logger.warning('Download $relativePath failed', e, s);
completer.complete(ICloudErr(type: ICloudErrType.generic, message: '$e'));
}
return completer.future;
}
/// Sync file between iCloud and local
///
/// - [relativePaths] is the path relative to [Paths.doc],
/// must not starts with `/`
/// - [bakPrefix] is the suffix of backup file, default to [null].
/// All files downloaded from cloud will be suffixed with [bakPrefix].
///
/// Return `null` if upload success, [ICloudErr] otherwise
static Future<SyncResult<String, ICloudErr>> syncFiles({
required Iterable<String> relativePaths,
String? bakPrefix,
}) async {
final uploadFiles = <String>[];
final downloadFiles = <String>[];
try {
final errs = <String, ICloudErr>{};
final allFiles = await getAll();
/// remove files not in relativePaths
allFiles.removeWhere((e) => !relativePaths.contains(e.relativePath));
final missions = <Future<void>>[];
/// upload files not in iCloud
final missed = relativePaths.where((e) {
return !allFiles.any((f) => f.relativePath == e);
});
missions.addAll(missed.map((e) async {
final err = await upload(relativePath: e);
if (err != null) {
errs[e] = err;
}
}));
final docPath = Paths.doc;
/// compare files in iCloud and local
missions.addAll(allFiles.map((file) async {
final relativePath = file.relativePath;
/// Check date
final localFile = File('$docPath/$relativePath');
if (!localFile.existsSync()) {
/// Local file not found, download remote file
final err = await download(relativePath: relativePath);
if (err != null) {
errs[relativePath] = err;
}
return;
}
final localDate = await localFile.lastModified();
final remoteDate = file.contentChangeDate;
/// Same date, skip
if (remoteDate.difference(localDate) == Duration.zero) return;
/// Local is newer than remote, so upload local file
if (remoteDate.isBefore(localDate)) {
await delete(relativePath);
final err = await upload(relativePath: relativePath);
if (err != null) {
errs[relativePath] = err;
}
uploadFiles.add(relativePath);
return;
}
/// Remote is newer than local, so download remote
final localPath = '$docPath/${bakPrefix ?? ''}$relativePath';
final err = await download(
relativePath: relativePath,
localPath: localPath,
);
if (err != null) {
errs[relativePath] = err;
}
downloadFiles.add(relativePath);
}));
await Future.wait(missions);
return SyncResult(up: uploadFiles, down: downloadFiles, err: errs);
} catch (e, s) {
_logger.warning('Sync: $relativePaths failed', e, s);
return SyncResult(up: uploadFiles, down: downloadFiles, err: {
'Generic': ICloudErr(type: ICloudErrType.generic, message: '$e')
});
} finally {
_logger.info('Sync, up: $uploadFiles, down: $downloadFiles');
}
}
static Future<void> sync() async {
final result = await download(relativePath: Miscs.bakFileName);
if (result != null) {
await backup();
return;
}
final dlFile = await File(Paths.bak).readAsString();
final dlBak = await Computer.shared.start(Backup.fromJsonString, dlFile);
await dlBak.restore();
await backup();
}
static Future<void> backup() async {
await Backup.backup();
final uploadResult = await upload(relativePath: Miscs.bakFileName);
if (uploadResult != null) {
_logger.warning('Upload backup failed: $uploadResult');
} else {
_logger.info('Upload backup success');
}
}
}

View File

@@ -1,127 +0,0 @@
import 'dart:io';
import 'package:computer/computer.dart';
import 'package:fl_lib/fl_lib.dart';
import 'package:logging/logging.dart';
import 'package:server_box/data/model/app/backup.dart';
import 'package:server_box/data/model/app/error.dart';
import 'package:server_box/data/res/misc.dart';
import 'package:server_box/data/res/store.dart';
import 'package:webdav_client/webdav_client.dart';
abstract final class Webdav {
/// Some WebDAV provider only support non-root path
static const _prefix = 'srvbox/';
static var _client = WebdavClient(
url: Stores.setting.webdavUrl.fetch(),
user: Stores.setting.webdavUser.fetch(),
pwd: Stores.setting.webdavPwd.fetch(),
);
static final _logger = Logger('Webdav');
static Future<String?> test(String url, String user, String pwd) async {
final client = WebdavClient(url: url, user: user, pwd: pwd);
try {
await client.ping();
return null;
} catch (e, s) {
_logger.warning('Test failed', e, s);
return e.toString();
}
}
static Future<WebdavErr?> upload({
required String relativePath,
String? localPath,
}) async {
try {
await _client.writeFile(
localPath ?? '${Paths.doc}/$relativePath',
_prefix + relativePath,
);
} catch (e, s) {
_logger.warning('Upload $relativePath failed', e, s);
return WebdavErr(type: WebdavErrType.generic, message: '$e');
}
return null;
}
static Future<WebdavErr?> delete(String relativePath) async {
try {
await _client.remove(_prefix + relativePath);
} catch (e, s) {
_logger.warning('Delete $relativePath failed', e, s);
return WebdavErr(type: WebdavErrType.generic, message: '$e');
}
return null;
}
static Future<WebdavErr?> download({
required String relativePath,
String? localPath,
}) async {
try {
await _client.readFile(
_prefix + relativePath,
localPath ?? '${Paths.doc}/$relativePath',
);
} catch (e) {
_logger.warning('Download $relativePath failed');
return WebdavErr(type: WebdavErrType.generic, message: '$e');
}
return null;
}
static Future<List<String>> list() async {
try {
final list = await _client.readDir(_prefix);
final names = <String>[];
for (final item in list) {
if ((item.isDir ?? true) || item.name == null) continue;
names.add(item.name!);
}
return names;
} catch (e, s) {
_logger.warning('List failed', e, s);
return [];
}
}
static void changeClient(String url, String user, String pwd) {
_client = WebdavClient(url: url, user: user, pwd: pwd);
Stores.setting.webdavUrl.put(url);
Stores.setting.webdavUser.put(user);
Stores.setting.webdavPwd.put(pwd);
}
static Future<void> sync() async {
final result = await download(relativePath: Miscs.bakFileName);
if (result != null) {
await backup();
return;
}
try {
final dlFile = await File(Paths.bak).readAsString();
final dlBak = await Computer.shared.start(Backup.fromJsonString, dlFile);
await dlBak.restore();
} catch (e) {
_logger.warning('Restore failed: $e');
}
await backup();
}
/// Create a local backup and upload it to WebDAV
static Future<void> backup() async {
await Backup.backup();
final uploadResult = await upload(relativePath: Miscs.bakFileName);
if (uploadResult != null) {
_logger.warning('Upload failed: $uploadResult');
} else {
_logger.info('Upload success');
}
}
}

View File

@@ -18,7 +18,7 @@ const backupFormatVersion = 1;
final _logger = Logger('Backup');
@JsonSerializable()
class Backup {
class Backup extends Mergeable {
// backup format version
final int version;
final String date;
@@ -28,8 +28,9 @@ class Backup {
final Map<String, dynamic> container;
final Map<String, dynamic> history;
final int? lastModTime;
final Map<String, dynamic> settings;
const Backup({
Backup({
required this.version,
required this.date,
required this.spis,
@@ -37,6 +38,7 @@ class Backup {
required this.keys,
required this.container,
required this.history,
required this.settings,
this.lastModTime,
});
@@ -52,7 +54,8 @@ class Backup {
keys = Stores.key.fetch(),
container = Stores.container.box.toJson(),
lastModTime = Stores.lastModTime,
history = Stores.history.box.toJson();
history = Stores.history.box.toJson(),
settings = Stores.setting.box.toJson();
static Future<String> backup([String? name]) async {
final result = _diyEncrypt(json.encode(Backup.loadFromStore().toJson()));
@@ -61,7 +64,8 @@ class Backup {
return path;
}
Future<void> restore({bool force = false}) async {
@override
Future<void> merge({bool force = false}) async {
final curTime = Stores.lastModTime ?? 0;
final bakTime = lastModTime ?? 0;
final shouldRestore = force || curTime < bakTime;
@@ -176,6 +180,26 @@ class Backup {
}
}
// Settings
if (force) {
Stores.setting.box.putAll(settings);
} else {
final nowSettings = Stores.setting.box.keys.toSet();
final bakSettings = settings.keys.toSet();
final newSettings = bakSettings.difference(nowSettings);
final delSettings = nowSettings.difference(bakSettings);
final updateSettings = nowSettings.intersection(bakSettings);
for (final s in newSettings) {
Stores.setting.box.put(s, settings[s]);
}
for (final s in delSettings) {
Stores.setting.box.delete(s);
}
for (final s in updateSettings) {
Stores.setting.box.put(s, settings[s]);
}
}
Provider.reload();
RNodes.app.notify();

View File

@@ -0,0 +1,16 @@
import 'package:fl_lib/fl_lib.dart';
final class NoBackupStore extends PersistentStore {
NoBackupStore._() : super('no_backup');
static final instance = NoBackupStore._();
/// Only valid on iOS and macOS
late final icloudSync = property('icloudSync', false);
/// Webdav sync
late final webdavSync = property('webdavSync', false);
late final webdavUrl = property('webdavUrl', '');
late final webdavUser = property('webdavUser', '');
late final webdavPwd = property('webdavPwd', '');
}

View File

@@ -125,8 +125,6 @@ class SettingStore extends PersistentStore {
/// Whether use system's primary color as the app's primary color
late final useSystemPrimaryColor = property('useSystemPrimaryColor', false);
/// Only valid on iOS and macOS
late final icloudSync = property('icloudSync', false);
/// Only valid on iOS / Android / Windows
late final useBioAuth = property('useBioAuth', false);
@@ -143,12 +141,6 @@ class SettingStore extends PersistentStore {
/// Show tip of suspend
late final showSuspendTip = property('showSuspendTip', true);
/// Webdav sync
late final webdavSync = property('webdavSync', false);
late final webdavUrl = property('webdavUrl', '', updateLastModified: false);
late final webdavUser = property('webdavUser', '', updateLastModified: false);
late final webdavPwd = property('webdavPwd', '', updateLastModified: false);
/// Whether collapse UI items by default
late final collapseUIDefault = property('collapseUIDefault', true);

View File

@@ -10,8 +10,7 @@ import 'package:hive_flutter/hive_flutter.dart';
import 'package:logging/logging.dart';
import 'package:server_box/app.dart';
import 'package:server_box/core/channel/bg_run.dart';
import 'package:server_box/core/utils/sync/icloud.dart';
import 'package:server_box/core/utils/sync/webdav.dart';
import 'package:server_box/core/sync.dart';
import 'package:server_box/data/model/app/menu/server_func.dart';
import 'package:server_box/data/model/app/net_view.dart';
import 'package:server_box/data/model/app/server_detail_card.dart';
@@ -28,7 +27,6 @@ import 'package:server_box/data/provider/snippet.dart';
import 'package:server_box/data/res/build_data.dart';
import 'package:server_box/data/res/misc.dart';
import 'package:server_box/data/res/store.dart';
Future<void> main() async {
_runInZone(() async {
await _initApp();
@@ -120,10 +118,7 @@ void _doPlatformRelated() async {
// Plus 1 to avoid 0.
Computer.shared.turnOn(workersCount: (serversCount / 3).round() + 1);
if (isIOS || isMacOS) {
if (Stores.setting.icloudSync.fetch()) ICloud.sync();
}
if (Stores.setting.webdavSync.fetch()) Webdav.sync();
sync.sync();
}
// It may contains some async heavy funcs.

View File

@@ -5,8 +5,7 @@ import 'package:computer/computer.dart';
import 'package:fl_lib/fl_lib.dart';
import 'package:flutter/material.dart';
import 'package:server_box/core/extension/context/locale.dart';
import 'package:server_box/core/utils/sync/icloud.dart';
import 'package:server_box/core/utils/sync/webdav.dart';
import 'package:server_box/core/sync.dart';
import 'package:server_box/data/model/app/backup.dart';
import 'package:server_box/data/model/server/server_private_info.dart';
import 'package:server_box/data/model/server/snippet.dart';
@@ -14,10 +13,13 @@ import 'package:server_box/data/provider/snippet.dart';
import 'package:server_box/data/res/misc.dart';
import 'package:server_box/data/res/store.dart';
import 'package:icons_plus/icons_plus.dart';
import 'package:server_box/data/store/no_backup.dart';
final icloudLoading = false.vn;
final webdavLoading = false.vn;
final _noBak = NoBackupStore.instance;
class BackupPage extends StatelessWidget {
const BackupPage({super.key});
@@ -91,9 +93,9 @@ class BackupPage extends StatelessWidget {
leading: const Icon(Icons.cloud),
title: const Text('iCloud'),
trailing: StoreSwitch(
prop: Stores.setting.icloudSync,
prop: _noBak.icloudSync,
validator: (p0) {
if (p0 && Stores.setting.webdavSync.fetch()) {
if (p0 && _noBak.webdavSync.fetch()) {
context.showSnackBar(l10n.autoBackupConflict);
return false;
}
@@ -102,7 +104,7 @@ class BackupPage extends StatelessWidget {
callback: (val) async {
if (val) {
icloudLoading.value = true;
await ICloud.sync();
await sync.sync(rs: icloud);
icloudLoading.value = false;
}
},
@@ -126,17 +128,17 @@ class BackupPage extends StatelessWidget {
ListTile(
title: Text(libL10n.auto),
trailing: StoreSwitch(
prop: Stores.setting.webdavSync,
prop: _noBak.webdavSync,
validator: (p0) {
if (p0) {
if (Stores.setting.webdavUrl.fetch().isEmpty ||
Stores.setting.webdavUser.fetch().isEmpty ||
Stores.setting.webdavPwd.fetch().isEmpty) {
if (_noBak.webdavUrl.fetch().isEmpty ||
_noBak.webdavUser.fetch().isEmpty ||
_noBak.webdavPwd.fetch().isEmpty) {
context.showSnackBar(l10n.webdavSettingEmpty);
return false;
}
}
if (Stores.setting.icloudSync.fetch()) {
if (_noBak.icloudSync.fetch()) {
context.showSnackBar(l10n.autoBackupConflict);
return false;
}
@@ -145,7 +147,7 @@ class BackupPage extends StatelessWidget {
callback: (val) async {
if (val) {
webdavLoading.value = true;
await Webdav.sync();
await sync.sync(rs: webdav);
webdavLoading.value = false;
}
},
@@ -298,7 +300,7 @@ class BackupPage extends StatelessWidget {
)),
actions: Btn.ok(
onTap: () async {
await backup.restore(force: true);
await backup.merge(force: true);
context.pop();
},
).toList,
@@ -312,7 +314,7 @@ class BackupPage extends StatelessWidget {
Future<void> _onTapWebdavDl(BuildContext context) async {
webdavLoading.value = true;
try {
final files = await Webdav.list();
final files = await webdav.list();
if (files.isEmpty) return context.showSnackBar(l10n.dirEmpty);
final fileName = await context.showPickSingleDialog(
@@ -321,13 +323,10 @@ class BackupPage extends StatelessWidget {
);
if (fileName == null) return;
final result = await Webdav.download(relativePath: fileName);
if (result != null) {
throw result;
}
await webdav.download(relativePath: fileName);
final dlFile = await File('${Paths.doc}/$fileName').readAsString();
final dlBak = await Computer.shared.start(Backup.fromJsonString, dlFile);
await dlBak.restore(force: true);
await dlBak.merge(force: true);
} catch (e, s) {
context.showErrDialog(e, s, libL10n.restore);
Loggers.app.warning('Download webdav backup failed', e, s);
@@ -342,10 +341,7 @@ class BackupPage extends StatelessWidget {
final bakName = '$date-${Miscs.bakFileName}';
try {
await Backup.backup(bakName);
final uploadResult = await Webdav.upload(relativePath: bakName);
if (uploadResult != null) {
throw uploadResult;
}
await webdav.upload(relativePath: bakName);
Loggers.app.info('Upload webdav backup success');
} catch (e, s) {
context.showErrDialog(e, s, l10n.upload);
@@ -356,9 +352,9 @@ class BackupPage extends StatelessWidget {
}
Future<void> _onTapWebdavSetting(BuildContext context) async {
final url = TextEditingController(text: Stores.setting.webdavUrl.fetch());
final user = TextEditingController(text: Stores.setting.webdavUser.fetch());
final pwd = TextEditingController(text: Stores.setting.webdavPwd.fetch());
final url = TextEditingController(text: _noBak.webdavUrl.fetch());
final user = TextEditingController(text: _noBak.webdavUser.fetch());
final pwd = TextEditingController(text: _noBak.webdavPwd.fetch());
final nodeUser = FocusNode();
final nodePwd = FocusNode();
final result = await context.showRoundDialog<bool>(
@@ -392,13 +388,18 @@ class BackupPage extends StatelessWidget {
actions: Btnx.oks,
);
if (result == true) {
final result = await Webdav.test(url.text, user.text, pwd.text);
if (result != null) {
context.showSnackBar(result);
return;
try {
await Webdav.test(url.text, user.text, pwd.text);
context.showSnackBar(libL10n.success);
webdav.init(WebdavInitArgs(
url: url.text,
user: user.text,
pwd: pwd.text,
prefix: 'serverbox/',
));
} catch (e, s) {
context.showErrDialog(e, s, 'Webdav');
}
context.showSnackBar(libL10n.success);
Webdav.changeClient(url.text, user.text, pwd.text);
}
}
@@ -427,7 +428,7 @@ class BackupPage extends StatelessWidget {
)),
actions: Btn.ok(
onTap: () async {
await backup.restore(force: true);
await backup.merge(force: true);
context.pop();
},
).toList,

View File

@@ -470,8 +470,8 @@ packages:
dependency: "direct main"
description:
path: "."
ref: "v1.0.149"
resolved-ref: "291a7b445fcf116517cfbb6b3534f6b535e8276c"
ref: "v1.0.150"
resolved-ref: "53c92f43fff4cf643fc03e186a22268b64085e86"
url: "https://github.com/lppcg/fl_lib"
source: git
version: "0.0.1"

View File

@@ -59,7 +59,7 @@ dependencies:
fl_lib:
git:
url: https://github.com/lppcg/fl_lib
ref: v1.0.149
ref: v1.0.150
dependency_overrides:
# dartssh2: