diff --git a/lib/core/sync.dart b/lib/core/sync.dart new file mode 100644 index 000000000..abda0c89b --- /dev/null +++ b/lib/core/sync.dart @@ -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 saveToFile() => Backup.backup(); + + @override + Future fromFile(String path) async { + final content = await File(path).readAsString(); + return Backup.fromJsonString(content); + } + + @override + Future 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; + } +} diff --git a/lib/core/utils/sync/icloud.dart b/lib/core/utils/sync/icloud.dart deleted file mode 100644 index ba878e3b2..000000000 --- a/lib/core/utils/sync/icloud.dart +++ /dev/null @@ -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 upload({ - required String relativePath, - String? localPath, - }) async { - final completer = Completer(); - 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> getAll() async { - return await ICloudStorage.gather( - containerId: _containerId, - ); - } - - static Future 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 download({ - required String relativePath, - String? localPath, - }) async { - final completer = Completer(); - 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> syncFiles({ - required Iterable relativePaths, - String? bakPrefix, - }) async { - final uploadFiles = []; - final downloadFiles = []; - - try { - final errs = {}; - - final allFiles = await getAll(); - - /// remove files not in relativePaths - allFiles.removeWhere((e) => !relativePaths.contains(e.relativePath)); - - final missions = >[]; - - /// 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 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 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'); - } - } -} diff --git a/lib/core/utils/sync/webdav.dart b/lib/core/utils/sync/webdav.dart deleted file mode 100644 index 396eea592..000000000 --- a/lib/core/utils/sync/webdav.dart +++ /dev/null @@ -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 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 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 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 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() async { - try { - final list = await _client.readDir(_prefix); - final names = []; - 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 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 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'); - } - } -} diff --git a/lib/data/model/app/backup.dart b/lib/data/model/app/backup.dart index 7f6f6ac33..a70d0ef8a 100644 --- a/lib/data/model/app/backup.dart +++ b/lib/data/model/app/backup.dart @@ -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 container; final Map history; final int? lastModTime; + final Map 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 backup([String? name]) async { final result = _diyEncrypt(json.encode(Backup.loadFromStore().toJson())); @@ -61,7 +64,8 @@ class Backup { return path; } - Future restore({bool force = false}) async { + @override + Future 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(); diff --git a/lib/data/store/no_backup.dart b/lib/data/store/no_backup.dart new file mode 100644 index 000000000..89b9fef49 --- /dev/null +++ b/lib/data/store/no_backup.dart @@ -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', ''); +} diff --git a/lib/data/store/setting.dart b/lib/data/store/setting.dart index 732ae7ea2..45799e34b 100644 --- a/lib/data/store/setting.dart +++ b/lib/data/store/setting.dart @@ -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); diff --git a/lib/main.dart b/lib/main.dart index eb0e1bbfb..b93642499 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 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. diff --git a/lib/view/page/backup.dart b/lib/view/page/backup.dart index ed0bb9dd6..b6ae3f474 100644 --- a/lib/view/page/backup.dart +++ b/lib/view/page/backup.dart @@ -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 _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 _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( @@ -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, diff --git a/pubspec.lock b/pubspec.lock index 88852f0cc..ecde61ce2 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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" diff --git a/pubspec.yaml b/pubspec.yaml index a8aec4511..5719e0a61 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: