#157 new: icloud sync

This commit is contained in:
lollipopkit
2023-09-11 19:20:29 +08:00
parent ddaee7c2f3
commit e4fd75ac5a
22 changed files with 448 additions and 125 deletions

View File

@@ -119,4 +119,4 @@ Future<void> _rmDownloadApks() async {
}
}
Future<String> get _dlDir async => joinPath((await docDir).path, 'Download');
Future<String> get _dlDir async => joinPath(await docDir, 'Download');

View File

@@ -0,0 +1,78 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import '../../data/model/app/backup.dart';
import '../../data/res/path.dart';
import '../../data/store/docker.dart';
import '../../data/store/private_key.dart';
import '../../data/store/server.dart';
import '../../data/store/setting.dart';
import '../../data/store/snippet.dart';
import '../../locator.dart';
final _server = locator<ServerStore>();
final _snippet = locator<SnippetStore>();
final _privateKey = locator<PrivateKeyStore>();
final _dockerHosts = locator<DockerStore>();
final _setting = locator<SettingStore>();
Future<String> get backupPath async => '${await docDir}/srvbox_bak.json';
const backupFormatVersion = 1;
Future<void> backup() async {
final result = _diyEncrtpt(
json.encode(
Backup(
version: backupFormatVersion,
date: DateTime.now().toString().split('.').first,
spis: _server.fetch(),
snippets: _snippet.fetch(),
keys: _privateKey.fetch(),
dockerHosts: _dockerHosts.fetchAll(),
settings: _setting.toJson(),
),
),
);
await File(await backupPath).writeAsString(result);
}
void restore(Backup backup) {
for (final s in backup.snippets) {
_snippet.put(s);
}
for (final s in backup.spis) {
_server.put(s);
}
for (final s in backup.keys) {
_privateKey.put(s);
}
for (final k in backup.dockerHosts.keys) {
final val = backup.dockerHosts[k];
if (val != null && val is String && val.isNotEmpty) {
_dockerHosts.put(k, val);
}
}
}
Future<Backup> decodeBackup(String raw) async {
return await compute(_decode, raw);
}
Backup _decode(String raw) {
final decrypted = _diyDecrypt(raw);
return Backup.fromJson(json.decode(decrypted));
}
String _diyEncrtpt(String raw) =>
json.encode(raw.codeUnits.map((e) => e * 2 + 1).toList(growable: false));
String _diyDecrypt(String raw) {
final list = json.decode(raw);
final sb = StringBuffer();
for (final e in list) {
sb.writeCharCode((e - 1) ~/ 2);
}
return sb.toString();
}

159
lib/core/utils/icloud.dart Normal file
View File

@@ -0,0 +1,159 @@
import 'dart:async';
import 'dart:io';
import 'package:icloud_storage/icloud_storage.dart';
import 'package:logging/logging.dart';
import '../../data/model/app/error.dart';
import '../../data/model/app/json.dart';
import '../../data/res/path.dart';
final _logger = Logger('iCloud');
class ICloud {
static const _containerId = 'iCloud.tech.lolli.serverbox';
const ICloud();
/// Upload file to iCloud
///
/// - [relativePath] is the path relative to [docDir],
/// 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?>();
await ICloudStorage.upload(
containerId: _containerId,
filePath: localPath ?? '${await docDir}/$relativePath',
destinationRelativePath: relativePath,
onProgress: (stream) {
stream.listen(
null,
onDone: () => completer.complete(null),
onError: (e) => 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 {
await ICloudStorage.delete(
containerId: _containerId,
relativePath: relativePath,
);
}
/// Download file from iCloud
///
/// - [relativePath] is the path relative to [docDir],
/// 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?>();
await ICloudStorage.download(
containerId: _containerId,
relativePath: relativePath,
destinationFilePath: localPath ?? '${await docDir}/$relativePath',
onProgress: (stream) {
stream.listen(
null,
onDone: () => completer.complete(null),
onError: (e) => completer.complete(
ICloudErr(type: ICloudErrType.generic, message: '$e'),
),
);
},
);
return completer.future;
}
/// Sync file between iCloud and local
///
/// - [relativePath] is the path relative to [docDir],
/// must not starts with `/`
///
/// Return `null` if upload success, `ICloudErr` otherwise
///
/// TODO: consider merge strategy, use [SyncAble] and [JsonSerializable]
static Future<Iterable<ICloudErr>?> sync({
required Iterable<String> relativePaths,
}) async {
try {
final errs = <ICloudErr>[];
final allFiles = await getAll();
// remove files not in relativePaths
allFiles.removeWhere((e) => !relativePaths.contains(e.relativePath));
// upload files not in iCloud
final missed = relativePaths.where((e) {
return !allFiles.any((f) => f.relativePath == e);
});
for (final e in missed) {
final err = await upload(relativePath: e);
if (err != null) {
errs.add(err);
}
}
final docPath = await docDir;
// compare files in iCloud and local
for (final file in allFiles) {
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.add(err);
}
continue;
}
final localDate = await localFile.lastModified();
if (file.contentChangeDate.isBefore(localDate)) {
/// Local is newer than remote, so upload local file
final err = await upload(relativePath: relativePath);
if (err != null) {
errs.add(err);
}
continue;
}
/// Remote is newer than local, so download remote
final err = await download(relativePath: relativePath);
if (err != null) {
errs.add(err);
}
}
_logger.info('Errs: $errs');
return errs.isEmpty ? null : errs;
} catch (e, s) {
_logger.warning('Sync failed: $relativePaths', e, s);
return [ICloudErr(type: ICloudErrType.generic, message: '$e')];
}
}
}

