diff --git a/lib/core/utils/icloud.dart b/lib/core/utils/sync/icloud.dart similarity index 98% rename from lib/core/utils/icloud.dart rename to lib/core/utils/sync/icloud.dart index e375eb65c..2965bbbb3 100644 --- a/lib/core/utils/icloud.dart +++ b/lib/core/utils/sync/icloud.dart @@ -7,8 +7,8 @@ import 'package:toolbox/data/model/app/backup.dart'; import 'package:toolbox/data/model/app/sync.dart'; import 'package:toolbox/data/res/logger.dart'; -import '../../data/model/app/error.dart'; -import '../../data/res/path.dart'; +import '../../../data/model/app/error.dart'; +import '../../../data/res/path.dart'; abstract final class ICloud { static const _containerId = 'iCloud.tech.lolli.serverbox'; diff --git a/lib/core/utils/sync/webdav.dart b/lib/core/utils/sync/webdav.dart new file mode 100644 index 000000000..5ea7edab0 --- /dev/null +++ b/lib/core/utils/sync/webdav.dart @@ -0,0 +1,96 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:toolbox/data/model/app/backup.dart'; +import 'package:toolbox/data/model/app/error.dart'; +import 'package:toolbox/data/res/logger.dart'; +import 'package:toolbox/data/res/path.dart'; +import 'package:toolbox/data/res/store.dart'; +// ignore: implementation_imports +import 'package:webdav_client/src/client.dart'; + +abstract final class Webdav { + static var _client = WebdavClient( + url: Stores.setting.webdavUrl.fetch(), + user: Stores.setting.webdavUser.fetch(), + pwd: Stores.setting.webdavPwd.fetch(), + ); + + static Future upload({ + required String relativePath, + String? localPath, + }) async { + try { + await _client.writeFile( + localPath ?? '${await Paths.doc}/$relativePath', + relativePath, + ); + } catch (e, s) { + Loggers.app.warning('Webdav upload failed', e, s); + return WebdavErr(type: WebdavErrType.generic, message: '$e'); + } + return null; + } + + static Future delete(String relativePath) async { + try { + await _client.remove(relativePath); + } catch (e, s) { + Loggers.app.warning('Webdav delete 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( + relativePath, + localPath ?? '${await Paths.doc}/$relativePath', + ); + } catch (e, s) { + Loggers.app.warning('Webdav download failed', e, s); + return WebdavErr(type: WebdavErrType.generic, message: '$e'); + } + return null; + } + + static void changeClient(String url, String user, String pwd) { + _client = WebdavClient(url: url, user: user, pwd: pwd); + } + + static Future sync() async { + try { + final result = await download(relativePath: Paths.bakName); + if (result != null) { + Loggers.app.warning('Download backup failed: $result'); + return; + } + } catch (e, s) { + Loggers.app.warning('Download backup failed', e, s); + } + final dlFile = await File(await Paths.bak).readAsString(); + final dlBak = await compute(Backup.fromJsonString, dlFile); + final restore = await dlBak.restore(); + switch (restore) { + case true: + Loggers.app.info('Restore from iCloud (${dlBak.lastModTime}) success'); + break; + case false: + await Backup.backup(); + final uploadResult = await upload(relativePath: Paths.bakName); + if (uploadResult != null) { + Loggers.app.warning('Upload iCloud backup failed: $uploadResult'); + } else { + Loggers.app.info('Upload iCloud backup success'); + } + break; + case null: + Loggers.app.info('Skip iCloud sync'); + break; + } + } +} diff --git a/lib/data/model/app/error.dart b/lib/data/model/app/error.dart index 08366e0ed..270046919 100644 --- a/lib/data/model/app/error.dart +++ b/lib/data/model/app/error.dart @@ -4,7 +4,9 @@ enum ErrFrom { docker, sftp, ssh, - status; + status, + icloud, + webdav,; } abstract class Err { @@ -61,10 +63,25 @@ enum ICloudErrType { class ICloudErr extends Err { ICloudErr({required ICloudErrType type, String? message}) - : super(from: ErrFrom.docker, type: type, message: message); + : super(from: ErrFrom.icloud, type: type, message: message); @override String toString() { return 'ICloudErr<$type>: $message'; } } + +enum WebdavErrType { + generic, + notFound,; +} + +class WebdavErr extends Err { + WebdavErr({required WebdavErrType type, String? message}) + : super(from: ErrFrom.webdav, type: type, message: message); + + @override + String toString() { + return 'WebdavErr<$type>: $message'; + } +} diff --git a/lib/data/model/app/remote_storage.dart b/lib/data/model/app/remote_storage.dart new file mode 100644 index 000000000..3cc15a0b9 --- /dev/null +++ b/lib/data/model/app/remote_storage.dart @@ -0,0 +1,13 @@ +abstract class RemoteStorage { + Future upload({ + required String relativePath, + String? localPath + }); + + Future download({ + required String relativePath, + String? localPath + }); + + Future delete(String relativePath); +} diff --git a/lib/data/store/setting.dart b/lib/data/store/setting.dart index 5fb366bc9..54350f2e8 100644 --- a/lib/data/store/setting.dart +++ b/lib/data/store/setting.dart @@ -203,6 +203,12 @@ class SettingStore extends PersistentStore { late final serverFuncBtnsDisplayName = property('serverFuncBtnsDisplayName', false); + /// Webdav sync + late final webdavSync = property('webdavSync', false); + late final webdavUrl = property('webdavUrl', ''); + late final webdavUser = property('webdavUser', ''); + late final webdavPwd = property('webdavPwd', ''); + // Never show these settings for users // // ------BEGIN------ diff --git a/lib/main.dart b/lib/main.dart index a4068a264..c234238b3 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -9,7 +9,7 @@ import 'package:macos_window_utils/window_manipulator.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:toolbox/core/channel/bg_run.dart'; -import 'package:toolbox/core/utils/icloud.dart'; +import 'package:toolbox/core/utils/sync/icloud.dart'; import 'package:toolbox/core/utils/platform/base.dart'; import 'package:toolbox/data/res/logger.dart'; import 'package:toolbox/data/res/provider.dart'; diff --git a/lib/view/page/backup.dart b/lib/view/page/backup.dart index ba84a0390..7680421c2 100644 --- a/lib/view/page/backup.dart +++ b/lib/view/page/backup.dart @@ -1,4 +1,3 @@ -import 'dart:async'; import 'dart:io'; import 'package:flutter/foundation.dart'; @@ -7,17 +6,19 @@ import 'package:toolbox/core/extension/context/common.dart'; import 'package:toolbox/core/extension/context/dialog.dart'; import 'package:toolbox/core/extension/context/locale.dart'; import 'package:toolbox/core/extension/context/snackbar.dart'; -import 'package:toolbox/core/persistant_store.dart'; -import 'package:toolbox/core/utils/icloud.dart'; +import 'package:toolbox/core/utils/sync/icloud.dart'; import 'package:toolbox/core/utils/platform/base.dart'; import 'package:toolbox/core/utils/share.dart'; +import 'package:toolbox/core/utils/sync/webdav.dart'; import 'package:toolbox/data/model/app/backup.dart'; import 'package:toolbox/data/res/logger.dart'; +import 'package:toolbox/data/res/path.dart'; import 'package:toolbox/data/res/provider.dart'; import 'package:toolbox/data/res/rebuild.dart'; import 'package:toolbox/data/res/store.dart'; import 'package:toolbox/view/widget/expand_tile.dart'; import 'package:toolbox/view/widget/cardx.dart'; +import 'package:toolbox/view/widget/input_field.dart'; import 'package:toolbox/view/widget/store_switch.dart'; import 'package:toolbox/view/widget/value_notifier.dart'; @@ -29,6 +30,7 @@ class BackupPage extends StatelessWidget { BackupPage({Key? key}) : super(key: key); final icloudLoading = ValueNotifier(false); + final webdavLoading = ValueNotifier(false); @override Widget build(BuildContext context) { @@ -45,6 +47,7 @@ class BackupPage extends StatelessWidget { padding: const EdgeInsets.all(17), children: [ if (isMacOS || isIOS) _buildIcloud(context), + _buildWebdav(context), _buildFile(context), ], ); @@ -53,6 +56,7 @@ class BackupPage extends StatelessWidget { Widget _buildFile(BuildContext context) { return CardX( ExpandTile( + leading: const Icon(Icons.file_open, size: 19), title: Text(l10n.files), initiallyExpanded: true, children: [ @@ -63,12 +67,73 @@ class BackupPage extends StatelessWidget { l10n.backupTip, style: UIs.textGrey, ), - onTap: _onBackup, + onTap: () async { + final path = await Backup.backup(); + + /// Issue #188 + if (isWindows) { + await Shares.text(await File(path).readAsString()); + } else { + await Shares.files([path]); + } + }, ), ListTile( trailing: const Icon(Icons.restore), title: Text(l10n.restore), - onTap: () => _onRestore(context), + onTap: () async { + final path = await pickOneFile(); + if (path == null) return; + + final file = File(path); + if (!await file.exists()) { + context.showSnackBar(l10n.fileNotExist(path)); + return; + } + + final text = await file.readAsString(); + if (text.isEmpty) { + context.showSnackBar(l10n.fieldMustNotEmpty); + return; + } + + try { + context.showLoadingDialog(); + final backup = + await compute(Backup.fromJsonString, text.trim()); + if (backupFormatVersion != backup.version) { + context.showSnackBar(l10n.backupVersionNotMatch); + return; + } + + await context.showRoundDialog( + title: Text(l10n.restore), + child: Text(l10n.askContinue( + '${l10n.restore} ${l10n.backup}(${backup.date})', + )), + actions: [ + TextButton( + onPressed: () => context.pop(), + child: Text(l10n.cancel), + ), + TextButton( + onPressed: () async { + await backup.restore(force: true); + Pros.reload(); + context.pop(); + RebuildNodes.app.rebuild(); + }, + child: Text(l10n.ok), + ), + ], + ); + } catch (e, trace) { + Loggers.app.warning('Import backup failed', e, trace); + context.showSnackBar(e.toString()); + } finally { + context.pop(); + } + }, ), ], ), @@ -78,23 +143,19 @@ class BackupPage extends StatelessWidget { Widget _buildIcloud(BuildContext context) { return CardX( ExpandTile( + leading: const Icon(Icons.cloud, size: 19), title: const Text('iCloud'), initiallyExpanded: true, - subtitle: Text( - l10n.syncTip, - style: UIs.textGrey, - ), children: [ ListTile( title: Text(l10n.auto), - subtitle: const Text( - 'Unavailable, please wait for optimization :)', - style: UIs.textGrey, - ), trailing: StoreSwitch( prop: Stores.setting.icloudSync, func: (val) async { if (val) { + icloudLoading.value = true; + await ICloud.sync(); + icloudLoading.value = false; } }, ), @@ -113,10 +174,26 @@ class BackupPage extends StatelessWidget { TextButton( onPressed: () async { icloudLoading.value = true; - final files = await PersistentStore.getFileNames(); - for (final file in files) { - await ICloud.download(relativePath: file); + try { + final result = await ICloud.download( + relativePath: Paths.bakName, + ); + if (result != null) { + Loggers.app + .warning('Download backup failed: $result'); + return; + } + } catch (e, s) { + Loggers.app.warning('Download backup failed', e, s); + context.showSnackBar(e.toString()); + icloudLoading.value = false; + return; } + final dlFile = + await File(await Paths.bak).readAsString(); + final dlBak = + await compute(Backup.fromJsonString, dlFile); + await dlBak.restore(force: true); icloudLoading.value = false; }, child: Text(l10n.download), @@ -125,9 +202,14 @@ class BackupPage extends StatelessWidget { TextButton( onPressed: () async { icloudLoading.value = true; - final files = await PersistentStore.getFileNames(); - for (final file in files) { - await ICloud.upload(relativePath: file); + await Backup.backup(); + final uploadResult = + await ICloud.upload(relativePath: Paths.bakName); + if (uploadResult != null) { + Loggers.app.warning( + 'Upload iCloud backup failed: $uploadResult'); + } else { + Loggers.app.info('Upload iCloud backup success'); } icloudLoading.value = false; }, @@ -143,68 +225,142 @@ class BackupPage extends StatelessWidget { ); } - Future _onBackup() async { - final path = await Backup.backup(); - - /// Issue #188 - if (isWindows) { - await Shares.text(await File(path).readAsString()); - } else { - await Shares.files([path]); - } - } - - Future _onRestore(BuildContext context) async { - final path = await pickOneFile(); - if (path == null) return; - - final file = File(path); - if (!await file.exists()) { - context.showSnackBar(l10n.fileNotExist(path)); - return; - } - - final text = await file.readAsString(); - if (text.isEmpty) { - context.showSnackBar(l10n.fieldMustNotEmpty); - return; - } - - try { - context.showLoadingDialog(); - final backup = await compute(Backup.fromJsonString, text.trim()); - if (backupFormatVersion != backup.version) { - context.showSnackBar(l10n.backupVersionNotMatch); - return; - } - - await context.showRoundDialog( - title: Text(l10n.restore), - child: Text(l10n.askContinue( - '${l10n.restore} ${l10n.backup}(${backup.date})', - )), - actions: [ - TextButton( - onPressed: () => context.pop(), - child: Text(l10n.cancel), - ), - TextButton( - onPressed: () async { - /// TODO: add checkbox for not force restore - await backup.restore(force: true); - Pros.reload(); - context.pop(); - RebuildNodes.app.rebuild(); + Widget _buildWebdav(BuildContext context) { + return CardX( + ExpandTile( + leading: const Icon(Icons.storage, size: 19), + title: const Text('WebDAV'), + initiallyExpanded: !(isIOS || isMacOS), + children: [ + ListTile( + title: Text(l10n.setting), + trailing: const Icon(Icons.settings), + onTap: () async { + final urlCtrl = TextEditingController( + text: Stores.setting.webdavUrl.fetch(), + ); + final userCtrl = TextEditingController( + text: Stores.setting.webdavUser.fetch(), + ); + final pwdCtrl = TextEditingController( + text: Stores.setting.webdavPwd.fetch(), + ); + final result = await context.showRoundDialog( + title: const Text('WebDAV'), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Input( + label: 'url', + controller: urlCtrl, + ), + Input( + label: l10n.user, + controller: userCtrl, + ), + Input( + label: l10n.pwd, + controller: pwdCtrl, + ), + ], + ), + actions: [ + TextButton( + onPressed: () { + context.pop(true); + }, + child: Text(l10n.ok), + ), + ], + ); + if (result == true) { + Webdav.changeClient( + urlCtrl.text, + userCtrl.text, + pwdCtrl.text, + ); + Stores.setting.webdavUrl.put(urlCtrl.text); + Stores.setting.webdavUser.put(userCtrl.text); + Stores.setting.webdavPwd.put(pwdCtrl.text); + } }, - child: Text(l10n.ok), + ), + ListTile( + title: Text(l10n.auto), + trailing: StoreSwitch( + prop: Stores.setting.webdavSync, + func: (val) async { + if (val) { + webdavLoading.value = true; + await Webdav.sync(); + webdavLoading.value = false; + } + }, + ), + ), + ListTile( + title: Text(l10n.manual), + trailing: ValueBuilder( + listenable: webdavLoading, + build: () { + if (webdavLoading.value) { + return UIs.centerSizedLoadingSmall; + } + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton( + onPressed: () async { + webdavLoading.value = true; + try { + final result = await Webdav.download( + relativePath: Paths.bakName, + ); + if (result != null) { + Loggers.app.warning( + 'Download webdav backup failed: $result'); + return; + } + } catch (e, s) { + Loggers.app + .warning('Download webdav backup failed', e, s); + context.showSnackBar(e.toString()); + webdavLoading.value = false; + return; + } + final dlFile = + await File(await Paths.bak).readAsString(); + final dlBak = + await compute(Backup.fromJsonString, dlFile); + await dlBak.restore(force: true); + webdavLoading.value = false; + }, + child: Text(l10n.download), + ), + UIs.width7, + TextButton( + onPressed: () async { + webdavLoading.value = true; + await Backup.backup(); + final uploadResult = + await Webdav.upload(relativePath: Paths.bakName); + if (uploadResult != null) { + Loggers.app.warning( + 'Upload webdav backup failed: $uploadResult'); + } else { + Loggers.app.info('Upload webdav backup success'); + } + webdavLoading.value = false; + }, + child: Text(l10n.upload), + ), + ], + ); + }, + ), ), ], - ); - } catch (e, trace) { - Loggers.app.warning('Import backup failed', e, trace); - context.showSnackBar(e.toString()); - } finally { - context.pop(); - } + ), + ); } } diff --git a/pubspec.lock b/pubspec.lock index af2280c22..5898d1e94 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1164,6 +1164,15 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.0" + webdav_client: + dependency: "direct main" + description: + path: "." + ref: main + resolved-ref: "233b3ebaa01b7bb35a414c9cfd5e2933b3a008ba" + url: "https://github.com/lollipopkit/webdav_client" + source: git + version: "1.2.1" win32: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 26a3c3720..6b95f826c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -62,6 +62,10 @@ dependencies: #flutter_secure_storage: ^9.0.0 xml: ^6.4.2 flutter_rfb: ^0.6.2 + webdav_client: + git: + ref: main + url: https://github.com/lollipopkit/webdav_client dev_dependencies: flutter_native_splash: ^2.1.6