From 1fad71347717a9b2a1fc64b4fc00428b183d459b Mon Sep 17 00:00:00 2001 From: "Nathan.fooo" <86001920+appflowy@users.noreply.github.com> Date: Fri, 24 Nov 2023 11:54:47 +0800 Subject: [PATCH] feat: custom server url in application (#3996) * chore:test * chore: update ui * feat: set appflowy cloud url * chore: add self host docs * fix: save user * fix: sign out when authenticator not match * fix: sign out when authenticator not match * fix: db lock * chore: remove unuse env file * test: disable supabase cloud test * test: disable supabase cloud test * chore: fix save --- frontend/appflowy_flutter/dev.env | 40 -- .../integration_test/runner.dart | 11 +- .../integration_test/util/auth_operation.dart | 6 +- .../integration_test/util/base.dart | 2 +- .../util/common_operations.dart | 2 +- .../appflowy_flutter/lib/core/config/kv.dart | 54 +-- .../lib/core/config/kv_keys.dart | 5 + .../appflowy_flutter/lib/env/backend_env.dart | 10 + frontend/appflowy_flutter/lib/env/env.dart | 300 ++++++++++----- .../personal_info_setting_group.dart | 2 +- .../lib/startup/deps_resolver.dart | 21 +- .../appflowy_flutter/lib/startup/startup.dart | 2 +- .../tasks/app_window_size_manager.dart | 2 +- .../lib/startup/tasks/rust_sdk.dart | 44 +-- .../lib/startup/tasks/supabase_task.dart | 4 +- .../auth/af_cloud_auth_service.dart | 4 + .../desktop_sign_in_screen.dart | 10 +- .../sign_in_screen/mobile_sign_in_screen.dart | 3 +- .../screens/skip_log_in_screen.dart | 2 +- .../presentation/screens/splash_screen.dart | 4 +- .../settings/appflowy_cloud_setting_bloc.dart | 91 +++++ .../settings/appflowy_cloud_urls_bloc.dart | 95 +++++ .../settings/application_data_storage.dart | 2 +- .../settings/cloud_setting_bloc.dart | 38 ++ .../settings/cloud_setting_listener.dart | 7 +- .../settings/create_file_settings_cubit.dart | 2 +- .../settings/setting_supabase_bloc.dart | 86 ----- .../settings/supabase_cloud_setting_bloc.dart | 97 +++++ .../settings/supabase_cloud_urls_bloc.dart | 115 ++++++ .../sidebar/folder/folder_bloc.dart | 4 +- .../workspace/application/view/view_bloc.dart | 4 +- .../home/menu/sidebar/rename_view_dialog.dart | 2 +- .../home/menu/sidebar/sidebar_user.dart | 2 +- .../settings/settings_dialog.dart | 12 +- .../widgets/setting_appflowy_cloud.dart | 259 +++++++++++++ .../settings/widgets/setting_cloud.dart | 166 ++++++++ .../settings/widgets/setting_cloud_view.dart | 196 ---------- .../settings/widgets/setting_local_cloud.dart | 40 ++ .../widgets/setting_supabase_cloud.dart | 356 ++++++++++++++++++ .../widgets/setting_third_party_login.dart | 3 +- .../settings/widgets/settings_menu.dart | 25 +- .../settings/widgets/settings_user_view.dart | 4 +- frontend/appflowy_tauri/src-tauri/Cargo.lock | 1 + frontend/resources/translations/en.json | 20 +- frontend/rust-lib/Cargo.lock | 1 + frontend/rust-lib/dart-ffi/src/env_serde.rs | 10 +- frontend/rust-lib/dart-ffi/src/lib.rs | 10 +- .../rust-lib/event-integration/tests/util.rs | 2 +- frontend/rust-lib/flowy-core/src/config.rs | 9 +- .../flowy-core/src/integrate/server.rs | 8 +- .../flowy-core/src/integrate/trait_impls.rs | 8 - frontend/rust-lib/flowy-error/Cargo.toml | 3 +- .../flowy-error/src/impl_from/collab.rs | 12 +- .../src/af_cloud_config.rs | 19 +- .../src/supabase_config.rs | 16 +- .../src/af_cloud/impls/user/dto.rs | 2 +- .../rust-lib/flowy-user-deps/src/entities.rs | 4 +- .../flowy-user/src/entities/user_setting.rs | 12 + .../rust-lib/flowy-user/src/event_handler.rs | 9 +- frontend/rust-lib/flowy-user/src/manager.rs | 97 ++--- .../flowy-user/src/services/database.rs | 89 ++--- .../flowy-user/src/services/entities.rs | 4 +- .../src/services/historical_user.rs | 9 +- 63 files changed, 1758 insertions(+), 721 deletions(-) delete mode 100644 frontend/appflowy_flutter/dev.env create mode 100644 frontend/appflowy_flutter/lib/workspace/application/settings/appflowy_cloud_setting_bloc.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/application/settings/appflowy_cloud_urls_bloc.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/application/settings/cloud_setting_bloc.dart delete mode 100644 frontend/appflowy_flutter/lib/workspace/application/settings/setting_supabase_bloc.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/application/settings/supabase_cloud_setting_bloc.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/application/settings/supabase_cloud_urls_bloc.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_cloud.dart delete mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_cloud_view.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_local_cloud.dart create mode 100644 frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_supabase_cloud.dart diff --git a/frontend/appflowy_flutter/dev.env b/frontend/appflowy_flutter/dev.env deleted file mode 100644 index 2dc13ab045..0000000000 --- a/frontend/appflowy_flutter/dev.env +++ /dev/null @@ -1,40 +0,0 @@ -# Copy the 'dev.env' file to '.env': -# Use the command 'cp dev.env .env' to create a copy of 'dev.env' named '.env'. -# After copying, update the '.env' file with the necessary environment parameters. - -# Generate the 'env.dart' from this '.env' file: -# Option 1: Use the "Generate Env File" task in VSCode. -# Option 2: Execute the commands in the appflowy_flutter directory: -# cd appflowy_flutter -# dart run build_runner clean && dart run build_runner build --delete-conflicting-outputs - -# Note on Configuration Priority: -# If both Supabase config and AppFlowy cloud config are provided in the '.env' file, -# the AppFlowy cloud config will be prioritized and the Supabase config ignored. -# Ensure only one of these configurations is active at any given time. - - -# Cloud Type Configuration -# Use this configuration file to specify the cloud type and its associated settings. The available cloud types are: -# Local: 0 -# Supabase: 1 -# AppFlowy Cloud: 2 -# By default, it's set to Local. -CLOUD_TYPE=0 - -# Supabase Configuration -# If using Supabase (CLOUD_TYPE=1), provide the following details: -SUPABASE_URL= -SUPABASE_ANON_KEY= - -# AppFlowy Cloud Configuration -# If using Supabase (CLOUD_TYPE=2), provide the following details: -# -# When using localhost for development. you can use the following settings: -# APPFLOWY_CLOUD_BASE_URL=http://localhost:8000 -# APPFLOWY_CLOUD_WS_BASE_URL=ws://localhost:8000/ws -# APPFLOWY_CLOUD_GOTRUE_URL=http://localhost:9998 - -APPFLOWY_CLOUD_BASE_URL= -APPFLOWY_CLOUD_WS_BASE_URL= -APPFLOWY_CLOUD_GOTRUE_URL= diff --git a/frontend/appflowy_flutter/integration_test/runner.dart b/frontend/appflowy_flutter/integration_test/runner.dart index 700e500938..ada0383dfd 100644 --- a/frontend/appflowy_flutter/integration_test/runner.dart +++ b/frontend/appflowy_flutter/integration_test/runner.dart @@ -1,8 +1,6 @@ -import 'package:appflowy/env/env.dart'; import 'package:integration_test/integration_test.dart'; import 'appearance_settings_test.dart' as appearance_test_runner; -import 'auth/auth_test.dart' as auth_test_runner; import 'board/board_test_runner.dart' as board_test_runner; import 'database_calendar_test.dart' as database_calendar_test; import 'database_cell_test.dart' as database_cell_test; @@ -32,7 +30,7 @@ import 'tabs_test.dart' as tabs_test; /// If flutter/flutter#101031 is resolved, this file can be removed completely. /// Once removed, the integration_test.yaml must be updated to exclude this as /// as the test target. -void main() { +Future main() async { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); // This test must be run first, otherwise the CI will fail. @@ -76,9 +74,10 @@ void main() { // User settings settings_test_runner.main(); - if (isCloudEnabled) { - auth_test_runner.main(); - } + // final cloudType = await getCloudType(); + // if (cloudType == CloudType.supabase) { + // auth_test_runner.main(); + // } // board_test.main(); // empty_document_test.main(); diff --git a/frontend/appflowy_flutter/integration_test/util/auth_operation.dart b/frontend/appflowy_flutter/integration_test/util/auth_operation.dart index 1f945dd5d6..9e79b8dca8 100644 --- a/frontend/appflowy_flutter/integration_test/util/auth_operation.dart +++ b/frontend/appflowy_flutter/integration_test/util/auth_operation.dart @@ -1,5 +1,5 @@ import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/setting_cloud_view.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/setting_supabase_cloud.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -37,7 +37,7 @@ extension AppFlowyAuthTest on WidgetTester { void assertEnableSyncSwitchValue(bool value) { assertSwitchValue( find.descendant( - of: find.byType(EnableSync), + of: find.byType(SupabaseEnableSync), matching: find.byWidgetPredicate((widget) => widget is Switch), ), value, @@ -55,7 +55,7 @@ extension AppFlowyAuthTest on WidgetTester { Future toggleEnableSync() async { final finder = find.descendant( - of: find.byType(EnableSync), + of: find.byType(SupabaseEnableSync), matching: find.byWidgetPredicate((widget) => widget is Switch), ); diff --git a/frontend/appflowy_flutter/integration_test/util/base.dart b/frontend/appflowy_flutter/integration_test/util/base.dart index b28188629b..835ca2ab87 100644 --- a/frontend/appflowy_flutter/integration_test/util/base.dart +++ b/frontend/appflowy_flutter/integration_test/util/base.dart @@ -83,7 +83,7 @@ extension AppFlowyTestBase on WidgetTester { } Future waitUntilSignInPageShow() async { - if (isCloudEnabled) { + if (isAuthEnabled) { final finder = find.byType(SignInAnonymousButton); await pumpUntilFound(finder); expect(finder, findsOneWidget); diff --git a/frontend/appflowy_flutter/integration_test/util/common_operations.dart b/frontend/appflowy_flutter/integration_test/util/common_operations.dart index ea7232a0fb..d5fb515293 100644 --- a/frontend/appflowy_flutter/integration_test/util/common_operations.dart +++ b/frontend/appflowy_flutter/integration_test/util/common_operations.dart @@ -303,7 +303,7 @@ extension CommonOperations on WidgetTester { KVKeys.showRenameDialogWhenCreatingNewFile, (value) => bool.parse(value), ); - final showRenameDialog = settingsOrFailure.fold((l) => false, (r) => r); + final showRenameDialog = settingsOrFailure.fold(() => false, (r) => r); if (showRenameDialog) { await tapOKButton(); } diff --git a/frontend/appflowy_flutter/lib/core/config/kv.dart b/frontend/appflowy_flutter/lib/core/config/kv.dart index 6b3cd813e9..971f9ab246 100644 --- a/frontend/appflowy_flutter/lib/core/config/kv.dart +++ b/frontend/appflowy_flutter/lib/core/config/kv.dart @@ -1,13 +1,10 @@ -import 'package:appflowy_backend/dispatch/dispatch.dart'; -import 'package:appflowy_backend/protobuf/flowy-config/entities.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:dartz/dartz.dart'; import 'package:shared_preferences/shared_preferences.dart'; abstract class KeyValueStorage { Future set(String key, String value); - Future> get(String key); - Future> getWithFormat( + Future> get(String key); + Future> getWithFormat( String key, T Function(String value) formatter, ); @@ -20,25 +17,25 @@ class DartKeyValue implements KeyValueStorage { SharedPreferences get sharedPreferences => _sharedPreferences!; @override - Future> get(String key) async { + Future> get(String key) async { await _initSharedPreferencesIfNeeded(); final value = sharedPreferences.getString(key); if (value != null) { - return Right(value); + return Some(value); } - return Left(FlowyError()); + return none(); } @override - Future> getWithFormat( + Future> getWithFormat( String key, T Function(String value) formatter, ) async { final value = await get(key); return value.fold( - (l) => left(l), - (r) => right(formatter(r)), + () => none(), + (s) => Some(formatter(s)), ); } @@ -67,38 +64,3 @@ class DartKeyValue implements KeyValueStorage { _sharedPreferences ??= await SharedPreferences.getInstance(); } } - -/// Key-value store -/// The data is stored in the local storage of the device. -class RustKeyValue { - static Future set(String key, String value) async { - await ConfigEventSetKeyValue( - KeyValuePB.create() - ..key = key - ..value = value, - ).send(); - } - - static Future> get(String key) async { - final payload = KeyPB.create()..key = key; - final response = await ConfigEventGetKeyValue(payload).send(); - return response.swap().map((r) => r.value); - } - - static Future> getWithFormat( - String key, - T Function(String value) formatter, - ) async { - final value = await get(key); - return value.fold( - (l) => left(l), - (r) => right(formatter(r)), - ); - } - - static Future remove(String key) async { - await ConfigEventRemoveKeyValue( - KeyPB.create()..key = key, - ).send(); - } -} diff --git a/frontend/appflowy_flutter/lib/core/config/kv_keys.dart b/frontend/appflowy_flutter/lib/core/config/kv_keys.dart index fef160c98c..264d40ed07 100644 --- a/frontend/appflowy_flutter/lib/core/config/kv_keys.dart +++ b/frontend/appflowy_flutter/lib/core/config/kv_keys.dart @@ -42,4 +42,9 @@ class KVKeys { /// The value is a boolean string. static const String showRenameDialogWhenCreatingNewFile = 'showRenameDialogWhenCreatingNewFile'; + + static const String kCloudType = 'kCloudType'; + static const String kAppflowyCloudBaseURL = 'kAppFlowyCloudBaseURL'; + static const String kSupabaseURL = 'kSupbaseURL'; + static const String kSupabaseAnonKey = 'kSupabaseAnonKey'; } diff --git a/frontend/appflowy_flutter/lib/env/backend_env.dart b/frontend/appflowy_flutter/lib/env/backend_env.dart index d4fb32b057..44958882d8 100644 --- a/frontend/appflowy_flutter/lib/env/backend_env.dart +++ b/frontend/appflowy_flutter/lib/env/backend_env.dart @@ -49,6 +49,10 @@ class SupabaseConfiguration { anon_key: '', ); } + + bool get isValid { + return url.isNotEmpty && anon_key.isNotEmpty; + } } @JsonSerializable() @@ -75,4 +79,10 @@ class AppFlowyCloudConfiguration { gotrue_url: '', ); } + + bool get isValid { + return base_url.isNotEmpty && + ws_base_url.isNotEmpty && + gotrue_url.isNotEmpty; + } } diff --git a/frontend/appflowy_flutter/lib/env/env.dart b/frontend/appflowy_flutter/lib/env/env.dart index 3cb2493926..750577f617 100644 --- a/frontend/appflowy_flutter/lib/env/env.dart +++ b/frontend/appflowy_flutter/lib/env/env.dart @@ -1,76 +1,104 @@ -// lib/env/env.dart +import 'package:appflowy/core/config/kv.dart'; +import 'package:appflowy/core/config/kv_keys.dart'; +import 'package:appflowy/env/backend_env.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy_backend/log.dart'; -import 'package:envied/envied.dart'; +import 'package:dartz/dartz.dart'; -part 'env.g.dart'; - -/// The environment variables are defined in `.env` file that is located in the -/// appflowy_flutter. -/// Run `dart run build_runner build --delete-conflicting-outputs` -/// to generate the keys from the env file. +/// Sets the cloud type for the application. /// -/// If you want to regenerate the keys, you need to run `dart run -/// build_runner clean` before running `dart run build_runner build -/// --delete-conflicting-outputs`. - -/// Follow the guide on https://supabase.com/docs/guides/auth/social-login/auth-google to setup the auth provider. +/// This method updates the cloud type setting in the key-value storage +/// using the [KeyValueStorage] service. The cloud type is identified +/// by the [CloudType] enum. /// -@Envied(path: '.env') -abstract class Env { - @EnviedField( - obfuscate: true, - varName: 'CLOUD_TYPE', - defaultValue: '0', - ) - static final int cloudType = _Env.cloudType; - - /// AppFlowy Cloud Configuration - @EnviedField( - obfuscate: true, - varName: 'APPFLOWY_CLOUD_BASE_URL', - defaultValue: '', - ) - static final String afCloudBaseUrl = _Env.afCloudBaseUrl; - - @EnviedField( - obfuscate: true, - varName: 'APPFLOWY_CLOUD_WS_BASE_URL', - defaultValue: '', - ) - static final String afCloudWSBaseUrl = _Env.afCloudWSBaseUrl; - - @EnviedField( - obfuscate: true, - varName: 'APPFLOWY_CLOUD_GOTRUE_URL', - defaultValue: '', - ) - static final String afCloudGoTrueUrl = _Env.afCloudGoTrueUrl; - - // Supabase Configuration: - @EnviedField( - obfuscate: true, - varName: 'SUPABASE_URL', - defaultValue: '', - ) - static final String supabaseUrl = _Env.supabaseUrl; - @EnviedField( - obfuscate: true, - varName: 'SUPABASE_ANON_KEY', - defaultValue: '', - ) - static final String supabaseAnonKey = _Env.supabaseAnonKey; +/// [ty] - The type of cloud to be set. It must be one of the values from +/// [CloudType] enum. The corresponding integer value of the enum is stored: +/// - `CloudType.local` is stored as "0". +/// - `CloudType.supabase` is stored as "1". +/// - `CloudType.appflowyCloud` is stored as "2". +Future setCloudType(CloudType ty) async { + switch (ty) { + case CloudType.local: + getIt().set(KVKeys.kCloudType, 0.toString()); + break; + case CloudType.supabase: + getIt().set(KVKeys.kCloudType, 1.toString()); + break; + case CloudType.appflowyCloud: + getIt().set(KVKeys.kCloudType, 2.toString()); + break; + } } -bool get isCloudEnabled { +/// Retrieves the currently set cloud type. +/// +/// This method fetches the cloud type setting from the key-value storage +/// using the [KeyValueStorage] service and returns the corresponding +/// [CloudType] enum value. +/// +/// Returns: +/// A Future that resolves to a [CloudType] enum value representing the +/// currently set cloud type. The default return value is `CloudType.local` +/// if no valid setting is found. +/// +Future getCloudType() async { + final value = await getIt().get(KVKeys.kCloudType); + return value.fold(() => CloudType.local, (s) { + switch (s) { + case "0": + return CloudType.local; + case "1": + return CloudType.supabase; + case "2": + return CloudType.appflowyCloud; + default: + return CloudType.local; + } + }); +} + +/// Determines whether authentication is enabled. +/// +/// This getter evaluates if authentication should be enabled based on the +/// current integration mode and cloud type settings. +/// +/// Returns: +/// A boolean value indicating whether authentication is enabled. It returns +/// `true` if the application is in release or develop mode, and the cloud type +/// is not set to `CloudType.local`. Additionally, it checks if either the +/// AppFlowy Cloud or Supabase configuration is valid. +/// Returns `false` otherwise. +bool get isAuthEnabled { // Only enable supabase in release and develop mode. if (integrationMode().isRelease || integrationMode().isDevelop) { - return currentCloudType().isEnabled; + final env = getIt(); + if (env.cloudType == CloudType.local) { + return false; + } + + if (env.cloudType == CloudType.supabase) { + return env.supabaseConfig.isValid; + } + + if (env.cloudType == CloudType.appflowyCloud) { + return env.appflowyCloudConfig.isValid; + } + + return false; } else { return false; } } +/// Checks if Supabase is enabled. +/// +/// This getter evaluates if Supabase should be enabled based on the +/// current integration mode and cloud type setting. +/// +/// Returns: +/// A boolean value indicating whether Supabase is enabled. It returns `true` +/// if the application is in release or develop mode and the current cloud type +/// is `CloudType.supabase`. Otherwise, it returns `false`. bool get isSupabaseEnabled { // Only enable supabase in release and develop mode. if (integrationMode().isRelease || integrationMode().isDevelop) { @@ -80,6 +108,15 @@ bool get isSupabaseEnabled { } } +/// Determines if AppFlowy Cloud is enabled. +/// +/// This getter assesses if AppFlowy Cloud should be enabled based on the +/// current integration mode and cloud type setting. +/// +/// Returns: +/// A boolean value indicating whether AppFlowy Cloud is enabled. It returns +/// `true` if the application is in release or develop mode and the current +/// cloud type is `CloudType.appflowyCloud`. Otherwise, it returns `false`. bool get isAppFlowyCloudEnabled { // Only enable appflowy cloud in release and develop mode. if (integrationMode().isRelease || integrationMode().isDevelop) { @@ -90,40 +127,125 @@ bool get isAppFlowyCloudEnabled { } enum CloudType { - unknown, + local, supabase, appflowyCloud; - bool get isEnabled => this != CloudType.unknown; + bool get isEnabled => this != CloudType.local; + int get value { + switch (this) { + case CloudType.local: + return 0; + case CloudType.supabase: + return 1; + case CloudType.appflowyCloud: + return 2; + } + } } CloudType currentCloudType() { - final value = Env.cloudType; - if (value == 1) { - if (Env.supabaseUrl.isEmpty || Env.supabaseAnonKey.isEmpty) { - Log.error( - "Supabase is not configured correctly. The values are: " - "url: ${Env.supabaseUrl}, anonKey: ${Env.supabaseAnonKey}", - ); - return CloudType.unknown; - } else { - return CloudType.supabase; - } - } - - if (value == 2) { - if (Env.afCloudBaseUrl.isEmpty || - Env.afCloudWSBaseUrl.isEmpty || - Env.afCloudGoTrueUrl.isEmpty) { - Log.error( - "AppFlowy cloud is not configured correctly. The values are: " - "baseUrl: ${Env.afCloudBaseUrl}, wsBaseUrl: ${Env.afCloudWSBaseUrl}, gotrueUrl: ${Env.afCloudGoTrueUrl}", - ); - return CloudType.unknown; - } else { - return CloudType.appflowyCloud; - } - } - - return CloudType.unknown; + return getIt().cloudType; +} + +Future setAppFlowyCloudBaseUrl(Option url) async { + await url.fold( + () => getIt().remove(KVKeys.kAppflowyCloudBaseURL), + (s) => getIt().set(KVKeys.kAppflowyCloudBaseURL, s), + ); +} + +/// Use getIt() to get the shared environment. +class AppFlowyCloudSharedEnv { + final CloudType cloudType; + final AppFlowyCloudConfiguration appflowyCloudConfig; + final SupabaseConfiguration supabaseConfig; + + AppFlowyCloudSharedEnv({ + required this.cloudType, + required this.appflowyCloudConfig, + required this.supabaseConfig, + }); +} + +Future getAppFlowyCloudConfig() async { + return AppFlowyCloudConfiguration( + base_url: await getAppFlowyCloudUrl(), + ws_base_url: await _getAppFlowyCloudWSUrl(), + gotrue_url: await _getAppFlowyCloudGotrueUrl(), + ); +} + +Future getAppFlowyCloudUrl() async { + final result = + await getIt().get(KVKeys.kAppflowyCloudBaseURL); + return result.fold( + () => "", + (url) => url, + ); +} + +Future _getAppFlowyCloudWSUrl() async { + try { + final serverUrl = await getAppFlowyCloudUrl(); + final uri = Uri.parse(serverUrl); + + // Construct the WebSocket URL directly from the parsed URI. + final wsScheme = uri.isScheme('HTTPS') ? 'wss' : 'ws'; + final wsUrl = Uri(scheme: wsScheme, host: uri.host, path: '/ws'); + + return wsUrl.toString(); + } catch (e) { + Log.error("Failed to get WebSocket URL: $e"); + return ""; + } +} + +Future _getAppFlowyCloudGotrueUrl() async { + final serverUrl = await getAppFlowyCloudUrl(); + return "$serverUrl/gotrue"; +} + +Future setSupbaseServer( + Option url, + Option anonKey, +) async { + assert( + (url.isSome() && anonKey.isSome()) || (url.isNone() && anonKey.isNone()), + "Either both Supabase URL and anon key must be set, or both should be unset", + ); + + await url.fold( + () => getIt().remove(KVKeys.kSupabaseURL), + (s) => getIt().set(KVKeys.kSupabaseURL, s), + ); + await anonKey.fold( + () => getIt().remove(KVKeys.kSupabaseAnonKey), + (s) => getIt().set(KVKeys.kSupabaseAnonKey, s), + ); +} + +Future getSupabaseCloudConfig() async { + final url = await _getSupbaseUrl(); + final anonKey = await _getSupabaseAnonKey(); + return SupabaseConfiguration( + url: url, + anon_key: anonKey, + ); +} + +Future _getSupbaseUrl() async { + final result = await getIt().get(KVKeys.kSupabaseURL); + return result.fold( + () => "", + (url) => url, + ); +} + +Future _getSupabaseAnonKey() async { + final result = await getIt().get(KVKeys.kSupabaseAnonKey); + return result.fold( + () => "", + (url) => url, + ); } diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/setting/personal_info/personal_info_setting_group.dart b/frontend/appflowy_flutter/lib/mobile/presentation/setting/personal_info/personal_info_setting_group.dart index a2807732a1..9b9e2499e3 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/setting/personal_info/personal_info_setting_group.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/setting/personal_info/personal_info_setting_group.dart @@ -33,7 +33,7 @@ class PersonalInfoSettingGroup extends StatelessWidget { settingItemList: [ MobileSettingItem( name: userName, - subtitle: isCloudEnabled + subtitle: isAuthEnabled ? Text( userProfile.email, style: theme.textTheme.bodyMedium?.copyWith( diff --git a/frontend/appflowy_flutter/lib/startup/deps_resolver.dart b/frontend/appflowy_flutter/lib/startup/deps_resolver.dart index cce062dd3a..433063d7f9 100644 --- a/frontend/appflowy_flutter/lib/startup/deps_resolver.dart +++ b/frontend/appflowy_flutter/lib/startup/deps_resolver.dart @@ -43,6 +43,10 @@ class DependencyResolver { GetIt getIt, IntegrationMode mode, ) async { + // getIt.registerFactory(() => RustKeyValue()); + getIt.registerFactory(() => DartKeyValue()); + + await _resolveCloudDeps(getIt); _resolveUserDeps(getIt, mode); _resolveHomeDeps(getIt); _resolveFolderDeps(getIt); @@ -52,12 +56,23 @@ class DependencyResolver { } } +Future _resolveCloudDeps(GetIt getIt) async { + final cloudType = await getCloudType(); + final appflowyCloudConfig = await getAppFlowyCloudConfig(); + final supabaseCloudConfig = await getSupabaseCloudConfig(); + getIt.registerFactory(() { + return AppFlowyCloudSharedEnv( + cloudType: cloudType, + appflowyCloudConfig: appflowyCloudConfig, + supabaseConfig: supabaseCloudConfig, + ); + }); +} + void _resolveCommonService( GetIt getIt, IntegrationMode mode, ) async { - // getIt.registerFactory(() => RustKeyValue()); - getIt.registerFactory(() => DartKeyValue()); getIt.registerFactory(() => FilePicker()); if (mode.isTest) { getIt.registerFactory( @@ -115,7 +130,7 @@ void _resolveCommonService( void _resolveUserDeps(GetIt getIt, IntegrationMode mode) { switch (currentCloudType()) { - case CloudType.unknown: + case CloudType.local: getIt.registerFactory( () => BackendAuthService( AuthTypePB.Local, diff --git a/frontend/appflowy_flutter/lib/startup/startup.dart b/frontend/appflowy_flutter/lib/startup/startup.dart index a325af3b54..709d3242d9 100644 --- a/frontend/appflowy_flutter/lib/startup/startup.dart +++ b/frontend/appflowy_flutter/lib/startup/startup.dart @@ -45,7 +45,7 @@ class FlowyRunner { await getIt.reset(); // Specify the env - initGetIt(getIt, mode, f, config); + await initGetIt(getIt, mode, f, config); final applicationDataDirectory = await getIt().getPath().then( diff --git a/frontend/appflowy_flutter/lib/startup/tasks/app_window_size_manager.dart b/frontend/appflowy_flutter/lib/startup/tasks/app_window_size_manager.dart index 6a7707998c..9683039465 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/app_window_size_manager.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/app_window_size_manager.dart @@ -50,7 +50,7 @@ class WindowSizeManager { Future getPosition() async { final position = await getIt().get(KVKeys.windowPosition); return position.fold( - (l) => null, + () => null, (r) { final offset = json.decode(r); return Offset(offset[dx], offset[dy]); diff --git a/frontend/appflowy_flutter/lib/startup/tasks/rust_sdk.dart b/frontend/appflowy_flutter/lib/startup/tasks/rust_sdk.dart index a86fe871c6..2ad1793abd 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/rust_sdk.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/rust_sdk.dart @@ -45,41 +45,15 @@ AppFlowyConfiguration _getAppFlowyConfiguration( String originAppPath, String deviceId, ) { - if (isCloudEnabled) { - final supabaseConfig = SupabaseConfiguration( - url: Env.supabaseUrl, - anon_key: Env.supabaseAnonKey, - ); - - final appflowyCloudConfig = AppFlowyCloudConfiguration( - base_url: Env.afCloudBaseUrl, - ws_base_url: Env.afCloudWSBaseUrl, - gotrue_url: Env.afCloudGoTrueUrl, - ); - - return AppFlowyConfiguration( - custom_app_path: customAppPath, - origin_app_path: originAppPath, - device_id: deviceId, - cloud_type: Env.cloudType, - supabase_config: supabaseConfig, - appflowy_cloud_config: appflowyCloudConfig, - ); - } else { - // Use the default configuration if the cloud feature is disabled - final supabaseConfig = SupabaseConfiguration.defaultConfig(); - final appflowyCloudConfig = AppFlowyCloudConfiguration.defaultConfig(); - - return AppFlowyConfiguration( - custom_app_path: customAppPath, - origin_app_path: originAppPath, - device_id: deviceId, - // 0 means the cloud type is local - cloud_type: 0, - supabase_config: supabaseConfig, - appflowy_cloud_config: appflowyCloudConfig, - ); - } + final env = getIt(); + return AppFlowyConfiguration( + custom_app_path: customAppPath, + origin_app_path: originAppPath, + device_id: deviceId, + cloud_type: env.cloudType.value, + supabase_config: env.supabaseConfig, + appflowy_cloud_config: env.appflowyCloudConfig, + ); } /// The default directory to store the user data. The directory can be diff --git a/frontend/appflowy_flutter/lib/startup/tasks/supabase_task.dart b/frontend/appflowy_flutter/lib/startup/tasks/supabase_task.dart index b8763f2e20..1dca479892 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/supabase_task.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/supabase_task.dart @@ -36,8 +36,8 @@ class InitSupabaseTask extends LaunchTask { supabase?.dispose(); supabase = null; final initializedSupabase = await Supabase.initialize( - url: Env.supabaseUrl, - anonKey: Env.supabaseAnonKey, + url: getIt().supabaseConfig.url, + anonKey: getIt().supabaseConfig.anon_key, debug: kDebugMode, localStorage: const SupabaseLocalStorage(), ); diff --git a/frontend/appflowy_flutter/lib/user/application/auth/af_cloud_auth_service.dart b/frontend/appflowy_flutter/lib/user/application/auth/af_cloud_auth_service.dart index 9e08959e6d..ee9dee46be 100644 --- a/frontend/appflowy_flutter/lib/user/application/auth/af_cloud_auth_service.dart +++ b/frontend/appflowy_flutter/lib/user/application/auth/af_cloud_auth_service.dart @@ -68,6 +68,7 @@ class AFCloudAuthService implements AuthService { final completer = Completer>(); _deeplinkSubscription = _appLinks.uriLinkStream.listen( (Uri? uri) async { + Log.info('onDeepLink: ${uri.toString()}'); await _handleUri(uri, completer); }, onError: (Object err, StackTrace stackTrace) { @@ -108,6 +109,9 @@ class AFCloudAuthService implements AuthService { .then((value) => value.swap()); _deeplinkSubscription?.cancel(); completer.complete(result); + } else { + Log.error('onDeepLinkError: Unexpect deep link: ${uri.toString()}'); + completer.complete(left(AuthError.signInWithOauthError)); } } else { Log.error('onDeepLinkError: Unexpect empty deep link callback'); diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/desktop_sign_in_screen.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/desktop_sign_in_screen.dart index 1da2e7cf84..cfe8efb7d2 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/desktop_sign_in_screen.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/desktop_sign_in_screen.dart @@ -1,4 +1,5 @@ import 'package:appflowy/core/frameless_window.dart'; +import 'package:appflowy/env/env.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart'; import 'package:appflowy/user/presentation/widgets/widgets.dart'; @@ -46,9 +47,12 @@ class DesktopSignInScreen extends StatelessWidget { // third-party sign in. const VSpace(20), - const _OrDivider(), - const VSpace(10), - const ThirdPartySignInButtons(), + + if (isAuthEnabled) ...[ + const _OrDivider(), + const VSpace(10), + const ThirdPartySignInButtons(), + ], const VSpace(20), // loading status const VSpace(indicatorMinHeight), diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/mobile_sign_in_screen.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/mobile_sign_in_screen.dart index 2449e9bb27..310f31e263 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/mobile_sign_in_screen.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/sign_in_screen/mobile_sign_in_screen.dart @@ -1,3 +1,4 @@ +import 'package:appflowy/env/env.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart'; @@ -85,7 +86,7 @@ class MobileSignInScreen extends StatelessWidget { ], ), const VSpace(spacing), - const ThirdPartySignInButtons(), + if (isAuthEnabled) const ThirdPartySignInButtons(), const VSpace(spacing), ], ), diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/skip_log_in_screen.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/skip_log_in_screen.dart index 9bcc4e5ff7..2cd43baa6a 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/skip_log_in_screen.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/skip_log_in_screen.dart @@ -67,7 +67,7 @@ class _SkipLogInScreenState extends State { ), const VSpace(32), SizedBox( - width: size.width * 0.5, + width: size.width * 0.7, child: FolderWidget( createFolderCallback: () async { _didCustomizeFolder = true; diff --git a/frontend/appflowy_flutter/lib/user/presentation/screens/splash_screen.dart b/frontend/appflowy_flutter/lib/user/presentation/screens/splash_screen.dart index 3cdba2c1fe..8f19b0b05f 100644 --- a/frontend/appflowy_flutter/lib/user/presentation/screens/splash_screen.dart +++ b/frontend/appflowy_flutter/lib/user/presentation/screens/splash_screen.dart @@ -95,10 +95,10 @@ class SplashScreen extends StatelessWidget { void _handleUnauthenticated(BuildContext context, Unauthenticated result) { Log.trace( - '_handleUnauthenticated -> cloud is enabled: $isCloudEnabled', + '_handleUnauthenticated -> cloud is enabled: $isAuthEnabled', ); // replace Splash screen as root page - if (isCloudEnabled) { + if (isAuthEnabled) { context.go(SignInScreen.routeName); } else { // if the env is not configured, we will skip to the 'skip login screen'. diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/appflowy_cloud_setting_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/appflowy_cloud_setting_bloc.dart new file mode 100644 index 0000000000..4777cbcae8 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/appflowy_cloud_setting_bloc.dart @@ -0,0 +1,91 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/settings/cloud_setting_listener.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_setting.pb.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:dartz/dartz.dart'; + +part 'appflowy_cloud_setting_bloc.freezed.dart'; + +class AppFlowyCloudSettingBloc + extends Bloc { + final UserCloudConfigListener _listener; + AppFlowyCloudSettingBloc(CloudSettingPB setting) + : _listener = UserCloudConfigListener(), + super(AppFlowyCloudSettingState.initial(setting)) { + on((event, emit) async { + await event.when( + initial: () async { + _listener.start( + onSettingChanged: (result) { + if (isClosed) { + return; + } + result.fold( + (setting) => + add(AppFlowyCloudSettingEvent.didReceiveSetting(setting)), + (error) => Log.error(error), + ); + }, + ); + }, + enableSync: (isEnable) async { + final config = UpdateCloudConfigPB.create()..enableSync = isEnable; + await UserEventSetCloudConfig(config).send(); + }, + didReceiveSetting: (CloudSettingPB setting) { + emit( + state.copyWith( + setting: setting, + ), + ); + }, + ); + }); + } + + @override + Future close() async { + _listener.stop(); + return super.close(); + } +} + +@freezed +class AppFlowyCloudSettingEvent with _$AppFlowyCloudSettingEvent { + const factory AppFlowyCloudSettingEvent.initial() = _Initial; + const factory AppFlowyCloudSettingEvent.enableSync(bool isEnable) = + _EnableSync; + const factory AppFlowyCloudSettingEvent.didReceiveSetting( + CloudSettingPB setting, + ) = _DidUpdateSetting; +} + +@freezed +class AppFlowyCloudSettingState with _$AppFlowyCloudSettingState { + const factory AppFlowyCloudSettingState({ + required CloudSettingPB setting, + }) = _AppFlowyCloudSettingState; + + factory AppFlowyCloudSettingState.initial(CloudSettingPB setting) => + AppFlowyCloudSettingState( + setting: setting, + ); +} + +Either validateUrl(String url) { + try { + // Use Uri.parse to validate the url. + final uri = Uri.parse(url); + if (uri.isScheme('HTTP') || uri.isScheme('HTTPS')) { + return right(()); + } else { + return left(LocaleKeys.settings_menu_invalidCloudURLScheme.tr()); + } + } catch (e) { + return left(e.toString()); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/appflowy_cloud_urls_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/appflowy_cloud_urls_bloc.dart new file mode 100644 index 0000000000..cdf88ee111 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/appflowy_cloud_urls_bloc.dart @@ -0,0 +1,95 @@ +import 'package:appflowy/env/backend_env.dart'; +import 'package:appflowy/env/env.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:dartz/dartz.dart'; + +part 'appflowy_cloud_urls_bloc.freezed.dart'; + +class AppFlowyCloudURLsBloc + extends Bloc { + AppFlowyCloudURLsBloc() : super(AppFlowyCloudURLsState.initial()) { + on((event, emit) async { + await event.when( + initial: () async {}, + updateServerUrl: (url) { + emit(state.copyWith(updatedServerUrl: url)); + }, + confirmUpdate: () async { + if (state.updatedServerUrl.isEmpty) { + emit( + state.copyWith( + updatedServerUrl: "", + urlError: none(), + restartApp: true, + ), + ); + await setAppFlowyCloudBaseUrl(none()); + } else { + validateUrl(state.updatedServerUrl).fold( + (error) => emit(state.copyWith(urlError: Some(error))), + (_) async { + if (state.config.base_url != state.updatedServerUrl) { + await setAppFlowyCloudBaseUrl(Some(state.updatedServerUrl)); + add(const AppFlowyCloudURLsEvent.didSaveConfig()); + } + }, + ); + } + }, + didSaveConfig: () { + emit( + state.copyWith( + urlError: none(), + restartApp: true, + ), + ); + }, + ); + }); + } +} + +@freezed +class AppFlowyCloudURLsEvent with _$AppFlowyCloudURLsEvent { + const factory AppFlowyCloudURLsEvent.initial() = _Initial; + const factory AppFlowyCloudURLsEvent.updateServerUrl(String text) = + _ServerUrl; + const factory AppFlowyCloudURLsEvent.confirmUpdate() = _UpdateConfig; + const factory AppFlowyCloudURLsEvent.didSaveConfig() = _DidSaveConfig; +} + +@freezed +class AppFlowyCloudURLsState with _$AppFlowyCloudURLsState { + const factory AppFlowyCloudURLsState({ + required AppFlowyCloudConfiguration config, + required String updatedServerUrl, + required Option urlError, + required bool restartApp, + }) = _AppFlowyCloudURLsState; + + factory AppFlowyCloudURLsState.initial() => AppFlowyCloudURLsState( + config: getIt().appflowyCloudConfig, + urlError: none(), + updatedServerUrl: + getIt().appflowyCloudConfig.base_url, + restartApp: false, + ); +} + +Either validateUrl(String url) { + try { + // Use Uri.parse to validate the url. + final uri = Uri.parse(url); + if (uri.isScheme('HTTP') || uri.isScheme('HTTPS')) { + return right(()); + } else { + return left(LocaleKeys.settings_menu_invalidCloudURLScheme.tr()); + } + } catch (e) { + return left(e.toString()); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/application_data_storage.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/application_data_storage.dart index 41963bdc34..400663f387 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/application_data_storage.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/application_data_storage.dart @@ -65,7 +65,7 @@ class ApplicationDataStorage { final response = await getIt().get(KVKeys.pathLocation); String path = await response.fold( - (error) async { + () async { // return the default path if the path is not set final directory = await appFlowyApplicationDataDirectory(); return directory.path; diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/cloud_setting_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/cloud_setting_bloc.dart new file mode 100644 index 0000000000..3bff8b7b40 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/cloud_setting_bloc.dart @@ -0,0 +1,38 @@ +import 'package:appflowy/env/env.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'cloud_setting_bloc.freezed.dart'; + +class CloudSettingBloc extends Bloc { + CloudSettingBloc(CloudType cloudType) + : super(CloudSettingState.initial(cloudType)) { + on((event, emit) async { + await event.when( + initial: () async {}, + updateCloudType: (CloudType newCloudType) async { + await setCloudType(newCloudType); + emit(state.copyWith(cloudType: newCloudType)); + }, + ); + }); + } +} + +@freezed +class CloudSettingEvent with _$CloudSettingEvent { + const factory CloudSettingEvent.initial() = _Initial; + const factory CloudSettingEvent.updateCloudType(CloudType newCloudType) = + _UpdateCloudType; +} + +@freezed +class CloudSettingState with _$CloudSettingState { + const factory CloudSettingState({ + required CloudType cloudType, + }) = _CloudSettingState; + + factory CloudSettingState.initial(CloudType cloudType) => CloudSettingState( + cloudType: cloudType, + ); +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/cloud_setting_listener.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/cloud_setting_listener.dart index be02c26501..a67da1a43d 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/cloud_setting_listener.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/cloud_setting_listener.dart @@ -10,21 +10,18 @@ import 'package:dartz/dartz.dart'; import '../../../core/notification/user_notification.dart'; class UserCloudConfigListener { - final String userId; StreamSubscription? _subscription; void Function(Either)? _onSettingChanged; UserNotificationParser? _userParser; - UserCloudConfigListener({ - required this.userId, - }); + UserCloudConfigListener(); void start({ void Function(Either)? onSettingChanged, }) { _onSettingChanged = onSettingChanged; _userParser = UserNotificationParser( - id: userId, + id: 'user_cloud_config', callback: _userNotificationCallback, ); _subscription = RustStreamReceiver.listen((observable) { diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/create_file_settings_cubit.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/create_file_settings_cubit.dart index 564fea5a51..143048c030 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/create_file_settings_cubit.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/create_file_settings_cubit.dart @@ -22,7 +22,7 @@ class CreateFileSettingsCubit extends Cubit { (value) => bool.parse(value), ); settingsOrFailure.fold( - (_) => emit(false), + () => emit(false), (settings) => emit(settings), ); } diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/setting_supabase_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/setting_supabase_bloc.dart deleted file mode 100644 index 4a064c665a..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/setting_supabase_bloc.dart +++ /dev/null @@ -1,86 +0,0 @@ -import 'package:appflowy/plugins/database_view/application/defines.dart'; -import 'package:appflowy_backend/dispatch/dispatch.dart'; -import 'package:appflowy_backend/log.dart'; -import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:dartz/dartz.dart'; - -import 'cloud_setting_listener.dart'; - -part 'setting_supabase_bloc.freezed.dart'; - -class CloudSettingBloc extends Bloc { - final UserCloudConfigListener _listener; - - CloudSettingBloc({ - required String userId, - required CloudSettingPB config, - }) : _listener = UserCloudConfigListener(userId: userId), - super(CloudSettingState.initial(config)) { - on((event, emit) async { - await event.when( - initial: () async { - _listener.start( - onSettingChanged: (result) { - if (isClosed) { - return; - } - - result.fold( - (config) => add(CloudSettingEvent.didReceiveConfig(config)), - (error) => Log.error(error), - ); - }, - ); - }, - enableSync: (bool enable) async { - final update = UpdateCloudConfigPB.create()..enableSync = enable; - updateCloudConfig(update); - }, - didReceiveConfig: (CloudSettingPB config) { - emit( - state.copyWith( - config: config, - loadingState: LoadingState.finish(left(unit)), - ), - ); - }, - enableEncrypt: (bool enable) { - final update = UpdateCloudConfigPB.create()..enableEncrypt = enable; - updateCloudConfig(update); - emit(state.copyWith(loadingState: const LoadingState.loading())); - }, - ); - }); - } - - Future updateCloudConfig(UpdateCloudConfigPB config) async { - await UserEventSetCloudConfig(config).send(); - } -} - -@freezed -class CloudSettingEvent with _$CloudSettingEvent { - const factory CloudSettingEvent.initial() = _Initial; - const factory CloudSettingEvent.didReceiveConfig( - CloudSettingPB config, - ) = _DidSyncSupabaseConfig; - const factory CloudSettingEvent.enableSync(bool enable) = _EnableSync; - const factory CloudSettingEvent.enableEncrypt(bool enable) = _EnableEncrypt; -} - -@freezed -class CloudSettingState with _$CloudSettingState { - const factory CloudSettingState({ - required CloudSettingPB config, - required Either successOrFailure, - required LoadingState loadingState, - }) = _CloudSettingState; - - factory CloudSettingState.initial(CloudSettingPB config) => CloudSettingState( - config: config, - successOrFailure: left(unit), - loadingState: LoadingState.finish(left(unit)), - ); -} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/supabase_cloud_setting_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/supabase_cloud_setting_bloc.dart new file mode 100644 index 0000000000..d8ad992eca --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/supabase_cloud_setting_bloc.dart @@ -0,0 +1,97 @@ +import 'package:appflowy/env/backend_env.dart'; +import 'package:appflowy/env/env.dart'; +import 'package:appflowy/plugins/database_view/application/defines.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:dartz/dartz.dart'; + +import 'cloud_setting_listener.dart'; + +part 'supabase_cloud_setting_bloc.freezed.dart'; + +class SupabaseCloudSettingBloc + extends Bloc { + final UserCloudConfigListener _listener; + + SupabaseCloudSettingBloc({ + required CloudSettingPB setting, + }) : _listener = UserCloudConfigListener(), + super(SupabaseCloudSettingState.initial(setting)) { + on((event, emit) async { + await event.when( + initial: () async { + _listener.start( + onSettingChanged: (result) { + if (isClosed) { + return; + } + result.fold( + (setting) => + add(SupabaseCloudSettingEvent.didReceiveSetting(setting)), + (error) => Log.error(error), + ); + }, + ); + }, + enableSync: (bool enable) async { + final update = UpdateCloudConfigPB.create()..enableSync = enable; + updateCloudConfig(update); + }, + didReceiveSetting: (CloudSettingPB setting) { + emit( + state.copyWith( + setting: setting, + loadingState: LoadingState.finish(left(unit)), + ), + ); + }, + enableEncrypt: (bool enable) { + final update = UpdateCloudConfigPB.create()..enableEncrypt = enable; + updateCloudConfig(update); + emit(state.copyWith(loadingState: const LoadingState.loading())); + }, + ); + }); + } + + Future updateCloudConfig(UpdateCloudConfigPB setting) async { + await UserEventSetCloudConfig(setting).send(); + } + + @override + Future close() async { + _listener.stop(); + return super.close(); + } +} + +@freezed +class SupabaseCloudSettingEvent with _$SupabaseCloudSettingEvent { + const factory SupabaseCloudSettingEvent.initial() = _Initial; + const factory SupabaseCloudSettingEvent.didReceiveSetting( + CloudSettingPB setting, + ) = _DidSyncSupabaseConfig; + const factory SupabaseCloudSettingEvent.enableSync(bool enable) = _EnableSync; + const factory SupabaseCloudSettingEvent.enableEncrypt(bool enable) = + _EnableEncrypt; +} + +@freezed +class SupabaseCloudSettingState with _$SupabaseCloudSettingState { + const factory SupabaseCloudSettingState({ + required LoadingState loadingState, + required SupabaseConfiguration config, + required CloudSettingPB setting, + }) = _SupabaseCloudSettingState; + + factory SupabaseCloudSettingState.initial(CloudSettingPB setting) => + SupabaseCloudSettingState( + loadingState: LoadingState.finish(left(unit)), + setting: setting, + config: getIt().supabaseConfig, + ); +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/supabase_cloud_urls_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/supabase_cloud_urls_bloc.dart new file mode 100644 index 0000000000..5186302c03 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/supabase_cloud_urls_bloc.dart @@ -0,0 +1,115 @@ +import 'package:appflowy/env/backend_env.dart'; +import 'package:appflowy/env/env.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:dartz/dartz.dart'; + +import 'appflowy_cloud_setting_bloc.dart'; + +part 'supabase_cloud_urls_bloc.freezed.dart'; + +class SupabaseCloudURLsBloc + extends Bloc { + SupabaseCloudURLsBloc() : super(SupabaseCloudURLsState.initial()) { + on((event, emit) async { + await event.when( + updateUrl: (String url) { + emit(state.copyWith(updatedUrl: url)); + }, + updateAnonKey: (String anonKey) { + emit(state.copyWith(upatedAnonKey: anonKey)); + }, + confirmUpdate: () async { + if (state.updatedUrl.isEmpty) { + emit( + state.copyWith( + urlError: none(), + anonKeyError: none(), + restartApp: true, + ), + ); + await setSupbaseServer(none(), none()); + } else { + // The anon key can't be empty if the url is not empty. + if (state.upatedAnonKey.isEmpty) { + emit( + state.copyWith( + urlError: none(), + anonKeyError: some( + LocaleKeys.settings_menu_cloudSupabaseAnonKeyCanNotBeEmpty + .tr(), + ), + restartApp: false, + ), + ); + return; + } + + validateUrl(state.updatedUrl).fold( + (error) => emit(state.copyWith(urlError: Some(error))), + (_) async { + await setSupbaseServer( + Some(state.updatedUrl), + Some(state.upatedAnonKey), + ); + + add(const SupabaseCloudURLsEvent.didSaveConfig()); + }, + ); + } + }, + didSaveConfig: () { + emit( + state.copyWith( + urlError: none(), + anonKeyError: none(), + restartApp: true, + ), + ); + }, + ); + }); + } + + Future updateCloudConfig(UpdateCloudConfigPB setting) async { + await UserEventSetCloudConfig(setting).send(); + } +} + +@freezed +class SupabaseCloudURLsEvent with _$SupabaseCloudURLsEvent { + const factory SupabaseCloudURLsEvent.updateUrl(String text) = _UpdateUrl; + const factory SupabaseCloudURLsEvent.updateAnonKey(String text) = + _UpdateAnonKey; + const factory SupabaseCloudURLsEvent.confirmUpdate() = _UpdateConfig; + const factory SupabaseCloudURLsEvent.didSaveConfig() = _DidSaveConfig; +} + +@freezed +class SupabaseCloudURLsState with _$SupabaseCloudURLsState { + const factory SupabaseCloudURLsState({ + required SupabaseConfiguration config, + required String updatedUrl, + required String upatedAnonKey, + required Option urlError, + required Option anonKeyError, + required bool restartApp, + }) = _SupabaseCloudURLsState; + + factory SupabaseCloudURLsState.initial() { + final config = getIt().supabaseConfig; + return SupabaseCloudURLsState( + updatedUrl: config.url, + upatedAnonKey: config.anon_key, + urlError: none(), + anonKeyError: none(), + restartApp: false, + config: config, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/application/sidebar/folder/folder_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/sidebar/folder/folder_bloc.dart index 95a888cfa3..19927043a4 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/sidebar/folder/folder_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/sidebar/folder/folder_bloc.dart @@ -35,7 +35,7 @@ class FolderBloc extends Bloc { Future _setFolderExpandStatus(bool isExpanded) async { final result = await getIt().get(KVKeys.expandedViews); final map = result.fold( - (l) => {}, + () => {}, (r) => jsonDecode(r), ); if (isExpanded) { @@ -50,7 +50,7 @@ class FolderBloc extends Bloc { Future _getFolderExpandStatus() async { return getIt().get(KVKeys.expandedViews).then((result) { - return result.fold((l) => true, (r) { + return result.fold(() => true, (r) { final map = jsonDecode(r); return map[state.type.name] ?? true; }); diff --git a/frontend/appflowy_flutter/lib/workspace/application/view/view_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/view/view_bloc.dart index ac8387346c..f25747903f 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/view/view_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/view/view_bloc.dart @@ -179,7 +179,7 @@ class ViewBloc extends Bloc { Future _setViewIsExpanded(ViewPB view, bool isExpanded) async { final result = await getIt().get(KVKeys.expandedViews); final map = result.fold( - (l) => {}, + () => {}, (r) => jsonDecode(r), ); if (isExpanded) { @@ -192,7 +192,7 @@ class ViewBloc extends Bloc { Future _getViewIsExpanded(ViewPB view) { return getIt().get(KVKeys.expandedViews).then((result) { - return result.fold((l) => false, (r) { + return result.fold(() => false, (r) { final map = jsonDecode(r); return map[view.id] ?? false; }); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/rename_view_dialog.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/rename_view_dialog.dart index 3856ce02f7..2dffa80360 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/rename_view_dialog.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/rename_view_dialog.dart @@ -21,7 +21,7 @@ Future createViewAndShowRenameDialogIfNeeded( KVKeys.showRenameDialogWhenCreatingNewFile, (value) => bool.parse(value), ); - final showRenameDialog = value.fold((l) => false, (r) => r); + final showRenameDialog = value.fold(() => false, (r) => r); if (context.mounted && showRenameDialog) { NavigatorTextFieldDialog( title: dialogTitle, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_user.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_user.dart index d726d92806..2d0a20ffdd 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_user.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/menu/sidebar/sidebar_user.dart @@ -89,7 +89,7 @@ class SidebarUser extends StatelessWidget { Log.warn("Can't pop dialog context"); } }, - didOpenUser: () async { + restartApp: () async { // Pop the dialog using the dialog context Navigator.of(dialogContext).pop(); await runAppFlowy(); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart index f4321cecb2..c33cf53ac1 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/settings_dialog.dart @@ -1,7 +1,6 @@ import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/settings_notifications_view.dart'; -import 'package:appflowy/workspace/presentation/settings/widgets/setting_cloud_view.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance_view.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/settings_customize_shortcuts_view.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/settings_file_system_view.dart'; @@ -14,6 +13,7 @@ import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'widgets/setting_cloud.dart'; const _dialogHorizontalPadding = EdgeInsets.symmetric(horizontal: 12); const _contentInsetPadding = EdgeInsets.fromLTRB(0.0, 12.0, 0.0, 16.0); @@ -21,13 +21,13 @@ const _contentInsetPadding = EdgeInsets.fromLTRB(0.0, 12.0, 0.0, 16.0); class SettingsDialog extends StatelessWidget { final VoidCallback dismissDialog; final VoidCallback didLogout; - final VoidCallback didOpenUser; + final VoidCallback restartApp; final UserProfilePB user; SettingsDialog( this.user, { required this.dismissDialog, required this.didLogout, - required this.didOpenUser, + required this.restartApp, Key? key, }) : super(key: ValueKey(user.id)); @@ -100,12 +100,14 @@ class SettingsDialog extends StatelessWidget { user, didLogin: () => dismissDialog(), didLogout: didLogout, - didOpenUser: didOpenUser, + didOpenUser: restartApp, ); case SettingsPage.notifications: return const SettingsNotificationsView(); case SettingsPage.cloud: - return SettingCloudView(userId: user.id.toString()); + return SettingCloud( + didResetServerUrl: () => restartApp(), + ); case SettingsPage.shortcuts: return const SettingsCustomizeShortcutsWrapper(); default: diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart new file mode 100644 index 0000000000..48828d2dff --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart @@ -0,0 +1,259 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/settings/appflowy_cloud_setting_bloc.dart'; +import 'package:appflowy/workspace/application/settings/appflowy_cloud_urls_bloc.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_setting.pb.dart'; +import 'package:dartz/dartz.dart' show Either; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/widget/error_page.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class SettingAppFlowyCloudView extends StatelessWidget { + final VoidCallback didResetServerUrl; + const SettingAppFlowyCloudView({required this.didResetServerUrl, super.key}); + + @override + Widget build(BuildContext context) { + return FutureBuilder>( + future: UserEventGetCloudConfig().send(), + builder: (context, snapshot) { + if (snapshot.data != null && + snapshot.connectionState == ConnectionState.done) { + return snapshot.data!.fold( + (setting) => _renderContent(setting), + (err) => FlowyErrorPage.message(err.toString(), howToFix: ""), + ); + } else { + return const Center( + child: CircularProgressIndicator(), + ); + } + }, + ); + } + + BlocProvider _renderContent( + CloudSettingPB setting, + ) { + return BlocProvider( + create: (context) => AppFlowyCloudSettingBloc(setting) + ..add(const AppFlowyCloudSettingEvent.initial()), + child: Column( + children: [ + const AppFlowyCloudEnableSync(), + const VSpace(40), + AppFlowyCloudURLs(didUpdateUrls: () => didResetServerUrl()), + ], + ), + ); + } +} + +class AppFlowyCloudURLs extends StatelessWidget { + final VoidCallback didUpdateUrls; + const AppFlowyCloudURLs({ + required this.didUpdateUrls, + super.key, + }); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => + AppFlowyCloudURLsBloc()..add(const AppFlowyCloudURLsEvent.initial()), + child: BlocListener( + listener: (context, state) { + if (state.restartApp) { + didUpdateUrls(); + } + }, + child: BlocBuilder( + builder: (context, state) { + return Column( + children: [ + const AppFlowySelfhostTip(), + CloudURLInput( + title: LocaleKeys.settings_menu_cloudURL.tr(), + url: state.config.base_url, + hint: LocaleKeys.settings_menu_cloudURLHint.tr(), + onChanged: (text) { + context.read().add( + AppFlowyCloudURLsEvent.updateServerUrl( + text, + ), + ); + }, + ), + const VSpace(20), + FlowyButton( + isSelected: true, + useIntrinsicWidth: true, + margin: const EdgeInsets.symmetric( + horizontal: 30, + vertical: 10, + ), + text: FlowyText( + LocaleKeys.settings_menu_restartApp.tr(), + ), + onTap: () { + NavigatorAlertDialog( + title: LocaleKeys.settings_menu_restartAppTip.tr(), + confirm: () => context.read().add( + const AppFlowyCloudURLsEvent.confirmUpdate(), + ), + ).show(context); + }, + ), + ], + ); + }, + ), + ), + ); + } +} + +class AppFlowySelfhostTip extends StatelessWidget { + final url = + "https://docs.appflowy.io/docs/guides/appflowy/self-hosting-appflowy#build-appflowy-with-a-self-hosted-server"; + const AppFlowySelfhostTip({super.key}); + + @override + Widget build(BuildContext context) { + return Opacity( + opacity: 0.6, + child: RichText( + text: TextSpan( + children: [ + TextSpan( + text: LocaleKeys.settings_menu_selfHostStart.tr(), + style: Theme.of(context).textTheme.bodySmall!, + ), + TextSpan( + text: " ${LocaleKeys.settings_menu_selfHostContent.tr()} ", + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + fontSize: FontSizes.s14, + color: Theme.of(context).colorScheme.primary, + decoration: TextDecoration.underline, + ), + recognizer: TapGestureRecognizer()..onTap = () => _launchURL(), + ), + TextSpan( + text: LocaleKeys.settings_menu_selfHostEnd.tr(), + style: Theme.of(context).textTheme.bodySmall!, + ), + ], + ), + ), + ); + } + + Future _launchURL() async { + final uri = Uri.parse(url); + if (await canLaunchUrl(uri)) { + await launchUrl(uri); + } else { + Log.error("Could not launch $url"); + } + } +} + +@visibleForTesting +class CloudURLInput extends StatefulWidget { + final String title; + final String url; + final String hint; + + final Function(String) onChanged; + + const CloudURLInput({ + required this.title, + required this.url, + required this.hint, + required this.onChanged, + Key? key, + }) : super(key: key); + + @override + CloudURLInputState createState() => CloudURLInputState(); +} + +class CloudURLInputState extends State { + late TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.url); + } + + @override + Widget build(BuildContext context) { + return TextField( + controller: _controller, + style: const TextStyle(fontSize: 12.0), + decoration: InputDecoration( + contentPadding: const EdgeInsets.symmetric(vertical: 6), + labelText: widget.title, + labelStyle: Theme.of(context) + .textTheme + .titleMedium! + .copyWith(fontWeight: FontWeight.w400, fontSize: 16), + enabledBorder: UnderlineInputBorder( + borderSide: + BorderSide(color: Theme.of(context).colorScheme.onBackground), + ), + focusedBorder: UnderlineInputBorder( + borderSide: BorderSide(color: Theme.of(context).colorScheme.primary), + ), + hintText: widget.hint, + errorText: context + .read() + .state + .urlError + .fold(() => null, (error) => error), + ), + onChanged: widget.onChanged, + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } +} + +class AppFlowyCloudEnableSync extends StatelessWidget { + const AppFlowyCloudEnableSync({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return Row( + children: [ + FlowyText.medium(LocaleKeys.settings_menu_enableSync.tr()), + const Spacer(), + Switch( + onChanged: (bool value) { + context.read().add( + AppFlowyCloudSettingEvent.enableSync(value), + ); + }, + value: state.setting.enableSync, + ), + ], + ); + }, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_cloud.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_cloud.dart new file mode 100644 index 0000000000..94de68f93c --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_cloud.dart @@ -0,0 +1,166 @@ +import 'package:appflowy/env/env.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/settings/cloud_setting_bloc.dart'; +import 'package:appflowy/workspace/presentation/settings/widgets/setting_local_cloud.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'setting_appflowy_cloud.dart'; +import 'setting_supabase_cloud.dart'; + +class SettingCloud extends StatelessWidget { + final VoidCallback didResetServerUrl; + const SettingCloud({required this.didResetServerUrl, super.key}); + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: getCloudType(), + builder: (BuildContext context, AsyncSnapshot snapshot) { + if (snapshot.hasData) { + final cloudType = snapshot.data!; + return BlocProvider( + create: (context) => CloudSettingBloc(cloudType), + child: BlocBuilder( + builder: (context, state) { + return Column( + children: [ + Row( + children: [ + Expanded( + child: FlowyText.medium( + LocaleKeys.settings_menu_cloudServerType.tr(), + ), + ), + Tooltip( + message: + LocaleKeys.settings_menu_cloudServerTypeTip.tr(), + child: CloudTypeSwitcher( + cloudType: state.cloudType, + onSelected: (newCloudType) { + context.read().add( + CloudSettingEvent.updateCloudType( + newCloudType, + ), + ); + }, + ), + ), + ], + ), + _viewFromCloudType(state.cloudType), + ], + ); + }, + ), + ); + } else { + return const Center( + child: CircularProgressIndicator(), + ); + } + }, + ); + } + + Widget _viewFromCloudType(CloudType cloudType) { + switch (cloudType) { + case CloudType.local: + return SettingLocalCloud(didResetServerUrl: didResetServerUrl); + case CloudType.supabase: + return SettingSupabaseCloudView( + didResetServerUrl: didResetServerUrl, + ); + case CloudType.appflowyCloud: + return SettingAppFlowyCloudView( + didResetServerUrl: didResetServerUrl, + ); + } + } +} + +class CloudTypeSwitcher extends StatelessWidget { + final CloudType cloudType; + final Function(CloudType) onSelected; + const CloudTypeSwitcher({ + required this.cloudType, + required this.onSelected, + super.key, + }); + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + direction: PopoverDirection.bottomWithRightAligned, + child: FlowyTextButton( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 6), + titleFromCloudType(cloudType), + fontColor: Theme.of(context).colorScheme.onBackground, + fillColor: Colors.transparent, + onPressed: () {}, + ), + popupBuilder: (BuildContext context) { + return ListView.builder( + shrinkWrap: true, + itemBuilder: (context, index) { + return CloudTypeItem( + cloudType: CloudType.values[index], + currentCloudtype: cloudType, + onSelected: onSelected, + ); + }, + itemCount: CloudType.values.length, + ); + }, + ); + } +} + +class CloudTypeItem extends StatelessWidget { + final CloudType cloudType; + final CloudType currentCloudtype; + final Function(CloudType) onSelected; + + const CloudTypeItem({ + required this.cloudType, + required this.currentCloudtype, + required this.onSelected, + super.key, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 32, + child: FlowyButton( + text: FlowyText.medium( + titleFromCloudType(cloudType), + ), + rightIcon: currentCloudtype == cloudType + ? const FlowySvg(FlowySvgs.check_s) + : null, + onTap: () { + if (currentCloudtype != cloudType) { + onSelected(cloudType); + } + PopoverContainer.of(context).close(); + }, + ), + ); + } +} + +String titleFromCloudType(CloudType cloudType) { + switch (cloudType) { + case CloudType.local: + return LocaleKeys.settings_menu_cloudLocal.tr(); + case CloudType.supabase: + return LocaleKeys.settings_menu_cloudSupabase.tr(); + case CloudType.appflowyCloud: + return LocaleKeys.settings_menu_cloudAppFlowy.tr(); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_cloud_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_cloud_view.dart deleted file mode 100644 index 45b693df84..0000000000 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_cloud_view.dart +++ /dev/null @@ -1,196 +0,0 @@ -import 'package:appflowy/env/env.dart'; -import 'package:appflowy/generated/locale_keys.g.dart'; -import 'package:appflowy/workspace/application/settings/setting_supabase_bloc.dart'; -import 'package:appflowy/workspace/presentation/home/toast.dart'; -import 'package:appflowy_backend/dispatch/dispatch.dart'; -import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-user/user_setting.pb.dart'; -import 'package:dartz/dartz.dart' show Either; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flowy_infra/size.dart'; -import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flowy_infra_ui/widget/error_page.dart'; -import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -class SettingCloudView extends StatelessWidget { - final String userId; - const SettingCloudView({required this.userId, super.key}); - - @override - Widget build(BuildContext context) { - return FutureBuilder>( - future: UserEventGetCloudConfig().send(), - builder: (context, snapshot) { - if (snapshot.data != null && - snapshot.connectionState == ConnectionState.done) { - return snapshot.data!.fold( - (config) { - return BlocProvider( - create: (context) => CloudSettingBloc( - userId: userId, - config: config, - )..add(const CloudSettingEvent.initial()), - child: BlocBuilder( - builder: (context, state) { - return Column( - children: [ - const EnableSync(), - // Currently the appflowy cloud is not support end-to-end encryption. - if (!isAppFlowyCloudEnabled) const EnableEncrypt(), - if (isAppFlowyCloudEnabled) - AppFlowyCloudInformationWidget( - url: state.config.serverUrl, - ), - ], - ); - }, - ), - ); - }, - (err) { - return FlowyErrorPage.message(err.toString(), howToFix: ""); - }, - ); - } else { - return const Center( - child: CircularProgressIndicator(), - ); - } - }, - ); - } -} - -class AppFlowyCloudInformationWidget extends StatelessWidget { - final String url; - - const AppFlowyCloudInformationWidget({required this.url, super.key}); - - @override - Widget build(BuildContext context) { - return Column( - children: [ - Row( - children: [ - Expanded( - // Wrap the Opacity widget with Expanded - child: Opacity( - opacity: 0.6, - child: FlowyText( - "${LocaleKeys.settings_menu_cloudURL.tr()}: $url", - maxLines: null, // Allow the text to wrap - ), - ), - ), - ], - ), - ], - ); - } -} - -class EnableEncrypt extends StatelessWidget { - const EnableEncrypt({super.key}); - - @override - Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - final indicator = state.loadingState.when( - loading: () => const CircularProgressIndicator.adaptive(), - finish: (successOrFail) => const SizedBox.shrink(), - ); - - return Column( - children: [ - Row( - children: [ - FlowyText.medium(LocaleKeys.settings_menu_enableEncrypt.tr()), - const Spacer(), - indicator, - const HSpace(3), - Switch( - onChanged: state.config.enableEncrypt - ? null - : (bool value) { - context - .read() - .add(CloudSettingEvent.enableEncrypt(value)); - }, - value: state.config.enableEncrypt, - ), - ], - ), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.start, - children: [ - IntrinsicHeight( - child: Opacity( - opacity: 0.6, - child: FlowyText.medium( - LocaleKeys.settings_menu_enableEncryptPrompt.tr(), - maxLines: 13, - ), - ), - ), - const VSpace(6), - SizedBox( - height: 40, - child: FlowyTooltip( - message: LocaleKeys.settings_menu_clickToCopySecret.tr(), - child: FlowyButton( - disable: !(state.config.enableEncrypt), - decoration: BoxDecoration( - borderRadius: Corners.s5Border, - border: Border.all( - color: Theme.of(context).colorScheme.secondary, - ), - ), - text: FlowyText.medium(state.config.encryptSecret), - onTap: () async { - await Clipboard.setData( - ClipboardData(text: state.config.encryptSecret), - ); - showMessageToast(LocaleKeys.message_copy_success.tr()); - }, - ), - ), - ), - ], - ), - ], - ); - }, - ); - } -} - -class EnableSync extends StatelessWidget { - const EnableSync({super.key}); - - @override - Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - return Row( - children: [ - FlowyText.medium(LocaleKeys.settings_menu_enableSync.tr()), - const Spacer(), - Switch( - onChanged: (bool value) { - context.read().add( - CloudSettingEvent.enableSync(value), - ); - }, - value: state.config.enableSync, - ), - ], - ); - }, - ); - } -} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_local_cloud.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_local_cloud.dart new file mode 100644 index 0000000000..8e2570ba43 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_local_cloud.dart @@ -0,0 +1,40 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/style_widget/button.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flutter/material.dart'; + +class SettingLocalCloud extends StatelessWidget { + final VoidCallback didResetServerUrl; + const SettingLocalCloud({ + required this.didResetServerUrl, + super.key, + }); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + FlowyButton( + isSelected: true, + useIntrinsicWidth: true, + margin: const EdgeInsets.symmetric( + horizontal: 30, + vertical: 10, + ), + text: FlowyText( + LocaleKeys.settings_menu_restartApp.tr(), + ), + onTap: () { + NavigatorAlertDialog( + title: LocaleKeys.settings_menu_restartAppTip.tr(), + confirm: didResetServerUrl, + ).show(context); + }, + ), + const Spacer(), + ], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_supabase_cloud.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_supabase_cloud.dart new file mode 100644 index 0000000000..91e5a6ec06 --- /dev/null +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_supabase_cloud.dart @@ -0,0 +1,356 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/application/settings/supabase_cloud_setting_bloc.dart'; +import 'package:appflowy/workspace/application/settings/supabase_cloud_urls_bloc.dart'; +import 'package:appflowy/workspace/presentation/home/toast.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_setting.pb.dart'; +import 'package:dartz/dartz.dart' show Either; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/widget/error_page.dart'; +import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class SettingSupabaseCloudView extends StatelessWidget { + final VoidCallback didResetServerUrl; + const SettingSupabaseCloudView({required this.didResetServerUrl, super.key}); + + @override + Widget build(BuildContext context) { + return FutureBuilder>( + future: UserEventGetCloudConfig().send(), + builder: (context, snapshot) { + if (snapshot.data != null && + snapshot.connectionState == ConnectionState.done) { + return snapshot.data!.fold( + (setting) { + return BlocProvider( + create: (context) => SupabaseCloudSettingBloc( + setting: setting, + )..add(const SupabaseCloudSettingEvent.initial()), + child: Column( + children: [ + BlocBuilder( + builder: (context, state) { + return const Column( + children: [ + SupabaseEnableSync(), + EnableEncrypt(), + ], + ); + }, + ), + const VSpace(40), + const SupabaseSelfhostTip(), + SupabaseCloudURLs( + didUpdateUrls: didResetServerUrl, + ), + ], + ), + ); + }, + (err) { + return FlowyErrorPage.message(err.toString(), howToFix: ""); + }, + ); + } else { + return const Center( + child: CircularProgressIndicator(), + ); + } + }, + ); + } +} + +class SupabaseCloudURLs extends StatelessWidget { + final VoidCallback didUpdateUrls; + const SupabaseCloudURLs({ + required this.didUpdateUrls, + super.key, + }); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => SupabaseCloudURLsBloc(), + child: BlocListener( + listener: (context, state) { + if (state.restartApp) { + didUpdateUrls(); + } + }, + child: BlocBuilder( + builder: (context, state) { + return Column( + children: [ + SupabaseInput( + title: LocaleKeys.settings_menu_cloudSupabaseUrl.tr(), + url: state.config.url, + hint: LocaleKeys.settings_menu_cloudURLHint.tr(), + onChanged: (text) { + context + .read() + .add(SupabaseCloudURLsEvent.updateUrl(text)); + }, + error: state.urlError.fold(() => null, (a) => a), + ), + SupabaseInput( + title: LocaleKeys.settings_menu_cloudSupabaseAnonKey.tr(), + url: state.config.anon_key, + hint: LocaleKeys.settings_menu_cloudURLHint.tr(), + onChanged: (text) { + context + .read() + .add(SupabaseCloudURLsEvent.updateAnonKey(text)); + }, + error: state.anonKeyError.fold(() => null, (a) => a), + ), + const VSpace(20), + FlowyButton( + isSelected: true, + useIntrinsicWidth: true, + margin: const EdgeInsets.symmetric( + horizontal: 30, + vertical: 10, + ), + text: FlowyText( + LocaleKeys.settings_menu_restartApp.tr(), + ), + onTap: () { + NavigatorAlertDialog( + title: LocaleKeys.settings_menu_restartAppTip.tr(), + confirm: () => context + .read() + .add(const SupabaseCloudURLsEvent.confirmUpdate()), + ).show(context); + }, + ), + ], + ); + }, + ), + ), + ); + } +} + +class EnableEncrypt extends StatelessWidget { + const EnableEncrypt({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final indicator = state.loadingState.when( + loading: () => const CircularProgressIndicator.adaptive(), + finish: (successOrFail) => const SizedBox.shrink(), + ); + + return Column( + children: [ + Row( + children: [ + FlowyText.medium(LocaleKeys.settings_menu_enableEncrypt.tr()), + const Spacer(), + indicator, + const HSpace(3), + Switch( + onChanged: state.setting.enableEncrypt + ? null + : (bool value) { + context.read().add( + SupabaseCloudSettingEvent.enableEncrypt(value), + ); + }, + value: state.setting.enableEncrypt, + ), + ], + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + IntrinsicHeight( + child: Opacity( + opacity: 0.6, + child: FlowyText.medium( + LocaleKeys.settings_menu_enableEncryptPrompt.tr(), + maxLines: 13, + ), + ), + ), + const VSpace(6), + SizedBox( + height: 40, + child: FlowyTooltip( + message: LocaleKeys.settings_menu_clickToCopySecret.tr(), + child: FlowyButton( + disable: !(state.setting.enableEncrypt), + decoration: BoxDecoration( + borderRadius: Corners.s5Border, + border: Border.all( + color: Theme.of(context).colorScheme.secondary, + ), + ), + text: FlowyText.medium(state.setting.encryptSecret), + onTap: () async { + await Clipboard.setData( + ClipboardData(text: state.setting.encryptSecret), + ); + showMessageToast(LocaleKeys.message_copy_success.tr()); + }, + ), + ), + ), + ], + ), + ], + ); + }, + ); + } +} + +class SupabaseEnableSync extends StatelessWidget { + const SupabaseEnableSync({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return Row( + children: [ + FlowyText.medium(LocaleKeys.settings_menu_enableSync.tr()), + const Spacer(), + Switch( + onChanged: (bool value) { + context.read().add( + SupabaseCloudSettingEvent.enableSync(value), + ); + }, + value: state.setting.enableSync, + ), + ], + ); + }, + ); + } +} + +@visibleForTesting +class SupabaseInput extends StatefulWidget { + final String title; + final String url; + final String hint; + final String? error; + final Function(String) onChanged; + + const SupabaseInput({ + required this.title, + required this.url, + required this.hint, + required this.onChanged, + required this.error, + Key? key, + }) : super(key: key); + + @override + SupabaseInputState createState() => SupabaseInputState(); +} + +class SupabaseInputState extends State { + late TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.url); + } + + @override + Widget build(BuildContext context) { + return TextField( + controller: _controller, + style: const TextStyle(fontSize: 12.0), + decoration: InputDecoration( + contentPadding: const EdgeInsets.symmetric(vertical: 6), + labelText: widget.title, + labelStyle: Theme.of(context) + .textTheme + .titleMedium! + .copyWith(fontWeight: FontWeight.w400, fontSize: 16), + enabledBorder: UnderlineInputBorder( + borderSide: + BorderSide(color: Theme.of(context).colorScheme.onBackground), + ), + focusedBorder: UnderlineInputBorder( + borderSide: BorderSide(color: Theme.of(context).colorScheme.primary), + ), + hintText: widget.hint, + errorText: widget.error, + ), + onChanged: widget.onChanged, + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } +} + +class SupabaseSelfhostTip extends StatelessWidget { + final url = + "https://docs.appflowy.io/docs/guides/appflowy/self-hosting-appflowy-using-supabase"; + const SupabaseSelfhostTip({super.key}); + + @override + Widget build(BuildContext context) { + return Opacity( + opacity: 0.6, + child: RichText( + text: TextSpan( + children: [ + TextSpan( + text: LocaleKeys.settings_menu_selfHostStart.tr(), + style: Theme.of(context).textTheme.bodySmall!, + ), + TextSpan( + text: " ${LocaleKeys.settings_menu_selfHostContent.tr()} ", + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + fontSize: FontSizes.s14, + color: Theme.of(context).colorScheme.primary, + decoration: TextDecoration.underline, + ), + recognizer: TapGestureRecognizer()..onTap = () => _launchURL(), + ), + TextSpan( + text: LocaleKeys.settings_menu_selfHostEnd.tr(), + style: Theme.of(context).textTheme.bodySmall!, + ), + ], + ), + ), + ); + } + + Future _launchURL() async { + final uri = Uri.parse(url); + if (await canLaunchUrl(uri)) { + await launchUrl(uri); + } else { + Log.error("Could not launch $url"); + } + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_third_party_login.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_third_party_login.dart index e823fa7550..7d79c30ed6 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_third_party_login.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/setting_third_party_login.dart @@ -1,3 +1,4 @@ +import 'package:appflowy/env/env.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/user/application/sign_in_bloc.dart'; @@ -55,7 +56,7 @@ class SettingThirdPartyLogin extends StatelessWidget { const VSpace(6), promptMessage, const VSpace(6), - const ThirdPartySignInButtons(), + if (isAuthEnabled) const ThirdPartySignInButtons(), const VSpace(6), ], ); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart index e0780b9f46..1113bf1b7e 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_menu.dart @@ -1,11 +1,8 @@ -import 'package:appflowy/env/env.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/application/settings/settings_dialog_bloc.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/settings_menu_element.dart'; -import 'package:appflowy_backend/protobuf/flowy-user/auth.pbenum.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; class SettingsMenu extends StatelessWidget { const SettingsMenu({ @@ -19,9 +16,6 @@ class SettingsMenu extends StatelessWidget { @override Widget build(BuildContext context) { - final bool showSyncSetting = isCloudEnabled && - context.read().state.userProfile.authType != - AuthTypePB.Local; return Column( children: [ SettingsMenuElement( @@ -63,17 +57,14 @@ class SettingsMenu extends StatelessWidget { icon: Icons.notifications_outlined, changeSelectedPage: changeSelectedPage, ), - // Only show supabase setting if supabase is enabled and the current auth type is not local - if (showSyncSetting) ...[ - const SizedBox(height: 10), - SettingsMenuElement( - page: SettingsPage.cloud, - selectedPage: currentPage, - label: LocaleKeys.settings_menu_cloudSetting.tr(), - icon: Icons.sync, - changeSelectedPage: changeSelectedPage, - ), - ], + const SizedBox(height: 10), + SettingsMenuElement( + page: SettingsPage.cloud, + selectedPage: currentPage, + label: LocaleKeys.settings_menu_cloudSetting.tr(), + icon: Icons.sync, + changeSelectedPage: changeSelectedPage, + ), const SizedBox(height: 10), SettingsMenuElement( page: SettingsPage.shortcuts, diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_user_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_user_view.dart index cf045d41b7..474ee86948 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_user_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/settings_user_view.dart @@ -54,7 +54,7 @@ class SettingsUserView extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ _buildUserIconSetting(context), - if (isCloudEnabled && user.authType != AuthTypePB.Local) ...[ + if (isAuthEnabled && user.authType != AuthTypePB.Local) ...[ const VSpace(12), UserEmailInput(user.email), ], @@ -190,7 +190,7 @@ class SettingsUserView extends StatelessWidget { BuildContext context, SettingsUserState state, ) { - if (!isCloudEnabled) { + if (!isAuthEnabled) { return const SizedBox.shrink(); } diff --git a/frontend/appflowy_tauri/src-tauri/Cargo.lock b/frontend/appflowy_tauri/src-tauri/Cargo.lock index 2d72eb4b6d..b38b5c3a43 100644 --- a/frontend/appflowy_tauri/src-tauri/Cargo.lock +++ b/frontend/appflowy_tauri/src-tauri/Cargo.lock @@ -2148,6 +2148,7 @@ dependencies = [ "client-api", "collab-database", "collab-document", + "collab-persistence", "fancy-regex 0.11.0", "flowy-codegen", "flowy-derive", diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 07d92af9cb..3ad9e271e9 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -266,9 +266,27 @@ "selfEncryptionLogoutPrompt": "Are you sure you want to log out? Please ensure you have copied the encryption secret", "syncSetting": "Sync Setting", "cloudSetting": "Cloud Setting", - "cloudURL": "Server URL", "enableSync": "Enable sync", "enableEncrypt": "Encrypt data", + "cloudURL": "Base URL", + "invalidCloudURLScheme": "Invalid Scheme", + "cloudServerType": "Cloud server", + "cloudServerTypeTip": "Please note that it might log out your current account after switching the cloud server", + "cloudLocal": "Local", + "cloudSupabase": "Supabase", + "cloudSupabaseUrl": "Supabase URL", + "cloudSupabaseAnonKey": "Supabase anon key", + "cloudSupabaseAnonKeyCanNotBeEmpty": "The anon key can't be empty if the supabase url is not empty", + "cloudAppFlowy": "AppFlowy Cloud", + "clickToCopy": "Click to copy", + "selfHostStart": "If you don't have a server, please refer to the", + "selfHostContent": "document", + "selfHostEnd": "for guidance on how to self-host your own server", + "cloudURLHint": "Input the base URL of your server", + "cloudWSURL": "Websocket URL", + "cloudWSURLHint": "Input the websocket address of your server", + "restartApp": "Restart", + "restartAppTip": "Restart the application for the changes to take effect. Please note that this might log out your current account", "enableEncryptPrompt": "Activate encryption to secure your data with this secret. Store it safely; once enabled, it can't be turned off. If lost, your data becomes irretrievable. Click to copy", "inputEncryptPrompt": "Please enter your encryption secret for", "clickToCopySecret": "Click to copy secret", diff --git a/frontend/rust-lib/Cargo.lock b/frontend/rust-lib/Cargo.lock index e7d513bd76..beb8953f56 100644 --- a/frontend/rust-lib/Cargo.lock +++ b/frontend/rust-lib/Cargo.lock @@ -1952,6 +1952,7 @@ dependencies = [ "client-api", "collab-database", "collab-document", + "collab-persistence", "fancy-regex 0.11.0", "flowy-codegen", "flowy-derive", diff --git a/frontend/rust-lib/dart-ffi/src/env_serde.rs b/frontend/rust-lib/dart-ffi/src/env_serde.rs index 75a897a073..0c9ff74e74 100644 --- a/frontend/rust-lib/dart-ffi/src/env_serde.rs +++ b/frontend/rust-lib/dart-ffi/src/env_serde.rs @@ -25,13 +25,7 @@ impl AppFlowyDartConfiguration { pub fn write_env_from(env_str: &str) { let configuration = Self::from_str(env_str); configuration.cloud_type.write_env(); - let is_valid = configuration.appflowy_cloud_config.write_env().is_ok(); - // Note on Configuration Priority: - // If both Supabase config and AppFlowy cloud config are provided in the '.env' file, - // the AppFlowy cloud config will be prioritized and the Supabase config ignored. - // Ensure only one of these configurations is active at any given time. - if !is_valid { - let _ = configuration.supabase_config.write_env(); - } + configuration.appflowy_cloud_config.write_env(); + configuration.supabase_config.write_env(); } } diff --git a/frontend/rust-lib/dart-ffi/src/lib.rs b/frontend/rust-lib/dart-ffi/src/lib.rs index 7d5c4bd0f7..ab116e1adc 100644 --- a/frontend/rust-lib/dart-ffi/src/lib.rs +++ b/frontend/rust-lib/dart-ffi/src/lib.rs @@ -55,14 +55,8 @@ pub extern "C" fn init_sdk(data: *mut c_char) -> i64 { let configuration = AppFlowyDartConfiguration::from_str(serde_str); configuration.cloud_type.write_env(); - let is_valid = configuration.appflowy_cloud_config.write_env().is_ok(); - // Note on Configuration Priority: - // If both Supabase config and AppFlowy cloud config are provided in the '.env' file, - // the AppFlowy cloud config will be prioritized and the Supabase config ignored. - // Ensure only one of these configurations is active at any given time. - if !is_valid { - let _ = configuration.supabase_config.write_env(); - } + configuration.appflowy_cloud_config.write_env(); + configuration.supabase_config.write_env(); let log_crates = vec!["flowy-ffi".to_string()]; let config = AppFlowyCoreConfig::new( diff --git a/frontend/rust-lib/event-integration/tests/util.rs b/frontend/rust-lib/event-integration/tests/util.rs index c0066bbfd7..c62b58360c 100644 --- a/frontend/rust-lib/event-integration/tests/util.rs +++ b/frontend/rust-lib/event-integration/tests/util.rs @@ -213,7 +213,7 @@ impl AFCloudTest { test.set_auth_type(AuthTypePB::AFCloud); test .server_provider - .set_authenticator(Authenticator::AFCloud); + .set_authenticator(Authenticator::AppFlowyCloud); Some(Self { inner: test }) } diff --git a/frontend/rust-lib/flowy-core/src/config.rs b/frontend/rust-lib/flowy-core/src/config.rs index 53ed1985c4..eb0a503158 100644 --- a/frontend/rust-lib/flowy-core/src/config.rs +++ b/frontend/rust-lib/flowy-core/src/config.rs @@ -35,6 +35,7 @@ impl fmt::Debug for AppFlowyCoreConfig { if let Some(config) = &self.cloud_config { debug.field("base_url", &config.base_url); debug.field("ws_url", &config.ws_base_url); + debug.field("gotrue_url", &config.gotrue_url); } debug.finish() } @@ -43,8 +44,12 @@ impl fmt::Debug for AppFlowyCoreConfig { fn migrate_local_version_data_folder(root: &str, url: &str) -> String { // Isolate the user data folder by using the base url of AppFlowy cloud. This is to avoid // the user data folder being shared by different AppFlowy cloud. - let server_base64 = URL_SAFE_ENGINE.encode(url); - let storage_path = format!("{}_{}", root, server_base64); + let storage_path = if !url.is_empty() { + let server_base64 = URL_SAFE_ENGINE.encode(url); + format!("{}_{}", root, server_base64) + } else { + root.to_string() + }; // Copy the user data folder from the root path to the isolated path // The root path without any suffix is the created by the local version AppFlowy diff --git a/frontend/rust-lib/flowy-core/src/integrate/server.rs b/frontend/rust-lib/flowy-core/src/integrate/server.rs index 57aa7ff2a6..666890ea6d 100644 --- a/frontend/rust-lib/flowy-core/src/integrate/server.rs +++ b/frontend/rust-lib/flowy-core/src/integrate/server.rs @@ -14,7 +14,6 @@ use flowy_server_config::af_cloud_config::AFCloudConfiguration; use flowy_server_config::supabase_config::SupabaseConfiguration; use flowy_sqlite::kv::StorePreferences; use flowy_user::services::database::{get_user_profile, get_user_workspace, open_user_db}; -use flowy_user_deps::cloud::UserCloudService; use flowy_user_deps::entities::*; use crate::AppFlowyCoreConfig; @@ -56,8 +55,6 @@ pub struct ServerProvider { providers: RwLock>>, pub(crate) encryption: RwLock>, pub(crate) store_preferences: Weak, - pub(crate) cache_user_service: RwLock>>, - pub(crate) enable_sync: RwLock, pub(crate) uid: Arc>>, } @@ -76,7 +73,6 @@ impl ServerProvider { enable_sync: RwLock::new(true), encryption: RwLock::new(Arc::new(encryption)), store_preferences, - cache_user_service: Default::default(), uid: Default::default(), } } @@ -148,7 +144,7 @@ impl From for ServerType { fn from(auth_provider: Authenticator) -> Self { match auth_provider { Authenticator::Local => ServerType::Local, - Authenticator::AFCloud => ServerType::AFCloud, + Authenticator::AppFlowyCloud => ServerType::AFCloud, Authenticator::Supabase => ServerType::Supabase, } } @@ -158,7 +154,7 @@ impl From for Authenticator { fn from(ty: ServerType) -> Self { match ty { ServerType::Local => Authenticator::Local, - ServerType::AFCloud => Authenticator::AFCloud, + ServerType::AFCloud => Authenticator::AppFlowyCloud, ServerType::Supabase => Authenticator::Supabase, } } diff --git a/frontend/rust-lib/flowy-core/src/integrate/trait_impls.rs b/frontend/rust-lib/flowy-core/src/integrate/trait_impls.rs index 2893e8ea5d..b6b732f20d 100644 --- a/frontend/rust-lib/flowy-core/src/integrate/trait_impls.rs +++ b/frontend/rust-lib/flowy-core/src/integrate/trait_impls.rs @@ -113,16 +113,8 @@ impl UserCloudServiceProvider for ServerProvider { /// Returns the [UserCloudService] base on the current [ServerType]. /// Creates a new [AppFlowyServer] if it doesn't exist. fn get_user_service(&self) -> Result, FlowyError> { - if let Some(user_service) = self.cache_user_service.read().get(&self.get_server_type()) { - return Ok(user_service.clone()); - } - let server_type = self.get_server_type(); let user_service = self.get_server(&server_type)?.user_service(); - self - .cache_user_service - .write() - .insert(server_type, user_service.clone()); Ok(user_service) } diff --git a/frontend/rust-lib/flowy-error/Cargo.toml b/frontend/rust-lib/flowy-error/Cargo.toml index 6a99f0b155..8047ac5119 100644 --- a/frontend/rust-lib/flowy-error/Cargo.toml +++ b/frontend/rust-lib/flowy-error/Cargo.toml @@ -27,6 +27,7 @@ r2d2 = { version = "0.8", optional = true } url = { version = "2.2", optional = true } collab-database = { version = "0.1.0", optional = true } collab-document = { version = "0.1.0", optional = true } +collab-persistence = { version = "0.1.0", optional = true } tokio-postgres = { version = "0.7.8", optional = true } client-api = { version = "0.1.0", optional = true } @@ -36,7 +37,7 @@ impl_from_dispatch_error = ["lib-dispatch"] impl_from_serde = [] impl_from_reqwest = ["reqwest"] impl_from_sqlite = ["flowy-sqlite", "r2d2"] -impl_from_collab = ["collab-database", "collab-document", "impl_from_reqwest"] +impl_from_collab = ["collab-database", "collab-document", "impl_from_reqwest", "collab-persistence"] impl_from_postgres = ["tokio-postgres"] impl_from_url = ["url"] impl_from_appflowy_cloud = ["client-api"] diff --git a/frontend/rust-lib/flowy-error/src/impl_from/collab.rs b/frontend/rust-lib/flowy-error/src/impl_from/collab.rs index f55c6f5440..f939653ad8 100644 --- a/frontend/rust-lib/flowy-error/src/impl_from/collab.rs +++ b/frontend/rust-lib/flowy-error/src/impl_from/collab.rs @@ -1,8 +1,18 @@ use collab_database::error::DatabaseError; use collab_document::error::DocumentError; +use collab_persistence::PersistenceError; -use crate::FlowyError; +use crate::{ErrorCode, FlowyError}; +impl From for FlowyError { + fn from(err: PersistenceError) -> Self { + match err { + PersistenceError::RocksdbCorruption(_) => FlowyError::new(ErrorCode::RocksdbCorruption, err), + PersistenceError::RocksdbIOError(_) => FlowyError::new(ErrorCode::RocksdbIOError, err), + _ => FlowyError::new(ErrorCode::RocksdbInternal, err), + } + } +} impl From for FlowyError { fn from(error: DatabaseError) -> Self { FlowyError::internal().with_context(error) diff --git a/frontend/rust-lib/flowy-server-config/src/af_cloud_config.rs b/frontend/rust-lib/flowy-server-config/src/af_cloud_config.rs index fb735a9131..d75c30a673 100644 --- a/frontend/rust-lib/flowy-server-config/src/af_cloud_config.rs +++ b/frontend/rust-lib/flowy-server-config/src/af_cloud_config.rs @@ -2,7 +2,7 @@ use std::fmt::Display; use serde::{Deserialize, Serialize}; -use flowy_error::{ErrorCode, FlowyError, FlowyResult}; +use flowy_error::{ErrorCode, FlowyError}; pub const APPFLOWY_CLOUD_BASE_URL: &str = "APPFLOWY_CLOUD_ENV_APPFLOWY_CLOUD_BASE_URL"; pub const APPFLOWY_CLOUD_WS_BASE_URL: &str = "APPFLOWY_CLOUD_ENV_APPFLOWY_CLOUD_WS_BASE_URL"; @@ -60,25 +60,10 @@ impl AFCloudConfiguration { }) } - pub fn validate(&self) -> Result<(), FlowyError> { - if self.base_url.is_empty() || self.ws_base_url.is_empty() || self.gotrue_url.is_empty() { - return Err(FlowyError::new( - ErrorCode::InvalidAuthConfig, - format!( - "Invalid APPFLOWY_CLOUD_BASE_URL: {}, APPFLOWY_CLOUD_WS_BASE_URL: {}, APPFLOWY_CLOUD_GOTRUE_URL: {}", - self.base_url, self.ws_base_url, self.gotrue_url, - )), - ); - } - Ok(()) - } - /// Write the configuration to the environment variables. - pub fn write_env(&self) -> FlowyResult<()> { - self.validate()?; + pub fn write_env(&self) { std::env::set_var(APPFLOWY_CLOUD_BASE_URL, &self.base_url); std::env::set_var(APPFLOWY_CLOUD_WS_BASE_URL, &self.ws_base_url); std::env::set_var(APPFLOWY_CLOUD_GOTRUE_URL, &self.gotrue_url); - Ok(()) } } diff --git a/frontend/rust-lib/flowy-server-config/src/supabase_config.rs b/frontend/rust-lib/flowy-server-config/src/supabase_config.rs index 6e2a08170b..90dbe39bc5 100644 --- a/frontend/rust-lib/flowy-server-config/src/supabase_config.rs +++ b/frontend/rust-lib/flowy-server-config/src/supabase_config.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -use flowy_error::{ErrorCode, FlowyError, FlowyResult}; +use flowy_error::{ErrorCode, FlowyError}; pub const SUPABASE_URL: &str = "APPFLOWY_CLOUD_ENV_SUPABASE_URL"; pub const SUPABASE_ANON_KEY: &str = "APPFLOWY_CLOUD_ENV_SUPABASE_ANON_KEY"; @@ -33,21 +33,9 @@ impl SupabaseConfiguration { Ok(Self { url, anon_key }) } - pub fn validate(&self) -> Result<(), FlowyError> { - if self.url.is_empty() || self.anon_key.is_empty() { - return Err(FlowyError::new( - ErrorCode::InvalidAuthConfig, - "Missing SUPABASE_URL or SUPABASE_ANON_KEY", - )); - } - Ok(()) - } - /// Write the configuration to the environment variables. - pub fn write_env(&self) -> FlowyResult<()> { - self.validate()?; + pub fn write_env(&self) { std::env::set_var(SUPABASE_URL, &self.url); std::env::set_var(SUPABASE_ANON_KEY, &self.anon_key); - Ok(()) } } diff --git a/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/dto.rs b/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/dto.rs index cda882766c..4b727e8427 100644 --- a/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/dto.rs +++ b/frontend/rust-lib/flowy-server/src/af_cloud/impls/user/dto.rs @@ -57,7 +57,7 @@ pub fn user_profile_from_af_profile( openai_key: openai_key.unwrap_or_default(), stability_ai_key: stability_ai_key.unwrap_or_default(), workspace_id: profile.latest_workspace_id.to_string(), - authenticator: Authenticator::AFCloud, + authenticator: Authenticator::AppFlowyCloud, encryption_type, uid: profile.uid, updated_at: profile.updated_at, diff --git a/frontend/rust-lib/flowy-user-deps/src/entities.rs b/frontend/rust-lib/flowy-user-deps/src/entities.rs index 05642f7da4..3ee7f535cf 100644 --- a/frontend/rust-lib/flowy-user-deps/src/entities.rs +++ b/frontend/rust-lib/flowy-user-deps/src/entities.rs @@ -327,7 +327,7 @@ pub enum Authenticator { Local = 0, /// Currently not supported. It will be supported in the future when the /// [AppFlowy-Server](https://github.com/AppFlowy-IO/AppFlowy-Server) ready. - AFCloud = 1, + AppFlowyCloud = 1, /// It uses Supabase as the backend. Supabase = 2, } @@ -348,7 +348,7 @@ impl From for Authenticator { fn from(value: i32) -> Self { match value { 0 => Authenticator::Local, - 1 => Authenticator::AFCloud, + 1 => Authenticator::AppFlowyCloud, 2 => Authenticator::Supabase, _ => Authenticator::Local, } diff --git a/frontend/rust-lib/flowy-user/src/entities/user_setting.rs b/frontend/rust-lib/flowy-user/src/entities/user_setting.rs index 810bcba00b..caaf524381 100644 --- a/frontend/rust-lib/flowy-user/src/entities/user_setting.rs +++ b/frontend/rust-lib/flowy-user/src/entities/user_setting.rs @@ -244,3 +244,15 @@ impl std::default::Default for NotificationSettingsPB { } } } + +#[derive(Default, ProtoBuf)] +pub struct AppFlowyCloudSettingPB { + #[pb(index = 1)] + pub base_url: bool, + + #[pb(index = 2)] + pub ws_addr: bool, + + #[pb(index = 3)] + pub gotrue_url: String, +} diff --git a/frontend/rust-lib/flowy-user/src/event_handler.rs b/frontend/rust-lib/flowy-user/src/event_handler.rs index ab36866300..7b1b3f0d1c 100644 --- a/frontend/rust-lib/flowy-user/src/event_handler.rs +++ b/frontend/rust-lib/flowy-user/src/event_handler.rs @@ -2,6 +2,7 @@ use std::sync::Weak; use std::{convert::TryInto, sync::Arc}; use serde_json::Value; +use tracing::event; use flowy_error::{ErrorCode, FlowyError, FlowyResult}; use flowy_sqlite::kv::StorePreferences; @@ -86,7 +87,7 @@ pub async fn get_user_profile_handler( ) -> DataResult { let manager = upgrade_manager(manager)?; let uid = manager.get_session()?.user_id; - let mut user_profile = manager.get_user_profile(uid).await?; + let mut user_profile = manager.get_user_profile_from_disk(uid).await?; let weak_manager = Arc::downgrade(&manager); let cloned_user_profile = user_profile.clone(); @@ -285,6 +286,7 @@ pub async fn sign_in_with_provider_handler( let manager = upgrade_manager(manager)?; tracing::debug!("Sign in with provider: {:?}", data.provider.as_str()); let sign_in_url = manager.generate_oauth_url(data.provider.as_str()).await?; + event!(tracing::Level::DEBUG, "Sign in url: {}", sign_in_url); data_result_ok(OauthProviderDataPB { oauth_url: sign_in_url, }) @@ -332,7 +334,7 @@ pub async fn check_encrypt_secret_handler( ) -> DataResult { let manager = upgrade_manager(manager)?; let uid = manager.get_session()?.user_id; - let profile = manager.get_user_profile(uid).await?; + let profile = manager.get_user_profile_from_disk(uid).await?; let is_need_secret = match profile.encryption_type { EncryptionType::NoEncryption => false, @@ -402,7 +404,8 @@ pub async fn set_cloud_config_handler( }; send_notification( - &session.user_id.to_string(), + // Don't change this key. it's also used in the frontend + "user_cloud_config", UserNotification::DidUpdateCloudConfig, ) .payload(payload) diff --git a/frontend/rust-lib/flowy-user/src/manager.rs b/frontend/rust-lib/flowy-user/src/manager.rs index d6200402c6..f90c0ffbcd 100644 --- a/frontend/rust-lib/flowy-user/src/manager.rs +++ b/frontend/rust-lib/flowy-user/src/manager.rs @@ -16,6 +16,7 @@ use tracing::{debug, error, event, info, instrument}; use collab_integrate::collab_builder::AppFlowyCollabBuilder; use collab_integrate::RocksCollabDB; use flowy_error::{internal_error, ErrorCode, FlowyResult}; +use flowy_server_config::AuthenticatorType; use flowy_sqlite::kv::StorePreferences; use flowy_sqlite::schema::user_table; use flowy_sqlite::ConnectionPool; @@ -157,7 +158,27 @@ impl UserManager { *self.collab_interact.write().await = Arc::new(collab_interact); if let Ok(session) = self.get_session() { - let user = self.get_user_profile(session.user_id).await?; + let user = self.get_user_profile_from_disk(session.user_id).await?; + + // Get the current authenticator from the environment variable + let current_authenticator = match AuthenticatorType::from_env() { + AuthenticatorType::Local => Authenticator::Local, + AuthenticatorType::Supabase => Authenticator::Supabase, + AuthenticatorType::AppFlowyCloud => Authenticator::AppFlowyCloud, + }; + + // If the current authenticator is different from the authenticator in the session and it's + // not a local authenticator, we need to sign out the user. + if user.authenticator != Authenticator::Local && user.authenticator != current_authenticator { + event!( + tracing::Level::INFO, + "Authenticator changed from {:?} to {:?}", + user.authenticator, + current_authenticator + ); + self.sign_out().await?; + return Ok(()); + } event!( tracing::Level::INFO, @@ -201,9 +222,7 @@ impl UserManager { // Do the user data migration if needed event!(tracing::Level::INFO, "Prepare user data migration"); match ( - self - .database - .get_collab_db(session.user_id, &self.user_config.device_id), + self.database.get_collab_db(session.user_id), self.database.get_pool(session.user_id), ) { (Ok(collab_db), Ok(sqlite_pool)) => { @@ -257,7 +276,7 @@ impl UserManager { pub fn get_collab_db(&self, uid: i64) -> Result, FlowyError> { self .database - .get_collab_db(uid, "") + .get_collab_db(uid) .map(|collab_db| Arc::downgrade(&collab_db)) } @@ -477,7 +496,7 @@ impl UserManager { let session = self.get_session()?; upsert_user_profile_change(session.user_id, self.db_pool(session.user_id)?, changeset)?; - let profile = self.get_user_profile(session.user_id).await?; + let profile = self.get_user_profile_from_disk(session.user_id).await?; self .update_user(session.user_id, profile.token, params) .await?; @@ -507,7 +526,7 @@ impl UserManager { } /// Fetches the user profile for the given user ID. - pub async fn get_user_profile(&self, uid: i64) -> Result { + pub async fn get_user_profile_from_disk(&self, uid: i64) -> Result { let user: UserProfile = user_table::dsl::user_table .filter(user_table::id.eq(&uid.to_string())) .first::(&*(self.db_connection(uid)?)) @@ -524,14 +543,18 @@ impl UserManager { #[tracing::instrument(level = "info", skip_all, err)] pub async fn refresh_user_profile(&self, old_user_profile: &UserProfile) -> FlowyResult<()> { - let now = chrono::Utc::now().timestamp(); + // If the user is a local user, no need to refresh the user profile + if old_user_profile.authenticator.is_local() { + return Ok(()); + } + let now = chrono::Utc::now().timestamp(); // Add debounce to avoid too many requests if now - self.refresh_user_profile_since.load(Ordering::SeqCst) < 5 { return Ok(()); } - self.refresh_user_profile_since.store(now, Ordering::SeqCst); + let uid = old_user_profile.uid; let result: Result = self .cloud_services @@ -541,27 +564,6 @@ impl UserManager { match result { Ok(new_user_profile) => { - // If the authentication type has changed, it indicates that the user has signed in - // using a different release package but is sharing the same data folder. - // In such cases, notify the frontend to log out. - if old_user_profile.authenticator != Authenticator::Local - && new_user_profile.authenticator != old_user_profile.authenticator - { - event!( - tracing::Level::INFO, - "User login with different authenticator: {:?} -> {:?}", - old_user_profile.authenticator, - new_user_profile.authenticator - ); - - send_auth_state_notification(AuthStateChangedPB { - state: AuthStatePB::InvalidAuth, - message: "User login with different cloud".to_string(), - }) - .send(); - return Ok(()); - } - // If the user profile is updated, save the new user profile if new_user_profile.updated_at > old_user_profile.updated_at { validate_encryption_sign(old_user_profile, &new_user_profile.encryption_type.sign()); @@ -578,6 +580,15 @@ impl UserManager { tracing::Level::ERROR, "User is unauthorized, sign out the user" ); + + self.add_historical_user( + uid, + &self.user_config.device_id, + old_user_profile.name.clone(), + &old_user_profile.authenticator, + self.user_dir(uid), + ); + self.sign_out().await?; send_auth_state_notification(AuthStateChangedPB { state: AuthStatePB::InvalidAuth, @@ -592,7 +603,7 @@ impl UserManager { #[instrument(level = "info", skip_all)] pub fn user_dir(&self, uid: i64) -> String { - self.user_paths.user_dir(uid) + self.user_paths.user_data_dir(uid) } pub fn user_setting(&self) -> Result { @@ -713,7 +724,9 @@ impl UserManager { &self, oauth_provider: &str, ) -> Result { - self.update_authenticator(&Authenticator::AFCloud).await; + self + .update_authenticator(&Authenticator::AppFlowyCloud) + .await; let auth_service = self.cloud_services.get_user_service()?; let url = auth_service .generate_oauth_url_with_provider(oauth_provider) @@ -757,7 +770,7 @@ impl UserManager { let session = self.get_session()?; if session.user_id == user_update.uid { debug!("Receive user update: {:?}", user_update); - let user_profile = self.get_user_profile(user_update.uid).await?; + let user_profile = self.get_user_profile_from_disk(user_update.uid).await?; if !validate_encryption_sign(&user_profile, &user_update.encryption_sign) { return Ok(()); } @@ -778,12 +791,8 @@ impl UserManager { old_user: &MigrationUser, new_user: &MigrationUser, ) -> Result<(), FlowyError> { - let old_collab_db = self - .database - .get_collab_db(old_user.session.user_id, &self.user_config.device_id)?; - let new_collab_db = self - .database - .get_collab_db(new_user.session.user_id, &self.user_config.device_id)?; + let old_collab_db = self.database.get_collab_db(old_user.session.user_id)?; + let new_collab_db = self.database.get_collab_db(new_user.session.user_id)?; migration_anon_user_on_sign_up(old_user, &old_collab_db, new_user, &new_collab_db)?; if let Err(err) = sync_user_data_to_cloud( @@ -859,24 +868,26 @@ impl UserPaths { fn new(root: String) -> Self { Self { root } } - fn user_dir(&self, uid: i64) -> String { + + /// Returns the path to the user's data directory. + fn user_data_dir(&self, uid: i64) -> String { format!("{}/{}", self.root, uid) } } impl UserDBPath for UserPaths { fn user_db_path(&self, uid: i64) -> PathBuf { - PathBuf::from(self.user_dir(uid)) + PathBuf::from(self.user_data_dir(uid)) } fn collab_db_path(&self, uid: i64) -> PathBuf { - let mut path = PathBuf::from(self.user_dir(uid)); + let mut path = PathBuf::from(self.user_data_dir(uid)); path.push("collab_db"); path } fn collab_db_history(&self, uid: i64, create_if_not_exist: bool) -> std::io::Result { - let path = PathBuf::from(self.user_dir(uid)).join("collab_db_history"); + let path = PathBuf::from(self.user_data_dir(uid)).join("collab_db_history"); if !path.exists() && create_if_not_exist { fs::create_dir_all(&path)?; } diff --git a/frontend/rust-lib/flowy-user/src/services/database.rs b/frontend/rust-lib/flowy-user/src/services/database.rs index e9a6a564f1..1ab6d34b28 100644 --- a/frontend/rust-lib/flowy-user/src/services/database.rs +++ b/frontend/rust-lib/flowy-user/src/services/database.rs @@ -7,7 +7,7 @@ use parking_lot::RwLock; use tracing::{error, event, info, instrument}; use collab_integrate::{PersistenceError, RocksCollabDB, YrsDocAction}; -use flowy_error::{ErrorCode, FlowyError}; +use flowy_error::FlowyError; use flowy_sqlite::schema::user_workspace_table; use flowy_sqlite::ConnectionPool; use flowy_sqlite::{ @@ -50,40 +50,47 @@ impl UserDB { /// attempts to restore the database from the latest backup. /// - If the CollabDB does not exist, it immediately attempts to restore from the latest backup. /// + #[instrument(level = "debug", skip_all)] pub fn backup_or_restore(&self, uid: i64, workspace_id: &str) { + // Obtain the path for the collaboration database. let collab_db_path = self.paths.collab_db_path(uid); + + // Obtain the history folder path, proceed if successful. if let Ok(history_folder) = self.paths.collab_db_history(uid, true) { + // Initialize the backup utility for the collaboration database. + let zip_backup = CollabDBZipBackup::new(collab_db_path.clone(), history_folder); + if collab_db_path.exists() { + // Validate the existing collaboration database. let is_ok = validate_collab_db(&collab_db_path, uid, workspace_id); - let zip_backup = CollabDBZipBackup::new(collab_db_path, history_folder); + if is_ok { - // If the database opens successfully, it attempts to back it up in the background. + // If database is valid, update the shared map and initiate backup. + // Asynchronous backup operation. af_spawn(async move { - let _ = tokio::task::spawn_blocking(move || { - if let Err(err) = zip_backup.backup() { - error!("backup collab db failed, {:?}", err); - } - }) - .await; + if let Err(err) = tokio::task::spawn_blocking(move || zip_backup.backup()).await { + error!("Backup of collab db failed: {:?}", err); + } }); } else if let Err(err) = zip_backup.restore_latest_backup() { - error!("restore collab db failed, {:?}", err); - } - } else { - let zip_backup = CollabDBZipBackup::new(collab_db_path, history_folder); - if let Err(err) = zip_backup.restore_latest_backup() { - error!("restore collab db failed, {:?}", err); + // If validation fails, attempt to restore from the latest backup. + error!("Restoring collab db failed: {:?}", err); } + } else if let Err(err) = zip_backup.restore_latest_backup() { + // If collab database does not exist, attempt to restore from the latest backup. + error!("Restoring collab db failed: {:?}", err); } } } + #[instrument(level = "debug", skip_all)] pub fn restore_if_need(&self, uid: i64, workspace_id: &str) { if let Ok(history_folder) = self.paths.collab_db_history(uid, false) { let collab_db_path = self.paths.collab_db_path(uid); let is_ok = validate_collab_db(&collab_db_path, uid, workspace_id); - let zip_backup = CollabDBZipBackup::new(collab_db_path, history_folder); + if !is_ok { + let zip_backup = CollabDBZipBackup::new(collab_db_path, history_folder); if let Err(err) = zip_backup.restore_latest_backup() { error!("restore collab db failed, {:?}", err); } @@ -118,15 +125,11 @@ impl UserDB { Ok(pool) } - pub(crate) fn get_collab_db( - &self, - user_id: i64, - _device_id: &str, - ) -> Result, FlowyError> { + pub(crate) fn get_collab_db(&self, user_id: i64) -> Result, FlowyError> { let collab_db = open_collab_db( - self.paths.user_db_path(user_id), + // self.paths.user_db_path(user_id), self.paths.collab_db_path(user_id), - self.paths.collab_db_history(user_id, false).ok(), + // self.paths.collab_db_history(user_id, false).ok(), user_id, )?; Ok(collab_db) @@ -175,31 +178,24 @@ pub fn get_user_workspace( /// Open a collab db for the user. If the db is already opened, return the opened db. /// fn open_collab_db( - _collab_backup_db_path: impl AsRef, collab_db_path: impl AsRef, - _collab_db_history: Option, uid: i64, -) -> Result, FlowyError> { +) -> Result, PersistenceError> { if let Some(collab_db) = COLLAB_DB_MAP.read().get(&uid) { return Ok(collab_db.clone()); } let mut write_guard = COLLAB_DB_MAP.write(); + info!( + "open collab db for user {} at path: {:?}", + uid, + collab_db_path.as_ref() + ); let db = match RocksCollabDB::open(&collab_db_path) { Ok(db) => Ok(db), Err(err) => { - tracing::error!("open collab db error, {:?}", err); - match err { - PersistenceError::RocksdbCorruption(_) => { - // try restore from the backup db - Err(FlowyError::new(ErrorCode::RocksdbCorruption, err)) - }, - PersistenceError::RocksdbIOError(_) => { - // - Err(FlowyError::new(ErrorCode::RocksdbIOError, err)) - }, - _ => Err(FlowyError::new(ErrorCode::RocksdbInternal, err)), - } + error!("open collab db error, {:?}", err); + Err(err) }, }?; @@ -337,12 +333,17 @@ pub(crate) fn validate_collab_db( // Attempt to open the collaboration database using the workspace_id. The workspace_id must already // exist in the collab database. If it does not, it may be indicative of corruption in the collab database // due to other factors. - let result = RocksCollabDB::open(&collab_db_path).map(|db| { - let read_txn = db.read_txn(); - read_txn.is_exist(uid, workspace_id) - }); - match result { - Ok(is_ok) => is_ok, + info!( + "open collab db to validate data integration for user {} at path: {:?}", + uid, + collab_db_path.as_ref() + ); + + match open_collab_db(&collab_db_path, uid) { + Ok(db) => { + let read_txn = db.read_txn(); + read_txn.is_exist(uid, workspace_id) + }, // return false if the error is not related to corruption Err(err) => !matches!( err, diff --git a/frontend/rust-lib/flowy-user/src/services/entities.rs b/frontend/rust-lib/flowy-user/src/services/entities.rs index f42dd84d06..7c779584e5 100644 --- a/frontend/rust-lib/flowy-user/src/services/entities.rs +++ b/frontend/rust-lib/flowy-user/src/services/entities.rs @@ -155,7 +155,7 @@ impl From for Authenticator { match pb { AuthTypePB::Supabase => Authenticator::Supabase, AuthTypePB::Local => Authenticator::Local, - AuthTypePB::AFCloud => Authenticator::AFCloud, + AuthTypePB::AFCloud => Authenticator::AppFlowyCloud, } } } @@ -165,7 +165,7 @@ impl From for AuthTypePB { match auth_type { Authenticator::Supabase => AuthTypePB::Supabase, Authenticator::Local => AuthTypePB::Local, - Authenticator::AFCloud => AuthTypePB::AFCloud, + Authenticator::AppFlowyCloud => AuthTypePB::AFCloud, } } } diff --git a/frontend/rust-lib/flowy-user/src/services/historical_user.rs b/frontend/rust-lib/flowy-user/src/services/historical_user.rs index 5b6a51c391..d3f06aa8e0 100644 --- a/frontend/rust-lib/flowy-user/src/services/historical_user.rs +++ b/frontend/rust-lib/flowy-user/src/services/historical_user.rs @@ -17,7 +17,10 @@ impl UserManager { // Only migrate the data if the user is login in as a guest and sign up as a new user if the current // auth type is not [AuthType::Local]. let session = self.get_session().ok()?; - let user_profile = self.get_user_profile(session.user_id).await.ok()?; + let user_profile = self + .get_user_profile_from_disk(session.user_id) + .await + .ok()?; if user_profile.authenticator == Authenticator::Local && !auth_type.is_local() { Some(MigrationUser { user_profile, @@ -44,7 +47,7 @@ impl UserManager { uid: i64, device_id: &str, user_name: String, - auth_type: &Authenticator, + authenticator: &Authenticator, storage_path: String, ) { let mut logger_users = self @@ -54,7 +57,7 @@ impl UserManager { logger_users.add_user(HistoricalUser { user_id: uid, user_name, - auth_type: auth_type.clone(), + auth_type: authenticator.clone(), sign_in_timestamp: timestamp(), storage_path, device_id: device_id.to_string(),