View File

@@ -43,6 +43,15 @@ void showSnackBarWithAction(
));
}
void showRestartSnackbar(BuildContext context, S s) {
showSnackBarWithAction(
context,
'${s.success}\n${s.needRestart}',
s.restart,
() => rebuildAll(context),
);
}
Future<bool> openUrl(String url) async {
return await launchUrl(url.uri, mode: LaunchMode.externalApplication);
}

View File

@@ -52,3 +52,19 @@ class DockerErr extends Err<DockerErrType> {
return 'DockerErr<$type>: $message';
}
}
enum ICloudErrType {
generic,
notFound,
multipleFiles,
}
class ICloudErr extends Err<ICloudErrType> {
ICloudErr({required ICloudErrType type, String? message})
: super(from: ErrFrom.docker, type: type, message: message);
@override
String toString() {
return 'ICloudErr<$type>: $message';
}
}

View File

@@ -0,0 +1,7 @@
abstract class JsonSerializable<T> {
/// Convert [this] to json
Map<String, dynamic> toJson();
/// Create [this] from json
T fromJson(Map<String, dynamic> json);
}

View File

@@ -0,0 +1,9 @@
abstract class SyncAble<T> {
/// If [other] is newer than [this] then return true,
/// else return false
bool needSync(T other);
/// Merge [other] into [this],
/// return [this] after merge
T merge(T other);
}

View File

