diff --git a/auth/lib/core/configuration.dart b/auth/lib/core/configuration.dart index 3e6540af65..da3ad9ebf8 100644 --- a/auth/lib/core/configuration.dart +++ b/auth/lib/core/configuration.dart @@ -6,6 +6,7 @@ import 'dart:typed_data'; import 'package:bip39/bip39.dart' as bip39; import 'package:ente_auth/core/constants.dart'; import 'package:ente_auth/core/event_bus.dart'; +import 'package:ente_auth/events/endpoint_updated_event.dart'; import 'package:ente_auth/events/signed_in_event.dart'; import 'package:ente_auth/events/signed_out_event.dart'; import 'package:ente_auth/models/key_attributes.dart'; @@ -42,6 +43,7 @@ class Configuration { static const userIDKey = "user_id"; static const hasMigratedSecureStorageKey = "has_migrated_secure_storage"; static const hasOptedForOfflineModeKey = "has_opted_for_offline_mode"; + static const endPointKey = "endpoint"; final List onlineSecureKeys = [ keyKey, secretKeyKey, @@ -317,7 +319,12 @@ class Configuration { } String getHttpEndpoint() { - return endpoint; + return _preferences.getString(endPointKey) ?? endpoint; + } + + Future setHttpEndpoint(String endpoint) async { + await _preferences.setString(endPointKey, endpoint); + Bus.instance.fire(EndpointUpdatedEvent()); } String? getToken() { diff --git a/auth/lib/core/network.dart b/auth/lib/core/network.dart index b9fba1cd8c..1942fcbb83 100644 --- a/auth/lib/core/network.dart +++ b/auth/lib/core/network.dart @@ -2,28 +2,24 @@ import 'dart:io'; import 'package:dio/dio.dart'; import 'package:ente_auth/core/configuration.dart'; -import 'package:ente_auth/core/constants.dart'; +import 'package:ente_auth/core/event_bus.dart'; +import 'package:ente_auth/events/endpoint_updated_event.dart'; import 'package:fk_user_agent/fk_user_agent.dart'; import 'package:flutter/foundation.dart'; import 'package:package_info_plus/package_info_plus.dart'; -import 'package:shared_preferences/shared_preferences.dart'; import 'package:uuid/uuid.dart'; int kConnectTimeout = 15000; class Network { - // apiEndpoint points to the Ente server's API endpoint - static const apiEndpoint = String.fromEnvironment( - "endpoint", - defaultValue: kDefaultProductionEndpoint, - ); late Dio _dio; late Dio _enteDio; Future init() async { await FkUserAgent.init(); final packageInfo = await PackageInfo.fromPlatform(); - final preferences = await SharedPreferences.getInstance(); + final endpoint = Configuration.instance.getHttpEndpoint(); + _dio = Dio( BaseOptions( connectTimeout: kConnectTimeout, @@ -34,10 +30,10 @@ class Network { }, ), ); - _dio.interceptors.add(RequestIdInterceptor()); + _enteDio = Dio( BaseOptions( - baseUrl: apiEndpoint, + baseUrl: endpoint, connectTimeout: kConnectTimeout, headers: { HttpHeaders.userAgentHeader: FkUserAgent.userAgent, @@ -46,7 +42,13 @@ class Network { }, ), ); - _enteDio.interceptors.add(EnteRequestInterceptor(preferences, apiEndpoint)); + _setupInterceptors(endpoint); + + Bus.instance.on().listen((event) { + final endpoint = Configuration.instance.getHttpEndpoint(); + _enteDio.options.baseUrl = endpoint; + _setupInterceptors(endpoint); + }); } Network._privateConstructor(); @@ -55,34 +57,41 @@ class Network { Dio getDio() => _dio; Dio get enteDio => _enteDio; + + void _setupInterceptors(String endpoint) { + _dio.interceptors.clear(); + _dio.interceptors.add(RequestIdInterceptor()); + + _enteDio.interceptors.clear(); + _enteDio.interceptors.add(EnteRequestInterceptor(endpoint)); + } } class RequestIdInterceptor extends Interceptor { @override void onRequest(RequestOptions options, RequestInterceptorHandler handler) { - // ignore: prefer_const_constructors - options.headers.putIfAbsent("x-request-id", () => Uuid().v4().toString()); + options.headers + .putIfAbsent("x-request-id", () => const Uuid().v4().toString()); return super.onRequest(options, handler); } } class EnteRequestInterceptor extends Interceptor { - final SharedPreferences _preferences; - final String enteEndpoint; + final String endpoint; - EnteRequestInterceptor(this._preferences, this.enteEndpoint); + EnteRequestInterceptor(this.endpoint); @override void onRequest(RequestOptions options, RequestInterceptorHandler handler) { if (kDebugMode) { assert( - options.baseUrl == enteEndpoint, + options.baseUrl == endpoint, "interceptor should only be used for API endpoint", ); } - // ignore: prefer_const_constructors - options.headers.putIfAbsent("x-request-id", () => Uuid().v4().toString()); - final String? tokenValue = _preferences.getString(Configuration.tokenKey); + options.headers + .putIfAbsent("x-request-id", () => const Uuid().v4().toString()); + final String? tokenValue = Configuration.instance.getToken(); if (tokenValue != null) { options.headers.putIfAbsent("X-Auth-Token", () => tokenValue); } diff --git a/auth/lib/events/endpoint_updated_event.dart b/auth/lib/events/endpoint_updated_event.dart new file mode 100644 index 0000000000..0a9915479a --- /dev/null +++ b/auth/lib/events/endpoint_updated_event.dart @@ -0,0 +1,3 @@ +import 'package:ente_auth/events/event.dart'; + +class EndpointUpdatedEvent extends Event {} diff --git a/auth/lib/gateway/authenticator.dart b/auth/lib/gateway/authenticator.dart index 1ea1bee328..ee19c79b0d 100644 --- a/auth/lib/gateway/authenticator.dart +++ b/auth/lib/gateway/authenticator.dart @@ -1,43 +1,29 @@ import 'package:dio/dio.dart'; -import 'package:ente_auth/core/configuration.dart'; import 'package:ente_auth/core/errors.dart'; +import 'package:ente_auth/core/network.dart'; import 'package:ente_auth/models/authenticator/auth_entity.dart'; import 'package:ente_auth/models/authenticator/auth_key.dart'; class AuthenticatorGateway { - final Dio _dio; - final Configuration _config; - late String _basedEndpoint; + late Dio _enteDio; - AuthenticatorGateway(this._dio, this._config) { - _basedEndpoint = _config.getHttpEndpoint() + "/authenticator"; + AuthenticatorGateway() { + _enteDio = Network.instance.enteDio; } Future createKey(String encKey, String header) async { - await _dio.post( - _basedEndpoint + "/key", + await _enteDio.post( + "/authenticator/key", data: { "encryptedKey": encKey, "header": header, }, - options: Options( - headers: { - "X-Auth-Token": _config.getToken(), - }, - ), ); } Future getKey() async { try { - final response = await _dio.get( - _basedEndpoint + "/key", - options: Options( - headers: { - "X-Auth-Token": _config.getToken(), - }, - ), - ); + final response = await _enteDio.get("/authenticator/key"); return AuthKey.fromMap(response.data); } on DioError catch (e) { if (e.response != null && (e.response!.statusCode ?? 0) == 404) { @@ -51,17 +37,12 @@ class AuthenticatorGateway { } Future createEntity(String encryptedData, String header) async { - final response = await _dio.post( - _basedEndpoint + "/entity", + final response = await _enteDio.post( + "/authenticator/entity", data: { "encryptedData": encryptedData, "header": header, }, - options: Options( - headers: { - "X-Auth-Token": _config.getToken(), - }, - ), ); return AuthEntity.fromMap(response.data); } @@ -71,50 +52,35 @@ class AuthenticatorGateway { String encryptedData, String header, ) async { - await _dio.put( - _basedEndpoint + "/entity", + await _enteDio.put( + "/authenticator/entity", data: { "id": id, "encryptedData": encryptedData, "header": header, }, - options: Options( - headers: { - "X-Auth-Token": _config.getToken(), - }, - ), ); } Future deleteEntity( String id, ) async { - await _dio.delete( - _basedEndpoint + "/entity", + await _enteDio.delete( + "/authenticator/entity", queryParameters: { "id": id, }, - options: Options( - headers: { - "X-Auth-Token": _config.getToken(), - }, - ), ); } Future> getDiff(int sinceTime, {int limit = 500}) async { try { - final response = await _dio.get( - _basedEndpoint + "/entity/diff", + final response = await _enteDio.get( + "/authenticator/entity/diff", queryParameters: { "sinceTime": sinceTime, "limit": limit, }, - options: Options( - headers: { - "X-Auth-Token": _config.getToken(), - }, - ), ); final List authEntities = []; final diff = response.data["diff"] as List; diff --git a/auth/lib/l10n/arb/app_en.arb b/auth/lib/l10n/arb/app_en.arb index 9ac0c98992..d67473d823 100644 --- a/auth/lib/l10n/arb/app_en.arb +++ b/auth/lib/l10n/arb/app_en.arb @@ -408,5 +408,12 @@ "hearUsExplanation": "We don't track app installs. It'd help if you told us where you found us!", "waitingForBrowserRequest": "Waiting for browser request...", "launchPasskeyUrlAgain": "Launch passkey URL again", - "passkey": "Passkey" + "passkey": "Passkey", + "developerSettingsWarning":"Are you sure that you want to modify Developer settings?", + "developerSettings": "Developer settings", + "serverEndpoint": "Server endpoint", + "invalidEndpoint": "Invalid endpoint", + "invalidEndpointMessage": "Sorry, the endpoint you entered is invalid. Please enter a valid endpoint and try again.", + "endpointUpdatedMessage": "Endpoint updated successfully", + "customEndpoint": "Connected to {endpoint}" } \ No newline at end of file diff --git a/auth/lib/onboarding/view/onboarding_page.dart b/auth/lib/onboarding/view/onboarding_page.dart index 78bf4e589d..7ae5ede1c1 100644 --- a/auth/lib/onboarding/view/onboarding_page.dart +++ b/auth/lib/onboarding/view/onboarding_page.dart @@ -17,6 +17,8 @@ import 'package:ente_auth/ui/common/gradient_button.dart'; import 'package:ente_auth/ui/components/buttons/button_widget.dart'; import 'package:ente_auth/ui/components/models/button_result.dart'; import 'package:ente_auth/ui/home_page.dart'; +import 'package:ente_auth/ui/settings/developer_settings_page.dart'; +import 'package:ente_auth/ui/settings/developer_settings_widget.dart'; import 'package:ente_auth/ui/settings/language_picker.dart'; import 'package:ente_auth/utils/dialog_util.dart'; import 'package:ente_auth/utils/navigation_util.dart'; @@ -33,8 +35,12 @@ class OnboardingPage extends StatefulWidget { } class _OnboardingPageState extends State { + static const kDeveloperModeTapCountThreshold = 7; + late StreamSubscription _triggerLogoutEvent; + int _developerModeTapCount = 0; + @override void initState() { _triggerLogoutEvent = @@ -56,114 +62,142 @@ class _OnboardingPageState extends State { final l10n = context.l10n; return Scaffold( body: SafeArea( - child: Center( - child: SingleChildScrollView( - child: Padding( - padding: - const EdgeInsets.symmetric(vertical: 40.0, horizontal: 40), - child: Column( - children: [ - Column( - children: [ - kDebugMode - ? GestureDetector( - child: const Align( - alignment: Alignment.topRight, - child: Text("Lang"), - ), - onTap: () async { - final locale = await getLocale(); - routeToPage( - context, - LanguageSelectorPage( - appSupportedLocales, - (locale) async { - await setLocale(locale); - App.setLocale(context, locale); - }, - locale, + child: GestureDetector( + onTap: () async { + _developerModeTapCount++; + if (_developerModeTapCount >= kDeveloperModeTapCountThreshold) { + _developerModeTapCount = 0; + final result = await showChoiceDialog( + context, + title: l10n.developerSettings, + firstButtonLabel: l10n.yes, + body: l10n.developerSettingsWarning, + isDismissible: false, + ); + if (result?.action == ButtonAction.first) { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) { + return const DeveloperSettingsPage(); + }, + ), + ); + setState(() {}); + } + } + }, + child: Center( + child: SingleChildScrollView( + child: Padding( + padding: + const EdgeInsets.symmetric(vertical: 40.0, horizontal: 40), + child: Column( + children: [ + Column( + children: [ + kDebugMode + ? GestureDetector( + child: const Align( + alignment: Alignment.topRight, + child: Text("Lang"), + ), + onTap: () async { + final locale = await getLocale(); + routeToPage( + context, + LanguageSelectorPage( + appSupportedLocales, + (locale) async { + await setLocale(locale); + App.setLocale(context, locale); + }, + locale, + ), + ).then((value) { + setState(() {}); + }); + }, + ) + : const SizedBox(), + Image.asset( + "assets/sheild-front-gradient.png", + width: 200, + height: 200, + ), + const SizedBox(height: 12), + const Text( + "ente", + style: TextStyle( + fontWeight: FontWeight.bold, + fontFamily: 'Montserrat', + fontSize: 42, + ), + ), + const SizedBox(height: 4), + Text( + "Authenticator", + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: 32), + Text( + l10n.onBoardingBody, + textAlign: TextAlign.center, + style: + Theme.of(context).textTheme.titleLarge!.copyWith( + color: Colors.white38, ), - ).then((value) { - setState(() {}); - }); - }, - ) - : const SizedBox(), - Image.asset( - "assets/sheild-front-gradient.png", - width: 200, - height: 200, - ), - const SizedBox(height: 12), - const Text( - "ente", - style: TextStyle( - fontWeight: FontWeight.bold, - fontFamily: 'Montserrat', - fontSize: 42, ), + ], + ), + const SizedBox(height: 100), + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 20), + child: GradientButton( + onTap: _navigateToSignUpPage, + text: l10n.newUser, ), - const SizedBox(height: 4), - Text( - "Authenticator", - style: Theme.of(context).textTheme.headlineMedium, - ), - const SizedBox(height: 32), - Text( - l10n.onBoardingBody, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleLarge!.copyWith( - color: Colors.white38, + ), + const SizedBox(height: 4), + Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(20, 12, 20, 0), + child: Hero( + tag: "log_in", + child: ElevatedButton( + style: Theme.of(context) + .colorScheme + .optionalActionButtonStyle, + onPressed: _navigateToSignInPage, + child: Text( + l10n.existingUser, + style: const TextStyle( + color: Colors.black, // same for both themes ), - ), - ], - ), - const SizedBox(height: 100), - Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 20), - child: GradientButton( - onTap: _navigateToSignUpPage, - text: l10n.newUser, - ), - ), - const SizedBox(height: 4), - Container( - width: double.infinity, - padding: const EdgeInsets.fromLTRB(20, 12, 20, 0), - child: Hero( - tag: "log_in", - child: ElevatedButton( - style: Theme.of(context) - .colorScheme - .optionalActionButtonStyle, - onPressed: _navigateToSignInPage, - child: Text( - l10n.existingUser, - style: const TextStyle( - color: Colors.black, // same for both themes ), ), ), ), - ), - const SizedBox(height: 4), - Container( - width: double.infinity, - padding: const EdgeInsets.only(top: 20, bottom: 20), - child: GestureDetector( - onTap: _optForOfflineMode, - child: Center( - child: Text( - l10n.useOffline, - style: body.copyWith( - color: Theme.of(context).colorScheme.mutedTextColor, + const SizedBox(height: 4), + Container( + width: double.infinity, + padding: const EdgeInsets.only(top: 20, bottom: 20), + child: GestureDetector( + onTap: _optForOfflineMode, + child: Center( + child: Text( + l10n.useOffline, + style: body.copyWith( + color: + Theme.of(context).colorScheme.mutedTextColor, + ), ), ), ), ), - ), - ], + const DeveloperSettingsWidget(), + ], + ), ), ), ), diff --git a/auth/lib/services/authenticator_service.dart b/auth/lib/services/authenticator_service.dart index cdc9a4fb31..12e8b52e0b 100644 --- a/auth/lib/services/authenticator_service.dart +++ b/auth/lib/services/authenticator_service.dart @@ -5,7 +5,6 @@ import 'dart:math'; import 'package:ente_auth/core/configuration.dart'; import 'package:ente_auth/core/errors.dart'; import 'package:ente_auth/core/event_bus.dart'; -import 'package:ente_auth/core/network.dart'; import 'package:ente_auth/events/codes_updated_event.dart'; import 'package:ente_auth/events/signed_in_event.dart'; import 'package:ente_auth/events/trigger_logout_event.dart'; @@ -26,6 +25,7 @@ enum AccountMode { online, offline, } + extension on AccountMode { bool get isOnline => this == AccountMode.online; bool get isOffline => this == AccountMode.offline; @@ -56,7 +56,7 @@ class AuthenticatorService { _prefs = await SharedPreferences.getInstance(); _db = AuthenticatorDB.instance; _offlineDb = OfflineAuthenticatorDB.instance; - _gateway = AuthenticatorGateway(Network.instance.getDio(), _config); + _gateway = AuthenticatorGateway(); if (Configuration.instance.hasConfiguredAccount()) { unawaited(onlineSync()); } @@ -154,7 +154,7 @@ class AuthenticatorService { } else { debugPrint("Skipping delete since account mode is offline"); } - if(accountMode.isOnline) { + if (accountMode.isOnline) { await _db.deleteByIDs(generatedIDs: [genID]); } else { await _offlineDb.deleteByIDs(generatedIDs: [genID]); @@ -163,7 +163,7 @@ class AuthenticatorService { Future onlineSync() async { try { - if(getAccountMode().isOffline) { + if (getAccountMode().isOffline) { debugPrint("Skipping sync since account mode is offline"); return false; } @@ -253,7 +253,7 @@ class AuthenticatorService { } Future getOrCreateAuthDataKey(AccountMode mode) async { - if(mode.isOffline) { + if (mode.isOffline) { return _config.getOfflineSecretKey()!; } if (_config.getAuthSecretKey() != null) { diff --git a/auth/lib/store/user_store.dart b/auth/lib/store/user_store.dart index 20f3ead72b..b191d169d8 100644 --- a/auth/lib/store/user_store.dart +++ b/auth/lib/store/user_store.dart @@ -7,10 +7,6 @@ class UserStore { late SharedPreferences _preferences; static final UserStore instance = UserStore._privateConstructor(); - static const endpoint = String.fromEnvironment( - "endpoint", - defaultValue: "https://api.ente.io", - ); Future init() async { _preferences = await SharedPreferences.getInstance(); diff --git a/auth/lib/ui/settings/developer_settings_page.dart b/auth/lib/ui/settings/developer_settings_page.dart new file mode 100644 index 0000000000..1f263e5e9d --- /dev/null +++ b/auth/lib/ui/settings/developer_settings_page.dart @@ -0,0 +1,89 @@ +import 'package:dio/dio.dart'; +import 'package:ente_auth/core/configuration.dart'; +import 'package:ente_auth/l10n/l10n.dart'; +import 'package:ente_auth/ui/common/gradient_button.dart'; +import 'package:ente_auth/utils/dialog_util.dart'; +import 'package:ente_auth/utils/toast_util.dart'; +import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; + +class DeveloperSettingsPage extends StatefulWidget { + const DeveloperSettingsPage({super.key}); + + @override + _DeveloperSettingsPageState createState() => _DeveloperSettingsPageState(); +} + +class _DeveloperSettingsPageState extends State { + final _logger = Logger('DeveloperSettingsPage'); + final _urlController = TextEditingController(); + + @override + void dispose() { + _urlController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + _logger.info( + "Current endpoint is: " + Configuration.instance.getHttpEndpoint(), + ); + return Scaffold( + appBar: AppBar( + title: Text(context.l10n.developerSettings), + ), + body: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + TextField( + controller: _urlController, + decoration: InputDecoration( + labelText: context.l10n.serverEndpoint, + hintText: Configuration.instance.getHttpEndpoint(), + ), + autofocus: true, + ), + const SizedBox(height: 40), + GradientButton( + onTap: () async { + String url = _urlController.text; + _logger.info("Entered endpoint: " + url); + try { + final uri = Uri.parse(url); + if ((uri.scheme == "http" || uri.scheme == "https")) { + await _ping(url); + await Configuration.instance.setHttpEndpoint(url); + showToast(context, context.l10n.endpointUpdatedMessage); + Navigator.of(context).pop(); + } else { + throw const FormatException(); + } + } catch (e) { + showErrorDialog( + context, + context.l10n.invalidEndpoint, + context.l10n.invalidEndpointMessage, + ); + } + }, + text: context.l10n.saveAction, + ), + ], + ), + ), + ); + } + + Future _ping(String endpoint) async { + try { + final response = await Dio().get(endpoint + '/ping'); + if (response.data['message'] != 'pong') { + throw Exception('Invalid response'); + } + } catch (e) { + throw Exception('Error occurred: $e'); + } + } +} diff --git a/auth/lib/ui/settings/developer_settings_widget.dart b/auth/lib/ui/settings/developer_settings_widget.dart new file mode 100644 index 0000000000..0fb32301ca --- /dev/null +++ b/auth/lib/ui/settings/developer_settings_widget.dart @@ -0,0 +1,27 @@ +import 'package:ente_auth/core/configuration.dart'; +import 'package:ente_auth/core/constants.dart'; +import 'package:ente_auth/l10n/l10n.dart'; +import 'package:flutter/material.dart'; + +class DeveloperSettingsWidget extends StatelessWidget { + const DeveloperSettingsWidget({super.key}); + + @override + Widget build(BuildContext context) { + final endpoint = Configuration.instance.getHttpEndpoint(); + if (endpoint != kDefaultProductionEndpoint) { + final endpointURI = Uri.parse(endpoint); + return Padding( + padding: const EdgeInsets.only(bottom: 20), + child: Text( + context.l10n.customEndpoint( + endpointURI.host + ":" + endpointURI.port.toString(), + ), + style: Theme.of(context).textTheme.bodySmall, + ), + ); + } else { + return const SizedBox.shrink(); + } + } +} diff --git a/auth/lib/ui/settings_page.dart b/auth/lib/ui/settings_page.dart index e5df8fcc31..cfe5ba874f 100644 --- a/auth/lib/ui/settings_page.dart +++ b/auth/lib/ui/settings_page.dart @@ -16,6 +16,7 @@ import 'package:ente_auth/ui/settings/account_section_widget.dart'; import 'package:ente_auth/ui/settings/app_version_widget.dart'; import 'package:ente_auth/ui/settings/data/data_section_widget.dart'; import 'package:ente_auth/ui/settings/data/export_widget.dart'; +import 'package:ente_auth/ui/settings/developer_settings_widget.dart'; import 'package:ente_auth/ui/settings/general_section_widget.dart'; import 'package:ente_auth/ui/settings/security_section_widget.dart'; import 'package:ente_auth/ui/settings/social_section_widget.dart'; @@ -149,6 +150,7 @@ class SettingsPage extends StatelessWidget { sectionSpacing, const AboutSectionWidget(), const AppVersionWidget(), + const DeveloperSettingsWidget(), const SupportDevWidget(), const Padding( padding: EdgeInsets.only(bottom: 60), diff --git a/auth/macos/Runner.xcodeproj/project.pbxproj b/auth/macos/Runner.xcodeproj/project.pbxproj index 6d9ed401f4..cbd8b2c8a3 100644 --- a/auth/macos/Runner.xcodeproj/project.pbxproj +++ b/auth/macos/Runner.xcodeproj/project.pbxproj @@ -203,7 +203,7 @@ isa = PBXProject; attributes = { LastSwiftUpdateCheck = 0920; - LastUpgradeCheck = 1300; + LastUpgradeCheck = 1430; ORGANIZATIONNAME = ""; TargetAttributes = { 33CC10EC2044A3C60003C045 = { diff --git a/auth/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/auth/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 3fc3ba1d45..38ba92a69c 100644 --- a/auth/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/auth/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@