@@ -3,23 +3,43 @@ import 'dart:io';
import 'package:path_provider/path_provider.dart';
import 'package:toolbox/core/utils/platform.dart';
Future<Directory> get docDir async {
String? _docDir;
String? _sftpDir;
String? _fontDir;
Future<String> get docDir async {
if (_docDir != null) {
return _docDir!;
}
if (isAndroid) {
final dir = await getExternalStorageDirectory();
if (dir != null) {
return dir;
_docDir = dir.path;
return dir.path;
}
// fallthrough to getApplicationDocumentsDirectory
}
return await getApplicationDocumentsDirectory();
final dir = await getApplicationDocumentsDirectory();
_docDir = dir.path;
return dir.path;
}
Future<Directory> get sftpDir async {
final dir = Directory('${(await docDir).path}/sftp');
return dir.create(recursive: true);
Future<String> get sftpDir async {
if (_sftpDir != null) {
return _sftpDir!;
}
_sftpDir = '${await docDir}/sftp';
final dir = Directory(_sftpDir!);
await dir.create(recursive: true);
return _sftpDir!;
}
Future<Directory> get fontDir async {
final dir = Directory('${(await docDir).path}/font');
return dir.create(recursive: true);
Future<String> get fontDir async {
if (_fontDir != null) {
return _fontDir!;
}
_fontDir = '${await docDir}/font';
final dir = Directory(_fontDir!);
await dir.create(recursive: true);
return _fontDir!;
}

View File

@@ -198,6 +198,13 @@ class SettingStore extends PersistentStore {
false,
);
/// Only valid on iOS and macOS
late final icloudSync = StoreProperty(
box,
'icloudSync',
false,
);
// Never show these settings for users
// Guide for these settings:
// - key should start with `_` and be shorter as possible

View File

@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';
@@ -6,6 +7,8 @@ import 'package:logging/logging.dart';
import 'package:macos_window_utils/window_manipulator.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:toolbox/core/utils/icloud.dart';
import 'package:toolbox/data/res/path.dart';
import 'app.dart';
import 'core/analysis.dart';
@@ -93,14 +96,18 @@ Future<void> initApp() async {
loadFontFile(settings.fontPath.fetch());
primaryColor = Color(settings.primaryColor.fetch());
// Android only
if (!isAndroid) return;
// Only start service when [bgRun] is true.
if (locator<SettingStore>().bgRun.fetch()) {
bgRunChannel.invokeMethod('startService');
if (isIOS || isMacOS) {
if (settings.icloudSync.fetch()) _syncApple();
}
if (isAndroid) {
// Only start service when [bgRun] is true.
if (locator<SettingStore>().bgRun.fetch()) {
bgRunChannel.invokeMethod('startService');
}
// SharedPreferences is only used on Android for saving home widgets settings.
SharedPreferences.setPrefix('');
}
// SharedPreferences is only used on Android for saving home widgets settings.
SharedPreferences.setPrefix('');
}
void _setupProviders() {
@@ -144,3 +151,12 @@ Future<void> _initMacOSWindow() async {
WindowManipulator.hideTitle();
await CustomAppBar.updateTitlebarHeight();
}
Future<void> _syncApple() async {
final docPath = await docDir;
final dir = Directory(docPath);
final files = await dir.list().toList();
files.removeWhere((e) => !e.path.endsWith('.hive'));
final paths = files.map((e) => e.path.replaceFirst('$docPath/', ''));
ICloud.sync(relativePaths: paths);
}

View File

@@ -1,35 +1,23 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:toolbox/core/extension/context.dart';
import 'package:toolbox/data/res/path.dart';
import 'package:toolbox/core/utils/backup.dart';
import 'package:toolbox/core/utils/platform.dart';
import 'package:toolbox/view/widget/round_rect_card.dart';
import '../../core/utils/misc.dart';
import '../../core/utils/ui.dart';
import '../../data/model/app/backup.dart';
import '../../data/res/ui.dart';
import '../../data/store/docker.dart';
import '../../data/store/private_key.dart';
import '../../data/store/server.dart';
import '../../data/store/setting.dart';
import '../../data/store/snippet.dart';
import '../../locator.dart';
import '../widget/custom_appbar.dart';
const backupFormatVersion = 1;
class BackupPage extends StatelessWidget {
BackupPage({Key? key}) : super(key: key);
final _server = locator<ServerStore>();
final _snippet = locator<SnippetStore>();
final _privateKey = locator<PrivateKeyStore>();
final _dockerHosts = locator<DockerStore>();
final _setting = locator<SettingStore>();
@override
@@ -44,12 +32,12 @@ class BackupPage extends StatelessWidget {
}
Widget _buildBody(BuildContext context, S s) {
final media = MediaQuery.of(context);
return Center(
child: Column(
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (isMacOS || isIOS) _buildIcloudSync(context),
height13,
Padding(
padding: const EdgeInsets.all(37),
child: Text(
@@ -61,7 +49,6 @@ class BackupPage extends StatelessWidget {
_buildCard(
s.restore,
Icons.download,
media,
() => _onRestore(context, s),
),
height13,
@@ -73,17 +60,18 @@ class BackupPage extends StatelessWidget {
_buildCard(
s.backup,
Icons.save,
media,
() => _onBackup(context, s),
() async {
await backup();
await shareFiles(context, [await backupPath]);
},
)
],
));
);
}
Widget _buildCard(
String text,
IconData icon,
MediaQueryData media,
FutureOr Function() onTap,
) {
return RoundRectCard(
@@ -105,6 +93,20 @@ class BackupPage extends StatelessWidget {
);
}
Widget _buildIcloudSync(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'iCloud',
textAlign: TextAlign.center,
),
width13,
buildSwitch(context, _setting.icloudSync)
],
);
}
Future<void> _onRestore(BuildContext context, S s) async {
final path = await pickOneFile();
if (path == null) {
@@ -120,25 +122,6 @@ class BackupPage extends StatelessWidget {
_import(text, context, s);
}
Future<void> _onBackup(BuildContext context, S s) async {
final result = _diyEncrtpt(
json.encode(
Backup(
version: backupFormatVersion,
date: DateTime.now().toString().split('.').first,
spis: _server.fetch(),
snippets: _snippet.fetch(),
keys: _privateKey.fetch(),
dockerHosts: _dockerHosts.fetchAll(),
settings: _setting.toJson(),
),
),
);
final path = '${(await docDir).path}/srvbox_bak.json';
await File(path).writeAsString(result);
await shareFiles(context, [path]);
}
Future<void> _import(String text, BuildContext context, S s) async {
if (text.isEmpty) {
showSnackBar(context, Text(s.fieldMustNotEmpty));
@@ -149,7 +132,7 @@ class BackupPage extends StatelessWidget {
Future<void> _importBackup(String raw, BuildContext context, S s) async {
try {
final backup = await compute(_decode, raw);
final backup = await decodeBackup(raw);
if (backupFormatVersion != backup.version) {
showSnackBar(context, Text(s.backupVersionNotMatch));
return;
@@ -166,37 +149,9 @@ class BackupPage extends StatelessWidget {
),
TextButton(
onPressed: () async {
for (final s in backup.snippets) {
_snippet.put(s);
}
for (final s in backup.spis) {
_server.put(s);
}
for (final s in backup.keys) {
_privateKey.put(s);
}
for (final k in backup.dockerHosts.keys) {
final val = backup.dockerHosts[k];
if (val != null && val is String && val.isNotEmpty) {
_dockerHosts.put(k, val);
}
}
restore(backup);
context.pop();
showRoundDialog(
context: context,
title: Text(s.restore),
child: Text(s.restoreSuccess),
actions: [
TextButton(
onPressed: () => rebuildAll(context),
child: Text(s.restart),
),
TextButton(
onPressed: () => context.pop(),
child: Text(s.cancel),
),
],
);
showRestartSnackbar(context, s);
},
child: Text(s.ok),
),
@@ -208,19 +163,3 @@ class BackupPage extends StatelessWidget {
}
}
}
Backup _decode(String raw) {
final decrypted = _diyDecrypt(raw);
return Backup.fromJson(json.decode(decrypted));
}
String _diyEncrtpt(String raw) =>
json.encode(raw.codeUnits.map((e) => e * 2 + 1).toList(growable: false));
String _diyDecrypt(String raw) {
final list = json.decode(raw);
final sb = StringBuffer();
for (final e in list) {
sb.writeCharCode((e - 1) ~/ 2);
}
return sb.toString();
}

View File

@@ -368,7 +368,7 @@ class _SettingPageState extends State<SettingPage> {
_setting.primaryColor.put(_selectedColorValue.value);
primaryColor = color;
context.pop();
_showRestartSnackbar();
showRestartSnackbar(context, _s);
}
// Widget _buildLaunchPage() {
@@ -560,7 +560,7 @@ class _SettingPageState extends State<SettingPage> {
onPressed: () {
_setting.fontPath.delete();
context.pop();
_showRestartSnackbar();
showRestartSnackbar(context, _s);
},
child: Text(_s.clear),
)
@@ -577,29 +577,19 @@ class _SettingPageState extends State<SettingPage> {
if (isIOS) {
_setting.fontPath.put(path);
} else {
final fontDir_ = await fontDir;
final fontFile = File(path);
final newPath = '${fontDir_.path}/${path.split('/').last}';
final newPath = '${await fontDir}/${path.split('/').last}';
await fontFile.copy(newPath);
_setting.fontPath.put(newPath);
}
context.pop();
_showRestartSnackbar();
showRestartSnackbar(context, _s);
return;
}
showSnackBar(context, Text(_s.failed));
}
void _showRestartSnackbar() {
showSnackBarWithAction(
context,
'${_s.success}\n${_s.needRestart}',
_s.restart,
() => rebuildAll(context),
);
}
Widget _buildBgRun() {
return ListTile(
title: Text(_s.bgRun),
@@ -681,7 +671,7 @@ class _SettingPageState extends State<SettingPage> {
onSelected: (String idx) {
_localeCode.value = idx;
_setting.locale.put(idx);
_showRestartSnackbar();
showRestartSnackbar(context, _s);
},
child: Text(
_s.languageName,
@@ -772,7 +762,7 @@ class _SettingPageState extends State<SettingPage> {
trailing: buildSwitch(
context,
_setting.fullScreen,
func: (_) => _showRestartSnackbar(),
func: (_) => showRestartSnackbar(context, _s),
),
);
}

View File

@@ -50,7 +50,7 @@ class _LocalStoragePageState extends State<LocalStoragePage> {
} else {
sftpDir.then((dir) {
setState(() {
_path = LocalPath(dir.path);
_path = LocalPath(dir);
});
});
}

View File

@@ -642,7 +642,7 @@ class _SftpPageState extends State<SftpPage> with AfterLayoutMixin {
}
Future<String> _getLocalPath(String remotePath) async {
return '${(await sftpDir).path}$remotePath';
return '${await sftpDir}$remotePath';
}
/// Only return true if the path is changed