diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 54a523199..7225ba82d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,10 +6,10 @@ on: jobs: build: name: Build and test - runs-on: macos-12 + runs-on: macos-13 env: DERIVED_DATA_PATH: 'DerivedData' - DEVICE: 'iPhone 12 Pro' + DEVICE: 'iPhone 14 Pro' if: "!contains(github.event.head_commit.message, '[ci skip]') && !contains(github.event.head_commit.message, '[skip ci]')" strategy: matrix: diff --git a/Cryptomator.xcodeproj/project.pbxproj b/Cryptomator.xcodeproj/project.pbxproj index 78ca95e2d..9f8817d8d 100644 --- a/Cryptomator.xcodeproj/project.pbxproj +++ b/Cryptomator.xcodeproj/project.pbxproj @@ -7,7 +7,6 @@ objects = { /* Begin PBXBuildFile section */ - 4A03255525A3685500E63D7A /* Coordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A03255425A3685500E63D7A /* Coordinator.swift */; }; 4A03255E25A368BF00E63D7A /* MainCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A03255D25A368BF00E63D7A /* MainCoordinator.swift */; }; 4A03257825A36A6900E63D7A /* VaultListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A03257725A36A6900E63D7A /* VaultListViewController.swift */; }; 4A03258125A36B7D00E63D7A /* UIViewController+Preview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A03258025A36B7D00E63D7A /* UIViewController+Preview.swift */; }; @@ -19,6 +18,9 @@ 4A09BFC62684D599000E40AB /* VaultDetailItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A09BFC52684D599000E40AB /* VaultDetailItem.swift */; }; 4A09E54C27071F3C0056D32A /* ErrorMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A09E54B27071F3C0056D32A /* ErrorMapperTests.swift */; }; 4A09E54E27071F4F0056D32A /* ErrorMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A09E54D27071F4F0056D32A /* ErrorMapper.swift */; }; + 4A0AA12B2AB8DB1800CF24FD /* PermissionProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A0AA12A2AB8DB1800CF24FD /* PermissionProvider.swift */; }; + 4A0AA12D2ABA277800CF24FD /* PermissionProviderImplTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A0AA12C2ABA277800CF24FD /* PermissionProviderImplTests.swift */; }; + 4A0AA12F2ABA2A1600CF24FD /* PermissionProviderMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A0AA12E2ABA2A1600CF24FD /* PermissionProviderMock.swift */; }; 4A0C07E225AC80C100B83211 /* UIView+Preview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A0C07E125AC80C100B83211 /* UIView+Preview.swift */; }; 4A0C07EB25AC832900B83211 /* VaultListPosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A0C07EA25AC832900B83211 /* VaultListPosition.swift */; }; 4A0EAAD2296F604200E27B56 /* SessionTaskRegistratorMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A0EAAD1296F604200E27B56 /* SessionTaskRegistratorMock.swift */; }; @@ -254,7 +256,6 @@ 4AA782E2282A8FC0001A71E3 /* CachedFileManagerFactoryMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AA782E1282A8FC0001A71E3 /* CachedFileManagerFactoryMock.swift */; }; 4AA782E4282A9007001A71E3 /* NSFileProviderDomainProviderMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AA782E3282A9007001A71E3 /* NSFileProviderDomainProviderMock.swift */; }; 4AA782E6282A91BD001A71E3 /* CacheManagerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AA782E5282A91BD001A71E3 /* CacheManagerMock.swift */; }; - 4AA8613725C19D4F002A59F5 /* DetectedMasterkeyViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AA8613625C19D4F002A59F5 /* DetectedMasterkeyViewModel.swift */; }; 4AA8614825C1C670002A59F5 /* OpenExistingVaultChooseFolderViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AA8614725C1C670002A59F5 /* OpenExistingVaultChooseFolderViewController.swift */; }; 4AA8615125C1DB5E002A59F5 /* OpenExistingVaultPasswordViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AA8615025C1DB5E002A59F5 /* OpenExistingVaultPasswordViewController.swift */; }; 4AAD444727E26D1800D16707 /* UploadTaskManagerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AAD444627E26D1800D16707 /* UploadTaskManagerMock.swift */; }; @@ -326,8 +327,6 @@ 4AEBE8C22653FAD40031487F /* WorkflowMiddleware.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AEBE8C12653FAD40031487F /* WorkflowMiddleware.swift */; }; 4AED9A69286B303000352951 /* S3Authenticator+VC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AED9A68286B303000352951 /* S3Authenticator+VC.swift */; }; 4AED9A6C286B305200352951 /* S3AuthenticationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AED9A6B286B305200352951 /* S3AuthenticationView.swift */; }; - 4AED9A6F286B38DA00352951 /* Introspect in Frameworks */ = {isa = PBXBuildFile; productRef = 4AED9A6E286B38DA00352951 /* Introspect */; }; - 4AED9A73286B3D6D00352951 /* SwiftUI+Focus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AED9A72286B3D6C00352951 /* SwiftUI+Focus.swift */; }; 4AED9A77286B4BEE00352951 /* S3AuthenticationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AED9A76286B4BEE00352951 /* S3AuthenticationViewController.swift */; }; 4AED9A79286B4DF500352951 /* S3Authenticating.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AED9A78286B4DF500352951 /* S3Authenticating.swift */; }; 4AEE22F82861D6DC00A9C785 /* OpenVaultIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AEE22F72861D6DC00A9C785 /* OpenVaultIntentHandler.swift */; }; @@ -362,6 +361,8 @@ 4AF91CEB25A7306E00ACF01E /* DatabaseManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AF91CEA25A7306E00ACF01E /* DatabaseManagerTests.swift */; }; 4AF91CF425A8BB0D00ACF01E /* VaultListViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AF91CF325A8BB0D00ACF01E /* VaultListViewModelTests.swift */; }; 4AF91D0D25A8D5EF00ACF01E /* ListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AF91D0C25A8D5EF00ACF01E /* ListViewModel.swift */; }; + 4AF9D44929C262B800EB3822 /* CryptomatorCommon in Frameworks */ = {isa = PBXBuildFile; productRef = 4AF9D44829C262B800EB3822 /* CryptomatorCommon */; }; + 4AF9D44B29C293E600EB3822 /* HubAddVaultCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AF9D44A29C293E600EB3822 /* HubAddVaultCoordinator.swift */; }; 4AFBFA142829206D00E30818 /* UploadProgressAlertController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AFBFA132829206D00E30818 /* UploadProgressAlertController.swift */; }; 4AFBFA1628293FE200E30818 /* UploadRetryingServiceSourceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AFBFA1528293FE200E30818 /* UploadRetryingServiceSourceTests.swift */; }; 4AFBFA182829414A00E30818 /* ProgressManagerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AFBFA172829414A00E30818 /* ProgressManagerMock.swift */; }; @@ -533,7 +534,6 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 4A03255425A3685500E63D7A /* Coordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Coordinator.swift; sourceTree = ""; }; 4A03255D25A368BF00E63D7A /* MainCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainCoordinator.swift; sourceTree = ""; }; 4A03257725A36A6900E63D7A /* VaultListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultListViewController.swift; sourceTree = ""; }; 4A03258025A36B7D00E63D7A /* UIViewController+Preview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Preview.swift"; sourceTree = ""; }; @@ -546,6 +546,9 @@ 4A09BFC52684D599000E40AB /* VaultDetailItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultDetailItem.swift; sourceTree = ""; }; 4A09E54B27071F3C0056D32A /* ErrorMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorMapperTests.swift; sourceTree = ""; }; 4A09E54D27071F4F0056D32A /* ErrorMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorMapper.swift; sourceTree = ""; }; + 4A0AA12A2AB8DB1800CF24FD /* PermissionProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionProvider.swift; sourceTree = ""; }; + 4A0AA12C2ABA277800CF24FD /* PermissionProviderImplTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionProviderImplTests.swift; sourceTree = ""; }; + 4A0AA12E2ABA2A1600CF24FD /* PermissionProviderMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionProviderMock.swift; sourceTree = ""; }; 4A0C07E125AC80C100B83211 /* UIView+Preview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+Preview.swift"; sourceTree = ""; }; 4A0C07EA25AC832900B83211 /* VaultListPosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultListPosition.swift; sourceTree = ""; }; 4A0EAAD1296F604200E27B56 /* SessionTaskRegistratorMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionTaskRegistratorMock.swift; sourceTree = ""; }; @@ -787,7 +790,6 @@ 4AA782E1282A8FC0001A71E3 /* CachedFileManagerFactoryMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CachedFileManagerFactoryMock.swift; sourceTree = ""; }; 4AA782E3282A9007001A71E3 /* NSFileProviderDomainProviderMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSFileProviderDomainProviderMock.swift; sourceTree = ""; }; 4AA782E5282A91BD001A71E3 /* CacheManagerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheManagerMock.swift; sourceTree = ""; }; - 4AA8613625C19D4F002A59F5 /* DetectedMasterkeyViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetectedMasterkeyViewModel.swift; sourceTree = ""; }; 4AA8614725C1C670002A59F5 /* OpenExistingVaultChooseFolderViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenExistingVaultChooseFolderViewController.swift; sourceTree = ""; }; 4AA8615025C1DB5E002A59F5 /* OpenExistingVaultPasswordViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenExistingVaultPasswordViewController.swift; sourceTree = ""; }; 4AAD444627E26D1800D16707 /* UploadTaskManagerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadTaskManagerMock.swift; sourceTree = ""; }; @@ -866,7 +868,6 @@ 4AEBE8C12653FAD40031487F /* WorkflowMiddleware.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkflowMiddleware.swift; sourceTree = ""; }; 4AED9A68286B303000352951 /* S3Authenticator+VC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "S3Authenticator+VC.swift"; sourceTree = ""; }; 4AED9A6B286B305200352951 /* S3AuthenticationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = S3AuthenticationView.swift; sourceTree = ""; }; - 4AED9A72286B3D6C00352951 /* SwiftUI+Focus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SwiftUI+Focus.swift"; sourceTree = ""; }; 4AED9A76286B4BEE00352951 /* S3AuthenticationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = S3AuthenticationViewController.swift; sourceTree = ""; }; 4AED9A78286B4DF500352951 /* S3Authenticating.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = S3Authenticating.swift; sourceTree = ""; }; 4AEE22F72861D6DC00A9C785 /* OpenVaultIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenVaultIntentHandler.swift; sourceTree = ""; }; @@ -898,6 +899,7 @@ 4AF91CEA25A7306E00ACF01E /* DatabaseManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseManagerTests.swift; sourceTree = ""; }; 4AF91CF325A8BB0D00ACF01E /* VaultListViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultListViewModelTests.swift; sourceTree = ""; }; 4AF91D0C25A8D5EF00ACF01E /* ListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListViewModel.swift; sourceTree = ""; }; + 4AF9D44A29C293E600EB3822 /* HubAddVaultCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HubAddVaultCoordinator.swift; sourceTree = ""; }; 4AFBFA132829206D00E30818 /* UploadProgressAlertController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadProgressAlertController.swift; sourceTree = ""; }; 4AFBFA1528293FE200E30818 /* UploadRetryingServiceSourceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadRetryingServiceSourceTests.swift; sourceTree = ""; }; 4AFBFA172829414A00E30818 /* ProgressManagerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressManagerMock.swift; sourceTree = ""; }; @@ -1061,6 +1063,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 4AF9D44929C262B800EB3822 /* CryptomatorCommon in Frameworks */, 4A9BED67268F379300721BAA /* libCryptomatorFileProvider.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1079,7 +1082,6 @@ buildActionMask = 2147483647; files = ( 4A9172822619F17C003C4043 /* CryptomatorCommon in Frameworks */, - 4AED9A6F286B38DA00352951 /* Introspect in Frameworks */, 4A1521E427C55EA2006C96B2 /* TPInAppReceipt in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1196,6 +1198,7 @@ 4A9C8DFC27A007C2000063E4 /* FileProviderNotificatorTests.swift */, 4AFBFA19282946BF00E30818 /* InMemoryProgressManagerTests.swift */, 4AB1D4EF27D20420009060AB /* LocalURLProviderTests.swift */, + 4A0AA12C2ABA277800CF24FD /* PermissionProviderImplTests.swift */, 4AC1157727F5BEFD0023F51B /* Promise+AllIgnoringResultsTests.swift */, 4ADC66C427A7F6D6002E6CC7 /* UnlockMonitorTests.swift */, 4A4F47F224B875070033328B /* URL+NameCollisionExtensionTests.swift */, @@ -1443,6 +1446,7 @@ 4A3D655E268099F9000DA764 /* VaultCoordinatorError.swift */, 4A2FD08125B5E2BA008565C8 /* VaultInstalling.swift */, 4A644B45267A3D21008CBB9A /* CreateNewVault */, + 4AF9D44C29C293F800EB3822 /* Hub */, 4A1EB0D6268A6CF5006D072B /* LocalVault */, 4AA8613F25C1AC4D002A59F5 /* OpenExistingVault */, ); @@ -1491,7 +1495,6 @@ 4A644B56267C958F008CBB9A /* ChildCoordinator.swift */, 4AFCE53925B9D6A60069C4FC /* CloudAuthenticator.swift */, 4AFCE51E25B89CD80069C4FC /* CloudProviderType+Localization.swift */, - 4A03255425A3685500E63D7A /* Coordinator.swift */, 4AF91CE125A7234500ACF01E /* DatabaseManager.swift */, 4A8A6423286CA72B001F5EB9 /* DefaultShowEditAccountBehavior.swift */, 4A512D69274277FF00DC26F8 /* EditableDataSource.swift */, @@ -1504,7 +1507,6 @@ 4A4246F727565D87005BE82D /* PoppingCloseCoordinator.swift */, 4A447E0325BF0B0F00D9520D /* SingleSectionTableViewController.swift */, 4A61F6B8274582E3007AA422 /* StaticUITableViewController.swift */, - 4AED9A72286B3D6C00352951 /* SwiftUI+Focus.swift */, 4A61F6B62745353E007AA422 /* TableViewModel.swift */, 4AF91CCF25A71C5800ACF01E /* UIImage+CloudProviderType.swift */, 4AC8626F273598CC00E15BA5 /* UIViewController+ProgressHUDError.swift */, @@ -1669,7 +1671,6 @@ 4AA8613F25C1AC4D002A59F5 /* OpenExistingVault */ = { isa = PBXGroup; children = ( - 4AA8613625C19D4F002A59F5 /* DetectedMasterkeyViewModel.swift */, 4A753DB82678A226005F79C1 /* OpenExistingLegacyVaultPasswordViewModel.swift */, 4AA8614725C1C670002A59F5 /* OpenExistingVaultChooseFolderViewController.swift */, 4A2FD08A25B5E437008565C8 /* OpenExistingVaultCoordinator.swift */, @@ -1723,6 +1724,7 @@ 4AEECD3E279EC48200C6E2B5 /* NSFileProviderChangeObserverMock.swift */, 4AA782E3282A9007001A71E3 /* NSFileProviderDomainProviderMock.swift */, 4AEECD3A279EB24300C6E2B5 /* NSFileProviderEnumerationObserverMock.swift */, + 4A0AA12E2ABA2A1600CF24FD /* PermissionProviderMock.swift */, 4AFBFA172829414A00E30818 /* ProgressManagerMock.swift */, 4A0EAAD1296F604200E27B56 /* SessionTaskRegistratorMock.swift */, 4ADC66C627A95E67002E6CC7 /* UnlockMonitorTaskExecutorMock.swift */, @@ -1876,6 +1878,14 @@ path = DB; sourceTree = ""; }; + 4AF9D44C29C293F800EB3822 /* Hub */ = { + isa = PBXGroup; + children = ( + 4AF9D44A29C293E600EB3822 /* HubAddVaultCoordinator.swift */, + ); + path = Hub; + sourceTree = ""; + }; 740375D82587AE7B0023FF53 /* CryptomatorFileProvider */ = { isa = PBXGroup; children = ( @@ -1899,6 +1909,7 @@ 4AB1D4EB27D0E027009060AB /* LocalURLProviderType.swift */, 4AA782DD282A8250001A71E3 /* NSFileProviderDomainProvider.swift */, 4AEE6EE02822A33400E1B35E /* NSFileProviderItemIdentifier+Database.swift */, + 4A0AA12A2AB8DB1800CF24FD /* PermissionProvider.swift */, 4AEE6EE92825716400E1B35E /* ProgressManager.swift */, 4AC1157527F5BD890023F51B /* Promise+AllIgnoringResult.swift */, 4ADD233F26737CD400374E4E /* RootFileProviderItem.swift */, @@ -2131,9 +2142,13 @@ buildRules = ( ); dependencies = ( + 4AC4C98E288AD858008C6D2B /* PBXTargetDependency */, 4A9BED69268F379300721BAA /* PBXTargetDependency */, ); name = FileProviderExtensionUI; + packageProductDependencies = ( + 4AF9D44829C262B800EB3822 /* CryptomatorCommon */, + ); productName = "File Provider ExtensionUI"; productReference = 4AA621E4249A6A8400A0BCBD /* FileProviderExtensionUI.appex */; productType = "com.apple.product-type.app-extension"; @@ -2182,7 +2197,6 @@ packageProductDependencies = ( 4A9172812619F17C003C4043 /* CryptomatorCommon */, 4A1521E327C55EA2006C96B2 /* TPInAppReceipt */, - 4AED9A6E286B38DA00352951 /* Introspect */, ); productName = Cryptomator; productReference = 4AE97DA824572E4900452814 /* Cryptomator.app */; @@ -2313,7 +2327,6 @@ mainGroup = 4A5E5B202453119100BD6298; packageReferences = ( 4A1521E227C55EA2006C96B2 /* XCRemoteSwiftPackageReference "TPInAppReceipt" */, - 4AED9A6D286B38D900352951 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */, ); productRefGroup = 4A5E5B2A2453119100BD6298 /* Products */; projectDirPath = ""; @@ -2444,7 +2457,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "if [ -f ./fastlane/scripts/.cloud-access-secrets.sh ]; then\n source ./fastlane/scripts/.cloud-access-secrets.sh \"${CONFIG_NAME}\"\nelse\n echo \"warning: .cloud-access-secrets.sh could not be found, please see README for instructions\"\nfi\n/usr/libexec/PlistBuddy -c \"Add :CFBundleURLTypes:1:CFBundleURLSchemes:0 string db-${DROPBOX_APP_KEY}\" \"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}\"\n/usr/libexec/PlistBuddy -c \"Add :CFBundleURLTypes:1:CFBundleURLSchemes:1 string ${GOOGLE_DRIVE_REDIRECT_URL_SCHEME}\" \"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}\"\n/usr/libexec/PlistBuddy -c \"Add :CFBundleURLTypes:1:CFBundleURLSchemes:2 string ${ONEDRIVE_REDIRECT_URI_SCHEME}\" \"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}\"\necho \"Updated ${TARGET_BUILD_DIR}/${INFOPLIST_PATH} by adding URL schemes\"\n"; + shellScript = "if [ -f ./fastlane/scripts/.cloud-access-secrets.sh ]; then\n source ./fastlane/scripts/.cloud-access-secrets.sh \"${CONFIG_NAME}\"\nelse\n echo \"warning: .cloud-access-secrets.sh could not be found, please see README for instructions\"\nfi\n/usr/libexec/PlistBuddy -c \"Add :CFBundleURLTypes:1:CFBundleURLSchemes:0 string db-${DROPBOX_APP_KEY}\" \"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}\"\n/usr/libexec/PlistBuddy -c \"Add :CFBundleURLTypes:1:CFBundleURLSchemes:1 string ${GOOGLE_DRIVE_REDIRECT_URL_SCHEME}\" \"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}\"\n/usr/libexec/PlistBuddy -c \"Add :CFBundleURLTypes:1:CFBundleURLSchemes:2 string ${ONEDRIVE_REDIRECT_URI_SCHEME}\" \"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}\"\n/usr/libexec/PlistBuddy -c \"Add :CFBundleURLTypes:1:CFBundleURLSchemes:3 string ${HUB_REDIRECT_URI_SCHEME}\" \"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}\"\necho \"Updated ${TARGET_BUILD_DIR}/${INFOPLIST_PATH} by adding URL schemes\"\n"; }; 742595D72552EE0000A8A008 /* Set Build Number */ = { isa = PBXShellScriptBuildPhase; @@ -2533,6 +2546,7 @@ 4AFBFA1628293FE200E30818 /* UploadRetryingServiceSourceTests.swift in Sources */, 4AB1C33C265E9DBC00DC7A49 /* CloudTaskExecutorTestCase.swift in Sources */, 4AE5196727F495BF00BA6E4A /* WorkflowDependencyTasksCollectionMock.swift in Sources */, + 4A0AA12F2ABA2A1600CF24FD /* PermissionProviderMock.swift in Sources */, 4AC1157827F5BEFD0023F51B /* Promise+AllIgnoringResultsTests.swift in Sources */, 4AE5196527F48D6600BA6E4A /* WorkflowDependencyFactoryTests.swift in Sources */, 4A49FABE271ECDE80069A0CC /* ItemEnumerationTaskManagerTests.swift in Sources */, @@ -2566,6 +2580,7 @@ 4ADC66C527A7F6D6002E6CC7 /* UnlockMonitorTests.swift in Sources */, 4ABC08D7250D1EB600E3CEDC /* DeletionTaskManagerTests.swift in Sources */, 4A511D45265EB13B000A0E01 /* ItemEnumerationTaskTests.swift in Sources */, + 4A0AA12D2ABA277800CF24FD /* PermissionProviderImplTests.swift in Sources */, 4A2F373724B47DB800460FD3 /* UploadTaskManagerTests.swift in Sources */, 4A248221266B8D37002D9F59 /* FileProviderAdapterImportDocumentTests.swift in Sources */, 4A511D5326615439000A0E01 /* ReparentTaskExecutorTests.swift in Sources */, @@ -2706,6 +2721,7 @@ 4A4246F827565D87005BE82D /* PoppingCloseCoordinator.swift in Sources */, 4A66F58B25C489C7001BE15E /* OpenExistingVaultPasswordViewModel.swift in Sources */, 4A03258125A36B7D00E63D7A /* UIViewController+Preview.swift in Sources */, + 4AF9D44B29C293E600EB3822 /* HubAddVaultCoordinator.swift in Sources */, 4A21B49226BBFFE9000D13DF /* AttributedTextHeaderFooterView.swift in Sources */, 4A707802278DC32800AEF4CE /* VaultKeepUnlockedViewModel.swift in Sources */, 4A90E7C327C79DCF00BC858B /* PurchaseCell.swift in Sources */, @@ -2765,7 +2781,6 @@ 4A5AC43D275A306F00342AA7 /* TrialExpiredNavigationController.swift in Sources */, 4A53CC13267CC1C100853BB3 /* CreateNewVaultPasswordViewController.swift in Sources */, 4A6A51FF268B1BEB006F7368 /* OpenExistingLocalVaultCoordinator.swift in Sources */, - 4A03255525A3685500E63D7A /* Coordinator.swift in Sources */, 4AED9A77286B4BEE00352951 /* S3AuthenticationViewController.swift in Sources */, 4A644B47267A3D43008CBB9A /* SetVaultNameViewModel.swift in Sources */, 4A1EB0D02689C7F8006D072B /* DetectedVaultFailureView.swift in Sources */, @@ -2775,7 +2790,6 @@ 4A587FA828B55CD600C69A1E /* WebDAVCredentialCoordinator.swift in Sources */, 4A53CC11267CBFA100853BB3 /* AddVaultSuccessCoordinator.swift in Sources */, 4A6CF80027428CCB0061380A /* VaultCellViewModel.swift in Sources */, - 4AED9A73286B3D6D00352951 /* SwiftUI+Focus.swift in Sources */, 4A8D05D625C5CBE10082C5F7 /* AddVaultSuccessViewController.swift in Sources */, 4A0C07EB25AC832900B83211 /* VaultListPosition.swift in Sources */, 4A3D658226838991000DA764 /* OpenExistingLocalVaultViewModel.swift in Sources */, @@ -2814,7 +2828,6 @@ 4A447E5625BF1F6A00D9520D /* CloudItemCell.swift in Sources */, 4A5F48EE272AA02A0084135F /* MaintenanceModeError+Localization.swift in Sources */, 4A63E4672742A8CB00026989 /* ListViewController.swift in Sources */, - 4AA8613725C19D4F002A59F5 /* DetectedMasterkeyViewModel.swift in Sources */, 7460FFEF26FCC6FC0018BCC4 /* OnboardingNavigationController.swift in Sources */, 4A1EB0D8268A6DE1006D072B /* AddLocalVaultViewController.swift in Sources */, 4A7B97E525B6F86E0044B7FB /* AccountListPosition.swift in Sources */, @@ -2933,6 +2946,7 @@ 4A511D5D26668E47000A0E01 /* ReparentTaskRecord.swift in Sources */, 747F2F272587BC250072FB30 /* ReparentTask.swift in Sources */, 747F2F282587BC250072FB30 /* ReparentTaskDBManager.swift in Sources */, + 4A0AA12B2AB8DB1800CF24FD /* PermissionProvider.swift in Sources */, 4AB1D4EC27D0E027009060AB /* LocalURLProviderType.swift in Sources */, 4A511D4E2660FF9E000A0E01 /* WorkflowScheduler.swift in Sources */, 4AD9481A2909A66900072110 /* MaintenanceModeHelperServiceSource.swift in Sources */, @@ -2988,6 +3002,10 @@ target = 740375D62587AE7A0023FF53 /* CryptomatorFileProvider */; targetProxy = 4A9BED68268F379300721BAA /* PBXContainerItemProxy */; }; + 4AC4C98E288AD858008C6D2B /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + productRef = 4AC4C98D288AD858008C6D2B /* AppAuth */; + }; 4AD3D7DC282EBDE7008188CD /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 4AD3D7D3282EBDE7008188CD /* CryptomatorIntents */; @@ -3266,7 +3284,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 2.4.9; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; @@ -3328,7 +3346,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 2.4.9; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; @@ -3675,14 +3693,6 @@ minimumVersion = 3.3.0; }; }; - 4AED9A6D286B38D900352951 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/siteline/SwiftUI-Introspect.git"; - requirement = { - kind = upToNextMinorVersion; - minimumVersion = 0.1.4; - }; - }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -3703,15 +3713,18 @@ isa = XCSwiftPackageProductDependency; productName = CryptomatorCommonCore; }; - 4AED9A6E286B38DA00352951 /* Introspect */ = { + 4AC4C98D288AD858008C6D2B /* AppAuth */ = { isa = XCSwiftPackageProductDependency; - package = 4AED9A6D286B38D900352951 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */; - productName = Introspect; + productName = AppAuth; }; 4AF91A0E2AC2F025002357BA /* Dependencies */ = { isa = XCSwiftPackageProductDependency; productName = Dependencies; }; + 4AF9D44829C262B800EB3822 /* CryptomatorCommon */ = { + isa = XCSwiftPackageProductDependency; + productName = CryptomatorCommon; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 4A5E5B212453119100BD6298 /* Project object */; diff --git a/Cryptomator.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Cryptomator.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index aa74da510..d69681916 100644 --- a/Cryptomator.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Cryptomator.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,214 +1,212 @@ { - "object": { - "pins": [ - { - "package": "AppAuth", - "repositoryURL": "https://github.com/openid/AppAuth-iOS.git", - "state": { - "branch": null, - "revision": "3d36a58a2b736f7bc499453e996a704929b25080", - "version": "1.6.0" - } - }, - { - "package": "ASN1Swift", - "repositoryURL": "https://github.com/tikhop/ASN1Swift", - "state": { - "branch": null, - "revision": "b53bee03a942623db25afc5bfb80227b2cb3b425", - "version": "1.2.4" - } - }, - { - "package": "AWSiOSSDKV2", - "repositoryURL": "https://github.com/aws-amplify/aws-sdk-ios-spm.git", - "state": { - "branch": null, - "revision": "51d99d74be7249ac6444581bd1e394fb60ea86a3", - "version": "2.30.4" - } - }, - { - "package": "Base32", - "repositoryURL": "https://github.com/norio-nomura/Base32.git", - "state": { - "branch": null, - "revision": "c4bc0a49689999ae2c7c778f3830a6a6e694efb8", - "version": "0.9.0" - } - }, - { - "package": "CryptomatorCloudAccess", - "repositoryURL": "https://github.com/cryptomator/cloud-access-swift.git", - "state": { - "branch": null, - "revision": "c9eaa84a3e73aceef10fc724d386eab3d6e3cbb7", - "version": "1.7.5" - } - }, - { - "package": "CocoaLumberjack", - "repositoryURL": "https://github.com/CocoaLumberjack/CocoaLumberjack.git", - "state": { - "branch": null, - "revision": "0188d31089b5881a269e01777be74c7316924346", - "version": "3.8.0" - } - }, - { - "package": "CryptomatorCryptoLib", - "repositoryURL": "https://github.com/cryptomator/cryptolib-swift.git", - "state": { - "branch": null, - "revision": "6e5dbea6e05742ad82a074bf7ee8c3305d92fbae", - "version": "1.1.0" - } - }, - { - "package": "ObjectiveDropboxOfficial", - "repositoryURL": "https://github.com/phil1995/dropbox-sdk-obj-c-spm.git", - "state": { - "branch": null, - "revision": "f0eafe25d26c52377c4a1c08f1dbd77320164994", - "version": "7.0.0" - } - }, - { - "package": "GoogleAPIClientForREST", - "repositoryURL": "https://github.com/google/google-api-objectivec-client-for-rest.git", - "state": { - "branch": null, - "revision": "260501c0425e95e038c65436436161266bf548e9", - "version": "3.0.0" - } - }, - { - "package": "GRDB", - "repositoryURL": "https://github.com/groue/GRDB.swift.git", - "state": { - "branch": null, - "revision": "dd7e7f39e8e4d7a22d258d9809a882f914690b01", - "version": "5.26.1" - } - }, - { - "package": "GTMSessionFetcher", - "repositoryURL": "https://github.com/google/gtm-session-fetcher.git", - "state": { - "branch": null, - "revision": "efda500b6d9858d38a76dbfbfa396bd644692e4a", - "version": "3.0.0" - } - }, - { - "package": "GTMAppAuth", - "repositoryURL": "https://github.com/google/GTMAppAuth.git", - "state": { - "branch": null, - "revision": "cee3c709307912d040bd1e06ca919875a92339c6", - "version": "2.0.0" - } - }, - { - "package": "JOSESwift", - "repositoryURL": "https://github.com/airsidemobile/JOSESwift.git", - "state": { - "branch": null, - "revision": "10ed3b6736def7c26eb87135466b1cb46ea7e37f", - "version": "2.4.0" - } - }, - { - "package": "MSAL", - "repositoryURL": "https://github.com/AzureAD/microsoft-authentication-library-for-objc.git", - "state": { - "branch": null, - "revision": "31a806298d6aa71b40504e7ebda6d6a8923f0ebf", - "version": "1.2.5" - } - }, - { - "package": "MSGraphClientModels", - "repositoryURL": "https://github.com/phil1995/msgraph-sdk-objc-models-spm.git", - "state": { - "branch": null, - "revision": "172b07fe8a7da6072149e2fd92051a510b25035e", - "version": "1.3.0" - } - }, - { - "package": "MSGraphClientSDK", - "repositoryURL": "https://github.com/phil1995/msgraph-sdk-objc-spm.git", - "state": { - "branch": null, - "revision": "0320c6a99207b53288970382afcf5054852f9724", - "version": "1.0.0" - } - }, - { - "package": "PCloudSDKSwift", - "repositoryURL": "https://github.com/pCloud/pcloud-sdk-swift.git", - "state": { - "branch": null, - "revision": "6da4ca6bb4e7068145d9325988e29862d26300ba", - "version": "3.2.0" - } - }, - { - "package": "Promises", - "repositoryURL": "https://github.com/google/promises.git", - "state": { - "branch": null, - "revision": "611337c330350c9c1823ad6d671e7f936af5ee13", - "version": "2.0.0" - } - }, - { - "package": "Dependencies", - "repositoryURL": "https://github.com/PhilLibs/simple-swift-dependencies", - "state": { - "branch": null, - "revision": "36e2e7732b5fe2bfec76e4af78d2ef532fe09456", - "version": "0.1.0" - } - }, - { - "package": "swift-log", - "repositoryURL": "https://github.com/apple/swift-log.git", - "state": { - "branch": null, - "revision": "6fe203dc33195667ce1759bf0182975e4653ba1c", - "version": "1.4.4" - } - }, - { - "package": "Introspect", - "repositoryURL": "https://github.com/siteline/SwiftUI-Introspect.git", - "state": { - "branch": null, - "revision": "f2616860a41f9d9932da412a8978fec79c06fe24", - "version": "0.1.4" - } - }, - { - "package": "TPInAppReceipt", - "repositoryURL": "https://github.com/tikhop/TPInAppReceipt.git", - "state": { - "branch": null, - "revision": "5b830d6ce6c34bb4bb976917576ab560e7945037", - "version": "3.3.4" - } - }, - { - "package": "xctest-dynamic-overlay", - "repositoryURL": "https://github.com/pointfreeco/xctest-dynamic-overlay", - "state": { - "branch": null, - "revision": "23cbf2294e350076ea4dbd7d5d047c1e76b03631", - "version": "1.0.2" - } - } - ] - }, - "version": 1 + "pins" : [ + { + "identity" : "appauth-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/openid/AppAuth-iOS.git", + "state" : { + "revision" : "71cde449f13d453227e687458144bde372d30fc7", + "version" : "1.6.2" + } + }, + { + "identity" : "asn1swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tikhop/ASN1Swift", + "state" : { + "revision" : "b53bee03a942623db25afc5bfb80227b2cb3b425", + "version" : "1.2.4" + } + }, + { + "identity" : "aws-sdk-ios-spm", + "kind" : "remoteSourceControl", + "location" : "https://github.com/aws-amplify/aws-sdk-ios-spm.git", + "state" : { + "revision" : "ca31418963a90bac80538e13f6b7af87ea14d279", + "version" : "2.33.4" + } + }, + { + "identity" : "base32", + "kind" : "remoteSourceControl", + "location" : "https://github.com/norio-nomura/Base32.git", + "state" : { + "revision" : "c4bc0a49689999ae2c7c778f3830a6a6e694efb8", + "version" : "0.9.0" + } + }, + { + "identity" : "cloud-access-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/cryptomator/cloud-access-swift.git", + "state" : { + "revision" : "1fe06a85f9ea38d9b22a84fb7dbd8de127c65f82", + "version" : "1.8.1" + } + }, + { + "identity" : "cocoalumberjack", + "kind" : "remoteSourceControl", + "location" : "https://github.com/CocoaLumberjack/CocoaLumberjack.git", + "state" : { + "revision" : "67ec5818a757aba4d7c534e21a905d878d128dbf", + "version" : "3.8.1" + } + }, + { + "identity" : "cryptolib-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/cryptomator/cryptolib-swift.git", + "state" : { + "revision" : "6e5dbea6e05742ad82a074bf7ee8c3305d92fbae", + "version" : "1.1.0" + } + }, + { + "identity" : "dropbox-sdk-obj-c-spm", + "kind" : "remoteSourceControl", + "location" : "https://github.com/phil1995/dropbox-sdk-obj-c-spm.git", + "state" : { + "revision" : "f0eafe25d26c52377c4a1c08f1dbd77320164994", + "version" : "7.0.0" + } + }, + { + "identity" : "google-api-objectivec-client-for-rest", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/google-api-objectivec-client-for-rest.git", + "state" : { + "revision" : "40930b2c3add6234b8be1a780c08cf88b6a7a1f7", + "version" : "3.2.0" + } + }, + { + "identity" : "grdb.swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/groue/GRDB.swift.git", + "state" : { + "revision" : "dd7e7f39e8e4d7a22d258d9809a882f914690b01", + "version" : "5.26.1" + } + }, + { + "identity" : "gtm-session-fetcher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/gtm-session-fetcher.git", + "state" : { + "revision" : "d415594121c9e8a4f9d79cecee0965cf35e74dbd", + "version" : "3.1.1" + } + }, + { + "identity" : "gtmappauth", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GTMAppAuth.git", + "state" : { + "revision" : "41aba100f28395ebe842cd66e5d371cdd46c6792", + "version" : "4.0.0" + } + }, + { + "identity" : "joseswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tobihagemann/JOSESwift.git", + "state" : { + "revision" : "11442e7f1f803ef42281909c68f386b38afc5096", + "version" : "2.4.0-cryptomator" + } + }, + { + "identity" : "microsoft-authentication-library-for-objc", + "kind" : "remoteSourceControl", + "location" : "https://github.com/AzureAD/microsoft-authentication-library-for-objc.git", + "state" : { + "revision" : "35846731c0971694f162b28fe8494c03b615ae74", + "version" : "1.2.16" + } + }, + { + "identity" : "msgraph-sdk-objc-models-spm", + "kind" : "remoteSourceControl", + "location" : "https://github.com/phil1995/msgraph-sdk-objc-models-spm.git", + "state" : { + "revision" : "172b07fe8a7da6072149e2fd92051a510b25035e", + "version" : "1.3.0" + } + }, + { + "identity" : "msgraph-sdk-objc-spm", + "kind" : "remoteSourceControl", + "location" : "https://github.com/phil1995/msgraph-sdk-objc-spm.git", + "state" : { + "revision" : "0320c6a99207b53288970382afcf5054852f9724", + "version" : "1.0.0" + } + }, + { + "identity" : "pcloud-sdk-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pCloud/pcloud-sdk-swift.git", + "state" : { + "revision" : "6da4ca6bb4e7068145d9325988e29862d26300ba", + "version" : "3.2.0" + } + }, + { + "identity" : "promises", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/promises.git", + "state" : { + "revision" : "e70e889c0196c76d22759eb50d6a0270ca9f1d9e", + "version" : "2.3.1" + } + }, + { + "identity" : "simple-swift-dependencies", + "kind" : "remoteSourceControl", + "location" : "https://github.com/PhilLibs/simple-swift-dependencies", + "state" : { + "revision" : "36e2e7732b5fe2bfec76e4af78d2ef532fe09456", + "version" : "0.1.0" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "532d8b529501fb73a2455b179e0bbb6d49b652ed", + "version" : "1.5.3" + } + }, + { + "identity" : "swiftui-introspect", + "kind" : "remoteSourceControl", + "location" : "https://github.com/siteline/SwiftUI-Introspect.git", + "state" : { + "revision" : "121c146fe591b1320238d054ae35c81ffa45f45a", + "version" : "0.12.0" + } + }, + { + "identity" : "tpinappreceipt", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tikhop/TPInAppReceipt.git", + "state" : { + "revision" : "5b830d6ce6c34bb4bb976917576ab560e7945037", + "version" : "3.3.4" + } + }, + { + "identity" : "xctest-dynamic-overlay", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state" : { + "revision" : "23cbf2294e350076ea4dbd7d5d047c1e76b03631", + "version" : "1.0.2" + } + } + ], + "version" : 2 } diff --git a/Cryptomator/AddVault/AddVaultSuccessCoordinator.swift b/Cryptomator/AddVault/AddVaultSuccessCoordinator.swift index fde389dc8..e04a118fd 100644 --- a/Cryptomator/AddVault/AddVaultSuccessCoordinator.swift +++ b/Cryptomator/AddVault/AddVaultSuccessCoordinator.swift @@ -28,9 +28,8 @@ class AddVaultSuccessCoordinator: AddVaultSuccesing, Coordinator { let viewModel = AddVaultSuccessViewModel(vaultName: vaultName, vaultUID: vaultUID) let successVC = AddVaultSuccessViewController(viewModel: viewModel) successVC.coordinator = self - navigationController.pushViewController(successVC, animated: true) // Remove the previous ViewControllers so that the user cannot navigate to the previous screens. - navigationController.viewControllers = [successVC] + navigationController.setViewControllers([successVC], animated: true) } // MARK: - AddVaultSuccesing diff --git a/Cryptomator/AddVault/Hub/HubAddVaultCoordinator.swift b/Cryptomator/AddVault/Hub/HubAddVaultCoordinator.swift new file mode 100644 index 000000000..92cb4af15 --- /dev/null +++ b/Cryptomator/AddVault/Hub/HubAddVaultCoordinator.swift @@ -0,0 +1,80 @@ +// +// HubAddVaultCoordinator.swift +// Cryptomator +// +// Created by Philipp Schmid on 16.03.23. +// Copyright © 2023 Skymatic GmbH. All rights reserved. +// + +import AppAuthCore +import CocoaLumberjackSwift +import CryptoKit +import CryptomatorCloudAccessCore +import CryptomatorCommon +import CryptomatorCommonCore +import JOSESwift +import SwiftUI +import UIKit + +class AddHubVaultCoordinator: Coordinator { + var childCoordinators = [Coordinator]() + var navigationController: UINavigationController + let downloadedVaultConfig: DownloadedVaultConfig + let vaultUID: String + let accountUID: String + let vaultItem: VaultItem + let vaultManager: VaultManager + weak var parentCoordinator: Coordinator? + weak var delegate: (VaultInstalling & AnyObject)? + + init(navigationController: UINavigationController, + downloadedVaultConfig: DownloadedVaultConfig, + vaultUID: String, + accountUID: String, + vaultItem: VaultItem, + vaultManager: VaultManager = VaultDBManager.shared) { + self.navigationController = navigationController + self.downloadedVaultConfig = downloadedVaultConfig + self.vaultUID = vaultUID + self.accountUID = accountUID + self.vaultItem = vaultItem + self.vaultManager = vaultManager + } + + func start() { + let unlockHandler = AddHubVaultUnlockHandler(vaultUID: vaultUID, + accountUID: accountUID, vaultItem: vaultItem, + downloadedVaultConfig: downloadedVaultConfig, + vaultManager: vaultManager, + delegate: self) + let child = HubAuthenticationCoordinator(navigationController: navigationController, + vaultConfig: downloadedVaultConfig.vaultConfig, + unlockHandler: unlockHandler, + parent: self, + delegate: self) + childCoordinators.append(child) + child.start() + } +} + +extension AddHubVaultCoordinator: HubVaultUnlockHandlerDelegate { + func successfullyProcessedUnlockedVault() { + delegate?.showSuccessfullyAddedVault(withName: vaultItem.name, vaultUID: vaultUID) + } + + func failedToProcessUnlockedVault(error: Error) { + handleError(error, for: navigationController, onOKTapped: { [weak self] in + self?.parentCoordinator?.childDidFinish(self) + }) + } +} + +extension AddHubVaultCoordinator: HubAuthenticationCoordinatorDelegate { + func userDidCancelHubAuthentication() { + // do nothing as the user already sees the login screen again + } + + func userDismissedHubAuthenticationErrorMessage() { + // do nothing as the user already sees the login screen again + } +} diff --git a/Cryptomator/AddVault/OpenExistingVault/DetectedMasterkeyViewModel.swift b/Cryptomator/AddVault/OpenExistingVault/DetectedMasterkeyViewModel.swift deleted file mode 100644 index b07fdc1be..000000000 --- a/Cryptomator/AddVault/OpenExistingVault/DetectedMasterkeyViewModel.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// DetectedMasterkeyViewModel.swift -// Cryptomator -// -// Created by Philipp Schmid on 27.01.21. -// Copyright © 2021 Skymatic GmbH. All rights reserved. -// - -import CryptomatorCloudAccessCore -import CryptomatorCommonCore -import Foundation - -struct DetectedMasterkeyViewModel { - let masterkeyPath: CloudPath - var text: String { - return String(format: LocalizedString.getValue("addVault.openExistingVault.detectedMasterkey.text"), vaultName) - } - - private var vaultName: String { - let masterkeyParentPath = masterkeyPath.deletingLastPathComponent() - return masterkeyParentPath.lastPathComponent - } -} diff --git a/Cryptomator/AddVault/OpenExistingVault/OpenExistingLegacyVaultPasswordViewModel.swift b/Cryptomator/AddVault/OpenExistingVault/OpenExistingLegacyVaultPasswordViewModel.swift index e90cfd352..c4f7b9408 100644 --- a/Cryptomator/AddVault/OpenExistingVault/OpenExistingLegacyVaultPasswordViewModel.swift +++ b/Cryptomator/AddVault/OpenExistingVault/OpenExistingLegacyVaultPasswordViewModel.swift @@ -12,8 +12,59 @@ import CryptomatorCommonCore import Foundation import Promises -class OpenExistingLegacyVaultPasswordViewModel: OpenExistingVaultPasswordViewModel { - override func addVault() -> Promise { +class OpenExistingLegacyVaultPasswordViewModel: SingleSectionTableViewModel, OpenExistingVaultPasswordViewModelProtocol { + var lastReturnButtonPressed: AnyPublisher { + return setupReturnButtonSupport(for: [passwordCellViewModel], subscribers: &subscribers) + } + + override var title: String? { + return LocalizedString.getValue("addVault.openExistingVault.title") + } + + override var cells: [TableViewCellViewModel] { + return [passwordCellViewModel] + } + + var enableVerifyButton: AnyPublisher { + return passwordCellViewModel.input.$value.map { input in + return !input.isEmpty + }.eraseToAnyPublisher() + } + + let provider: CloudProvider + let account: CloudProviderAccount + + let vault: VaultItem + var vaultName: String { + return vault.name + } + + let vaultUID: String + let passwordCellViewModel = TextFieldCellViewModel(type: .password, isInitialFirstResponder: true) + var password: String { + return passwordCellViewModel.input.value + } + + let downloadedMasterkeyFile: DownloadedMasterkeyFile + + private lazy var subscribers = Set() + + init(provider: CloudProvider, account: CloudProviderAccount, vault: VaultItem, vaultUID: String, downloadedMasterkeyFile: DownloadedMasterkeyFile) { + self.provider = provider + self.account = account + self.vault = vault + self.vaultUID = vaultUID + self.downloadedMasterkeyFile = downloadedMasterkeyFile + } + + func addVault() -> Promise { return VaultDBManager.shared.createLegacyFromExisting(withVaultUID: vaultUID, delegateAccountUID: account.accountUID, vaultItem: vault, password: password, storePasswordInKeychain: false) } + + override func getFooterTitle(for section: Int) -> String? { + guard section == 0 else { + return nil + } + return String(format: LocalizedString.getValue("addVault.openExistingVault.password.footer"), vaultName) + } } diff --git a/Cryptomator/AddVault/OpenExistingVault/OpenExistingVaultCoordinator.swift b/Cryptomator/AddVault/OpenExistingVault/OpenExistingVaultCoordinator.swift index d4a8588f0..aa2d9be69 100644 --- a/Cryptomator/AddVault/OpenExistingVault/OpenExistingVaultCoordinator.swift +++ b/Cryptomator/AddVault/OpenExistingVault/OpenExistingVaultCoordinator.swift @@ -6,10 +6,13 @@ // Copyright © 2021 Skymatic GmbH. All rights reserved. // +import AppAuth import CocoaLumberjackSwift import CryptomatorCloudAccessCore +import CryptomatorCommon import CryptomatorCommonCore import Foundation +import Promises import UIKit class OpenExistingVaultCoordinator: AccountListing, CloudChoosing, DefaultShowEditAccountBehavior, Coordinator { @@ -121,22 +124,90 @@ private class AuthenticatedOpenExistingVaultCoordinator: VaultInstalling, Folder } func chooseItem(_ item: Item) { - let viewModel: OpenExistingVaultPasswordViewModelProtocol guard let vaultItem = item as? VaultDetailItem else { handleError(VaultCoordinatorError.wrongItemType, for: navigationController) return } if vaultItem.isLegacyVault { - viewModel = OpenExistingLegacyVaultPasswordViewModel(provider: provider, account: account, vault: vaultItem, vaultUID: UUID().uuidString) + downloadAndProcessExistingLegacyVault(vaultItem) } else { - viewModel = OpenExistingVaultPasswordViewModel(provider: provider, account: account, vault: vaultItem, vaultUID: UUID().uuidString) + downloadAndProcessExistingVault(vaultItem) } + } + private func downloadAndProcessExistingLegacyVault(_ vaultItem: VaultItem) { + let hud = ProgressHUD() + hud.text = LocalizedString.getValue("addVault.openExistingVault.downloadVault.progress") + hud.show(presentingViewController: navigationController) + VaultDBManager.shared.downloadMasterkeyFile(delegateAccountUID: account.accountUID, vaultItem: vaultItem).then { downloadedMasterkeyFile in + all(hud.dismiss(animated: true), Promise(downloadedMasterkeyFile)) + }.then { _, downloadedMasterkeyFile in + self.processDownloadedMasterkeyFile(downloadedMasterkeyFile, vaultItem: vaultItem) + }.catch { error in + hud.dismiss(animated: true).then { + self.handleError(error, for: self.navigationController) + } + } + } + + private func processDownloadedMasterkeyFile(_ downloadedMasterkeyFile: DownloadedMasterkeyFile, vaultItem: VaultItem) { + let viewModel = OpenExistingLegacyVaultPasswordViewModel(provider: provider, + account: account, + vault: vaultItem, + vaultUID: UUID().uuidString, + downloadedMasterkeyFile: downloadedMasterkeyFile) let passwordVC = OpenExistingVaultPasswordViewController(viewModel: viewModel) passwordVC.coordinator = self navigationController.pushViewController(passwordVC, animated: true) } + private func downloadAndProcessExistingVault(_ vaultItem: VaultItem) { + let hud = ProgressHUD() + hud.text = LocalizedString.getValue("addVault.openExistingVault.downloadVault.progress") + hud.show(presentingViewController: navigationController) + VaultDBManager.shared.getUnverifiedVaultConfig(delegateAccountUID: account.accountUID, vaultItem: vaultItem).then { downloadedVaultConfig in + all(hud.dismiss(animated: true), Promise(downloadedVaultConfig)) + }.then { _, downloadedVaultConfig in + self.processDownloadedVaultConfig(downloadedVaultConfig, vaultItem: vaultItem) + }.catch { error in + hud.dismiss(animated: true).then { + self.handleError(error, for: self.navigationController) + } + } + } + + private func processDownloadedVaultConfig(_ downloadedVaultConfig: DownloadedVaultConfig, vaultItem: VaultItem) { + switch VaultConfigHelper.getType(for: downloadedVaultConfig.vaultConfig) { + case .masterkeyFile: + handleMasterkeyFileVaultConfig(downloadedVaultConfig, vaultItem: vaultItem) + case .hub: + handleHubVaultConfig(downloadedVaultConfig, vaultItem: vaultItem) + case .unknown: + handleError(error: VaultProviderFactoryError.unsupportedVaultConfig) + } + } + + private func handleMasterkeyFileVaultConfig(_ downloadedVaultConfig: DownloadedVaultConfig, vaultItem: VaultItem) { + VaultDBManager.shared.downloadMasterkeyFile(delegateAccountUID: account.accountUID, vaultItem: vaultItem).then { downloadedMasterkeyFile in + let viewModel = OpenExistingVaultPasswordViewModel(provider: self.provider, account: self.account, vault: vaultItem, vaultUID: UUID().uuidString, downloadedVaultConfig: downloadedVaultConfig, downloadedMasterkeyFile: downloadedMasterkeyFile) + let passwordVC = OpenExistingVaultPasswordViewController(viewModel: viewModel) + passwordVC.coordinator = self + self.navigationController.pushViewController(passwordVC, animated: true) + } + } + + private func handleHubVaultConfig(_ downloadedVaultConfig: DownloadedVaultConfig, vaultItem: VaultItem) { + let child = AddHubVaultCoordinator(navigationController: navigationController, + downloadedVaultConfig: downloadedVaultConfig, + vaultUID: UUID().uuidString, + accountUID: account.accountUID, + vaultItem: vaultItem) + child.parentCoordinator = self + child.delegate = self + childCoordinators.append(child) + child.start() + } + func showCreateNewFolder(parentPath: CloudPath) {} func handleError(error: Error) { diff --git a/Cryptomator/AddVault/OpenExistingVault/OpenExistingVaultPasswordViewModel.swift b/Cryptomator/AddVault/OpenExistingVault/OpenExistingVaultPasswordViewModel.swift index 9f5df55cf..e29ba053e 100644 --- a/Cryptomator/AddVault/OpenExistingVault/OpenExistingVaultPasswordViewModel.swift +++ b/Cryptomator/AddVault/OpenExistingVault/OpenExistingVaultPasswordViewModel.swift @@ -16,60 +16,22 @@ protocol OpenExistingVaultPasswordViewModelProtocol: SingleSectionTableViewModel var vaultName: String { get } var vaultUID: String { get } var enableVerifyButton: AnyPublisher { get } - // This function is later no longer asynchronous func addVault() -> Promise } -class OpenExistingVaultPasswordViewModel: SingleSectionTableViewModel, OpenExistingVaultPasswordViewModelProtocol { - var lastReturnButtonPressed: AnyPublisher { - return setupReturnButtonSupport(for: [passwordCellViewModel], subscribers: &subscribers) - } - - override var title: String? { - return LocalizedString.getValue("addVault.openExistingVault.title") - } - - override var cells: [TableViewCellViewModel] { - return [passwordCellViewModel] - } - - var enableVerifyButton: AnyPublisher { - return passwordCellViewModel.input.$value.map { input in - return !input.isEmpty - }.eraseToAnyPublisher() - } - - let provider: CloudProvider - let account: CloudProviderAccount - - let vault: VaultItem - var vaultName: String { - return vault.name - } - - let vaultUID: String - let passwordCellViewModel = TextFieldCellViewModel(type: .password, isInitialFirstResponder: true) - var password: String { - return passwordCellViewModel.input.value - } - - private lazy var subscribers = Set() - - init(provider: CloudProvider, account: CloudProviderAccount, vault: VaultItem, vaultUID: String) { - self.provider = provider - self.account = account - self.vault = vault - self.vaultUID = vaultUID - } +class OpenExistingVaultPasswordViewModel: OpenExistingLegacyVaultPasswordViewModel { + let downloadedVaultConfig: DownloadedVaultConfig - func addVault() -> Promise { - return VaultDBManager.shared.createFromExisting(withVaultUID: vaultUID, delegateAccountUID: account.accountUID, vaultItem: vault, password: password, storePasswordInKeychain: false) + init(provider: CloudProvider, account: CloudProviderAccount, vault: VaultItem, vaultUID: String, downloadedVaultConfig: DownloadedVaultConfig, downloadedMasterkeyFile: DownloadedMasterkeyFile) { + self.downloadedVaultConfig = downloadedVaultConfig + super.init(provider: provider, + account: account, + vault: vault, + vaultUID: vaultUID, + downloadedMasterkeyFile: downloadedMasterkeyFile) } - override func getFooterTitle(for section: Int) -> String? { - guard section == 0 else { - return nil - } - return String(format: LocalizedString.getValue("addVault.openExistingVault.password.footer"), vaultName) + override func addVault() -> Promise { + return VaultDBManager.shared.createFromExisting(withVaultUID: vaultUID, delegateAccountUID: account.accountUID, downloadedVaultConfig: downloadedVaultConfig, downloadedMasterkey: downloadedMasterkeyFile, vaultItem: vault, password: password) } } diff --git a/Cryptomator/Common/ChildCoordinator.swift b/Cryptomator/Common/ChildCoordinator.swift index 174634cac..b2a35d185 100644 --- a/Cryptomator/Common/ChildCoordinator.swift +++ b/Cryptomator/Common/ChildCoordinator.swift @@ -6,6 +6,7 @@ // Copyright © 2021 Skymatic GmbH. All rights reserved. // +import CryptomatorCommonCore import Foundation protocol ChildCoordinator: Coordinator { diff --git a/Cryptomator/Common/CloudAccountList/AccountListViewController.swift b/Cryptomator/Common/CloudAccountList/AccountListViewController.swift index 47f04730b..f02db08d8 100644 --- a/Cryptomator/Common/CloudAccountList/AccountListViewController.swift +++ b/Cryptomator/Common/CloudAccountList/AccountListViewController.swift @@ -6,6 +6,7 @@ // Copyright © 2021 Skymatic GmbH. All rights reserved. // +import CryptomatorCommon import CryptomatorCommonCore import Foundation import Promises diff --git a/Cryptomator/Common/LocalWeb/LocalWebViewController.swift b/Cryptomator/Common/LocalWeb/LocalWebViewController.swift index 1f297407b..32084aba1 100644 --- a/Cryptomator/Common/LocalWeb/LocalWebViewController.swift +++ b/Cryptomator/Common/LocalWeb/LocalWebViewController.swift @@ -6,6 +6,7 @@ // Copyright © 2021 Skymatic GmbH. All rights reserved. // +import CryptomatorCommonCore import Foundation import UIKit import WebKit diff --git a/Cryptomator/Common/PoppingCloseCoordinator.swift b/Cryptomator/Common/PoppingCloseCoordinator.swift index 2331f7f11..baa1fe3c4 100644 --- a/Cryptomator/Common/PoppingCloseCoordinator.swift +++ b/Cryptomator/Common/PoppingCloseCoordinator.swift @@ -6,7 +6,9 @@ // Copyright © 2021 Skymatic GmbH. All rights reserved. // +import CryptomatorCommonCore import UIKit + protocol PoppingCloseCoordinator: Coordinator { var oldTopViewController: UIViewController? { get } } diff --git a/Cryptomator/S3/S3AuthenticationView.swift b/Cryptomator/S3/S3AuthenticationView.swift index 83c9f2a0b..521aea10b 100644 --- a/Cryptomator/S3/S3AuthenticationView.swift +++ b/Cryptomator/S3/S3AuthenticationView.swift @@ -56,9 +56,7 @@ struct S3AuthenticationView: View { .disableAutocorrection(true) .autocapitalization(.none) } - .introspectTableView(customize: { tableView in - tableView.backgroundColor = .cryptomatorBackground - }) + .setListBackgroundColor(.cryptomatorBackground) } } diff --git a/Cryptomator/S3/S3Authenticator+VC.swift b/Cryptomator/S3/S3Authenticator+VC.swift index f4cd6ea4f..caa9999d0 100644 --- a/Cryptomator/S3/S3Authenticator+VC.swift +++ b/Cryptomator/S3/S3Authenticator+VC.swift @@ -7,6 +7,7 @@ // import CryptomatorCloudAccessCore +import CryptomatorCommonCore import Promises import UIKit diff --git a/Cryptomator/Settings/SettingsViewModel.swift b/Cryptomator/Settings/SettingsViewModel.swift index 3fa630c9f..d761538c1 100644 --- a/Cryptomator/Settings/SettingsViewModel.swift +++ b/Cryptomator/Settings/SettingsViewModel.swift @@ -9,6 +9,7 @@ import Combine import CryptomatorCommonCore import CryptomatorFileProvider +import Dependencies import Foundation import Promises import StoreKit @@ -90,13 +91,13 @@ class SettingsViewModel: TableViewModel { return viewModel }() - private let fileProviderConnector: FileProviderConnector + @Dependency(\.fileProviderConnector) private var fileProviderConnector + private var subscribers = Set() private lazy var showDebugModeWarningPublisher = PassthroughSubject() - init(cryptomatorSettings: CryptomatorSettings = CryptomatorUserDefaults.shared, fileProviderConnector: FileProviderConnector = FileProviderXPCConnector.shared) { + init(cryptomatorSettings: CryptomatorSettings = CryptomatorUserDefaults.shared) { self.cryptomatorSettings = cryptomatorSettings - self.fileProviderConnector = fileProviderConnector } func refreshCacheSize() -> Promise { diff --git a/Cryptomator/VaultDetail/ChangePassword/ChangePasswordViewModel.swift b/Cryptomator/VaultDetail/ChangePassword/ChangePasswordViewModel.swift index fbe93f71b..ad659fb43 100644 --- a/Cryptomator/VaultDetail/ChangePassword/ChangePasswordViewModel.swift +++ b/Cryptomator/VaultDetail/ChangePassword/ChangePasswordViewModel.swift @@ -10,6 +10,7 @@ import CocoaLumberjackSwift import Combine import CryptomatorCommonCore import CryptomatorCryptoLib +import Dependencies import FileProvider import Foundation import Promises @@ -60,27 +61,23 @@ class ChangePasswordViewModel: TableViewModel, ChangePass return _sections } - lazy var cells: [ChangePasswordSection: [BindableTableViewCellViewModel]] = { - return [ - .oldPassword: [oldPasswordCellViewModel], - .newPassword: [newPasswordCellViewModel], - .newPasswordConfirmation: [newPasswordConfirmationCellViewModel] - ] - }() - - private lazy var _sections: [Section] = { - return [ - Section(id: .oldPassword, elements: [oldPasswordCellViewModel]), - Section(id: .newPassword, elements: [newPasswordCellViewModel]), - Section(id: .newPasswordConfirmation, elements: [newPasswordConfirmationCellViewModel]) - ] - }() + lazy var cells: [ChangePasswordSection: [BindableTableViewCellViewModel]] = [ + .oldPassword: [oldPasswordCellViewModel], + .newPassword: [newPasswordCellViewModel], + .newPasswordConfirmation: [newPasswordConfirmationCellViewModel] + ] + + private lazy var _sections: [Section] = [ + Section(id: .oldPassword, elements: [oldPasswordCellViewModel]), + Section(id: .newPassword, elements: [newPasswordCellViewModel]), + Section(id: .newPasswordConfirmation, elements: [newPasswordConfirmationCellViewModel]) + ] private static let minimumPasswordLength = 8 private let vaultAccount: VaultAccount private let domain: NSFileProviderDomain private let vaultManager: VaultManager - private let fileProviderConnector: FileProviderConnector + @Dependency(\.fileProviderConnector) private var fileProviderConnector private let oldPasswordCellViewModel = TextFieldCellViewModel(type: .password, isInitialFirstResponder: true) private let newPasswordCellViewModel = TextFieldCellViewModel(type: .password) @@ -100,11 +97,10 @@ class ChangePasswordViewModel: TableViewModel, ChangePass private lazy var subscribers = Set() - init(vaultAccount: VaultAccount, domain: NSFileProviderDomain, vaultManager: VaultManager = VaultDBManager.shared, fileProviderConnector: FileProviderConnector = FileProviderXPCConnector.shared) { + init(vaultAccount: VaultAccount, domain: NSFileProviderDomain, vaultManager: VaultManager = VaultDBManager.shared) { self.vaultAccount = vaultAccount self.domain = domain self.vaultManager = vaultManager - self.fileProviderConnector = fileProviderConnector super.init() } diff --git a/Cryptomator/VaultDetail/KeepUnlocked/VaultKeepUnlockedViewModel.swift b/Cryptomator/VaultDetail/KeepUnlocked/VaultKeepUnlockedViewModel.swift index 08e1f36d3..8ed08501e 100644 --- a/Cryptomator/VaultDetail/KeepUnlocked/VaultKeepUnlockedViewModel.swift +++ b/Cryptomator/VaultDetail/KeepUnlocked/VaultKeepUnlockedViewModel.swift @@ -8,6 +8,7 @@ import Combine import CryptomatorCommonCore +import Dependencies import FileProvider import Foundation import Promises @@ -40,7 +41,7 @@ class VaultKeepUnlockedViewModel: TableViewModel, Vaul private(set) var keepUnlockedItems = [KeepUnlockedDurationItem]() private let vaultKeepUnlockedSettings: VaultKeepUnlockedSettings private let masterkeyCacheManager: MasterkeyCacheManager - private let fileProviderConnector: FileProviderConnector + @Dependency(\.fileProviderConnector) private var fileProviderConnector private let vaultInfo: VaultInfo private let currentKeepUnlockedDuration: Bindable private var subscriber: AnyCancellable? @@ -48,11 +49,10 @@ class VaultKeepUnlockedViewModel: TableViewModel, Vaul return vaultInfo.vaultUID } - init(currentKeepUnlockedDuration: Bindable, vaultInfo: VaultInfo, vaultKeepUnlockedSettings: VaultKeepUnlockedSettings = VaultKeepUnlockedManager.shared, masterkeyCacheManager: MasterkeyCacheManager = MasterkeyCacheKeychainManager.shared, fileProviderConnector: FileProviderConnector = FileProviderXPCConnector.shared) { + init(currentKeepUnlockedDuration: Bindable, vaultInfo: VaultInfo, vaultKeepUnlockedSettings: VaultKeepUnlockedSettings = VaultKeepUnlockedManager.shared, masterkeyCacheManager: MasterkeyCacheManager = MasterkeyCacheKeychainManager.shared) { self.vaultInfo = vaultInfo self.vaultKeepUnlockedSettings = vaultKeepUnlockedSettings self.masterkeyCacheManager = masterkeyCacheManager - self.fileProviderConnector = fileProviderConnector self.currentKeepUnlockedDuration = currentKeepUnlockedDuration self.keepUnlockedItems = KeepUnlockedDuration.allCases.map { diff --git a/Cryptomator/VaultDetail/MoveVault/MoveVaultViewModel.swift b/Cryptomator/VaultDetail/MoveVault/MoveVaultViewModel.swift index cffb4c33a..48861fcc1 100644 --- a/Cryptomator/VaultDetail/MoveVault/MoveVaultViewModel.swift +++ b/Cryptomator/VaultDetail/MoveVault/MoveVaultViewModel.swift @@ -9,6 +9,7 @@ import CocoaLumberjackSwift import CryptomatorCloudAccessCore import CryptomatorCommonCore +import Dependencies import FileProvider import Foundation import GRDB @@ -28,19 +29,17 @@ class MoveVaultViewModel: ChooseFolderViewModel, MoveVaultViewModelProtocol { private let vaultManager: VaultManager private let vaultInfo: VaultInfo private let domain: NSFileProviderDomain - private let fileProviderConnector: FileProviderConnector + @Dependency(\.fileProviderConnector) private var fileProviderConnector init(provider: CloudProvider, currentFolderChoosingCloudPath: CloudPath, vaultInfo: VaultInfo, domain: NSFileProviderDomain, cloudProviderManager: CloudProviderManager = CloudProviderDBManager.shared, - vaultManager: VaultManager = VaultDBManager.shared, - fileProviderConnector: FileProviderConnector = FileProviderXPCConnector.shared) { + vaultManager: VaultManager = VaultDBManager.shared) { self.vaultInfo = vaultInfo self.domain = domain self.vaultManager = vaultManager - self.fileProviderConnector = fileProviderConnector super.init(canCreateFolder: true, cloudPath: currentFolderChoosingCloudPath, provider: provider) } diff --git a/Cryptomator/VaultDetail/RenameVault/RenameVaultViewModel.swift b/Cryptomator/VaultDetail/RenameVault/RenameVaultViewModel.swift index 4e36d213f..244328848 100644 --- a/Cryptomator/VaultDetail/RenameVault/RenameVaultViewModel.swift +++ b/Cryptomator/VaultDetail/RenameVault/RenameVaultViewModel.swift @@ -9,6 +9,7 @@ import CocoaLumberjackSwift import CryptomatorCloudAccessCore import CryptomatorCommonCore +import Dependencies import FileProvider import Foundation import GRDB @@ -31,19 +32,18 @@ class RenameVaultViewModel: SetVaultNameViewModel, RenameVaultViewModelProtcol { // swiftlint:disable:next weak_delegate private let delegate: MoveVaultViewModel private let vaultInfo: VaultInfo + @Dependency(\.fileProviderConnector) private var fileProviderConnector init(provider: CloudProvider, vaultInfo: VaultInfo, domain: NSFileProviderDomain, - vaultManager: VaultManager = VaultDBManager.shared, - fileProviderConnector: FileProviderConnector = FileProviderXPCConnector.shared) { + vaultManager: VaultManager = VaultDBManager.shared) { self.delegate = MoveVaultViewModel( provider: provider, currentFolderChoosingCloudPath: CloudPath("/"), vaultInfo: vaultInfo, domain: domain, - vaultManager: vaultManager, - fileProviderConnector: fileProviderConnector + vaultManager: vaultManager ) self.vaultInfo = vaultInfo } diff --git a/Cryptomator/VaultDetail/VaultDetailViewModel.swift b/Cryptomator/VaultDetail/VaultDetailViewModel.swift index 3d098fb69..ef21377cd 100644 --- a/Cryptomator/VaultDetail/VaultDetailViewModel.swift +++ b/Cryptomator/VaultDetail/VaultDetailViewModel.swift @@ -10,6 +10,7 @@ import CocoaLumberjackSwift import Combine import CryptomatorCloudAccessCore import CryptomatorCommonCore +import Dependencies import GRDB import LocalAuthentication import Promises @@ -73,7 +74,7 @@ class VaultDetailViewModel: VaultDetailViewModelProtocol { private let vaultInfo: VaultInfo private let vaultManager: VaultManager - private let fileProviderConnector: FileProviderConnector + @Dependency(\.fileProviderConnector) private var fileProviderConnector private let context = LAContext() private let vaultKeepUnlockedSettings: VaultKeepUnlockedSettings private let passwordManager: VaultPasswordManager @@ -136,12 +137,10 @@ class VaultDetailViewModel: VaultDetailViewModelProtocol { } } - private lazy var sectionFooter: [VaultDetailSection: HeaderFooterViewModel] = { - [.vaultInfoSection: VaultDetailInfoFooterViewModel(vault: vaultInfo), - .changeVaultPasswordSection: BaseHeaderFooterViewModel(title: LocalizedString.getValue("vaultDetail.changePassword.footer")), - .lockingSection: unlockSectionFooterViewModel, - .removeVaultSection: BaseHeaderFooterViewModel(title: LocalizedString.getValue("vaultDetail.removeVault.footer"))] - }() + private lazy var sectionFooter: [VaultDetailSection: HeaderFooterViewModel] = [.vaultInfoSection: VaultDetailInfoFooterViewModel(vault: vaultInfo), + .changeVaultPasswordSection: BaseHeaderFooterViewModel(title: LocalizedString.getValue("vaultDetail.changePassword.footer")), + .lockingSection: unlockSectionFooterViewModel, + .removeVaultSection: BaseHeaderFooterViewModel(title: LocalizedString.getValue("vaultDetail.removeVault.footer"))] private lazy var unlockSectionFooterViewModel = UnlockSectionFooterViewModel(vaultUnlocked: vaultInfo.vaultIsUnlocked.value, biometricalUnlockEnabled: biometricalUnlockEnabled, biometryTypeName: context.enrolledBiometricsAuthenticationName(), keepUnlockedDuration: currentKeepUnlockedDuration.value) @@ -156,13 +155,12 @@ class VaultDetailViewModel: VaultDetailViewModelProtocol { private var observation: DatabaseCancellable? convenience init(vaultInfo: VaultInfo) { - self.init(vaultInfo: vaultInfo, vaultManager: VaultDBManager.shared, fileProviderConnector: FileProviderXPCConnector.shared, passwordManager: VaultPasswordKeychainManager(), dbManager: DatabaseManager.shared, vaultKeepUnlockedSettings: VaultKeepUnlockedManager.shared) + self.init(vaultInfo: vaultInfo, vaultManager: VaultDBManager.shared, passwordManager: VaultPasswordKeychainManager(), dbManager: DatabaseManager.shared, vaultKeepUnlockedSettings: VaultKeepUnlockedManager.shared) } - init(vaultInfo: VaultInfo, vaultManager: VaultManager, fileProviderConnector: FileProviderConnector, passwordManager: VaultPasswordManager, dbManager: DatabaseManager, vaultKeepUnlockedSettings: VaultKeepUnlockedSettings) { + init(vaultInfo: VaultInfo, vaultManager: VaultManager, passwordManager: VaultPasswordManager, dbManager: DatabaseManager, vaultKeepUnlockedSettings: VaultKeepUnlockedSettings) { self.vaultInfo = vaultInfo self.vaultManager = vaultManager - self.fileProviderConnector = fileProviderConnector self.passwordManager = passwordManager self.title = Bindable(vaultInfo.vaultName) self.vaultKeepUnlockedSettings = vaultKeepUnlockedSettings diff --git a/Cryptomator/VaultList/VaultCellViewModel.swift b/Cryptomator/VaultList/VaultCellViewModel.swift index 0119693f8..a97e41c98 100644 --- a/Cryptomator/VaultList/VaultCellViewModel.swift +++ b/Cryptomator/VaultList/VaultCellViewModel.swift @@ -8,6 +8,7 @@ import Combine import CryptomatorCommonCore +import Dependencies import Promises import UIKit @@ -33,11 +34,10 @@ class VaultCellViewModel: TableViewCellViewModel, VaultCellViewModelProtocol { let vault: VaultInfo private lazy var errorPublisher = PassthroughSubject() - private let fileProviderConnector: FileProviderConnector + @Dependency(\.fileProviderConnector) private var fileProviderConnector - init(vault: VaultInfo, fileProviderConnector: FileProviderConnector = FileProviderXPCConnector.shared) { + init(vault: VaultInfo) { self.vault = vault - self.fileProviderConnector = fileProviderConnector } func lockVault() -> Promise { diff --git a/Cryptomator/VaultList/VaultListViewModel.swift b/Cryptomator/VaultList/VaultListViewModel.swift index ed39128ab..4c6254a58 100644 --- a/Cryptomator/VaultList/VaultListViewModel.swift +++ b/Cryptomator/VaultList/VaultListViewModel.swift @@ -9,6 +9,7 @@ import CocoaLumberjackSwift import Combine import CryptomatorCommonCore +import Dependencies import FileProvider import Foundation import GRDB @@ -27,7 +28,7 @@ class VaultListViewModel: ViewModel, VaultListViewModelProtocol { var vaultCellViewModels: [VaultCellViewModel] private let dbManager: DatabaseManager private let vaultManager: VaultDBManager - private let fileProviderConnector: FileProviderConnector + @Dependency(\.fileProviderConnector) private var fileProviderConnector private var observation: DatabaseCancellable? private lazy var subscribers = Set() private lazy var errorPublisher = PassthroughSubject() @@ -35,13 +36,12 @@ class VaultListViewModel: ViewModel, VaultListViewModelProtocol { private var removedRow = false convenience init() { - self.init(dbManager: DatabaseManager.shared, vaultManager: VaultDBManager.shared, fileProviderConnector: FileProviderXPCConnector.shared) + self.init(dbManager: DatabaseManager.shared, vaultManager: VaultDBManager.shared) } - init(dbManager: DatabaseManager, vaultManager: VaultDBManager, fileProviderConnector: FileProviderConnector) { + init(dbManager: DatabaseManager, vaultManager: VaultDBManager) { self.dbManager = dbManager self.vaultManager = vaultManager - self.fileProviderConnector = fileProviderConnector self.vaultCellViewModels = [VaultCellViewModel]() } diff --git a/Cryptomator/WebDAV/WebDAVAuthentication.swift b/Cryptomator/WebDAV/WebDAVAuthentication.swift index 1a17f8fa2..02b0cd3b0 100644 --- a/Cryptomator/WebDAV/WebDAVAuthentication.swift +++ b/Cryptomator/WebDAV/WebDAVAuthentication.swift @@ -39,9 +39,7 @@ struct WebDAVAuthentication: View { } .focusedLegacy($focusedField, equals: .password) } - .introspectTableView(customize: { tableView in - tableView.backgroundColor = .cryptomatorBackground - }) + .setListBackgroundColor(.cryptomatorBackground) } } diff --git a/Cryptomator/WebDAV/WebDAVCredentialCoordinator.swift b/Cryptomator/WebDAV/WebDAVCredentialCoordinator.swift index b3468d65b..6b66ddddf 100644 --- a/Cryptomator/WebDAV/WebDAVCredentialCoordinator.swift +++ b/Cryptomator/WebDAV/WebDAVCredentialCoordinator.swift @@ -7,6 +7,7 @@ // import CryptomatorCloudAccessCore +import CryptomatorCommonCore import Foundation import UIKit diff --git a/CryptomatorCommon/Package.swift b/CryptomatorCommon/Package.swift index 6c5184aee..23e7e2842 100644 --- a/CryptomatorCommon/Package.swift +++ b/CryptomatorCommon/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.1 +// swift-tools-version:5.7 // // Package.swift @@ -13,7 +13,7 @@ import PackageDescription let package = Package( name: "CryptomatorCommon", platforms: [ - .iOS(.v13) + .iOS(.v14) ], products: [ .library( @@ -26,24 +26,26 @@ let package = Package( ) ], dependencies: [ - .package(url: "https://github.com/cryptomator/cloud-access-swift.git", .upToNextMinor(from: "1.7.0")), + .package(url: "https://github.com/cryptomator/cloud-access-swift.git", .upToNextMinor(from: "1.8.0")), .package(url: "https://github.com/CocoaLumberjack/CocoaLumberjack.git", .upToNextMinor(from: "3.8.0")), - .package(url: "https://github.com/PhilLibs/simple-swift-dependencies", .upToNextMajor(from: "0.1.0")) + .package(url: "https://github.com/PhilLibs/simple-swift-dependencies", .upToNextMajor(from: "0.1.0")), + .package(url: "https://github.com/siteline/SwiftUI-Introspect.git", .upToNextMajor(from: "0.3.0")) ], targets: [ .target( name: "CryptomatorCommon", dependencies: [ "CryptomatorCommonCore", - "CryptomatorCloudAccess" + .product(name: "CryptomatorCloudAccess", package: "cloud-access-swift") ] ), .target( name: "CryptomatorCommonCore", dependencies: [ - "CocoaLumberjackSwift", - "CryptomatorCloudAccessCore", - .product(name: "Dependencies", package: "simple-swift-dependencies") + .product(name: "CocoaLumberjackSwift", package: "CocoaLumberjack"), + .product(name: "CryptomatorCloudAccessCore", package: "cloud-access-swift"), + .product(name: "Dependencies", package: "simple-swift-dependencies"), + .product(name: "Introspect", package: "SwiftUI-Introspect") ] ), .testTarget( diff --git a/CryptomatorCommon/Sources/CryptomatorCommon/CryptomatorHubAuthenticator+HubAuthenticating.swift b/CryptomatorCommon/Sources/CryptomatorCommon/CryptomatorHubAuthenticator+HubAuthenticating.swift new file mode 100644 index 000000000..f34c331bd --- /dev/null +++ b/CryptomatorCommon/Sources/CryptomatorCommon/CryptomatorHubAuthenticator+HubAuthenticating.swift @@ -0,0 +1,59 @@ +// +// CryptomatorHubAuthenticator+HubAuthenticating.swift +// +// +// Created by Philipp Schmid on 22.07.22. +// + +import AppAuth +import Base32 +import CryptoKit +import CryptomatorCloudAccessCore +import CryptomatorCommonCore +import Dependencies +import UIKit + +enum HubAuthenticationError: Error { + case invalidAuthEndpoint + case invalidTokenEndpoint + case invalidRedirectURL +} + +extension CryptomatorHubAuthenticator: HubAuthenticating { + private static var currentAuthorizationFlow: OIDExternalUserAgentSession? + + public func authenticate(with hubConfig: HubConfig, from viewController: UIViewController) async throws -> OIDAuthState { + guard let authorizationEndpoint = URL(string: hubConfig.authEndpoint) else { + throw HubAuthenticationError.invalidAuthEndpoint + } + guard let tokenEndpoint = URL(string: hubConfig.tokenEndpoint) else { + throw HubAuthenticationError.invalidTokenEndpoint + } + guard let redirectURL = URL(string: "hub.org.cryptomator.ios:/auth") else { + throw HubAuthenticationError.invalidRedirectURL + } + let configuration = OIDServiceConfiguration(authorizationEndpoint: authorizationEndpoint, + tokenEndpoint: tokenEndpoint) + + let request = OIDAuthorizationRequest(configuration: configuration, clientId: hubConfig.clientId, scopes: nil, redirectURL: redirectURL, responseType: OIDResponseTypeCode, additionalParameters: nil) + return try await withCheckedThrowingContinuation({ continuation in + DispatchQueue.main.async { + CryptomatorHubAuthenticator.currentAuthorizationFlow = + OIDAuthState.authState(byPresenting: request, presenting: viewController) { authState, error in + switch (authState, error) { + case let (.some(authState), nil): + continuation.resume(returning: authState) + case let (nil, .some(error)): + continuation.resume(throwing: error) + default: + continuation.resume(throwing: CryptomatorHubAuthenticatorError.unexpectedError) + } + } + } + }) + } +} + +extension HubAuthenticatingKey: DependencyKey { + public static var liveValue: HubAuthenticating = CryptomatorHubAuthenticator() +} diff --git a/CryptomatorCommon/Sources/CryptomatorCommon/Placeholder.swift b/CryptomatorCommon/Sources/CryptomatorCommon/Placeholder.swift deleted file mode 100644 index 9589047d7..000000000 --- a/CryptomatorCommon/Sources/CryptomatorCommon/Placeholder.swift +++ /dev/null @@ -1,9 +0,0 @@ -// -// Placeholder.swift -// CryptomatorCommon -// -// Created by Philipp Schmid on 04.04.21. -// Copyright © 2020 Skymatic GmbH. All rights reserved. -// - -// Workaround to create an "empty" target for SPM diff --git a/Cryptomator/Common/Coordinator.swift b/CryptomatorCommon/Sources/CryptomatorCommonCore/Coordinator.swift similarity index 72% rename from Cryptomator/Common/Coordinator.swift rename to CryptomatorCommon/Sources/CryptomatorCommonCore/Coordinator.swift index fd78c21e5..dba176a72 100644 --- a/Cryptomator/Common/Coordinator.swift +++ b/CryptomatorCommon/Sources/CryptomatorCommonCore/Coordinator.swift @@ -1,27 +1,29 @@ // // Coordinator.swift -// Cryptomator +// CryptomatorCommon // // Created by Philipp Schmid on 04.01.21. // Copyright © 2021 Skymatic GmbH. All rights reserved. // import CocoaLumberjackSwift -import CryptomatorCommonCore import UIKit -protocol Coordinator: AnyObject { +public protocol Coordinator: AnyObject { var childCoordinators: [Coordinator] { get set } var navigationController: UINavigationController { get set } func start() } -extension Coordinator { - func handleError(_ error: Error, for viewController: UIViewController) { +public extension Coordinator { + func handleError(_ error: Error, for viewController: UIViewController, onOKTapped: (() -> Void)? = nil) { DDLogError("Error: \(error)") let alertController = UIAlertController(title: LocalizedString.getValue("common.alert.error.title"), message: error.localizedDescription, preferredStyle: .alert) - alertController.addAction(UIAlertAction(title: LocalizedString.getValue("common.button.ok"), style: .default)) + let okAction = UIAlertAction(title: LocalizedString.getValue("common.button.ok"), style: .default) { _ in + onOKTapped?() + } + alertController.addAction(okAction) viewController.present(alertController, animated: true) } diff --git a/CryptomatorCommon/Sources/CryptomatorCommonCore/CryptomatorDatabase.swift b/CryptomatorCommon/Sources/CryptomatorCommonCore/CryptomatorDatabase.swift index b6c690fd5..e8fcd36ad 100644 --- a/CryptomatorCommon/Sources/CryptomatorCommonCore/CryptomatorDatabase.swift +++ b/CryptomatorCommon/Sources/CryptomatorCommonCore/CryptomatorDatabase.swift @@ -87,6 +87,9 @@ public class CryptomatorDatabase { migrator.registerMigration("s3DisplayNameMigration") { db in try s3DisplayNameMigration(db) } + migrator.registerMigration("initialHubSupport") { db in + try initialHubSupportMigration(db) + } return migrator } @@ -195,6 +198,13 @@ public class CryptomatorDatabase { """) } + class func initialHubSupportMigration(_ db: Database) throws { + try db.create(table: "hubVaultAccount", body: { table in + table.column("vaultUID", .text).primaryKey().references("vaultAccounts", onDelete: .cascade) + table.column("subscriptionState", .text).notNull() + }) + } + public static func openSharedDatabase(at databaseURL: URL) throws -> DatabasePool { let coordinator = NSFileCoordinator(filePresenter: nil) var coordinatorError: NSError? diff --git a/CryptomatorCommon/Sources/CryptomatorCommonCore/CryptomatorErrorView.swift b/CryptomatorCommon/Sources/CryptomatorCommonCore/CryptomatorErrorView.swift new file mode 100644 index 000000000..07cca92fc --- /dev/null +++ b/CryptomatorCommon/Sources/CryptomatorCommonCore/CryptomatorErrorView.swift @@ -0,0 +1,26 @@ +import SwiftUI + +public struct CryptomatorErrorView: View { + let text: String? + + public init(text: String? = nil) { + self.text = text + } + + public var body: some View { + VStack(spacing: 20) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 120)) + .foregroundColor(Color(UIColor.cryptomatorYellow)) + if let text { + Text(text) + } + }.padding(.vertical, 20) + } +} + +struct CryptomatorErrorView_Previews: PreviewProvider { + static var previews: some View { + CryptomatorErrorView() + } +} diff --git a/CryptomatorCommon/Sources/CryptomatorCommonCore/CryptomatorKeychain.swift b/CryptomatorCommon/Sources/CryptomatorCommonCore/CryptomatorKeychain.swift index a4907ed79..57e3c887c 100644 --- a/CryptomatorCommon/Sources/CryptomatorCommonCore/CryptomatorKeychain.swift +++ b/CryptomatorCommon/Sources/CryptomatorCommonCore/CryptomatorKeychain.swift @@ -29,6 +29,7 @@ class CryptomatorKeychain: CryptomatorKeychainType { static let localFileSystem = CryptomatorKeychain(service: "localFileSystem.auth") static let upgrade = CryptomatorKeychain(service: "upgrade") static let keepUnlocked = CryptomatorKeychain(service: "keepUnlocked") + static let hub = CryptomatorKeychain(service: "hub") init(service: String) { self.service = service diff --git a/CryptomatorCommon/Sources/CryptomatorCommonCore/CryptomatorSimpleButtonView.swift b/CryptomatorCommon/Sources/CryptomatorCommonCore/CryptomatorSimpleButtonView.swift new file mode 100644 index 000000000..79bee3c53 --- /dev/null +++ b/CryptomatorCommon/Sources/CryptomatorCommonCore/CryptomatorSimpleButtonView.swift @@ -0,0 +1,37 @@ +import SwiftUI + +struct CryptomatorSimpleButtonView: View { + let buttonTitle: String + let onButtonTap: () -> Void + let headerTitle: String + + var body: some View { + List { + Section { + Button(buttonTitle) { + onButtonTap() + } + } header: { + HStack { + Spacer() + VStack(alignment: .center, spacing: 20) { + Image("bot-vault") + Text(headerTitle) + .textCase(.none) + .foregroundColor(.primary) + .font(.body) + } + .padding(.bottom, 12) + Spacer() + } + } + } + .setListBackgroundColor(.cryptomatorBackground) + } +} + +struct CryptomatorSimpleButtonView_Previews: PreviewProvider { + static var previews: some View { + CryptomatorSimpleButtonView(buttonTitle: "Button", onButtonTap: {}, headerTitle: "Header title.") + } +} diff --git a/CryptomatorCommon/Sources/CryptomatorCommonCore/FileProviderXPC/FileProviderConnector.swift b/CryptomatorCommon/Sources/CryptomatorCommonCore/FileProviderXPC/FileProviderConnector.swift index 60eb802f6..3b9bb4fc7 100644 --- a/CryptomatorCommon/Sources/CryptomatorCommonCore/FileProviderXPC/FileProviderConnector.swift +++ b/CryptomatorCommon/Sources/CryptomatorCommonCore/FileProviderXPC/FileProviderConnector.swift @@ -7,6 +7,7 @@ // import CocoaLumberjackSwift +import Dependencies import FileProvider import Foundation import Promises @@ -46,6 +47,20 @@ public extension FileProviderConnector { } } +private enum FileProviderConnectorKey: DependencyKey { + static var liveValue: FileProviderConnector { FileProviderXPCConnector() } + #if DEBUG + static var testValue: FileProviderConnector = UnimplementedFileProviderConnector() + #endif +} + +public extension DependencyValues { + var fileProviderConnector: FileProviderConnector { + get { self[FileProviderConnectorKey.self] } + set { self[FileProviderConnectorKey.self] = newValue } + } +} + public struct XPC { public let proxy: T let doneHandler: () -> Void @@ -69,8 +84,6 @@ public class FileProviderXPCConnector: FileProviderConnector { } } - public static let shared = FileProviderXPCConnector() - public func getXPC(serviceName: NSFileProviderServiceName, domain: NSFileProviderDomain?) -> Promise> { var url = NSFileProviderManager.default.documentStorageURL if let domain = domain { @@ -119,3 +132,17 @@ public extension XPC { self.init(proxy: proxy, doneHandler: {}) } } + +#if DEBUG +private struct UnimplementedFileProviderConnector: FileProviderConnector { + func getXPC(serviceName: NSFileProviderServiceName, domain: NSFileProviderDomain?) -> Promise> { + unimplemented("\(Self.self).getXPC(serviceName:domain:) not implemented", placeholder: Promise(UnimplementedError())) + } + + func getXPC(serviceName: NSFileProviderServiceName, domainIdentifier: NSFileProviderDomainIdentifier) -> Promise> { + unimplemented("\(Self.self).getXPC(serviceName:domainIdentifier:) not implemented", placeholder: Promise(UnimplementedError())) + } + + private struct UnimplementedError: Error {} +} +#endif diff --git a/CryptomatorCommon/Sources/CryptomatorCommonCore/FileProviderXPC/VaultUnlocking.swift b/CryptomatorCommon/Sources/CryptomatorCommonCore/FileProviderXPC/VaultUnlocking.swift index 783ebb863..143b54ba1 100644 --- a/CryptomatorCommon/Sources/CryptomatorCommonCore/FileProviderXPC/VaultUnlocking.swift +++ b/CryptomatorCommon/Sources/CryptomatorCommonCore/FileProviderXPC/VaultUnlocking.swift @@ -8,11 +8,41 @@ import FileProvider import Foundation +import Promises + @objc public protocol VaultUnlocking: NSFileProviderServiceSource { // "Because communication over XPC is asynchronous, all methods in the protocol must have a return type of void. If you need to return data, you can define a reply block [...]" see: https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/CreatingXPCServices.html func unlockVault(kek: [UInt8], reply: @escaping (NSError?) -> Void) func startBiometricalUnlock() func endBiometricalUnlock() + + func unlockVault(rawKey: [UInt8], reply: @escaping (NSError?) -> Void) +} + +public extension VaultUnlocking { + func unlockVault(kek: [UInt8]) -> Promise { + return Promise { fulfill, reject in + self.unlockVault(kek: kek) { error in + if let error = error { + reject(error) + } else { + fulfill(()) + } + } + } + } + + func unlockVault(rawKey: [UInt8]) -> Promise { + return Promise { fulfill, reject in + self.unlockVault(rawKey: rawKey) { error in + if let error = error { + reject(error) + } else { + fulfill(()) + } + } + } + } } public extension NSFileProviderServiceName { diff --git a/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/CryptomatorHubAuthenticator.swift b/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/CryptomatorHubAuthenticator.swift new file mode 100644 index 000000000..f32906807 --- /dev/null +++ b/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/CryptomatorHubAuthenticator.swift @@ -0,0 +1,159 @@ +// +// CryptomatorHubAuthenticator.swift +// CryptomatorCommonCore +// +// Created by Philipp Schmid on 22.07.22. +// Copyright © 2022 Skymatic GmbH. All rights reserved. +// + +import AppAuthCore +import CryptoKit +import CryptomatorCloudAccessCore +import Dependencies +import Foundation + +public enum HubAuthenticationFlow { + case success(Data, [AnyHashable: Any]) + case accessNotGranted + case needsDeviceRegistration + case licenseExceeded +} + +public enum CryptomatorHubAuthenticatorError: Error { + case unexpectedError + case unexpectedResponse + case deviceNameAlreadyExists + + case invalidBaseURL + case invalidDeviceResourceURL + case missingAccessToken +} + +public class CryptomatorHubAuthenticator: HubDeviceRegistering, HubKeyReceiving { + private static let scheme = "hub+" + @Dependency(\.cryptomatorHubKeyProvider) private var cryptomatorHubKeyProvider + + public init() {} + + public func receiveKey(authState: OIDAuthState, vaultConfig: UnverifiedVaultConfig) async throws -> HubAuthenticationFlow { + guard let baseURL = createBaseURL(vaultConfig: vaultConfig) else { + throw CryptomatorHubAuthenticatorError.invalidBaseURL + } + let deviceID = try getDeviceID() + let url = baseURL.appendingPathComponent("/keys").appendingPathComponent("/\(deviceID)") + let (accessToken, _) = try await authState.performAction() + guard let accessToken = accessToken else { + throw CryptomatorHubAuthenticatorError.missingAccessToken + } + var urlRequest = URLRequest(url: url) + urlRequest.allHTTPHeaderFields = ["Authorization": "Bearer \(accessToken)"] + let (data, response) = try await URLSession.shared.data(with: urlRequest) + let httpResponse = response as? HTTPURLResponse + switch httpResponse?.statusCode { + case 200: + return .success(data, httpResponse?.allHeaderFields ?? [:]) + case 402: + return .licenseExceeded + case 403: + return .accessNotGranted + case 404: + return .needsDeviceRegistration + default: + throw CryptomatorHubAuthenticatorError.unexpectedResponse + } + } + + public func registerDevice(withName name: String, hubConfig: HubConfig, authState: OIDAuthState) async throws { + let deviceID = try getDeviceID() + let publicKey = try cryptomatorHubKeyProvider.getPublicKey() + let derPubKey = publicKey.derRepresentation + let dto = CreateDeviceDto(id: deviceID, name: name, type: "MOBILE", publicKey: derPubKey.base64URLEncodedString()) + guard let devicesResourceURL = URL(string: hubConfig.devicesResourceUrl) else { + throw CryptomatorHubAuthenticatorError.invalidDeviceResourceURL + } + let keyURL = devicesResourceURL.appendingPathComponent("\(deviceID)") + var request = URLRequest(url: keyURL) + request.httpMethod = "PUT" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONEncoder().encode(dto) + let (accessToken, _) = try await authState.performAction() + guard let accessToken = accessToken else { + throw CryptomatorHubAuthenticatorError.missingAccessToken + } + request.allHTTPHeaderFields = ["Authorization": "Bearer \(accessToken)"] + let (_, response) = try await URLSession.shared.data(with: request) + switch (response as? HTTPURLResponse)?.statusCode { + case 201: + break + case 409: + throw CryptomatorHubAuthenticatorError.deviceNameAlreadyExists + default: + throw CryptomatorHubAuthenticatorError.unexpectedResponse + } + } + + func createBaseURL(vaultConfig: UnverifiedVaultConfig) -> URL? { + guard let keyId = vaultConfig.keyId, keyId.hasPrefix(CryptomatorHubAuthenticator.scheme) else { + return nil + } + let baseURLPath = keyId.deletingPrefix(CryptomatorHubAuthenticator.scheme) + return URL(string: baseURLPath) + } + + func getDeviceID() throws -> String { + let publicKey = try cryptomatorHubKeyProvider.getPublicKey() + let digest = SHA256.hash(data: publicKey.derRepresentation) + return digest.data.base16EncodedString + } + + struct CreateDeviceDto: Codable { + let id: String + let name: String + let type: String + let publicKey: String + } +} + +extension URLSession { + @available(iOS, deprecated: 15.0, message: "This extension is no longer necessary. Use API built into SDK") + func data(with request: URLRequest) async throws -> (Data, URLResponse) { + try await withCheckedThrowingContinuation { continuation in + let task = self.dataTask(with: request) { data, response, error in + guard let data = data, let response = response else { + let error = error ?? URLError(.badServerResponse) + return continuation.resume(throwing: error) + } + + continuation.resume(returning: (data, response)) + } + + task.resume() + } + } +} + +extension Digest { + var bytes: [UInt8] { Array(makeIterator()) } + var data: Data { Data(bytes) } +} + +extension OIDAuthState { + func performAction() async throws -> (String?, String?) { + try await withCheckedThrowingContinuation({ continuation in + performAction { accessToken, idToken, error in + if let error = error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: (accessToken, idToken)) + } + } + }) + } +} + +extension String { + func deletingPrefix(_ prefix: String) -> String { + guard hasPrefix(prefix) else { return self } + return String(dropFirst(prefix.count)) + } +} diff --git a/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/CryptomatorHubKeyProvider.swift b/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/CryptomatorHubKeyProvider.swift new file mode 100644 index 000000000..845a96232 --- /dev/null +++ b/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/CryptomatorHubKeyProvider.swift @@ -0,0 +1,108 @@ +// +// CryptomatorHubKeyProvider.swift +// CryptomatorCommonCore +// +// Created by Philipp Schmid on 20.07.22. +// Copyright © 2022 Skymatic GmbH. All rights reserved. +// + +import CryptoKit +import Dependencies +import Foundation + +protocol CryptomatorHubKeyProvider { + func getPublicKey() throws -> P384.KeyAgreement.PublicKey + + func getPrivateKey() throws -> P384.KeyAgreement.PrivateKey +} + +private enum CryptomatorHubKeyProviderKey: DependencyKey { + static let liveValue: CryptomatorHubKeyProvider = CryptomatorHubKeyProviderImpl(keychain: CryptomatorKeychain.hub) + #if DEBUG + static let testValue: CryptomatorHubKeyProvider = CryptomatorHubKeyProviderMock() + #endif +} + +extension DependencyValues { + var cryptomatorHubKeyProvider: CryptomatorHubKeyProvider { + get { self[CryptomatorHubKeyProviderKey.self] } + set { self[CryptomatorHubKeyProviderKey.self] = newValue } + } +} + +public struct CryptomatorHubKeyProviderImpl: CryptomatorHubKeyProvider { + public static let shared: CryptomatorHubKeyProviderImpl = .init(keychain: CryptomatorKeychain.hub) + let keychain: CryptomatorKeychainType + private let keychainKey = "privateKey" + + public func getPublicKey() throws -> P384.KeyAgreement.PublicKey { + let privateKey = try getPrivateKey() + return privateKey.publicKey + } + + public func getPrivateKey() throws -> P384.KeyAgreement.PrivateKey { + let privateKey: P384.KeyAgreement.PrivateKey + if let existingKeyData = keychain.getAsData(keychainKey) { + privateKey = try P384.KeyAgreement.PrivateKey(rawRepresentation: existingKeyData) + } else { + privateKey = P384.KeyAgreement.PrivateKey(compactRepresentable: false) + try saveKey(privateKey) + } + return privateKey + } + + private func saveKey(_ privateKey: P384.KeyAgreement.PrivateKey) throws { + try keychain.set(keychainKey, value: privateKey.rawRepresentation) + } + + public func delete() { + try? keychain.delete(keychainKey) + } +} + +#if DEBUG + +// MARK: - CryptomatorHubKeyProviderMock - + +// swiftlint: disable all +final class CryptomatorHubKeyProviderMock: CryptomatorHubKeyProvider { + // MARK: - getPublicKey + + var getPublicKeyThrowableError: Error? + var getPublicKeyCallsCount = 0 + var getPublicKeyCalled: Bool { + getPublicKeyCallsCount > 0 + } + + var getPublicKeyReturnValue: P384.KeyAgreement.PublicKey! + var getPublicKeyClosure: (() throws -> P384.KeyAgreement.PublicKey)? + + func getPublicKey() throws -> P384.KeyAgreement.PublicKey { + if let error = getPublicKeyThrowableError { + throw error + } + getPublicKeyCallsCount += 1 + return try getPublicKeyClosure.map({ try $0() }) ?? getPublicKeyReturnValue + } + + // MARK: - getPrivateKey + + var getPrivateKeyThrowableError: Error? + var getPrivateKeyCallsCount = 0 + var getPrivateKeyCalled: Bool { + getPrivateKeyCallsCount > 0 + } + + var getPrivateKeyReturnValue: P384.KeyAgreement.PrivateKey! + var getPrivateKeyClosure: (() throws -> P384.KeyAgreement.PrivateKey)? + + func getPrivateKey() throws -> P384.KeyAgreement.PrivateKey { + if let error = getPrivateKeyThrowableError { + throw error + } + getPrivateKeyCallsCount += 1 + return try getPrivateKeyClosure.map({ try $0() }) ?? getPrivateKeyReturnValue + } +} +// swiftlint: enable all +#endif diff --git a/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubAuthenticating.swift b/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubAuthenticating.swift new file mode 100644 index 000000000..c43627ce5 --- /dev/null +++ b/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubAuthenticating.swift @@ -0,0 +1,25 @@ +import AppAuthCore +import CryptomatorCloudAccessCore +import Dependencies +import UIKit + +public protocol HubAuthenticating { + func authenticate(with hubConfig: HubConfig, from viewController: UIViewController) async throws -> OIDAuthState +} + +public enum HubAuthenticatingKey: TestDependencyKey { + public static var testValue: HubAuthenticating = UnimplementedHubAuthenticatingService() +} + +public extension DependencyValues { + var hubAuthenticationService: HubAuthenticating { + get { self[HubAuthenticatingKey.self] } + set { self[HubAuthenticatingKey.self] = newValue } + } +} + +struct UnimplementedHubAuthenticatingService: HubAuthenticating { + func authenticate(with hubConfig: CryptomatorCloudAccessCore.HubConfig, from viewController: UIViewController) async throws -> OIDAuthState { + unimplemented(placeholder: OIDAuthState(authorizationResponse: .init(request: .init(configuration: .init(authorizationEndpoint: URL(string: "example.com")!, tokenEndpoint: URL(string: "example.com")!), clientId: "", scopes: nil, redirectURL: URL(string: "example.com")!, responseType: "code", additionalParameters: nil), parameters: [:]))) + } +} diff --git a/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubAuthenticationCoordinator.swift b/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubAuthenticationCoordinator.swift new file mode 100644 index 000000000..9e700e326 --- /dev/null +++ b/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubAuthenticationCoordinator.swift @@ -0,0 +1,108 @@ +import AppAuthCore +import CryptomatorCloudAccessCore +import Dependencies +import SwiftUI +import UIKit + +public protocol HubAuthenticationCoordinatorDelegate: AnyObject { + @MainActor + func userDidCancelHubAuthentication() + + @MainActor + func userDismissedHubAuthenticationErrorMessage() +} + +public final class HubAuthenticationCoordinator: Coordinator { + public var childCoordinators = [Coordinator]() + public var navigationController: UINavigationController + public weak var parent: Coordinator? + + private let vaultConfig: UnverifiedVaultConfig + private var progressHUD: ProgressHUD? + private let unlockHandler: HubVaultUnlockHandler + @Dependency(\.hubAuthenticationService) var hubAuthenticator + private weak var delegate: HubAuthenticationCoordinatorDelegate? + + public init(navigationController: UINavigationController, + vaultConfig: UnverifiedVaultConfig, + unlockHandler: HubVaultUnlockHandler, + parent: Coordinator?, + delegate: HubAuthenticationCoordinatorDelegate) { + self.navigationController = navigationController + self.vaultConfig = vaultConfig + self.unlockHandler = unlockHandler + self.parent = parent + self.delegate = delegate + } + + public func start() { + guard let hubConfig = vaultConfig.allegedHubConfig else { + handleError(HubAuthenticationViewModelError.missingHubConfig, for: navigationController, onOKTapped: { [weak self] in + guard let self else { return } + parent?.childDidFinish(self) + }) + return + } + Task { @MainActor in + let authenticator = HubUserAuthenticator(hubAuthenticator: hubAuthenticator, viewController: navigationController) + let authState: OIDAuthState + do { + authState = try await authenticator.authenticate(with: hubConfig) + } catch let error as NSError where error.domain == OIDGeneralErrorDomain && error.code == OIDErrorCode.userCanceledAuthorizationFlow.rawValue { + // do not show alert if user canceled it on purpose + delegate?.userDidCancelHubAuthentication() + parent?.childDidFinish(self) + return + } catch { + handleError(error, for: navigationController, onOKTapped: { [weak self] in + guard let self else { return } + delegate?.userDismissedHubAuthenticationErrorMessage() + parent?.childDidFinish(self) + }) + return + } + let viewModel = HubAuthenticationViewModel(authState: authState, + vaultConfig: vaultConfig, + unlockHandler: unlockHandler, + delegate: self) + await viewModel.continueToAccessCheck() + guard !viewModel.isLoggedIn else { + // Do not show the authentication view if the user already authenticated successfully + return + } + navigationController.setNavigationBarHidden(false, animated: false) + let viewController = HubAuthenticationViewController(viewModel: viewModel) + navigationController.pushViewController(viewController, animated: true) + } + } + + private func showProgressHUD() { + assert(progressHUD == nil, "showProgressHUD called although one is already shown") + progressHUD = ProgressHUD() + progressHUD?.show(presentingViewController: navigationController) + progressHUD?.showLoadingIndicator() + } + + private func hideProgressHUD() async { + await withCheckedContinuation { continuation in + guard let progressHUD else { + continuation.resume() + return + } + progressHUD.dismiss(animated: true, completion: { [weak self] in + continuation.resume() + self?.progressHUD = nil + }) + } + } +} + +extension HubAuthenticationCoordinator: HubAuthenticationViewModelDelegate { + public func hubAuthenticationViewModelWantsToShowLoadingIndicator() { + showProgressHUD() + } + + public func hubAuthenticationViewModelWantsToHideLoadingIndicator() async { + await hideProgressHUD() + } +} diff --git a/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubAuthenticationFlowDelegate.swift b/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubAuthenticationFlowDelegate.swift new file mode 100644 index 000000000..8269b4726 --- /dev/null +++ b/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubAuthenticationFlowDelegate.swift @@ -0,0 +1,12 @@ +import CryptoKit +import JOSESwift + +public protocol HubAuthenticationFlowDelegate: AnyObject { + func didSuccessfullyRemoteUnlock(_ response: HubUnlockResponse) async +} + +public struct HubUnlockResponse { + public let jwe: JWE + public let privateKey: P384.KeyAgreement.PrivateKey + public let subscriptionState: HubSubscriptionState +} diff --git a/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubAuthenticationView.swift b/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubAuthenticationView.swift new file mode 100644 index 000000000..adc7c43ff --- /dev/null +++ b/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubAuthenticationView.swift @@ -0,0 +1,47 @@ +import SwiftUI + +public struct HubAuthenticationView: View { + @ObservedObject var viewModel: HubAuthenticationViewModel + + public init(viewModel: HubAuthenticationViewModel) { + self.viewModel = viewModel + } + + public var body: some View { + ZStack { + Color.cryptomatorBackground + .ignoresSafeArea() + VStack(spacing: 20) { + switch viewModel.authenticationFlowState { + case .deviceRegistration: + HubDeviceRegistrationView( + deviceName: $viewModel.deviceName, + onRegisterTap: { Task { await viewModel.register() }} + ) + case .accessNotGranted: + HubAccessNotGrantedView(onRefresh: { Task { await viewModel.refresh() }}) + case .licenseExceeded: + CryptomatorErrorView(text: LocalizedString.getValue("hubAuthentication.licenseExceeded")) + case let .error(description): + CryptomatorErrorView(text: description) + case .none: + EmptyView() + } + } + .padding() + .navigationTitle(LocalizedString.getValue("hubAuthentication.title")) + .alert( + isPresented: .init( + get: { viewModel.authenticationFlowState == .deviceRegistration(.needsAuthorization) }, + set: { _ in Task { await viewModel.continueToAccessCheck() }} + ) + ) { + Alert( + title: Text(LocalizedString.getValue("hubAuthentication.deviceRegistration.needsAuthorization.alert.title")), + message: Text(LocalizedString.getValue("hubAuthentication.deviceRegistration.needsAuthorization.alert.message")), + dismissButton: .default(Text(LocalizedString.getValue("common.button.ok"))) + ) + } + } + } +} diff --git a/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubAuthenticationViewController.swift b/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubAuthenticationViewController.swift new file mode 100644 index 000000000..25152feb3 --- /dev/null +++ b/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubAuthenticationViewController.swift @@ -0,0 +1,73 @@ +import Combine +import Foundation +import SwiftUI + +/** + ViewController for the `HubAuthenticationView`. + + This ViewController build the bridge between UIKit and the SwiftUI `HubAuthenticationView`. + This bridge is needed to show the tool bar items of `HubAuthenticationView` in a UIKit `UINavigationController`. + */ +public class HubAuthenticationViewController: UIViewController { + private let viewModel: HubAuthenticationViewModel + private var cancellables = Set() + + public init(viewModel: HubAuthenticationViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override public func viewDidLoad() { + super.viewDidLoad() + title = LocalizedString.getValue("hubAuthentication.title") + + setupToolBar() + setupSwiftUIView() + } + + private func setupSwiftUIView() { + let child = UIHostingController(rootView: HubAuthenticationView(viewModel: viewModel)) + addChild(child) + view.addSubview(child.view) + child.didMove(toParent: self) + child.view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate(child.view.constraints(equalTo: view)) + } + + private func setupToolBar() { + if let initialState = viewModel.authenticationFlowState { + updateToolbar(state: initialState) + } + + viewModel.$authenticationFlowState + .compactMap { $0 } + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] in + self?.updateToolbar(state: $0) + }) + .store(in: &cancellables) + } + + /** + Updates the `UINavigationItem` based on the given `state`. + - Note: This solution is far from ideal as we need to update the content of the tool bar in two places, i.e. in this method and inside the SwiftUI itself. Otherwise the behavior can differ when used inside a UINavigationController and a "SwiftUI native" `NavigationView`/ `NavigationStackView`. + */ + private func updateToolbar(state: HubAuthenticationViewModel.State) { + switch state { + case .deviceRegistration: + let registerButton = UIBarButtonItem(title: "Register", style: .done, target: self, action: #selector(registerButtonTapped)) + navigationItem.rightBarButtonItem = registerButton + default: + navigationItem.rightBarButtonItem = nil + } + } + + @objc private func registerButtonTapped() { + Task { await viewModel.register() } + } +} diff --git a/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubAuthenticationViewModel.swift b/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubAuthenticationViewModel.swift new file mode 100644 index 000000000..197f351f3 --- /dev/null +++ b/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubAuthenticationViewModel.swift @@ -0,0 +1,151 @@ +import AppAuthCore +import CocoaLumberjackSwift +import CryptoKit +import CryptomatorCloudAccessCore +import Dependencies +import Foundation +import JOSESwift +import UIKit + +public enum HubAuthenticationViewModelError: Error { + case missingHubConfig + case missingAuthState + case missingSubscriptionHeader + case unexpectedSubscriptionHeader +} + +public protocol HubAuthenticationViewModelDelegate: AnyObject { + @MainActor + func hubAuthenticationViewModelWantsToShowLoadingIndicator() + + @MainActor + func hubAuthenticationViewModelWantsToHideLoadingIndicator() async +} + +public final class HubAuthenticationViewModel: ObservableObject { + public enum State: Equatable { + case accessNotGranted + case licenseExceeded + case deviceRegistration(DeviceRegistration) + case error(description: String) + } + + public enum DeviceRegistration: Equatable { + case deviceName + case needsAuthorization + } + + private enum Constants { + static var subscriptionState: String { "hub-subscription-state" } + } + + @Published var authenticationFlowState: State? + @Published public var deviceName: String = UIDevice.current.name + private(set) var isLoggedIn = false + + private let vaultConfig: UnverifiedVaultConfig + private let authState: OIDAuthState + private let unlockHandler: HubVaultUnlockHandler + @Dependency(\.hubDeviceRegisteringService) var deviceRegisteringService + @Dependency(\.hubKeyService) var hubKeyService + @Dependency(\.cryptomatorHubKeyProvider) var cryptomatorHubKeyProvider + private weak var delegate: HubAuthenticationViewModelDelegate? + + public init(authState: OIDAuthState, + vaultConfig: UnverifiedVaultConfig, + unlockHandler: HubVaultUnlockHandler, + delegate: HubAuthenticationViewModelDelegate) { + self.authState = authState + self.vaultConfig = vaultConfig + self.unlockHandler = unlockHandler + self.delegate = delegate + } + + public func register() async { + guard let hubConfig = vaultConfig.allegedHubConfig else { + await setStateToErrorState(with: HubAuthenticationViewModelError.missingHubConfig) + return + } + + do { + try await deviceRegisteringService.registerDevice(withName: deviceName, hubConfig: hubConfig, authState: authState) + } catch { + await setStateToErrorState(with: error) + return + } + await setState(to: .deviceRegistration(.needsAuthorization)) + } + + public func refresh() async { + await continueToAccessCheck() + } + + public func continueToAccessCheck() async { + await delegate?.hubAuthenticationViewModelWantsToShowLoadingIndicator() + + let authFlow: HubAuthenticationFlow + do { + authFlow = try await hubKeyService.receiveKey(authState: authState, vaultConfig: vaultConfig) + } catch { + await setStateToErrorState(with: error) + return + } + await delegate?.hubAuthenticationViewModelWantsToHideLoadingIndicator() + + switch authFlow { + case let .success(data, header): + await receivedExistingKey(data: data, header: header) + case .accessNotGranted: + await setState(to: .accessNotGranted) + case .needsDeviceRegistration: + await setState(to: .deviceRegistration(.deviceName)) + case .licenseExceeded: + await setState(to: .licenseExceeded) + } + } + + private func receivedExistingKey(data: Data, header: [AnyHashable: Any]) async { + let privateKey: P384.KeyAgreement.PrivateKey + let jwe: JWE + let subscriptionState: HubSubscriptionState + do { + privateKey = try cryptomatorHubKeyProvider.getPrivateKey() + jwe = try JWE(compactSerialization: data) + subscriptionState = try getSubscriptionState(from: header) + } catch { + await setStateToErrorState(with: error) + return + } + let response = HubUnlockResponse(jwe: jwe, + privateKey: privateKey, + subscriptionState: subscriptionState) + await MainActor.run { isLoggedIn = true } + await unlockHandler.didSuccessfullyRemoteUnlock(response) + } + + @MainActor + private func setState(to newState: State) { + authenticationFlowState = newState + } + + private func setStateToErrorState(with error: Error) async { + await delegate?.hubAuthenticationViewModelWantsToHideLoadingIndicator() + await setState(to: .error(description: error.localizedDescription)) + } + + private func getSubscriptionState(from header: [AnyHashable: Any]) throws -> HubSubscriptionState { + guard let subscriptionStateValue = header[Constants.subscriptionState] as? String else { + DDLogError("Can't retrieve hub subscription state from header -> missing value") + throw HubAuthenticationViewModelError.missingSubscriptionHeader + } + switch subscriptionStateValue { + case "ACTIVE": + return .active + case "INACTIVE": + return .inactive + default: + DDLogError("Can't retrieve hub subscription state from header -> unexpected value") + throw HubAuthenticationViewModelError.unexpectedSubscriptionHeader + } + } +} diff --git a/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubDeviceRegisteredSuccessfullyView.swift b/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubDeviceRegisteredSuccessfullyView.swift new file mode 100644 index 000000000..c0328584f --- /dev/null +++ b/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubDeviceRegisteredSuccessfullyView.swift @@ -0,0 +1,19 @@ +import SwiftUI + +struct HubAccessNotGrantedView: View { + var onRefresh: () -> Void + + var body: some View { + CryptomatorSimpleButtonView( + buttonTitle: LocalizedString.getValue("common.button.refresh"), + onButtonTap: onRefresh, + headerTitle: LocalizedString.getValue("hubAuthentication.accessNotGranted") + ) + } +} + +struct HubDeviceRegisteredSuccessfullyView_Previews: PreviewProvider { + static var previews: some View { + HubAccessNotGrantedView(onRefresh: {}) + } +} diff --git a/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubDeviceRegisteringService.swift b/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubDeviceRegisteringService.swift new file mode 100644 index 000000000..b1bd034f5 --- /dev/null +++ b/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubDeviceRegisteringService.swift @@ -0,0 +1,67 @@ +// +// HubDeviceRegisteringService.swift +// CryptomatorCommonCore +// +// Created by Philipp Schmid on 19.11.23. +// Copyright © 2023 Skymatic GmbH. All rights reserved. +// + +import AppAuthCore +import CryptomatorCloudAccessCore +import Dependencies +import Foundation +import XCTestDynamicOverlay + +public protocol HubDeviceRegistering { + func registerDevice(withName name: String, hubConfig: HubConfig, authState: OIDAuthState) async throws +} + +private enum HubDeviceRegisteringKey: DependencyKey { + static var liveValue: HubDeviceRegistering = CryptomatorHubAuthenticator() + #if DEBUG + static var testValue: HubDeviceRegistering = UnimplementedHubDeviceRegisteringService() + #endif +} + +extension DependencyValues { + var hubDeviceRegisteringService: HubDeviceRegistering { + get { self[HubDeviceRegisteringKey.self] } + set { self[HubDeviceRegisteringKey.self] = newValue } + } +} + +#if DEBUG +final class UnimplementedHubDeviceRegisteringService: HubDeviceRegistering { + func registerDevice(withName name: String, hubConfig: HubConfig, authState: OIDAuthState) async throws { + XCTFail("\(Self.self).registerDevice is unimplemented.") + } +} + +// MARK: - HubDeviceRegisteringMock - + +// swiftlint: disable all +final class HubDeviceRegisteringMock: HubDeviceRegistering { + // MARK: - registerDevice + + var registerDeviceWithNameHubConfigAuthStateThrowableError: Error? + var registerDeviceWithNameHubConfigAuthStateCallsCount = 0 + var registerDeviceWithNameHubConfigAuthStateCalled: Bool { + registerDeviceWithNameHubConfigAuthStateCallsCount > 0 + } + + var registerDeviceWithNameHubConfigAuthStateReceivedArguments: (name: String, hubConfig: HubConfig, authState: OIDAuthState)? + var registerDeviceWithNameHubConfigAuthStateReceivedInvocations: [(name: String, hubConfig: HubConfig, authState: OIDAuthState)] = [] + var registerDeviceWithNameHubConfigAuthStateClosure: ((String, HubConfig, OIDAuthState) throws -> Void)? + + func registerDevice(withName name: String, hubConfig: HubConfig, authState: OIDAuthState) throws { + if let error = registerDeviceWithNameHubConfigAuthStateThrowableError { + throw error + } + registerDeviceWithNameHubConfigAuthStateCallsCount += 1 + registerDeviceWithNameHubConfigAuthStateReceivedArguments = (name: name, hubConfig: hubConfig, authState: authState) + registerDeviceWithNameHubConfigAuthStateReceivedInvocations.append((name: name, hubConfig: hubConfig, authState: authState)) + try registerDeviceWithNameHubConfigAuthStateClosure?(name, hubConfig, authState) + } +} +// swiftlint: enable all +#endif diff --git a/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubDeviceRegistrationView.swift b/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubDeviceRegistrationView.swift new file mode 100644 index 000000000..67260b4ed --- /dev/null +++ b/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubDeviceRegistrationView.swift @@ -0,0 +1,41 @@ +import SwiftUI + +struct HubDeviceRegistrationView: View { + @Binding var deviceName: String + var onRegisterTap: () -> Void + + @FocusStateLegacy private var field: Field? = .deviceName + + private enum Field: CaseIterable { + case deviceName + } + + var body: some View { + List { + Section { + TextField( + LocalizedString.getValue("hubAuthentication.deviceRegistration.deviceName.cells.name"), + text: $deviceName, + onCommit: onRegisterTap + ) + .focusedLegacy($field, equals: .deviceName) + } footer: { + Text(LocalizedString.getValue("hubAuthentication.deviceRegistration.deviceName.footer.title")) + } + } + .setListBackgroundColor(.cryptomatorBackground) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button(LocalizedString.getValue("common.button.register")) { + onRegisterTap() + } + } + } + } +} + +struct HubDeviceRegistrationView_Previews: PreviewProvider { + static var previews: some View { + HubDeviceRegistrationView(deviceName: .constant(""), onRegisterTap: {}) + } +} diff --git a/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubKeyService.swift b/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubKeyService.swift new file mode 100644 index 000000000..d156d04fb --- /dev/null +++ b/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubKeyService.swift @@ -0,0 +1,65 @@ +// +// HubKeyService.swift +// CryptomatorCommonCore +// +// Created by Philipp Schmid on 19.11.23. +// Copyright © 2023 Skymatic GmbH. All rights reserved. +// + +import AppAuthCore +import CryptomatorCloudAccessCore +import Dependencies +import Foundation + +public protocol HubKeyReceiving { + func receiveKey(authState: OIDAuthState, vaultConfig: UnverifiedVaultConfig) async throws -> HubAuthenticationFlow +} + +private enum HubKeyReceivingDependencyKey: DependencyKey { + static let liveValue: HubKeyReceiving = CryptomatorHubAuthenticator() + #if DEBUG + static let testValue: HubKeyReceiving = UnimplementedHubKeyReceivingService() + #endif +} + +extension DependencyValues { + var hubKeyService: HubKeyReceiving { + get { self[HubKeyReceivingDependencyKey.self] } + set { self[HubKeyReceivingDependencyKey.self] = newValue } + } +} + +#if DEBUG +final class UnimplementedHubKeyReceivingService: HubKeyReceiving { + func receiveKey(authState: OIDAuthState, vaultConfig: UnverifiedVaultConfig) async throws -> HubAuthenticationFlow { + unimplemented(placeholder: .accessNotGranted) + } +} + +// MARK: - HubKeyReceivingMock - + +final class HubKeyReceivingMock: HubKeyReceiving { + // MARK: - receiveKey + + var receiveKeyAuthStateVaultConfigThrowableError: Error? + var receiveKeyAuthStateVaultConfigCallsCount = 0 + var receiveKeyAuthStateVaultConfigCalled: Bool { + receiveKeyAuthStateVaultConfigCallsCount > 0 + } + + var receiveKeyAuthStateVaultConfigReceivedArguments: (authState: OIDAuthState, vaultConfig: UnverifiedVaultConfig)? + var receiveKeyAuthStateVaultConfigReceivedInvocations: [(authState: OIDAuthState, vaultConfig: UnverifiedVaultConfig)] = [] + var receiveKeyAuthStateVaultConfigReturnValue: HubAuthenticationFlow! + var receiveKeyAuthStateVaultConfigClosure: ((OIDAuthState, UnverifiedVaultConfig) throws -> HubAuthenticationFlow)? + + func receiveKey(authState: OIDAuthState, vaultConfig: UnverifiedVaultConfig) throws -> HubAuthenticationFlow { + if let error = receiveKeyAuthStateVaultConfigThrowableError { + throw error + } + receiveKeyAuthStateVaultConfigCallsCount += 1 + receiveKeyAuthStateVaultConfigReceivedArguments = (authState: authState, vaultConfig: vaultConfig) + receiveKeyAuthStateVaultConfigReceivedInvocations.append((authState: authState, vaultConfig: vaultConfig)) + return try receiveKeyAuthStateVaultConfigClosure.map({ try $0(authState, vaultConfig) }) ?? receiveKeyAuthStateVaultConfigReturnValue + } +} +#endif diff --git a/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubLoginView.swift b/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubLoginView.swift new file mode 100644 index 000000000..86dffcd3b --- /dev/null +++ b/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubLoginView.swift @@ -0,0 +1,19 @@ +import SwiftUI + +struct HubLoginView: View { + var onLogin: () -> Void + + var body: some View { + CryptomatorSimpleButtonView( + buttonTitle: "Login", + onButtonTap: onLogin, + headerTitle: "Login to unlock your vault" + ) + } +} + +struct HubLoginView_Previews: PreviewProvider { + static var previews: some View { + HubLoginView(onLogin: {}) + } +} diff --git a/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubRepository.swift b/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubRepository.swift new file mode 100644 index 000000000..f44ee2488 --- /dev/null +++ b/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubRepository.swift @@ -0,0 +1,72 @@ +import Dependencies +import Foundation +import GRDB + +public protocol HubRepository { + func save(_ vault: HubVault) throws + func getHubVault(vaultID: String) throws -> HubVault? +} + +public struct HubVault: Equatable { + public let vaultUID: String + public let subscriptionState: HubSubscriptionState +} + +private struct HubVaultRow: Codable, Equatable, PersistableRecord, FetchableRecord { + public static let databaseTableName = "hubVaultAccount" + + let vaultUID: String + let subscriptionState: HubSubscriptionState + + init(from vault: HubVault) { + self.vaultUID = vault.vaultUID + self.subscriptionState = vault.subscriptionState + } + + func toHubVault() -> HubVault { + HubVault(vaultUID: vaultUID, subscriptionState: subscriptionState) + } + + enum Columns: String, ColumnExpression { + case vaultUID, subscriptionState + } + + public func encode(to container: inout PersistenceContainer) { + container[Columns.vaultUID] = vaultUID + container[Columns.subscriptionState] = subscriptionState + } +} + +extension HubSubscriptionState: DatabaseValueConvertible {} + +public extension DependencyValues { + var hubRepository: HubRepository { + get { self[HubRepositoryKey.self] } + set { self[HubRepositoryKey.self] = newValue } + } +} + +private enum HubRepositoryKey: DependencyKey { + static var liveValue: HubRepository = HubDBRepository() + #if DEBUG + static var testValue: HubRepository = HubRepositoryMock() + #endif +} + +public class HubDBRepository: HubRepository { + @Dependency(\.database) private var database + + public func save(_ vault: HubVault) throws { + let row = HubVaultRow(from: vault) + try database.write { db in + try row.save(db) + } + } + + public func getHubVault(vaultID: String) throws -> HubVault? { + let row = try database.read { db in + try HubVaultRow.fetchOne(db, key: vaultID) + } + return row?.toHubVault() + } +} diff --git a/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubSubscriptionState.swift b/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubSubscriptionState.swift new file mode 100644 index 000000000..daf4d3185 --- /dev/null +++ b/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubSubscriptionState.swift @@ -0,0 +1,4 @@ +public enum HubSubscriptionState: String, Codable { + case active + case inactive +} diff --git a/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubUserAuthenticator.swift b/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubUserAuthenticator.swift new file mode 100644 index 000000000..d8f144599 --- /dev/null +++ b/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubUserAuthenticator.swift @@ -0,0 +1,17 @@ +import AppAuthCore +import CryptomatorCloudAccessCore +import UIKit + +struct HubUserAuthenticator: HubUserLogin { + private let hubAuthenticator: HubAuthenticating + private let viewController: UIViewController + + init(hubAuthenticator: HubAuthenticating, viewController: UIViewController) { + self.hubAuthenticator = hubAuthenticator + self.viewController = viewController + } + + func authenticate(with hubConfig: HubConfig) async throws -> OIDAuthState { + try await hubAuthenticator.authenticate(with: hubConfig, from: viewController) + } +} diff --git a/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubUserLogin.swift b/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubUserLogin.swift new file mode 100644 index 000000000..219bae4b1 --- /dev/null +++ b/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubUserLogin.swift @@ -0,0 +1,7 @@ +import AppAuthCore +import CryptomatorCloudAccessCore +import Foundation + +public protocol HubUserLogin { + func authenticate(with hubConfig: HubConfig) async throws -> OIDAuthState +} diff --git a/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubXPCLoginCoordinator.swift b/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubXPCLoginCoordinator.swift new file mode 100644 index 000000000..3f58f2a85 --- /dev/null +++ b/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubXPCLoginCoordinator.swift @@ -0,0 +1,70 @@ +import AppAuthCore +import CryptoKit +import CryptomatorCloudAccessCore +import CryptomatorCryptoLib +import Dependencies +import JOSESwift +import SwiftUI +import UIKit + +public final class HubXPCLoginCoordinator: Coordinator { + public var childCoordinators = [Coordinator]() + public var navigationController: UINavigationController + let domain: NSFileProviderDomain + let vaultConfig: UnverifiedVaultConfig + public let onUnlocked: () -> Void + public let onErrorAlertDismissed: () -> Void + @Dependency(\.hubRepository) private var hubRepository + @Dependency(\.fileProviderConnector) private var fileProviderConnector + + public init(navigationController: UINavigationController, + domain: NSFileProviderDomain, + vaultConfig: UnverifiedVaultConfig, + onUnlocked: @escaping () -> Void, + onErrorAlertDismissed: @escaping () -> Void) { + self.navigationController = navigationController + self.domain = domain + self.vaultConfig = vaultConfig + self.onUnlocked = onUnlocked + self.onErrorAlertDismissed = onErrorAlertDismissed + } + + public func start() { + let unlockHandler = HubXPCVaultUnlockHandler(fileProviderConnector: fileProviderConnector, domain: domain, delegate: self) + prepareNavigationControllerForLogin() + let child = HubAuthenticationCoordinator(navigationController: navigationController, + vaultConfig: vaultConfig, + unlockHandler: unlockHandler, + parent: self, + delegate: self) + childCoordinators.append(child) + child.start() + } + + /// Prepares the `UINavigationController` for the hub authentication flow. + /// + /// As the FileProviderExtensionUI is always shown as a sheet and the login is initially just a alert which asks the user to open a website, we want to hide the navigation bar initially. + private func prepareNavigationControllerForLogin() { + navigationController.setNavigationBarHidden(true, animated: false) + } +} + +extension HubXPCLoginCoordinator: HubVaultUnlockHandlerDelegate { + public func successfullyProcessedUnlockedVault() { + onUnlocked() + } + + public func failedToProcessUnlockedVault(error: Error) { + handleError(error, for: navigationController, onOKTapped: onErrorAlertDismissed) + } +} + +extension HubXPCLoginCoordinator: HubAuthenticationCoordinatorDelegate { + public func userDidCancelHubAuthentication() { + onErrorAlertDismissed() + } + + public func userDismissedHubAuthenticationErrorMessage() { + onErrorAlertDismissed() + } +} diff --git a/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/UnlockHandler/AddHubVaultUnlockHandler.swift b/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/UnlockHandler/AddHubVaultUnlockHandler.swift new file mode 100644 index 000000000..8be0234a6 --- /dev/null +++ b/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/UnlockHandler/AddHubVaultUnlockHandler.swift @@ -0,0 +1,41 @@ +import Foundation + +public struct AddHubVaultUnlockHandler: HubVaultUnlockHandler { + private let vaultUID: String + private let accountUID: String + private let vaultItem: VaultItem + private let downloadedVaultConfig: DownloadedVaultConfig + private let vaultManager: VaultManager + private weak var delegate: HubVaultUnlockHandlerDelegate? + + public init(vaultUID: String, + accountUID: String, + vaultItem: VaultItem, + downloadedVaultConfig: DownloadedVaultConfig, + vaultManager: VaultManager, + delegate: HubVaultUnlockHandlerDelegate?) { + self.vaultUID = vaultUID + self.accountUID = accountUID + self.vaultItem = vaultItem + self.downloadedVaultConfig = downloadedVaultConfig + self.vaultManager = vaultManager + self.delegate = delegate + } + + public func didSuccessfullyRemoteUnlock(_ response: HubUnlockResponse) async { + let jwe = response.jwe + let privateKey = response.privateKey + let hubVault = ExistingHubVault(vaultUID: vaultUID, + delegateAccountUID: accountUID, + jweData: jwe.compactSerializedData, + privateKey: privateKey, + vaultItem: vaultItem, + downloadedVaultConfig: downloadedVaultConfig) + do { + try await vaultManager.addExistingHubVault(hubVault).getValue() + await delegate?.successfullyProcessedUnlockedVault() + } catch { + await delegate?.failedToProcessUnlockedVault(error: error) + } + } +} diff --git a/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/UnlockHandler/HubVaultUnlockHandler.swift b/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/UnlockHandler/HubVaultUnlockHandler.swift new file mode 100644 index 000000000..b99e7fc77 --- /dev/null +++ b/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/UnlockHandler/HubVaultUnlockHandler.swift @@ -0,0 +1,38 @@ +import Foundation + +public protocol HubVaultUnlockHandler { + func didSuccessfullyRemoteUnlock(_ response: HubUnlockResponse) async +} + +public protocol HubVaultUnlockHandlerDelegate: AnyObject { + @MainActor + func successfullyProcessedUnlockedVault() + @MainActor + func failedToProcessUnlockedVault(error: Error) +} + +// MARK: - HubVaultUnlockHandlerMock - + +#if DEBUG +// swiftlint: disable all +final class HubVaultUnlockHandlerMock: HubVaultUnlockHandler { + // MARK: - didSuccessfullyRemoteUnlock + + var didSuccessfullyRemoteUnlockCallsCount = 0 + var didSuccessfullyRemoteUnlockCalled: Bool { + didSuccessfullyRemoteUnlockCallsCount > 0 + } + + var didSuccessfullyRemoteUnlockReceivedResponse: HubUnlockResponse? + var didSuccessfullyRemoteUnlockReceivedInvocations: [HubUnlockResponse] = [] + var didSuccessfullyRemoteUnlockClosure: ((HubUnlockResponse) -> Void)? + + func didSuccessfullyRemoteUnlock(_ response: HubUnlockResponse) { + didSuccessfullyRemoteUnlockCallsCount += 1 + didSuccessfullyRemoteUnlockReceivedResponse = response + didSuccessfullyRemoteUnlockReceivedInvocations.append(response) + didSuccessfullyRemoteUnlockClosure?(response) + } +} +// / swiftlint: enable all +#endif diff --git a/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/UnlockHandler/HubXPCVaultUnlockHandler.swift b/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/UnlockHandler/HubXPCVaultUnlockHandler.swift new file mode 100644 index 000000000..4e78362c7 --- /dev/null +++ b/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/UnlockHandler/HubXPCVaultUnlockHandler.swift @@ -0,0 +1,41 @@ +import CryptomatorCryptoLib +import Dependencies +import FileProvider + +struct HubXPCVaultUnlockHandler: HubVaultUnlockHandler { + private let fileProviderConnector: FileProviderConnector + private let domain: NSFileProviderDomain + private weak var delegate: HubVaultUnlockHandlerDelegate? + @Dependency(\.hubRepository) private var hubRepository + + init(fileProviderConnector: FileProviderConnector, + domain: NSFileProviderDomain, + delegate: HubVaultUnlockHandlerDelegate) { + self.fileProviderConnector = fileProviderConnector + self.domain = domain + self.delegate = delegate + } + + func didSuccessfullyRemoteUnlock(_ response: HubUnlockResponse) async { + let masterkey: Masterkey + do { + masterkey = try JWEHelper.decrypt(jwe: response.jwe, with: response.privateKey) + } catch { + await delegate?.failedToProcessUnlockedVault(error: error) + return + } + do { + let xpc: XPC = try await fileProviderConnector.getXPC(serviceName: .vaultUnlocking, domain: domain) + defer { + fileProviderConnector.invalidateXPC(xpc) + } + try await xpc.proxy.unlockVault(rawKey: masterkey.rawKey).getValue() + let hubVault = HubVault(vaultUID: domain.identifier.rawValue, subscriptionState: response.subscriptionState) + try hubRepository.save(hubVault) + await delegate?.successfullyProcessedUnlockedVault() + } catch { + await delegate?.failedToProcessUnlockedVault(error: error) + return + } + } +} diff --git a/CryptomatorCommon/Sources/CryptomatorCommonCore/JWEHelper.swift b/CryptomatorCommon/Sources/CryptomatorCommonCore/JWEHelper.swift new file mode 100644 index 000000000..2d88abc2b --- /dev/null +++ b/CryptomatorCommon/Sources/CryptomatorCommonCore/JWEHelper.swift @@ -0,0 +1,32 @@ +// +// JWEHelper.swift +// +// +// Created by Philipp Schmid on 22.07.22. +// + +import CryptoKit +import CryptomatorCryptoLib +import Foundation +import JOSESwift + +public enum JWEHelper { + public static func decrypt(jwe: JWE, with privateKey: P384.KeyAgreement.PrivateKey) throws -> Masterkey { + // see https://developer.apple.com/forums/thread/680554 + let x = privateKey.x963Representation[1 ..< 49] + let y = privateKey.x963Representation[49 ..< 97] + let k = privateKey.x963Representation[97 ..< 145] + let decryptionKey = try ECPrivateKey(crv: "P-384", x: x.base64UrlEncodedString(), y: y.base64UrlEncodedString(), privateKey: k.base64UrlEncodedString()) + + guard let decrypter = Decrypter(keyManagementAlgorithm: .ECDH_ES, contentEncryptionAlgorithm: .A256GCM, decryptionKey: decryptionKey) else { + throw VaultManagerError.invalidDecrypter + } + let payload = try jwe.decrypt(using: decrypter) + let payloadMasterkey = try JSONDecoder().decode(PayloadMasterkey.self, from: payload.data()) + + guard let masterkeyData = Data(base64Encoded: payloadMasterkey.key) else { + throw VaultManagerError.invalidPayloadMasterkey + } + return Masterkey.createFromRaw(rawKey: [UInt8](masterkeyData)) + } +} diff --git a/CryptomatorCommon/Sources/CryptomatorCommonCore/Manager/ExistingHubVault.swift b/CryptomatorCommon/Sources/CryptomatorCommonCore/Manager/ExistingHubVault.swift new file mode 100644 index 000000000..6851589c3 --- /dev/null +++ b/CryptomatorCommon/Sources/CryptomatorCommonCore/Manager/ExistingHubVault.swift @@ -0,0 +1,20 @@ +import CryptoKit +import Foundation + +public struct ExistingHubVault { + let vaultUID: String + let delegateAccountUID: String + let jweData: Data + let privateKey: P384.KeyAgreement.PrivateKey + let vaultItem: VaultItem + let downloadedVaultConfig: DownloadedVaultConfig + + public init(vaultUID: String, delegateAccountUID: String, jweData: Data, privateKey: P384.KeyAgreement.PrivateKey, vaultItem: VaultItem, downloadedVaultConfig: DownloadedVaultConfig) { + self.vaultUID = vaultUID + self.delegateAccountUID = delegateAccountUID + self.jweData = jweData + self.privateKey = privateKey + self.vaultItem = vaultItem + self.downloadedVaultConfig = downloadedVaultConfig + } +} diff --git a/CryptomatorCommon/Sources/CryptomatorCommonCore/Manager/VaultDBCache.swift b/CryptomatorCommon/Sources/CryptomatorCommonCore/Manager/VaultDBCache.swift index b97878335..5c81ae1b5 100644 --- a/CryptomatorCommon/Sources/CryptomatorCommonCore/Manager/VaultDBCache.swift +++ b/CryptomatorCommon/Sources/CryptomatorCommonCore/Manager/VaultDBCache.swift @@ -23,7 +23,7 @@ public protocol VaultCache { public struct CachedVault: Codable, Equatable { let vaultUID: String public let masterkeyFileData: Data - let vaultConfigToken: Data? + public let vaultConfigToken: Data? let lastUpToDateCheck: Date var masterkeyFileLastModifiedDate: Date? var vaultConfigLastModifiedDate: Date? diff --git a/CryptomatorCommon/Sources/CryptomatorCommonCore/Manager/VaultDBManager.swift b/CryptomatorCommon/Sources/CryptomatorCommonCore/Manager/VaultDBManager.swift index 81505f07c..2b146f7e9 100644 --- a/CryptomatorCommon/Sources/CryptomatorCommonCore/Manager/VaultDBManager.swift +++ b/CryptomatorCommon/Sources/CryptomatorCommonCore/Manager/VaultDBManager.swift @@ -7,10 +7,12 @@ // import CocoaLumberjackSwift +import CryptoKit import CryptomatorCloudAccessCore import CryptomatorCryptoLib import FileProvider import Foundation +import JOSESwift import os.log import Promises @@ -19,6 +21,9 @@ public enum VaultManagerError: Error { case vaultVersionNotSupported case fileProviderDomainNotFound case moveVaultInsideItself + case invalidDecrypter + case invalidPayloadMasterkey + case missingVaultConfigToken } public protocol VaultManager { @@ -31,6 +36,8 @@ public protocol VaultManager { func removeAllUnusedFileProviderDomains() -> Promise func moveVault(account: VaultAccount, to targetVaultPath: CloudPath) -> Promise func changePassphrase(oldPassphrase: String, newPassphrase: String, forVaultUID vaultUID: String) -> Promise + func addExistingHubVault(_ vault: ExistingHubVault) -> Promise + func manualUnlockVault(withUID vaultUID: String, rawKey: [UInt8]) throws -> CloudProvider } public class VaultDBManager: VaultManager { @@ -219,6 +226,122 @@ public class VaultDBManager: VaultManager { } } + // swiftlint:disable:next function_parameter_count + public func createFromExisting(withVaultUID vaultUID: String, delegateAccountUID: String, downloadedVaultConfig: DownloadedVaultConfig, downloadedMasterkey: DownloadedMasterkeyFile, vaultItem: VaultItem, password: String) -> Promise { + let provider: LocalizedCloudProviderDecorator + do { + provider = try LocalizedCloudProviderDecorator(delegate: providerManager.getProvider(with: delegateAccountUID)) + } catch { + return Promise(error) + } + let vaultPath = vaultItem.vaultPath + let vaultConfigMetadata = downloadedVaultConfig.metadata + let vaultConfigToken = downloadedVaultConfig.token + let masterkeyFile = downloadedMasterkey.masterkeyFile + let masterkeyFileData = downloadedMasterkey.masterkeyFileData + let masterkeyFileMetadata = downloadedMasterkey.metadata + do { + let masterkey = try masterkeyFile.unlock(passphrase: password) + _ = try VaultProviderFactory.createVaultProvider(from: downloadedVaultConfig.vaultConfig, masterkey: masterkey, vaultPath: vaultPath, with: provider.delegate) + } catch { + return Promise(error) + } + let vaultConfigLastModifiedDate = vaultConfigMetadata.lastModifiedDate + let masterkeyFileLastModifiedDate = masterkeyFileMetadata.lastModifiedDate + let lastUpToDateCheck: Date = (vaultConfigLastModifiedDate ?? .distantPast) < (masterkeyFileLastModifiedDate ?? .distantPast) ? masterkeyFileLastModifiedDate! : vaultConfigLastModifiedDate ?? Date() + let cachedVault = CachedVault(vaultUID: vaultUID, masterkeyFileData: masterkeyFileData, vaultConfigToken: vaultConfigToken, lastUpToDateCheck: lastUpToDateCheck, masterkeyFileLastModifiedDate: masterkeyFileMetadata.lastModifiedDate, vaultConfigLastModifiedDate: vaultConfigMetadata.lastModifiedDate) + return addFileProviderDomain(forVaultUID: vaultUID, displayName: vaultItem.name).then { + let vaultAccount = VaultAccount(vaultUID: vaultUID, delegateAccountUID: delegateAccountUID, vaultPath: vaultPath, vaultName: vaultItem.name) + try self.vaultAccountManager.saveNewAccount(vaultAccount) + try self.postProcessVaultCreation(cachedVault: cachedVault, password: password, storePasswordInKeychain: false) + DDLogInfo("Opened existing vault \"\(vaultItem.name)\" (\(vaultUID))") + } + } + + public func getUnverifiedVaultConfig(delegateAccountUID: String, vaultItem: VaultItem) -> Promise { + let provider: LocalizedCloudProviderDecorator + do { + provider = try LocalizedCloudProviderDecorator(delegate: providerManager.getProvider(with: delegateAccountUID)) + } catch { + return Promise(error) + } + let tmpDirURL = FileManager.default.temporaryDirectory + let localVaultConfigURL = tmpDirURL.appendingPathComponent(UUID().uuidString, isDirectory: false) + let vaultPath = vaultItem.vaultPath + let vaultConfigPath = vaultPath.appendingPathComponent("vault.cryptomator") + return provider.downloadFileWithMetadata(from: vaultConfigPath, to: localVaultConfigURL).then { vaultConfigMetadata -> DownloadedVaultConfig in + let vaultConfigToken = try Data(contentsOf: localVaultConfigURL) + let unverifiedVaultConfig = try UnverifiedVaultConfig(token: vaultConfigToken) + return DownloadedVaultConfig(vaultConfig: unverifiedVaultConfig, token: vaultConfigToken, metadata: vaultConfigMetadata) + } + } + + public func downloadMasterkeyFile(delegateAccountUID: String, vaultItem: VaultItem) -> Promise { + let provider: LocalizedCloudProviderDecorator + do { + provider = try LocalizedCloudProviderDecorator(delegate: providerManager.getProvider(with: delegateAccountUID)) + } catch { + return Promise(error) + } + let tmpDirURL = FileManager.default.temporaryDirectory + let localMasterkeyURL = tmpDirURL.appendingPathComponent(UUID().uuidString, isDirectory: false) + let vaultPath = vaultItem.vaultPath + let masterkeyPath = vaultPath.appendingPathComponent("masterkey.cryptomator") + return provider.downloadFileWithMetadata(from: masterkeyPath, to: localMasterkeyURL).then { masterkeyFileMetadata -> DownloadedMasterkeyFile in + let masterkeyFileData = try Data(contentsOf: localMasterkeyURL) + let masterkeyFile = try MasterkeyFile.withContentFromData(data: masterkeyFileData) + return DownloadedMasterkeyFile(masterkeyFile: masterkeyFile, metadata: masterkeyFileMetadata, masterkeyFileData: masterkeyFileData) + } + } + + public func addExistingHubVault(_ vault: ExistingHubVault) -> Promise { + let delegateAccountUID = vault.delegateAccountUID + let provider: LocalizedCloudProviderDecorator + do { + provider = try LocalizedCloudProviderDecorator(delegate: providerManager.getProvider(with: delegateAccountUID)) + } catch { + return Promise(error) + } + let vaultItem = vault.vaultItem + let downloadedVaultConfig = vault.downloadedVaultConfig + let jweData = vault.jweData + + let vaultPath = vaultItem.vaultPath + let vaultConfigMetadata = downloadedVaultConfig.metadata + let vaultConfigToken = downloadedVaultConfig.token + let masterkey: Masterkey + do { + let jwe = try JWE(compactSerialization: jweData) + masterkey = try JWEHelper.decrypt(jwe: jwe, with: vault.privateKey) + } catch { + return Promise(error) + } + do { + _ = try VaultProviderFactory.createVaultProvider(from: downloadedVaultConfig.vaultConfig, masterkey: masterkey, vaultPath: vaultPath, with: provider.delegate) + } catch { + return Promise(error) + } + let vaultUID = vault.vaultUID + let cachedVault = CachedVault(vaultUID: vaultUID, + masterkeyFileData: jweData, + vaultConfigToken: vaultConfigToken, + lastUpToDateCheck: Date(), + masterkeyFileLastModifiedDate: nil, + vaultConfigLastModifiedDate: vaultConfigMetadata.lastModifiedDate) + return addFileProviderDomain(forVaultUID: vaultUID, displayName: vaultItem.name).then { + let vaultAccount = VaultAccount(vaultUID: vaultUID, delegateAccountUID: delegateAccountUID, vaultPath: vaultPath, vaultName: vaultItem.name) + try self.vaultAccountManager.saveNewAccount(vaultAccount) + do { + try self.postProcessVaultCreation(cachedVault: cachedVault, password: nil) + } catch { + try self.vaultAccountManager.removeAccount(with: vaultUID) + _ = self.removeFileProviderDomain(withVaultUID: vaultUID) + throw error + } + DDLogInfo("Opened existing vault \"\(vaultItem.name)\" (\(vaultUID))") + } + } + /** Imports an existing legacy Vault. @@ -333,6 +456,26 @@ public class VaultDBManager: VaultManager { return try createVaultProvider(cachedVault: cachedVault, masterkey: masterkey, masterkeyFile: masterkeyFile) } + public func manualUnlockVault(withUID vaultUID: String, rawKey: [UInt8]) throws -> CloudProvider { + let cachedVault = try vaultCache.getCachedVault(withVaultUID: vaultUID) + + guard let vaultConfigToken = cachedVault.vaultConfigToken else { + throw VaultManagerError.missingVaultConfigToken + } + let unverifiedVaultConfig = try UnverifiedVaultConfig(token: vaultConfigToken) + let vaultAccount = try vaultAccountManager.getAccount(with: vaultUID) + let provider = try providerManager.getProvider(with: vaultAccount.delegateAccountUID) + let masterkey = Masterkey.createFromRaw(rawKey: rawKey) + let decorator = try VaultProviderFactory.createVaultProvider(from: unverifiedVaultConfig, + masterkey: masterkey, + vaultPath: vaultAccount.vaultPath, + with: provider) + if masterkeyCacheHelper.shouldCacheMasterkey(forVaultUID: vaultUID) { + try masterkeyCacheManager.cacheMasterkey(masterkey, forVaultUID: vaultUID) + } + return decorator + } + public func createVaultProvider(withUID vaultUID: String, masterkey: Masterkey) throws -> CloudProvider { let cachedVault = try vaultCache.getCachedVault(withVaultUID: vaultUID) let masterkeyFile = try MasterkeyFile.withContentFromData(data: cachedVault.masterkeyFileData) @@ -424,6 +567,16 @@ public class VaultDBManager: VaultManager { } } + /** + Post-processing the vault creation by caching the vault and storing the corresponding master password (if set) in the keychain. + */ + func postProcessVaultCreation(cachedVault: CachedVault, password: String?) throws { + try vaultCache.cache(cachedVault) + if let password = password { + try passwordManager.setPassword(password, forVaultUID: cachedVault.vaultUID) + } + } + func postProcessChangePassphrase(masterkeyFileData: Data, masterkeyFileDataLastModifiedDate: Date?, forVaultUID vaultUID: String, newPassphrase: String) throws { try vaultCache.setMasterkeyFileData(masterkeyFileData, forVaultUID: vaultUID, lastModifiedDate: masterkeyFileDataLastModifiedDate) if try passwordManager.hasPassword(forVaultUID: vaultUID) { @@ -557,3 +710,19 @@ public extension NSFileProviderDomain { self.init(identifier: identifier, displayName: "") } } + +public struct DownloadedVaultConfig { + public let vaultConfig: UnverifiedVaultConfig + let token: Data + let metadata: CloudItemMetadata +} + +public struct DownloadedMasterkeyFile { + let masterkeyFile: MasterkeyFile + let metadata: CloudItemMetadata + let masterkeyFileData: Data +} + +struct PayloadMasterkey: Codable { + let key: String +} diff --git a/CryptomatorCommon/Sources/CryptomatorCommonCore/Mocks/HubRepositoryMock.swift b/CryptomatorCommon/Sources/CryptomatorCommonCore/Mocks/HubRepositoryMock.swift new file mode 100644 index 000000000..92e0d7896 --- /dev/null +++ b/CryptomatorCommon/Sources/CryptomatorCommonCore/Mocks/HubRepositoryMock.swift @@ -0,0 +1,53 @@ +import Foundation + +#if DEBUG + +// MARK: - HubRepositoryMock - + +final class HubRepositoryMock: HubRepository { + // MARK: - save + + var saveThrowableError: Error? + var saveCallsCount = 0 + var saveCalled: Bool { + saveCallsCount > 0 + } + + var saveReceivedVault: HubVault? + var saveReceivedInvocations: [HubVault] = [] + var saveClosure: ((HubVault) throws -> Void)? + + func save(_ vault: HubVault) throws { + if let error = saveThrowableError { + throw error + } + saveCallsCount += 1 + saveReceivedVault = vault + saveReceivedInvocations.append(vault) + try saveClosure?(vault) + } + + // MARK: - getHubVault + + var getHubVaultVaultIDThrowableError: Error? + var getHubVaultVaultIDCallsCount = 0 + var getHubVaultVaultIDCalled: Bool { + getHubVaultVaultIDCallsCount > 0 + } + + var getHubVaultVaultIDReceivedVaultID: String? + var getHubVaultVaultIDReceivedInvocations: [String] = [] + var getHubVaultVaultIDReturnValue: HubVault? + var getHubVaultVaultIDClosure: ((String) throws -> HubVault?)? + + func getHubVault(vaultID: String) throws -> HubVault? { + if let error = getHubVaultVaultIDThrowableError { + throw error + } + getHubVaultVaultIDCallsCount += 1 + getHubVaultVaultIDReceivedVaultID = vaultID + getHubVaultVaultIDReceivedInvocations.append(vaultID) + return try getHubVaultVaultIDClosure.map({ try $0(vaultID) }) ?? getHubVaultVaultIDReturnValue + } +} +#endif diff --git a/CryptomatorCommon/Sources/CryptomatorCommonCore/Mocks/VaultManagerMock.swift b/CryptomatorCommon/Sources/CryptomatorCommonCore/Mocks/VaultManagerMock.swift index b3ffaaa36..e6b2ef67c 100644 --- a/CryptomatorCommon/Sources/CryptomatorCommonCore/Mocks/VaultManagerMock.swift +++ b/CryptomatorCommon/Sources/CryptomatorCommonCore/Mocks/VaultManagerMock.swift @@ -12,7 +12,7 @@ import CryptomatorCryptoLib import Foundation import Promises -// swiftlint:disable all +// swiftlint: disable all final class VaultManagerMock: VaultManager { // MARK: - createNewVault @@ -220,7 +220,53 @@ final class VaultManagerMock: VaultManager { changePassphraseOldPassphraseNewPassphraseForVaultUIDReceivedInvocations.append((oldPassphrase: oldPassphrase, newPassphrase: newPassphrase, vaultUID: vaultUID)) return changePassphraseOldPassphraseNewPassphraseForVaultUIDClosure.map({ $0(oldPassphrase, newPassphrase, vaultUID) }) ?? changePassphraseOldPassphraseNewPassphraseForVaultUIDReturnValue } + + // MARK: - addExistingHubVault + + var addExistingHubVaultThrowableError: Error? + var addExistingHubVaultCallsCount = 0 + var addExistingHubVaultCalled: Bool { + addExistingHubVaultCallsCount > 0 + } + + var addExistingHubVaultReceivedVault: ExistingHubVault? + var addExistingHubVaultReceivedInvocations: [ExistingHubVault] = [] + var addExistingHubVaultReturnValue: Promise! + var addExistingHubVaultClosure: ((ExistingHubVault) -> Promise)? + + func addExistingHubVault(_ vault: ExistingHubVault) -> Promise { + if let error = addExistingHubVaultThrowableError { + return Promise(error) + } + addExistingHubVaultCallsCount += 1 + addExistingHubVaultReceivedVault = vault + addExistingHubVaultReceivedInvocations.append(vault) + return addExistingHubVaultClosure.map({ $0(vault) }) ?? addExistingHubVaultReturnValue + } + + // MARK: - manualUnlockVault + + var manualUnlockVaultWithUIDRawKeyThrowableError: Error? + var manualUnlockVaultWithUIDRawKeyCallsCount = 0 + var manualUnlockVaultWithUIDRawKeyCalled: Bool { + manualUnlockVaultWithUIDRawKeyCallsCount > 0 + } + + var manualUnlockVaultWithUIDRawKeyReceivedArguments: (vaultUID: String, rawKey: [UInt8])? + var manualUnlockVaultWithUIDRawKeyReceivedInvocations: [(vaultUID: String, rawKey: [UInt8])] = [] + var manualUnlockVaultWithUIDRawKeyReturnValue: CloudProvider! + var manualUnlockVaultWithUIDRawKeyClosure: ((String, [UInt8]) throws -> CloudProvider)? + + func manualUnlockVault(withUID vaultUID: String, rawKey: [UInt8]) throws -> CloudProvider { + if let error = manualUnlockVaultWithUIDRawKeyThrowableError { + throw error + } + manualUnlockVaultWithUIDRawKeyCallsCount += 1 + manualUnlockVaultWithUIDRawKeyReceivedArguments = (vaultUID: vaultUID, rawKey: rawKey) + manualUnlockVaultWithUIDRawKeyReceivedInvocations.append((vaultUID: vaultUID, rawKey: rawKey)) + return try manualUnlockVaultWithUIDRawKeyClosure.map({ try $0(vaultUID, rawKey) }) ?? manualUnlockVaultWithUIDRawKeyReturnValue + } } -// swiftlint:enable all +// swiftlint: enable all #endif diff --git a/CryptomatorCommon/Sources/CryptomatorCommonCore/Promise+StructuredConcurrency.swift b/CryptomatorCommon/Sources/CryptomatorCommonCore/Promise+StructuredConcurrency.swift new file mode 100644 index 000000000..6b8d02d4d --- /dev/null +++ b/CryptomatorCommon/Sources/CryptomatorCommonCore/Promise+StructuredConcurrency.swift @@ -0,0 +1,9 @@ +import Promises + +public extension Promise { + func getValue() async throws -> Value { + try await withCheckedThrowingContinuation({ continuation in + self.then(continuation.resume(returning:)).catch(continuation.resume(throwing:)) + }) + } +} diff --git a/Cryptomator/Common/SwiftUI+Focus.swift b/CryptomatorCommon/Sources/CryptomatorCommonCore/SwiftUI/SwiftUI+Focus.swift similarity index 100% rename from Cryptomator/Common/SwiftUI+Focus.swift rename to CryptomatorCommon/Sources/CryptomatorCommonCore/SwiftUI/SwiftUI+Focus.swift diff --git a/CryptomatorCommon/Sources/CryptomatorCommonCore/SwiftUI/SwiftUI+ListBackground.swift b/CryptomatorCommon/Sources/CryptomatorCommonCore/SwiftUI/SwiftUI+ListBackground.swift new file mode 100644 index 000000000..779e849e3 --- /dev/null +++ b/CryptomatorCommon/Sources/CryptomatorCommonCore/SwiftUI/SwiftUI+ListBackground.swift @@ -0,0 +1,25 @@ +import Introspect +import SwiftUI + +public extension View { + func setListBackgroundColor(_ color: Color) -> some View { + modifier(ListBackgroundModifier(color: color)) + } +} + +struct ListBackgroundModifier: ViewModifier { + let color: Color + + public func body(content: Content) -> some View { + if #available(iOS 16, *) { + content + .scrollContentBackground(.hidden) + .background(color) + } else { + content + .introspectTableView { + $0.backgroundColor = UIColor(color) + } + } + } +} diff --git a/CryptomatorCommon/Sources/CryptomatorCommonCore/UIColor+CryptomatorColors.swift b/CryptomatorCommon/Sources/CryptomatorCommonCore/UIColor+CryptomatorColors.swift index 86deea7f6..aec402502 100644 --- a/CryptomatorCommon/Sources/CryptomatorCommonCore/UIColor+CryptomatorColors.swift +++ b/CryptomatorCommon/Sources/CryptomatorCommonCore/UIColor+CryptomatorColors.swift @@ -6,6 +6,7 @@ // Copyright © 2022 Skymatic GmbH. All rights reserved. // +import SwiftUI import UIKit public extension UIColor { @@ -21,3 +22,9 @@ public extension UIColor { return UIColor(named: "yellow")! } } + +public extension Color { + static var cryptomatorPrimary: Color { Color(UIColor.cryptomatorPrimary) } + static var cryptomatorBackground: Color { Color(UIColor.cryptomatorBackground) } + static var cryptomatorYellow: Color { Color(UIColor.cryptomatorYellow) } +} diff --git a/CryptomatorCommon/Tests/CryptomatorCommonCoreTests/Hub/AddHubVaultUnlockHandlerTests.swift b/CryptomatorCommon/Tests/CryptomatorCommonCoreTests/Hub/AddHubVaultUnlockHandlerTests.swift new file mode 100644 index 000000000..1f2601b0e --- /dev/null +++ b/CryptomatorCommon/Tests/CryptomatorCommonCoreTests/Hub/AddHubVaultUnlockHandlerTests.swift @@ -0,0 +1,110 @@ +// +// AddHubVaultUnlockHandlerTests.swift +// CryptomatorCommonCore +// +// Created by Philipp Schmid on 19.11.23. +// Copyright © 2023 Skymatic GmbH. All rights reserved. + +import JOSESwift +import Promises +import XCTest +@testable import CryptomatorCloudAccessCore +@testable import CryptomatorCommonCore +@testable import CryptomatorCryptoLib + +final class AddHubVaultUnlockHandlerTests: XCTestCase { + private let vaultUID = "vault-123456789" + private let accountUID = "account-123456789" + private var vaultManagerMock: VaultManagerMock! + private var unlockHandlerDelegateMock: HubVaultUnlockHandlerDelegateMock! + + override func setUpWithError() throws { + vaultManagerMock = VaultManagerMock() + unlockHandlerDelegateMock = HubVaultUnlockHandlerDelegateMock() + } + + func testDidSuccessfullyRemoteUnlock() async throws { + let vaultConfig = VaultConfig(id: "ABB9F673-F3E8-41A7-A43B-D29F5DA65068", format: 8, cipherCombo: .sivCtrMac, shorteningThreshold: 220) + let masterkey = Masterkey.createFromRaw(aesMasterKey: [UInt8](repeating: 0x55, count: 32), macMasterKey: [UInt8](repeating: 0x77, count: 32)) + + let token = try vaultConfig.toToken(keyId: "masterkeyfile:masterkey.cryptomator", rawKey: masterkey.rawKey) + let unverifiedVaultConfig = try UnverifiedVaultConfig(token: token) + let metadata = CloudItemMetadata(name: "masterkey.cryptomator", + cloudPath: .init("/masterkey.cryptomator"), + itemType: .file, + lastModifiedDate: nil, + size: nil) + let jwe = try JWE(compactSerialization: "eyJhbGciOiJFQ0RILUVTIiwiZW5jIjoiQTI1NkdDTSIsImVwayI6eyJjcnYiOiJQLTM4NCIsImV4dCI6dHJ1ZSwia2V5X29wcyI6W10sImt0eSI6IkVDIiwieCI6Im9DLWlIcDhjZzVsUy1Qd3JjRjZxS0NzbWxfMFJzaEtCV0JJTUYzVjhuTGg2NGlCWTdsX0VsZ3Fjd0JZLXNsR3IiLCJ5IjoiVWozVzdYYVBQakJiMFRwWUFHeXlweVRIR3ByQU1hRXdWTk5Gb05tNEJuNjZuVkNKLU9pUUJYN3RhaVUtby1yWSJ9LCJhcHUiOiIiLCJhcHYiOiIifQ.._r7LC8HLc00jk2SI.ooeI0-E29jryMJ_wbGWKVc_IfHOh3Mlfh5geRYEmLTA4GKHItRYmDdZvGsCj9pJRoNORyHdmlAMxXXIXq_v9ZocoCwZrN7EsaB8A3Kukka35i1sr7kpNbksk3G_COsGRmwQ.GJCKBE-OZ7Nm5RMf_9UwVg") + + let privateKeyPemRepresentation = "-----BEGIN PRIVATE KEY-----\nMIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDDqcfr7I2SUcaYK/QHn\njhDGMpoAI1VBqzlGQ+QENqkwmGsk7N/mIQ3IJp5o7avKNJehZANiAASbOrmxoDPp\nb4AuVnUCyE1nw9KzDluGH8rozjUrteMS8ntzNlzK218iJgpRi6I3rLs8IoWTHrGE\nkfgDMgV4fk+7OC8AlKdofJudF/YcBsC00bhQ2lhlEP+PtcpgkkcJbAI=\n-----END PRIVATE KEY-----" + + let downloadedVaultConfig = DownloadedVaultConfig(vaultConfig: unverifiedVaultConfig, + token: token, + metadata: metadata) + let unlockHandler = AddHubVaultUnlockHandler(vaultUID: vaultUID, + accountUID: accountUID, vaultItem: VaultItemStub(), downloadedVaultConfig: downloadedVaultConfig, + vaultManager: vaultManagerMock, + delegate: unlockHandlerDelegateMock) + vaultManagerMock.addExistingHubVaultReturnValue = Promise(()) + + // WHEN + // calling didSuccessfullyRemoteUnlock + try await unlockHandler.didSuccessfullyRemoteUnlock(.init(jwe: jwe, privateKey: .init(pemRepresentation: privateKeyPemRepresentation), subscriptionState: .active)) + + // THEN + // the hub vault has been added as an existing one + let savedHubVault = vaultManagerMock.addExistingHubVaultReceivedVault + XCTAssertEqual(savedHubVault?.vaultUID, vaultUID) + XCTAssertEqual(savedHubVault?.delegateAccountUID, accountUID) + XCTAssertEqual(savedHubVault?.jweData, jwe.compactSerializedData) + XCTAssertEqual(savedHubVault?.downloadedVaultConfig.token, token) + + // and the delegate gets informed that the handler successfully processed the unlocked vault + XCTAssertEqual(unlockHandlerDelegateMock.successfullyProcessedUnlockedVaultCallsCount, 1) + XCTAssertFalse(unlockHandlerDelegateMock.failedToProcessUnlockedVaultErrorCalled) + } + + func testDidSuccessfullyRemoteUnlock_fails_informsDelegateAboutFailure() async throws { + let vaultConfig = VaultConfig(id: "ABB9F673-F3E8-41A7-A43B-D29F5DA65068", format: 8, cipherCombo: .sivCtrMac, shorteningThreshold: 220) + let masterkey = Masterkey.createFromRaw(aesMasterKey: [UInt8](repeating: 0x55, count: 32), macMasterKey: [UInt8](repeating: 0x77, count: 32)) + + let token = try vaultConfig.toToken(keyId: "masterkeyfile:masterkey.cryptomator", rawKey: masterkey.rawKey) + let unverifiedVaultConfig = try UnverifiedVaultConfig(token: token) + let metadata = CloudItemMetadata(name: "masterkey.cryptomator", + cloudPath: .init("/masterkey.cryptomator"), + itemType: .file, + lastModifiedDate: nil, + size: nil) + let jwe = try JWE(compactSerialization: "eyJhbGciOiJFQ0RILUVTIiwiZW5jIjoiQTI1NkdDTSIsImVwayI6eyJjcnYiOiJQLTM4NCIsImV4dCI6dHJ1ZSwia2V5X29wcyI6W10sImt0eSI6IkVDIiwieCI6Im9DLWlIcDhjZzVsUy1Qd3JjRjZxS0NzbWxfMFJzaEtCV0JJTUYzVjhuTGg2NGlCWTdsX0VsZ3Fjd0JZLXNsR3IiLCJ5IjoiVWozVzdYYVBQakJiMFRwWUFHeXlweVRIR3ByQU1hRXdWTk5Gb05tNEJuNjZuVkNKLU9pUUJYN3RhaVUtby1yWSJ9LCJhcHUiOiIiLCJhcHYiOiIifQ.._r7LC8HLc00jk2SI.ooeI0-E29jryMJ_wbGWKVc_IfHOh3Mlfh5geRYEmLTA4GKHItRYmDdZvGsCj9pJRoNORyHdmlAMxXXIXq_v9ZocoCwZrN7EsaB8A3Kukka35i1sr7kpNbksk3G_COsGRmwQ.GJCKBE-OZ7Nm5RMf_9UwVg") + + let privateKeyPemRepresentation = "-----BEGIN PRIVATE KEY-----\nMIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDDqcfr7I2SUcaYK/QHn\njhDGMpoAI1VBqzlGQ+QENqkwmGsk7N/mIQ3IJp5o7avKNJehZANiAASbOrmxoDPp\nb4AuVnUCyE1nw9KzDluGH8rozjUrteMS8ntzNlzK218iJgpRi6I3rLs8IoWTHrGE\nkfgDMgV4fk+7OC8AlKdofJudF/YcBsC00bhQ2lhlEP+PtcpgkkcJbAI=\n-----END PRIVATE KEY-----" + + let downloadedVaultConfig = DownloadedVaultConfig(vaultConfig: unverifiedVaultConfig, + token: token, + metadata: metadata) + let unlockHandler = AddHubVaultUnlockHandler(vaultUID: vaultUID, + accountUID: accountUID, vaultItem: VaultItemStub(), downloadedVaultConfig: downloadedVaultConfig, + vaultManager: vaultManagerMock, + delegate: unlockHandlerDelegateMock) + // GIVEN + // the existing hub vault can't be added due to an error + vaultManagerMock.addExistingHubVaultReturnValue = Promise(TestError()) + + // WHEN + // calling didSuccessfullyRemoteUnlock + try await unlockHandler.didSuccessfullyRemoteUnlock(.init(jwe: jwe, privateKey: .init(pemRepresentation: privateKeyPemRepresentation), subscriptionState: .active)) + + // THEN + // the delegate gets informed that the handler failed to process the unlocked vault + XCTAssertEqual(unlockHandlerDelegateMock.failedToProcessUnlockedVaultErrorCallsCount, 1) + XCTAssert(unlockHandlerDelegateMock.failedToProcessUnlockedVaultErrorReceivedError is TestError) + XCTAssertFalse(unlockHandlerDelegateMock.successfullyProcessedUnlockedVaultCalled) + } + + private struct VaultItemStub: VaultItem { + let name = "name" + let vaultPath = CloudPath("/name") + } + + private struct TestError: Error {} +} diff --git a/CryptomatorCommon/Tests/CryptomatorCommonCoreTests/Hub/HubAuthenticationViewModelTests.swift b/CryptomatorCommon/Tests/CryptomatorCommonCoreTests/Hub/HubAuthenticationViewModelTests.swift new file mode 100644 index 000000000..7f0266703 --- /dev/null +++ b/CryptomatorCommon/Tests/CryptomatorCommonCoreTests/Hub/HubAuthenticationViewModelTests.swift @@ -0,0 +1,294 @@ +// +// HubAuthenticationViewModelTests.swift +// +// +// Created by Philipp Schmid on 19.11.23. +// + +import AppAuthCore +import CryptoKit +import XCTest +@testable import CryptomatorCloudAccessCore +@testable import CryptomatorCommonCore +@testable import CryptomatorCryptoLib +@testable import Dependencies + +final class HubAuthenticationViewModelTests: XCTestCase { + private var unlockHandlerMock: HubVaultUnlockHandlerMock! + private var delegateMock: HubAuthenticationViewModelDelegateMock! + private var hubKeyServiceMock: HubKeyReceivingMock! + private var viewModel: HubAuthenticationViewModel! + + override func setUpWithError() throws { + unlockHandlerMock = HubVaultUnlockHandlerMock() + delegateMock = HubAuthenticationViewModelDelegateMock() + hubKeyServiceMock = HubKeyReceivingMock() + + let unverifiedVaultConfig = try UnverifiedVaultConfig(token: validHubVaultConfig()) + + viewModel = HubAuthenticationViewModel(authState: .stub, + vaultConfig: unverifiedVaultConfig, + unlockHandler: unlockHandlerMock, + delegate: delegateMock) + } + + // MARK: continueToAccessCheck + + func testContinueToAccessCheck_showsLoadingSpinnerWhileReceivingKey() async throws { + XCTAssertFalse(delegateMock.hubAuthenticationViewModelWantsToShowLoadingIndicatorCalled) + XCTAssertFalse(delegateMock.hubAuthenticationViewModelWantsToHideLoadingIndicatorCalled) + DependencyValues.mockDependency(\.hubKeyService, with: hubKeyServiceMock) + let hubKeyProviderMock = CryptomatorHubKeyProviderMock() + DependencyValues.mockDependency(\.cryptomatorHubKeyProvider, with: hubKeyProviderMock) + hubKeyProviderMock.getPrivateKeyReturnValue = P384.KeyAgreement.PrivateKey(compactRepresentable: false) + + let calledReceiveKey = XCTestExpectation() + hubKeyServiceMock.receiveKeyAuthStateVaultConfigClosure = { _, _ in + calledReceiveKey.fulfill() + return .success(Data(), [:]) + } + + let calledShowLoadingIndicator = XCTestExpectation() + delegateMock.hubAuthenticationViewModelWantsToShowLoadingIndicatorClosure = { + calledShowLoadingIndicator.fulfill() + } + + let calledHideLoadingIndicator = XCTestExpectation() + delegateMock.hubAuthenticationViewModelWantsToHideLoadingIndicatorClosure = { + calledHideLoadingIndicator.fulfill() + } + + // WHEN + // continue the access check + await viewModel.continueToAccessCheck() + + // THEN + // the loading indicator should be displayed while receiving the key + await fulfillment(of: [calledShowLoadingIndicator, calledReceiveKey, calledHideLoadingIndicator], enforceOrder: true) + } + + func testContinueToAccessCheck_showsLoadingSpinnerWhileReceivingKeyHidesIfFailed() async throws { + XCTAssertFalse(delegateMock.hubAuthenticationViewModelWantsToShowLoadingIndicatorCalled) + XCTAssertFalse(delegateMock.hubAuthenticationViewModelWantsToHideLoadingIndicatorCalled) + DependencyValues.mockDependency(\.hubKeyService, with: hubKeyServiceMock) + let calledReceiveKey = XCTestExpectation() + hubKeyServiceMock.receiveKeyAuthStateVaultConfigClosure = { _, _ in + calledReceiveKey.fulfill() + throw TestError() + } + + let calledShowLoadingIndicator = XCTestExpectation() + delegateMock.hubAuthenticationViewModelWantsToShowLoadingIndicatorClosure = { + calledShowLoadingIndicator.fulfill() + } + + let calledHideLoadingIndicator = XCTestExpectation() + delegateMock.hubAuthenticationViewModelWantsToHideLoadingIndicatorClosure = { + calledHideLoadingIndicator.fulfill() + } + + // WHEN + // continue the access check + await viewModel.continueToAccessCheck() + + // THEN + // the loading indicator should be displayed while receiving the key and gets hidden even if the operation fails + await fulfillment(of: [calledShowLoadingIndicator, calledReceiveKey, calledHideLoadingIndicator], enforceOrder: true) + } + + func testContinueToAccessCheck_success_hubSubscriptionStateIsActive() async throws { + DependencyValues.mockDependency(\.hubKeyService, with: hubKeyServiceMock) + let hubKeyProviderMock = CryptomatorHubKeyProviderMock() + DependencyValues.mockDependency(\.cryptomatorHubKeyProvider, with: hubKeyProviderMock) + + // GIVEN + // the hub key service returns success with an active Cryptomator Hub subscription state + hubKeyServiceMock.receiveKeyAuthStateVaultConfigReturnValue = .success(validHubResponseData(), ["hub-subscription-state": "ACTIVE"]) + hubKeyProviderMock.getPrivateKeyReturnValue = P384.KeyAgreement.PrivateKey(compactRepresentable: false) + + // WHEN + // continue the access check + await viewModel.continueToAccessCheck() + + // THEN + // the unlock handler gets informed about the successful remote unlock with an active Cryptomator Hub subscription state + let receivedResponse = unlockHandlerMock.didSuccessfullyRemoteUnlockReceivedResponse + XCTAssertEqual(unlockHandlerMock.didSuccessfullyRemoteUnlockCallsCount, 1) + XCTAssertEqual(receivedResponse?.subscriptionState, .active) + } + + func testContinueToAccessCheck_success_hubSubscriptionStateIsInactive() async throws { + DependencyValues.mockDependency(\.hubKeyService, with: hubKeyServiceMock) + let hubKeyProviderMock = CryptomatorHubKeyProviderMock() + DependencyValues.mockDependency(\.cryptomatorHubKeyProvider, with: hubKeyProviderMock) + + // GIVEN + // the hub key service returns success with an inactive Cryptomator Hub subscription state + hubKeyServiceMock.receiveKeyAuthStateVaultConfigReturnValue = .success(validHubResponseData(), ["hub-subscription-state": "INACTIVE"]) + hubKeyProviderMock.getPrivateKeyReturnValue = P384.KeyAgreement.PrivateKey(compactRepresentable: false) + + // WHEN + // continue the access check + await viewModel.continueToAccessCheck() + + // THEN + // the unlock handler gets informed about the successful remote unlock with an inactive Cryptomator Hub subscription state + let receivedResponse = unlockHandlerMock.didSuccessfullyRemoteUnlockReceivedResponse + XCTAssertEqual(unlockHandlerMock.didSuccessfullyRemoteUnlockCallsCount, 1) + XCTAssertEqual(receivedResponse?.subscriptionState, .inactive) + } + + func testContinueToAccessCheck_success_hubSubscriptionStateIsUnknown() async throws { + DependencyValues.mockDependency(\.hubKeyService, with: hubKeyServiceMock) + let hubKeyProviderMock = CryptomatorHubKeyProviderMock() + DependencyValues.mockDependency(\.cryptomatorHubKeyProvider, with: hubKeyProviderMock) + + // GIVEN + // the hub key service returns success with an unknown Cryptomator Hub subscription state + hubKeyServiceMock.receiveKeyAuthStateVaultConfigReturnValue = .success(validHubResponseData(), ["hub-subscription-state": "FOO"]) + hubKeyProviderMock.getPrivateKeyReturnValue = P384.KeyAgreement.PrivateKey(compactRepresentable: false) + + // WHEN + // continue the access check + await viewModel.continueToAccessCheck() + + // THEN + // the unlock handler gets not informed about a successful remote unlock + XCTAssertFalse(unlockHandlerMock.didSuccessfullyRemoteUnlockCalled) + // the user gets informed about the error + let currentAuthenticationFlowState = try XCTUnwrap(viewModel.authenticationFlowState) + XCTAssert(currentAuthenticationFlowState.isError) + } + + func testContinueToAccessCheck_accessNotGranted() async throws { + DependencyValues.mockDependency(\.hubKeyService, with: hubKeyServiceMock) + + // GIVEN + // the hub key service returns access not granted + hubKeyServiceMock.receiveKeyAuthStateVaultConfigReturnValue = .accessNotGranted + + // WHEN + // continue the access check + await viewModel.continueToAccessCheck() + + // THEN + // the authentication flow state is set to accessNotGranted + XCTAssertEqual(viewModel.authenticationFlowState, .accessNotGranted) + } + + func testContinueToAccessCheck_needsDeviceRegistration() async throws { + DependencyValues.mockDependency(\.hubKeyService, with: hubKeyServiceMock) + + // GIVEN + // the hub key service returns needs device registration + hubKeyServiceMock.receiveKeyAuthStateVaultConfigReturnValue = .needsDeviceRegistration + + // WHEN + // continue the access check + await viewModel.continueToAccessCheck() + + // THEN + // the authentication flow state is set to needsDeviceRegistration where the user needs to set the device name + XCTAssertEqual(viewModel.authenticationFlowState, .deviceRegistration(.deviceName)) + } + + func testContinueToAccessCheck_licenseExceeded() async throws { + DependencyValues.mockDependency(\.hubKeyService, with: hubKeyServiceMock) + + // GIVEN + // the hub key service returns that the Cryptomator Hub License is exceeded + hubKeyServiceMock.receiveKeyAuthStateVaultConfigReturnValue = .licenseExceeded + + // WHEN + // continue the access check + await viewModel.continueToAccessCheck() + + // THEN + // the authentication flow state is set to licenseExceeded + XCTAssertEqual(viewModel.authenticationFlowState, .licenseExceeded) + } + + // MARK: Register + + func testRegister_registersDevice_withName() async { + let deviceRegisteringMock = HubDeviceRegisteringMock() + DependencyValues.mockDependency(\.hubDeviceRegisteringService, with: deviceRegisteringMock) + + // GIVEN + // a name has been set by the user + viewModel.deviceName = "My Device 123" + + // WHEN + // the user taps on register + await viewModel.register() + + // THEN + // the registerDevice got called on the device registering servie + let receivedArguments = deviceRegisteringMock.registerDeviceWithNameHubConfigAuthStateReceivedArguments + XCTAssertEqual(deviceRegisteringMock.registerDeviceWithNameHubConfigAuthStateCallsCount, 1) + // with the name set by the user + XCTAssertEqual(receivedArguments?.name, "My Device 123") + } + + private struct TestError: Error {} + + private func validHubVaultConfig() -> Data { + "eyJraWQiOiJodWIraHR0cHM6Ly90ZXN0aW5nLmh1Yi5jcnlwdG9tYXRvci5vcmcvaHViMjkvYXBpL3ZhdWx0cy9mYjUzMDdmMC1jOWI4LTRjNWYtYjJiMi03ZDM4ODE4ZjZhNGIiLCJ0eXAiOiJqd3QiLCJhbGciOiJIUzI1NiIsImh1YiI6eyJjbGllbnRJZCI6ImNyeXB0b21hdG9yIiwiYXV0aEVuZHBvaW50IjoiaHR0cHM6Ly90ZXN0aW5nLmh1Yi5jcnlwdG9tYXRvci5vcmcva2MvcmVhbG1zL2h1YjI5L3Byb3RvY29sL29wZW5pZC1jb25uZWN0L2F1dGgiLCJ0b2tlbkVuZHBvaW50IjoiaHR0cHM6Ly90ZXN0aW5nLmh1Yi5jcnlwdG9tYXRvci5vcmcva2MvcmVhbG1zL2h1YjI5L3Byb3RvY29sL29wZW5pZC1jb25uZWN0L3Rva2VuIiwiZGV2aWNlc1Jlc291cmNlVXJsIjoiaHR0cHM6Ly90ZXN0aW5nLmh1Yi5jcnlwdG9tYXRvci5vcmcvaHViMjkvYXBpL2RldmljZXMvIiwiYXV0aFN1Y2Nlc3NVcmwiOiJodHRwczovL3Rlc3RpbmcuaHViLmNyeXB0b21hdG9yLm9yZy9odWIyOS9hcHAvdW5sb2NrLXN1Y2Nlc3M_dmF1bHQ9ZmI1MzA3ZjAtYzliOC00YzVmLWIyYjItN2QzODgxOGY2YTRiIiwiYXV0aEVycm9yVXJsIjoiaHR0cHM6Ly90ZXN0aW5nLmh1Yi5jcnlwdG9tYXRvci5vcmcvaHViMjkvYXBwL3VubG9jay1lcnJvcj92YXVsdD1mYjUzMDdmMC1jOWI4LTRjNWYtYjJiMi03ZDM4ODE4ZjZhNGIifX0.eyJqdGkiOiJmYjUzMDdmMC1jOWI4LTRjNWYtYjJiMi03ZDM4ODE4ZjZhNGIiLCJmb3JtYXQiOjgsImNpcGhlckNvbWJvIjoiU0lWX0dDTSIsInNob3J0ZW5pbmdUaHJlc2hvbGQiOjIyMH0.2iFWE4Jj5lV6iaVTPOzGovnrNreuuAJCy_gPmK90MMU".data(using: .utf8)! + } + + private func validHubResponseData() -> Data { + "eyJhbGciOiJFQ0RILUVTIiwiZW5jIjoiQTI1NkdDTSIsImVwayI6eyJjcnYiOiJQLTM4NCIsImV4dCI6dHJ1ZSwia2V5X29wcyI6W10sImt0eSI6IkVDIiwieCI6Im9DLWlIcDhjZzVsUy1Qd3JjRjZxS0NzbWxfMFJzaEtCV0JJTUYzVjhuTGg2NGlCWTdsX0VsZ3Fjd0JZLXNsR3IiLCJ5IjoiVWozVzdYYVBQakJiMFRwWUFHeXlweVRIR3ByQU1hRXdWTk5Gb05tNEJuNjZuVkNKLU9pUUJYN3RhaVUtby1yWSJ9LCJhcHUiOiIiLCJhcHYiOiIifQ.._r7LC8HLc00jk2SI.ooeI0-E29jryMJ_wbGWKVc_IfHOh3Mlfh5geRYEmLTA4GKHItRYmDdZvGsCj9pJRoNORyHdmlAMxXXIXq_v9ZocoCwZrN7EsaB8A3Kukka35i1sr7kpNbksk3G_COsGRmwQ.GJCKBE-OZ7Nm5RMf_9UwVg".data(using: .utf8)! + } +} + +private extension OIDAuthState { + static var stub: Self { + .init(authorizationResponse: .init(request: .init(configuration: .init(authorizationEndpoint: URL(string: "example.com")!, tokenEndpoint: URL(string: "example.com")!), clientId: "", scopes: nil, redirectURL: URL(string: "example.com")!, responseType: "code", additionalParameters: nil), parameters: [:])) + } +} + +private extension HubAuthenticationViewModel.State { + var isError: Bool { + switch self { + case .error: + return true + default: + return false + } + } +} + +// MARK: - HubAuthenticationViewModelDelegateMock - + +// swiftlint: disable all +final class HubAuthenticationViewModelDelegateMock: HubAuthenticationViewModelDelegate { + // MARK: - hubAuthenticationViewModelWantsToShowLoadingIndicator + + var hubAuthenticationViewModelWantsToShowLoadingIndicatorCallsCount = 0 + var hubAuthenticationViewModelWantsToShowLoadingIndicatorCalled: Bool { + hubAuthenticationViewModelWantsToShowLoadingIndicatorCallsCount > 0 + } + + var hubAuthenticationViewModelWantsToShowLoadingIndicatorClosure: (() -> Void)? + + func hubAuthenticationViewModelWantsToShowLoadingIndicator() { + hubAuthenticationViewModelWantsToShowLoadingIndicatorCallsCount += 1 + hubAuthenticationViewModelWantsToShowLoadingIndicatorClosure?() + } + + // MARK: - hubAuthenticationViewModelWantsToHideLoadingIndicator + + var hubAuthenticationViewModelWantsToHideLoadingIndicatorCallsCount = 0 + var hubAuthenticationViewModelWantsToHideLoadingIndicatorCalled: Bool { + hubAuthenticationViewModelWantsToHideLoadingIndicatorCallsCount > 0 + } + + var hubAuthenticationViewModelWantsToHideLoadingIndicatorClosure: (() -> Void)? + + func hubAuthenticationViewModelWantsToHideLoadingIndicator() { + hubAuthenticationViewModelWantsToHideLoadingIndicatorCallsCount += 1 + hubAuthenticationViewModelWantsToHideLoadingIndicatorClosure?() + } +} + +// swiftlint: enable all diff --git a/CryptomatorCommon/Tests/CryptomatorCommonCoreTests/Hub/HubDBRepositoryTests.swift b/CryptomatorCommon/Tests/CryptomatorCommonCoreTests/Hub/HubDBRepositoryTests.swift new file mode 100644 index 000000000..211b2f87f --- /dev/null +++ b/CryptomatorCommon/Tests/CryptomatorCommonCoreTests/Hub/HubDBRepositoryTests.swift @@ -0,0 +1,89 @@ +import GRDB +import XCTest +@testable import CryptomatorCommonCore + +final class HubDBRepositoryTests: XCTestCase { + private var inMemoryDB: DatabaseQueue! + private var repository: HubDBRepository! + private var vaultAccountManager: VaultAccountManager! + private var cloudAccountManager: CloudProviderAccountManager! + + override func setUpWithError() throws { + repository = HubDBRepository() + vaultAccountManager = VaultAccountDBManager() + cloudAccountManager = CloudProviderAccountDBManager() + } + + func testSaveAndRetrieve() throws { + // GIVEN + // a cloud account has been created + let cloudAccount = CloudProviderAccount(accountUID: "", cloudProviderType: .dropbox) + try cloudAccountManager.saveNewAccount(cloudAccount) + + // and a vault account has been created + let vaultID = "123456789" + let vaultAccount = VaultAccount(vaultUID: vaultID, delegateAccountUID: "", vaultPath: .init(""), vaultName: "") + try vaultAccountManager.saveNewAccount(vaultAccount) + + // WHEN + // saving a hub vault + let vault = HubVault(vaultUID: vaultID, subscriptionState: .active) + try repository.save(vault) + + // THEN + // it can be retrieved + let retrievedVault = try repository.getHubVault(vaultID: vaultID) + XCTAssertEqual(vault, retrievedVault) + } + + func testSaveToUpdate() throws { + // GIVEN + // a cloud account has been created + let cloudAccount = CloudProviderAccount(accountUID: "", cloudProviderType: .dropbox) + try cloudAccountManager.saveNewAccount(cloudAccount) + + // and a vault account has been created + let vaultID = "123456789" + let vaultAccount = VaultAccount(vaultUID: vaultID, delegateAccountUID: "", vaultPath: .init(""), vaultName: "") + try vaultAccountManager.saveNewAccount(vaultAccount) + + // WHEN + // saving a hub vault + let initialVault = HubVault(vaultUID: vaultID, subscriptionState: .active) + try repository.save(initialVault) + + // and saving the hub vault with the same vault ID but a changed subscription state + let updatedVault = HubVault(vaultUID: vaultID, subscriptionState: .inactive) + try repository.save(updatedVault) + + // THEN + // it the updated version can be retrieved + let retrievedVault = try repository.getHubVault(vaultID: vaultID) + XCTAssertEqual(updatedVault, retrievedVault) + } + + func testDeleteVaultAccountAlsoDeletesHubVault() throws { + // GIVEN + // a cloud account has been created + let cloudAccount = CloudProviderAccount(accountUID: "", cloudProviderType: .dropbox) + try cloudAccountManager.saveNewAccount(cloudAccount) + + // and a vault account has been created + let vaultID = "123456789" + let vaultAccount = VaultAccount(vaultUID: vaultID, delegateAccountUID: "", vaultPath: .init(""), vaultName: "") + try vaultAccountManager.saveNewAccount(vaultAccount) + + // and a hub vault has been created for the vault id + let vault = HubVault(vaultUID: vaultID, subscriptionState: .active) + try repository.save(vault) + + // WHEN + // the vault account gets deleted + try vaultAccountManager.removeAccount(with: vaultID) + + // THEN + // the hub vault account has been deleted and can not be retrieved + let retrievedVault = try repository.getHubVault(vaultID: vaultID) + XCTAssertNil(retrievedVault) + } +} diff --git a/CryptomatorCommon/Tests/CryptomatorCommonCoreTests/Hub/HubVaultUnlockHandlerDelegateMock.swift b/CryptomatorCommon/Tests/CryptomatorCommonCoreTests/Hub/HubVaultUnlockHandlerDelegateMock.swift new file mode 100644 index 000000000..aa6ec4fb1 --- /dev/null +++ b/CryptomatorCommon/Tests/CryptomatorCommonCoreTests/Hub/HubVaultUnlockHandlerDelegateMock.swift @@ -0,0 +1,45 @@ +// +// HubVaultUnlockHandlerDelegateMock.swift +// CryptomatorCommonCore +// +// Created by Philipp Schmid on 19.11.23. +// Copyright © 2023 Skymatic GmbH. All rights reserved. + +import Foundation +@testable import CryptomatorCommonCore +// swiftlint:disable all +final class HubVaultUnlockHandlerDelegateMock: HubVaultUnlockHandlerDelegate { + // MARK: - successfullyProcessedUnlockedVault + + var successfullyProcessedUnlockedVaultCallsCount = 0 + var successfullyProcessedUnlockedVaultCalled: Bool { + successfullyProcessedUnlockedVaultCallsCount > 0 + } + + var successfullyProcessedUnlockedVaultClosure: (() -> Void)? + + func successfullyProcessedUnlockedVault() { + successfullyProcessedUnlockedVaultCallsCount += 1 + successfullyProcessedUnlockedVaultClosure?() + } + + // MARK: - failedToProcessUnlockedVault + + var failedToProcessUnlockedVaultErrorCallsCount = 0 + var failedToProcessUnlockedVaultErrorCalled: Bool { + failedToProcessUnlockedVaultErrorCallsCount > 0 + } + + var failedToProcessUnlockedVaultErrorReceivedError: Error? + var failedToProcessUnlockedVaultErrorReceivedInvocations: [Error] = [] + var failedToProcessUnlockedVaultErrorClosure: ((Error) -> Void)? + + func failedToProcessUnlockedVault(error: Error) { + failedToProcessUnlockedVaultErrorCallsCount += 1 + failedToProcessUnlockedVaultErrorReceivedError = error + failedToProcessUnlockedVaultErrorReceivedInvocations.append(error) + failedToProcessUnlockedVaultErrorClosure?(error) + } +} + +// swiftlint:enable all diff --git a/CryptomatorCommon/Tests/CryptomatorCommonCoreTests/Manager/VaultManagerTests.swift b/CryptomatorCommon/Tests/CryptomatorCommonCoreTests/Manager/VaultManagerTests.swift index 2a75305a2..946e967e7 100644 --- a/CryptomatorCommon/Tests/CryptomatorCommonCoreTests/Manager/VaultManagerTests.swift +++ b/CryptomatorCommon/Tests/CryptomatorCommonCoreTests/Manager/VaultManagerTests.swift @@ -15,7 +15,7 @@ import XCTest @testable import CryptomatorCommonCore @testable import CryptomatorCryptoLib -class VaultManagerMock: VaultDBManager { +private final class VaultManagerMock: VaultDBManager { var removedVaultUIDs = [String]() var addedFileProviderDomainDisplayName = [String: String]() diff --git a/CryptomatorFileProvider/DB/WorkingSetObserver.swift b/CryptomatorFileProvider/DB/WorkingSetObserver.swift index 8b35d1d62..b4f411807 100644 --- a/CryptomatorFileProvider/DB/WorkingSetObserver.swift +++ b/CryptomatorFileProvider/DB/WorkingSetObserver.swift @@ -7,6 +7,7 @@ // import CocoaLumberjackSwift +import Dependencies import FileProvider import Foundation import GRDB @@ -23,8 +24,13 @@ class WorkingSetObserver: WorkingSetObserving { private let notificator: FileProviderNotificatorType private var currentWorkingSetItems = Set() private let domainIdentifier: NSFileProviderDomainIdentifier + @Dependency(\.permissionProvider) private var permissionProvider - init(domainIdentifier: NSFileProviderDomainIdentifier, database: DatabaseReader, notificator: FileProviderNotificatorType, uploadTaskManager: UploadTaskManager, cachedFileManager: CachedFileManager) { + init(domainIdentifier: NSFileProviderDomainIdentifier, + database: DatabaseReader, + notificator: FileProviderNotificatorType, + uploadTaskManager: UploadTaskManager, + cachedFileManager: CachedFileManager) { self.domainIdentifier = domainIdentifier self.database = database self.notificator = notificator diff --git a/CryptomatorFileProvider/FileProviderAdapter.swift b/CryptomatorFileProvider/FileProviderAdapter.swift index ce2564f5d..2ff7e112c 100644 --- a/CryptomatorFileProvider/FileProviderAdapter.swift +++ b/CryptomatorFileProvider/FileProviderAdapter.swift @@ -74,6 +74,7 @@ public class FileProviderAdapter: FileProviderAdapterType { private let domainIdentifier: NSFileProviderDomainIdentifier private let fileCoordinator: NSFileCoordinator private let taskRegistrator: SessionTaskRegistrator + @Dependency(\.permissionProvider) private var permissionProvider init(domainIdentifier: NSFileProviderDomainIdentifier, uploadTaskManager: UploadTaskManager, diff --git a/CryptomatorFileProvider/FileProviderAdapterManager.swift b/CryptomatorFileProvider/FileProviderAdapterManager.swift index 3a374fef3..d53e08185 100644 --- a/CryptomatorFileProvider/FileProviderAdapterManager.swift +++ b/CryptomatorFileProvider/FileProviderAdapterManager.swift @@ -9,6 +9,7 @@ import CocoaLumberjackSwift import CryptomatorCloudAccessCore import CryptomatorCommonCore +import Dependencies import FileProvider import Foundation import GRDB @@ -32,12 +33,27 @@ public class FileProviderAdapterManager: FileProviderAdapterProviding { private let notificatorManager: FileProviderNotificatorManagerType private let queue = DispatchQueue(label: "FileProviderAdapterManager", qos: .userInitiated) private let providerIdentifier: String + @Dependency(\.permissionProvider) private var permissionProvider convenience init() { - self.init(masterkeyCacheManager: MasterkeyCacheKeychainManager.shared, vaultKeepUnlockedHelper: VaultKeepUnlockedManager.shared, vaultKeepUnlockedSettings: VaultKeepUnlockedManager.shared, vaultManager: VaultDBManager.shared, adapterCache: FileProviderAdapterCache(), notificatorManager: FileProviderNotificatorManager.shared, unlockMonitor: UnlockMonitor(), providerIdentifier: NSFileProviderManager.default.providerIdentifier) + self.init(masterkeyCacheManager: MasterkeyCacheKeychainManager.shared, + vaultKeepUnlockedHelper: VaultKeepUnlockedManager.shared, + vaultKeepUnlockedSettings: VaultKeepUnlockedManager.shared, + vaultManager: VaultDBManager.shared, + adapterCache: FileProviderAdapterCache(), + notificatorManager: FileProviderNotificatorManager.shared, + unlockMonitor: UnlockMonitor(), + providerIdentifier: NSFileProviderManager.default.providerIdentifier) } - init(masterkeyCacheManager: MasterkeyCacheManager, vaultKeepUnlockedHelper: VaultKeepUnlockedHelper, vaultKeepUnlockedSettings: VaultKeepUnlockedSettings, vaultManager: VaultManager, adapterCache: FileProviderAdapterCacheType, notificatorManager: FileProviderNotificatorManagerType, unlockMonitor: UnlockMonitorType, providerIdentifier: String) { + init(masterkeyCacheManager: MasterkeyCacheManager, + vaultKeepUnlockedHelper: VaultKeepUnlockedHelper, + vaultKeepUnlockedSettings: VaultKeepUnlockedSettings, + vaultManager: VaultManager, + adapterCache: FileProviderAdapterCacheType, + notificatorManager: FileProviderNotificatorManagerType, + unlockMonitor: UnlockMonitorType, + providerIdentifier: String) { self.masterkeyCacheManager = masterkeyCacheManager self.vaultKeepUnlockedHelper = vaultKeepUnlockedHelper self.vaultKeepUnlockedSettings = vaultKeepUnlockedSettings @@ -81,6 +97,30 @@ public class FileProviderAdapterManager: FileProviderAdapterProviding { return } let provider = try vaultManager.manualUnlockVault(withUID: domainIdentifier.rawValue, kek: kek) + try unlockVaultPostProcessing(provider: provider, + domainIdentifier: domainIdentifier, + dbPath: dbPath, + delegate: delegate, + notificator: notificator, + taskRegistrator: taskRegistrator) + } + + // swiftlint:disable:next function_parameter_count + public func unlockVault(with domainIdentifier: NSFileProviderDomainIdentifier, rawKey: [UInt8], dbPath: URL?, delegate: FileProviderAdapterDelegate, notificator: FileProviderNotificatorType, taskRegistrator: SessionTaskRegistrator) throws { + guard let dbPath = dbPath else { + return + } + let provider = try vaultManager.manualUnlockVault(withUID: domainIdentifier.rawValue, rawKey: rawKey) + try unlockVaultPostProcessing(provider: provider, + domainIdentifier: domainIdentifier, + dbPath: dbPath, + delegate: delegate, + notificator: notificator, + taskRegistrator: taskRegistrator) + } + + // swiftlint:disable:next function_parameter_count + func unlockVaultPostProcessing(provider: CloudProvider, domainIdentifier: NSFileProviderDomainIdentifier, dbPath: URL, delegate: FileProviderAdapterDelegate, notificator: FileProviderNotificatorType, taskRegistrator: SessionTaskRegistrator) throws { let item = try createAdapterCacheItem(domainIdentifier: domainIdentifier, cloudProvider: provider, dbPath: dbPath, delegate: delegate, notificator: notificator, taskRegistrator: taskRegistrator) try vaultKeepUnlockedSettings.setLastUsedDate(Date(), forVaultUID: domainIdentifier.rawValue) adapterCache.cacheItem(item, identifier: domainIdentifier) @@ -166,7 +206,12 @@ public class FileProviderAdapterManager: FileProviderAdapterProviding { notificator: notificator, localURLProvider: delegate, taskRegistrator: taskRegistrator) - let workingSetObserver = WorkingSetObserver(domainIdentifier: domainIdentifier, database: database, notificator: notificator, uploadTaskManager: uploadTaskManager, cachedFileManager: cachedFileManager) + + let workingSetObserver = WorkingSetObserver(domainIdentifier: domainIdentifier, + database: database, + notificator: notificator, + uploadTaskManager: uploadTaskManager, + cachedFileManager: cachedFileManager) workingSetObserver.startObservation() return AdapterCacheItem(adapter: adapter, maintenanceManager: maintenanceManager, workingSetObserver: workingSetObserver) } diff --git a/CryptomatorFileProvider/FileProviderItem.swift b/CryptomatorFileProvider/FileProviderItem.swift index 64c822b6f..2b0bc5955 100644 --- a/CryptomatorFileProvider/FileProviderItem.swift +++ b/CryptomatorFileProvider/FileProviderItem.swift @@ -24,6 +24,7 @@ public class FileProviderItem: NSObject, NSFileProviderItem { let localURL: URL? let domainIdentifier: NSFileProviderDomainIdentifier @Dependency(\.fullVersionChecker) private var fullVersionChecker + @Dependency(\.permissionProvider) private var permissionProvider init(metadata: ItemMetadata, domainIdentifier: NSFileProviderDomainIdentifier, newestVersionLocallyCached: Bool = false, localURL: URL? = nil, error: Error? = nil) { self.metadata = metadata @@ -50,19 +51,7 @@ public class FileProviderItem: NSObject, NSFileProviderItem { } public var capabilities: NSFileProviderItemCapabilities { - if metadata.statusCode == .uploadError { - return .allowsDeleting - } - if !fullVersionChecker.isFullVersion { - return FileProviderItem.readOnlyCapabilities - } - if metadata.type == .folder { - return [.allowsAddingSubItems, .allowsContentEnumerating, .allowsReading, .allowsDeleting, .allowsRenaming, .allowsReparenting] - } - if metadata.statusCode == .isUploading { - return .allowsReading - } - return [.allowsWriting, .allowsReading, .allowsDeleting, .allowsRenaming, .allowsReparenting] + return permissionProvider.getPermissions(for: metadata, at: domainIdentifier) } public var filename: String { diff --git a/CryptomatorFileProvider/Middleware/TaskExecutor/DownloadTaskExecutor.swift b/CryptomatorFileProvider/Middleware/TaskExecutor/DownloadTaskExecutor.swift index c3feed4e7..6e1c23446 100644 --- a/CryptomatorFileProvider/Middleware/TaskExecutor/DownloadTaskExecutor.swift +++ b/CryptomatorFileProvider/Middleware/TaskExecutor/DownloadTaskExecutor.swift @@ -8,6 +8,7 @@ import CocoaLumberjackSwift import CryptomatorCloudAccessCore +import Dependencies import Foundation import Promises @@ -30,8 +31,13 @@ class DownloadTaskExecutor: WorkflowMiddleware { private let downloadTaskManager: DownloadTaskManager private let provider: CloudProvider private let domainIdentifier: NSFileProviderDomainIdentifier + @Dependency(\.permissionProvider) private var permissionProvider - init(domainIdentifier: NSFileProviderDomainIdentifier, provider: CloudProvider, itemMetadataManager: ItemMetadataManager, cachedFileManager: CachedFileManager, downloadTaskManager: DownloadTaskManager) { + init(domainIdentifier: NSFileProviderDomainIdentifier, + provider: CloudProvider, + itemMetadataManager: ItemMetadataManager, + cachedFileManager: CachedFileManager, + downloadTaskManager: DownloadTaskManager) { self.domainIdentifier = domainIdentifier self.provider = provider self.itemMetadataManager = itemMetadataManager diff --git a/CryptomatorFileProvider/Middleware/TaskExecutor/FolderCreationTaskExecutor.swift b/CryptomatorFileProvider/Middleware/TaskExecutor/FolderCreationTaskExecutor.swift index 23235e3b6..fd2623508 100644 --- a/CryptomatorFileProvider/Middleware/TaskExecutor/FolderCreationTaskExecutor.swift +++ b/CryptomatorFileProvider/Middleware/TaskExecutor/FolderCreationTaskExecutor.swift @@ -29,7 +29,9 @@ class FolderCreationTaskExecutor: WorkflowMiddleware { private let provider: CloudProvider private let domainIdentifier: NSFileProviderDomainIdentifier - init(domainIdentifier: NSFileProviderDomainIdentifier, provider: CloudProvider, itemMetadataManager: ItemMetadataManager) { + init(domainIdentifier: NSFileProviderDomainIdentifier, + provider: CloudProvider, + itemMetadataManager: ItemMetadataManager) { self.domainIdentifier = domainIdentifier self.provider = provider self.itemMetadataManager = itemMetadataManager @@ -53,11 +55,13 @@ class FolderCreationTaskExecutor: WorkflowMiddleware { assert(itemMetadata.id != nil) assert(itemMetadata.type == .folder) - return provider.createFolder(at: itemMetadata.cloudPath).then { _ -> FileProviderItem in + return provider.createFolder(at: itemMetadata.cloudPath).then { [domainIdentifier, itemMetadataManager] _ -> FileProviderItem in itemMetadata.statusCode = .isUploaded itemMetadata.isPlaceholderItem = false - try self.itemMetadataManager.updateMetadata(itemMetadata) - return FileProviderItem(metadata: itemMetadata, domainIdentifier: self.domainIdentifier, newestVersionLocallyCached: true) + try itemMetadataManager.updateMetadata(itemMetadata) + return FileProviderItem(metadata: itemMetadata, + domainIdentifier: domainIdentifier, + newestVersionLocallyCached: true) } } } diff --git a/CryptomatorFileProvider/Middleware/TaskExecutor/ItemEnumerationTaskExecutor.swift b/CryptomatorFileProvider/Middleware/TaskExecutor/ItemEnumerationTaskExecutor.swift index db91a48d7..5d4e96be5 100644 --- a/CryptomatorFileProvider/Middleware/TaskExecutor/ItemEnumerationTaskExecutor.swift +++ b/CryptomatorFileProvider/Middleware/TaskExecutor/ItemEnumerationTaskExecutor.swift @@ -37,7 +37,15 @@ class ItemEnumerationTaskExecutor: WorkflowMiddleware { private let provider: CloudProvider private let domainIdentifier: NSFileProviderDomainIdentifier - init(domainIdentifier: NSFileProviderDomainIdentifier, provider: CloudProvider, itemMetadataManager: ItemMetadataManager, cachedFileManager: CachedFileManager, uploadTaskManager: UploadTaskManager, reparentTaskManager: ReparentTaskManager, deletionTaskManager: DeletionTaskManager, itemEnumerationTaskManager: ItemEnumerationTaskManager, deleteItemHelper: DeleteItemHelper) { + init(domainIdentifier: NSFileProviderDomainIdentifier, + provider: CloudProvider, + itemMetadataManager: ItemMetadataManager, + cachedFileManager: CachedFileManager, + uploadTaskManager: UploadTaskManager, + reparentTaskManager: ReparentTaskManager, + deletionTaskManager: DeletionTaskManager, + itemEnumerationTaskManager: ItemEnumerationTaskManager, + deleteItemHelper: DeleteItemHelper) { self.domainIdentifier = domainIdentifier self.provider = provider self.itemMetadataManager = itemMetadataManager diff --git a/CryptomatorFileProvider/Middleware/TaskExecutor/ReparentTaskExecutor.swift b/CryptomatorFileProvider/Middleware/TaskExecutor/ReparentTaskExecutor.swift index 6593c372a..7b85d9a3c 100644 --- a/CryptomatorFileProvider/Middleware/TaskExecutor/ReparentTaskExecutor.swift +++ b/CryptomatorFileProvider/Middleware/TaskExecutor/ReparentTaskExecutor.swift @@ -7,6 +7,7 @@ // import CryptomatorCloudAccessCore +import Dependencies import FileProvider import Foundation import Promises @@ -30,8 +31,13 @@ class ReparentTaskExecutor: WorkflowMiddleware { private let itemMetadataManager: ItemMetadataManager private let cachedFileManager: CachedFileManager private let domainIdentifier: NSFileProviderDomainIdentifier + @Dependency(\.permissionProvider) private var permissionProvider - init(domainIdentifier: NSFileProviderDomainIdentifier, provider: CloudProvider, reparentTaskManager: ReparentTaskManager, itemMetadataManager: ItemMetadataManager, cachedFileManager: CachedFileManager) { + init(domainIdentifier: NSFileProviderDomainIdentifier, + provider: CloudProvider, + reparentTaskManager: ReparentTaskManager, + itemMetadataManager: ItemMetadataManager, + cachedFileManager: CachedFileManager) { self.domainIdentifier = domainIdentifier self.provider = provider self.reparentTaskManager = reparentTaskManager diff --git a/CryptomatorFileProvider/Middleware/TaskExecutor/UploadTaskExecutor.swift b/CryptomatorFileProvider/Middleware/TaskExecutor/UploadTaskExecutor.swift index a670cc153..2fc5abf38 100644 --- a/CryptomatorFileProvider/Middleware/TaskExecutor/UploadTaskExecutor.swift +++ b/CryptomatorFileProvider/Middleware/TaskExecutor/UploadTaskExecutor.swift @@ -8,6 +8,7 @@ import CocoaLumberjackSwift import CryptomatorCloudAccessCore +import Dependencies import FileProvider import Foundation import Promises @@ -32,8 +33,14 @@ class UploadTaskExecutor: WorkflowMiddleware { let uploadTaskManager: UploadTaskManager let domainIdentifier: NSFileProviderDomainIdentifier let progressManager: ProgressManager + @Dependency(\.permissionProvider) private var permissionProvider - init(domainIdentifier: NSFileProviderDomainIdentifier, provider: CloudProvider, cachedFileManager: CachedFileManager, itemMetadataManager: ItemMetadataManager, uploadTaskManager: UploadTaskManager, progressManager: ProgressManager = InMemoryProgressManager.shared) { + init(domainIdentifier: NSFileProviderDomainIdentifier, + provider: CloudProvider, + cachedFileManager: CachedFileManager, + itemMetadataManager: ItemMetadataManager, + uploadTaskManager: UploadTaskManager, + progressManager: ProgressManager = InMemoryProgressManager.shared) { self.domainIdentifier = domainIdentifier self.provider = provider self.cachedFileManager = cachedFileManager diff --git a/CryptomatorFileProvider/PermissionProvider.swift b/CryptomatorFileProvider/PermissionProvider.swift new file mode 100644 index 000000000..bcdbee887 --- /dev/null +++ b/CryptomatorFileProvider/PermissionProvider.swift @@ -0,0 +1,127 @@ +// +// PermissionProvider.swift +// CryptomatorFileProvider +// +// Created by Philipp Schmid on 18.09.23. +// Copyright © 2023 Skymatic GmbH. All rights reserved. +// + +import CocoaLumberjackSwift +import CryptomatorCommonCore +import Dependencies +import FileProvider +import Foundation + +public protocol PermissionProvider { + /** + Returns the permission for a given `item` at a given `domain`. + + The following restrictions can apply to any item: + - in case of an upload error it's only allowed to delete the item. + - in case of a free version only reading is allowed, except if the vault belongs to Cryptomator Hub and it has an active subscription state. + + The following capabilities hold for files: + - reading + - adding sub items + - content enumerating + - deleting + - renaming + - reparenting + + - Note: In case of an running upload, i.e. a creation of the folder in the cloud, the capabilities do not get restricted except if something listed above restricts all items of the vault. + + The following capabilities hold for files: + - reading + - writing + - deleting + - renaming + - reparenting + - Note: In case of an running upload for a file it's only allowed to read the item. To prevent additional modifications. + + */ + func getPermissions(for item: ItemMetadata, at domain: NSFileProviderDomainIdentifier) -> NSFileProviderItemCapabilities + + func getPermissionsForRootItem(at domain: NSFileProviderDomainIdentifier?) -> NSFileProviderItemCapabilities +} + +private enum PermissionProviderKey: DependencyKey { + static let liveValue: PermissionProvider = PermissionProviderImpl() + #if DEBUG + static let testValue: PermissionProvider = UnimplementedPermissionProvider() + #endif +} + +extension DependencyValues { + var permissionProvider: PermissionProvider { + get { self[PermissionProviderKey.self] } + set { self[PermissionProviderKey.self] = newValue } + } +} + +struct PermissionProviderImpl: PermissionProvider { + @Dependency(\.fullVersionChecker) private var fullVersionChecker + @Dependency(\.hubRepository) private var hubRepository + + func getPermissions(for item: ItemMetadata, at domain: NSFileProviderDomainIdentifier) -> NSFileProviderItemCapabilities { + if item.statusCode == .uploadError { + return .allowsDeleting + } + + let vaultID = domain.rawValue + let hubSubscriptionState: HubSubscriptionState? + do { + let hubVault = try hubRepository.getHubVault(vaultID: vaultID) + hubSubscriptionState = hubVault?.subscriptionState + } catch { + hubSubscriptionState = nil + DDLogError("Failed to retrieve possible hub vault for with id: \(vaultID)") + } + + if !fullVersionChecker.isFullVersion && hubSubscriptionState != .active { + return FileProviderItem.readOnlyCapabilities + } + if item.type == .folder { + return [.allowsAddingSubItems, .allowsContentEnumerating, .allowsReading, .allowsDeleting, .allowsRenaming, .allowsReparenting] + } + if item.statusCode == .isUploading { + return FileProviderItem.readOnlyCapabilities + } + return [.allowsWriting, .allowsReading, .allowsDeleting, .allowsRenaming, .allowsReparenting] + } + + func getPermissionsForRootItem(at domain: NSFileProviderDomainIdentifier?) -> NSFileProviderItemCapabilities { + if fullVersionChecker.isFullVersion { + return [.allowsAll] + } + guard let domain else { + return FileProviderItem.readOnlyCapabilities + } + let vaultID = domain.rawValue + let hubSubscriptionState: HubSubscriptionState? + do { + let hubVault = try hubRepository.getHubVault(vaultID: vaultID) + hubSubscriptionState = hubVault?.subscriptionState + } catch { + hubSubscriptionState = nil + DDLogError("Failed to retrieve possible hub vault for with id: \(vaultID)") + } + switch hubSubscriptionState { + case .active: + return [.allowsAll] + case .inactive, nil: + return FileProviderItem.readOnlyCapabilities + } + } +} + +#if DEBUG +struct UnimplementedPermissionProvider: PermissionProvider { + func getPermissions(for item: ItemMetadata, at domain: NSFileProviderDomainIdentifier) -> NSFileProviderItemCapabilities { + unimplemented("\(Self.self).getPermissions", placeholder: .allowsReading) + } + + func getPermissionsForRootItem(at domain: NSFileProviderDomainIdentifier?) -> NSFileProviderItemCapabilities { + unimplemented("\(Self.self).getPermissionsForRootItem", placeholder: .allowsReading) + } +} +#endif diff --git a/CryptomatorFileProvider/RootFileProviderItem.swift b/CryptomatorFileProvider/RootFileProviderItem.swift index c46984e8b..fafb4dafb 100644 --- a/CryptomatorFileProvider/RootFileProviderItem.swift +++ b/CryptomatorFileProvider/RootFileProviderItem.swift @@ -19,12 +19,13 @@ public class RootFileProviderItem: NSObject, NSFileProviderItem { public let typeIdentifier = kUTTypeFolder as String public let documentSize: NSNumber? = nil public var capabilities: NSFileProviderItemCapabilities { - if fullVersionChecker.isFullVersion { - return [.allowsAll] - } else { - return FileProviderItem.readOnlyCapabilities - } + return permissionProvider.getPermissionsForRootItem(at: domain?.identifier) } - @Dependency(\.fullVersionChecker) private var fullVersionChecker + private let domain: NSFileProviderDomain? + @Dependency(\.permissionProvider) private var permissionProvider + + public init(domain: NSFileProviderDomain?) { + self.domain = domain + } } diff --git a/CryptomatorFileProvider/ServiceSource/VaultUnlockingServiceSource.swift b/CryptomatorFileProvider/ServiceSource/VaultUnlockingServiceSource.swift index c5103ac12..c80fd9d83 100644 --- a/CryptomatorFileProvider/ServiceSource/VaultUnlockingServiceSource.swift +++ b/CryptomatorFileProvider/ServiceSource/VaultUnlockingServiceSource.swift @@ -64,4 +64,26 @@ public class VaultUnlockingServiceSource: ServiceSource, VaultUnlocking { DDLogInfo("endBiometricalUnlock called for \(vaultUID)") FileProviderAdapterManager.shared.unlockMonitor.endBiometricalUnlock(forVaultUID: vaultUID) } + + public func unlockVault(rawKey: [UInt8], reply: @escaping (NSError?) -> Void) { + let domain = self.domain + let vaultUID = vaultUID + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + guard let notificator = self.notificator else { + DDLogError("Unlocking vault failed, unable to find FileProviderDomain") + reply(VaultManagerError.fileProviderDomainNotFound as NSError) + return + } + do { + try FileProviderAdapterManager.shared.unlockVault(with: domain.identifier, rawKey: rawKey, dbPath: self.dbPath, delegate: self.localURLProvider, notificator: notificator, taskRegistrator: self.taskRegistrator) + FileProviderAdapterManager.shared.unlockMonitor.unlockSucceeded(forVaultUID: vaultUID) + DDLogInfo("Unlocked vault \"\(domain.displayName)\" (\(domain.identifier.rawValue))") + reply(nil) + } catch { + FileProviderAdapterManager.shared.unlockMonitor.unlockFailed(forVaultUID: vaultUID) + DDLogError("Unlocking vault \"\(domain.displayName)\" (\(domain.identifier.rawValue)) failed with error: \(error)") + reply(XPCErrorHelper.bridgeError(error)) + } + } + } } diff --git a/CryptomatorFileProvider/Workflow/WorkflowFactory.swift b/CryptomatorFileProvider/Workflow/WorkflowFactory.swift index 2ebe1387e..17a92c359 100644 --- a/CryptomatorFileProvider/Workflow/WorkflowFactory.swift +++ b/CryptomatorFileProvider/Workflow/WorkflowFactory.swift @@ -7,6 +7,7 @@ // import CryptomatorCloudAccessCore +import Dependencies import FileProvider import Foundation @@ -21,6 +22,7 @@ struct WorkflowFactory { let downloadTaskManager: DownloadTaskManager let dependencyFactory = WorkflowDependencyFactory() let domainIdentifier: NSFileProviderDomainIdentifier + @Dependency(\.permissionProvider) private var permissionProvider func createWorkflow(for deletionTask: DeletionTask) -> Workflow { let taskExecutor = DeletionTaskExecutor(provider: provider, itemMetadataManager: itemMetadataManager) diff --git a/CryptomatorFileProviderTests/FileProviderAdapter/FileProviderAdapterEnumerateItemTests.swift b/CryptomatorFileProviderTests/FileProviderAdapter/FileProviderAdapterEnumerateItemTests.swift index a7882b36f..ca98e991a 100644 --- a/CryptomatorFileProviderTests/FileProviderAdapter/FileProviderAdapterEnumerateItemTests.swift +++ b/CryptomatorFileProviderTests/FileProviderAdapter/FileProviderAdapterEnumerateItemTests.swift @@ -9,6 +9,7 @@ import CryptomatorCloudAccessCore import XCTest @testable import CryptomatorFileProvider +@testable import Dependencies class FileProviderAdapterEnumerateItemTests: FileProviderAdapterTestCase { override func setUpWithError() throws { @@ -34,6 +35,9 @@ class FileProviderAdapterEnumerateItemTests: FileProviderAdapterTestCase { ItemMetadata(id: 3, name: "TestFolder", type: .file, size: nil, parentID: 4, lastModifiedDate: nil, statusCode: .isUploaded, cloudPath: CloudPath("/Foo/TestFolder"), isPlaceholderItem: false, isCandidateForCacheCleanup: false, favoriteRank: 1, tagData: nil) ] metadataManagerMock.workingSetMetadata = mockMetadata + let permissionProviderMock = PermissionProviderMock() + DependencyValues.mockDependency(\.permissionProvider, with: permissionProviderMock) + permissionProviderMock.getPermissionsForAtReturnValue = .allowsReading let expectation = XCTestExpectation() adapter.enumerateItems(for: .workingSet, withPageToken: nil).then { itemList in XCTAssertEqual(mockMetadata.map { FileProviderItem(metadata: $0, domainIdentifier: .test) }, itemList.items) diff --git a/CryptomatorFileProviderTests/FileProviderAdapter/FileProviderAdapterImportDocumentTests.swift b/CryptomatorFileProviderTests/FileProviderAdapter/FileProviderAdapterImportDocumentTests.swift index 2c83892f7..e2f4fb8cd 100644 --- a/CryptomatorFileProviderTests/FileProviderAdapter/FileProviderAdapterImportDocumentTests.swift +++ b/CryptomatorFileProviderTests/FileProviderAdapter/FileProviderAdapterImportDocumentTests.swift @@ -12,6 +12,7 @@ import Promises import XCTest @testable import CryptomatorCommonCore @testable import CryptomatorFileProvider +@testable import Dependencies class FileProviderAdapterImportDocumentTests: FileProviderAdapterTestCase { let itemID: Int64 = 2 @@ -26,6 +27,9 @@ class FileProviderAdapterImportDocumentTests: FileProviderAdapterTestCase { // MARK: LocalItemImport func testLocalItemImport() throws { + let permissionProviderMock = PermissionProviderMock() + DependencyValues.mockDependency(\.permissionProvider, with: permissionProviderMock) + permissionProviderMock.getPermissionsForAtReturnValue = .allowsReading let fileURL = tmpDirectory.appendingPathComponent("ItemToBeImported.txt", isDirectory: false) let fileContent = "TestContent" try fileContent.write(to: fileURL, atomically: true, encoding: .utf8) diff --git a/CryptomatorFileProviderTests/FileProviderEnumeratorTests.swift b/CryptomatorFileProviderTests/FileProviderEnumeratorTests.swift index 131daf148..912b7bc19 100644 --- a/CryptomatorFileProviderTests/FileProviderEnumeratorTests.swift +++ b/CryptomatorFileProviderTests/FileProviderEnumeratorTests.swift @@ -13,6 +13,7 @@ import Promises import XCTest @testable import CryptomatorCommonCore @testable import CryptomatorFileProvider +@testable import Dependencies class FileProviderEnumeratorTestCase: XCTestCase { var enumerationObserverMock: NSFileProviderEnumerationObserverMock! @@ -50,6 +51,10 @@ class FileProviderEnumeratorTestCase: XCTestCase { } func assertChangeObserverUpdated(deletedItems: [NSFileProviderItemIdentifier], updatedItems: [FileProviderItem], currentSyncAnchor: NSFileProviderSyncAnchor) { + let permissionProviderMock = PermissionProviderMock() + DependencyValues.mockDependency(\.permissionProvider, with: permissionProviderMock) + permissionProviderMock.getPermissionsForAtReturnValue = .allowsReading + XCTAssertEqual([deletedItems], changeObserverMock.didDeleteItemsWithIdentifiersReceivedInvocations) let receivedUpdatedItems = changeObserverMock.didUpdateReceivedInvocations as? [[FileProviderItem]] XCTAssertEqual([updatedItems], receivedUpdatedItems) @@ -179,6 +184,10 @@ class FileProviderEnumeratorTests: FileProviderEnumeratorTestCase { } private func assertEnumerateItemObserverSucceeded(itemList: FileProviderItemList) { + let permissionProviderMock = PermissionProviderMock() + DependencyValues.mockDependency(\.permissionProvider, with: permissionProviderMock) + permissionProviderMock.getPermissionsForAtReturnValue = .allowsReading + XCTAssertEqual([itemList.nextPageToken], enumerationObserverMock.finishEnumeratingUpToReceivedInvocations) let receivedInvocations = enumerationObserverMock.didEnumerateReceivedInvocations as? [[FileProviderItem]] XCTAssertEqual([items], receivedInvocations) diff --git a/CryptomatorFileProviderTests/FileProviderItemTests.swift b/CryptomatorFileProviderTests/FileProviderItemTests.swift index 1c4af1510..e8829ef6c 100644 --- a/CryptomatorFileProviderTests/FileProviderItemTests.swift +++ b/CryptomatorFileProviderTests/FileProviderItemTests.swift @@ -108,59 +108,19 @@ class FileProviderItemTests: XCTestCase { // MARK: Capabilities - func testUploadingItemRestrictsCapabilityToRead() { - let fullVersionCheckerMock = FullVersionCheckerMock() - fullVersionCheckerMock.isFullVersion = true - DependencyValues.mockDependency(\.fullVersionChecker, with: fullVersionCheckerMock) + func testCapabilitiesArePassedThroughFromPermissionProvider() { + let permissionProviderMock = PermissionProviderMock() + DependencyValues.mockDependency(\.permissionProvider, with: permissionProviderMock) let cloudPath = CloudPath("/test.txt") let metadata = ItemMetadata(id: 2, name: "test.txt", type: .file, size: 100, parentID: NSFileProviderItemIdentifier.rootContainerDatabaseValue, lastModifiedDate: nil, statusCode: .isUploading, cloudPath: cloudPath, isPlaceholderItem: false) let item = FileProviderItem(metadata: metadata, domainIdentifier: .test) - XCTAssertEqual(NSFileProviderItemCapabilities.allowsReading, item.capabilities) - } - - func testUploadingFolderDoesNotRestrictCapabilities() { - let fullVersionCheckerMock = FullVersionCheckerMock() - fullVersionCheckerMock.isFullVersion = true - DependencyValues.mockDependency(\.fullVersionChecker, with: fullVersionCheckerMock) - - let cloudPath = CloudPath("/test") - let metadata = ItemMetadata(id: 2, name: "test", type: .folder, size: nil, parentID: NSFileProviderItemIdentifier.rootContainerDatabaseValue, lastModifiedDate: nil, statusCode: .isUploading, cloudPath: cloudPath, isPlaceholderItem: false) - let item = FileProviderItem(metadata: metadata, domainIdentifier: .test) - XCTAssertEqual([.allowsAddingSubItems, .allowsContentEnumerating, .allowsReading, .allowsDeleting, .allowsRenaming, .allowsReparenting], item.capabilities) - } - - func testCapabilitiesForRestrictedVersion() { - let fullVersionCheckerMock = FullVersionCheckerMock() - fullVersionCheckerMock.isFullVersion = false - DependencyValues.mockDependency(\.fullVersionChecker, with: fullVersionCheckerMock) - - let cloudPath = CloudPath("/test.txt") - let metadata = ItemMetadata(id: 2, name: "test.txt", type: .file, size: 100, parentID: NSFileProviderItemIdentifier.rootContainerDatabaseValue, lastModifiedDate: nil, statusCode: .isUploaded, cloudPath: cloudPath, isPlaceholderItem: false) - let item = FileProviderItem(metadata: metadata, domainIdentifier: .test) - XCTAssertEqual(NSFileProviderItemCapabilities.allowsReading, item.capabilities) - } - - func testFailedUploadItemCapabilitiesForRestrictedVersion() { - let fullVersionCheckerMock = FullVersionCheckerMock() - fullVersionCheckerMock.isFullVersion = false - DependencyValues.mockDependency(\.fullVersionChecker, with: fullVersionCheckerMock) - let cloudPath = CloudPath("/test.txt") - let metadata = ItemMetadata(id: 2, name: "test.txt", type: .file, size: 100, parentID: NSFileProviderItemIdentifier.rootContainerDatabaseValue, lastModifiedDate: nil, statusCode: .uploadError, cloudPath: cloudPath, isPlaceholderItem: false) - let item = FileProviderItem(metadata: metadata, domainIdentifier: .test) - XCTAssertEqual(NSFileProviderItemCapabilities.allowsDeleting, item.capabilities) - } - - func testFailedUploadFolderCapabilitiesForRestrictedVersion() { - let fullVersionCheckerMock = FullVersionCheckerMock() - fullVersionCheckerMock.isFullVersion = false - DependencyValues.mockDependency(\.fullVersionChecker, with: fullVersionCheckerMock) - - let cloudPath = CloudPath("/test") - let metadata = ItemMetadata(id: 2, name: "test", type: .folder, size: 100, parentID: NSFileProviderItemIdentifier.rootContainerDatabaseValue, lastModifiedDate: nil, statusCode: .uploadError, cloudPath: cloudPath, isPlaceholderItem: false) - let item = FileProviderItem(metadata: metadata, domainIdentifier: .test) - XCTAssertEqual(NSFileProviderItemCapabilities.allowsDeleting, item.capabilities) + let capabilities: [NSFileProviderItemCapabilities] = [.allowsAddingSubItems, .allowsContentEnumerating, .allowsDeleting, .allowsReading, .allowsReparenting, .allowsWriting] + for capability in capabilities { + permissionProviderMock.getPermissionsForAtReturnValue = capability + XCTAssertEqual(capability, item.capabilities) + } } // MARK: Evict File From Cache Action diff --git a/CryptomatorFileProviderTests/FileProviderNotificatorTests.swift b/CryptomatorFileProviderTests/FileProviderNotificatorTests.swift index 650d54507..4e54026a2 100644 --- a/CryptomatorFileProviderTests/FileProviderNotificatorTests.swift +++ b/CryptomatorFileProviderTests/FileProviderNotificatorTests.swift @@ -9,6 +9,7 @@ import CryptomatorCloudAccessCore import XCTest @testable import CryptomatorFileProvider +@testable import Dependencies @available(iOS 14.0, *) class FileProviderNotificatorTests: XCTestCase { @@ -97,6 +98,11 @@ class FileProviderNotificatorTests: XCTestCase { }) let actualItems = notificator.popUpdateContainerItems() as? [FileProviderItem] + + let permissionProviderMock = PermissionProviderMock() + DependencyValues.mockDependency(\.permissionProvider, with: permissionProviderMock) + permissionProviderMock.getPermissionsForAtReturnValue = .allowsReading + XCTAssertEqual([updatedItem], actualItems?.sorted()) XCTAssert(notificator.popUpdateWorkingSetItems().isEmpty) XCTAssert(notificator.getItemIdentifiersToDeleteFromWorkingSet().isEmpty) @@ -109,6 +115,9 @@ class FileProviderNotificatorTests: XCTestCase { } private func assertUpdateWorkingSetHasUpdatedItems() { + let permissionProviderMock = PermissionProviderMock() + DependencyValues.mockDependency(\.permissionProvider, with: permissionProviderMock) + permissionProviderMock.getPermissionsForAtReturnValue = .allowsReading let actualItems = notificator.popUpdateWorkingSetItems() as? [FileProviderItem] XCTAssertEqual(updatedItems.sorted(), actualItems?.sorted()) } diff --git a/CryptomatorFileProviderTests/Middleware/TaskExecutor/ItemEnumerationTaskTests.swift b/CryptomatorFileProviderTests/Middleware/TaskExecutor/ItemEnumerationTaskTests.swift index 05dc2be97..1c4ca9b14 100644 --- a/CryptomatorFileProviderTests/Middleware/TaskExecutor/ItemEnumerationTaskTests.swift +++ b/CryptomatorFileProviderTests/Middleware/TaskExecutor/ItemEnumerationTaskTests.swift @@ -10,6 +10,7 @@ import CryptomatorCloudAccessCore import Promises import XCTest @testable import CryptomatorFileProvider +@testable import Dependencies class ItemEnumerationTaskTests: CloudTaskExecutorTestCase { override func setUpWithError() throws { @@ -201,6 +202,7 @@ class ItemEnumerationTaskTests: CloudTaskExecutorTestCase { // MARK: Folder + // swiftlint:disable:next function_body_length func testFolderEnumeration() throws { let expectation = XCTestExpectation(description: "Folder Enumeration") @@ -222,6 +224,9 @@ class ItemEnumerationTaskTests: CloudTaskExecutorTestCase { let expectedSubFolderFileProviderItems = expectedItemMetadataInsideSubFolder.map { FileProviderItem(metadata: $0, domainIdentifier: .test) } let taskExecutor = ItemEnumerationTaskExecutor(domainIdentifier: .test, provider: cloudProviderMock, itemMetadataManager: metadataManagerMock, cachedFileManager: cachedFileManagerMock, uploadTaskManager: uploadTaskManagerMock, reparentTaskManager: reparentTaskManagerMock, deletionTaskManager: deletionTaskManagerMock, itemEnumerationTaskManager: itemEnumerationTaskManagerMock, deleteItemHelper: deleteItemHelper) + let permissionProviderMock = PermissionProviderMock() + DependencyValues.mockDependency(\.permissionProvider, with: permissionProviderMock) + permissionProviderMock.getPermissionsForAtReturnValue = .allowsReading taskExecutor.execute(task: enumerationTask).then { fileProviderItemList -> FileProviderItem in XCTAssertEqual(5, fileProviderItemList.items.count) @@ -283,6 +288,10 @@ class ItemEnumerationTaskTests: CloudTaskExecutorTestCase { FileProviderItem(metadata: ItemMetadata(id: 6, name: "File 4", type: .file, size: 14, parentID: NSFileProviderItemIdentifier.rootContainerDatabaseValue, lastModifiedDate: nil, statusCode: .isUploaded, cloudPath: CloudPath("/File 4"), isPlaceholderItem: false), domainIdentifier: .test), FileProviderItem(metadata: ItemMetadata(id: 7, name: "NewFileFromCloud", type: .file, size: 24, parentID: NSFileProviderItemIdentifier.rootContainerDatabaseValue, lastModifiedDate: nil, statusCode: .isUploaded, cloudPath: CloudPath("/NewFileFromCloud"), isPlaceholderItem: false), domainIdentifier: .test)] + let permissionProviderMock = PermissionProviderMock() + DependencyValues.mockDependency(\.permissionProvider, with: permissionProviderMock) + permissionProviderMock.getPermissionsForAtReturnValue = .allowsReading + let taskExecutor = ItemEnumerationTaskExecutor(domainIdentifier: .test, provider: cloudProviderMock, itemMetadataManager: metadataManagerMock, cachedFileManager: cachedFileManagerMock, uploadTaskManager: uploadTaskManagerMock, reparentTaskManager: reparentTaskManagerMock, deletionTaskManager: deletionTaskManagerMock, itemEnumerationTaskManager: itemEnumerationTaskManagerMock, deleteItemHelper: deleteItemHelper) taskExecutor.execute(task: enumerationTask).then { fileProviderItemList -> Promise in diff --git a/CryptomatorFileProviderTests/Mocks/PermissionProviderMock.swift b/CryptomatorFileProviderTests/Mocks/PermissionProviderMock.swift new file mode 100644 index 000000000..7571ceee7 --- /dev/null +++ b/CryptomatorFileProviderTests/Mocks/PermissionProviderMock.swift @@ -0,0 +1,51 @@ +// +// PermissionProviderMock.swift +// CryptomatorFileProviderTests +// +// Created by Philipp Schmid on 19.09.23. +// Copyright © 2023 Skymatic GmbH. All rights reserved. +// + +import CryptomatorFileProvider +import FileProvider +import Foundation + +final class PermissionProviderMock: PermissionProvider { + // MARK: - getPermissions + + var getPermissionsForAtCallsCount = 0 + var getPermissionsForAtCalled: Bool { + getPermissionsForAtCallsCount > 0 + } + + var getPermissionsForAtReceivedArguments: (item: ItemMetadata, domain: NSFileProviderDomainIdentifier)? + var getPermissionsForAtReceivedInvocations: [(item: ItemMetadata, domain: NSFileProviderDomainIdentifier)] = [] + var getPermissionsForAtReturnValue: NSFileProviderItemCapabilities! + var getPermissionsForAtClosure: ((ItemMetadata, NSFileProviderDomainIdentifier) -> NSFileProviderItemCapabilities)? + + func getPermissions(for item: ItemMetadata, at domain: NSFileProviderDomainIdentifier) -> NSFileProviderItemCapabilities { + getPermissionsForAtCallsCount += 1 + getPermissionsForAtReceivedArguments = (item: item, domain: domain) + getPermissionsForAtReceivedInvocations.append((item: item, domain: domain)) + return getPermissionsForAtClosure.map({ $0(item, domain) }) ?? getPermissionsForAtReturnValue + } + + // MARK: - getPermissionsForRootItem + + var getPermissionsForRootItemAtCallsCount = 0 + var getPermissionsForRootItemAtCalled: Bool { + getPermissionsForRootItemAtCallsCount > 0 + } + + var getPermissionsForRootItemAtReceivedDomain: NSFileProviderDomainIdentifier? + var getPermissionsForRootItemAtReceivedInvocations: [NSFileProviderDomainIdentifier?] = [] + var getPermissionsForRootItemAtReturnValue: NSFileProviderItemCapabilities! + var getPermissionsForRootItemAtClosure: ((NSFileProviderDomainIdentifier?) -> NSFileProviderItemCapabilities)? + + func getPermissionsForRootItem(at domain: NSFileProviderDomainIdentifier?) -> NSFileProviderItemCapabilities { + getPermissionsForRootItemAtCallsCount += 1 + getPermissionsForRootItemAtReceivedDomain = domain + getPermissionsForRootItemAtReceivedInvocations.append(domain) + return getPermissionsForRootItemAtClosure.map({ $0(domain) }) ?? getPermissionsForRootItemAtReturnValue + } +} diff --git a/CryptomatorFileProviderTests/PermissionProviderImplTests.swift b/CryptomatorFileProviderTests/PermissionProviderImplTests.swift new file mode 100644 index 000000000..67fdb5a8e --- /dev/null +++ b/CryptomatorFileProviderTests/PermissionProviderImplTests.swift @@ -0,0 +1,137 @@ +// +// PermissionProviderImplTests.swift +// CryptomatorFileProviderTests +// +// Created by Philipp Schmid on 19.09.23. +// Copyright © 2023 Skymatic GmbH. All rights reserved. +// + +import CryptomatorCloudAccessCore +import XCTest +@testable import CryptomatorCommonCore +@testable import CryptomatorFileProvider +@testable import Dependencies + +final class PermissionProviderImplTests: XCTestCase { + private static let defaultFolderCapabilities: NSFileProviderItemCapabilities = [.allowsAddingSubItems, .allowsContentEnumerating, .allowsReading, .allowsDeleting, .allowsRenaming, .allowsReparenting] + private var fullVersionCheckerMock: FullVersionCheckerMock! + private var hubRepositoryMock: HubRepositoryMock! + private var permissionProvider: PermissionProviderImpl! + + override func setUpWithError() throws { + fullVersionCheckerMock = FullVersionCheckerMock() + hubRepositoryMock = HubRepositoryMock() + DependencyValues.mockDependency(\.hubRepository, with: hubRepositoryMock) + DependencyValues.mockDependency(\.fullVersionChecker, with: fullVersionCheckerMock) + permissionProvider = PermissionProviderImpl() + } + + // MARK: Full Version + + func testUploadingItemRestrictsCapabilityToRead() { + fullVersionCheckerMock.isFullVersion = true + + let cloudPath = CloudPath("/test.txt") + let metadata = ItemMetadata(id: 2, name: "test.txt", type: .file, size: 100, parentID: NSFileProviderItemIdentifier.rootContainerDatabaseValue, lastModifiedDate: nil, statusCode: .isUploading, cloudPath: cloudPath, isPlaceholderItem: false) + let actualCapabilities = permissionProvider.getPermissions(for: metadata, at: .test) + XCTAssertEqual(NSFileProviderItemCapabilities.allowsReading, actualCapabilities) + } + + func testUploadingFolderDoesNotRestrictCapabilities() { + fullVersionCheckerMock.isFullVersion = true + + let cloudPath = CloudPath("/test") + let metadata = ItemMetadata(id: 2, name: "test", type: .folder, size: nil, parentID: NSFileProviderItemIdentifier.rootContainerDatabaseValue, lastModifiedDate: nil, statusCode: .isUploading, cloudPath: cloudPath, isPlaceholderItem: false) + let actualCapabilities = permissionProvider.getPermissions(for: metadata, at: .test) + XCTAssertEqual(Self.defaultFolderCapabilities, actualCapabilities) + } + + func testCapabilitiesForRestrictedVersion() { + fullVersionCheckerMock.isFullVersion = false + + let cloudPath = CloudPath("/test.txt") + let metadata = ItemMetadata(id: 2, name: "test.txt", type: .file, size: 100, parentID: NSFileProviderItemIdentifier.rootContainerDatabaseValue, lastModifiedDate: nil, statusCode: .isUploaded, cloudPath: cloudPath, isPlaceholderItem: false) + let actualCapabilities = permissionProvider.getPermissions(for: metadata, at: .test) + XCTAssertEqual(NSFileProviderItemCapabilities.allowsReading, actualCapabilities) + } + + func testFailedUploadItemCapabilitiesForRestrictedVersion() { + fullVersionCheckerMock.isFullVersion = false + + let cloudPath = CloudPath("/test.txt") + let metadata = ItemMetadata(id: 2, name: "test.txt", type: .file, size: 100, parentID: NSFileProviderItemIdentifier.rootContainerDatabaseValue, lastModifiedDate: nil, statusCode: .uploadError, cloudPath: cloudPath, isPlaceholderItem: false) + let actualCapabilities = permissionProvider.getPermissions(for: metadata, at: .test) + XCTAssertEqual(NSFileProviderItemCapabilities.allowsDeleting, actualCapabilities) + } + + func testFailedUploadFolderCapabilitiesForRestrictedVersion() { + fullVersionCheckerMock.isFullVersion = false + + let cloudPath = CloudPath("/test") + let metadata = ItemMetadata(id: 2, name: "test", type: .folder, size: 100, parentID: NSFileProviderItemIdentifier.rootContainerDatabaseValue, lastModifiedDate: nil, statusCode: .uploadError, cloudPath: cloudPath, isPlaceholderItem: false) + let actualCapabilities = permissionProvider.getPermissions(for: metadata, at: .test) + XCTAssertEqual(NSFileProviderItemCapabilities.allowsDeleting, actualCapabilities) + } + + func testFullVersionNoActiveHubScriptionReturnsFullPermissionsForFile() { + fullVersionCheckerMock.isFullVersion = true + hubRepositoryMock.getHubVaultVaultIDReturnValue = .init(vaultUID: "12345", subscriptionState: .inactive) + + let cloudPath = CloudPath("/test.txt") + let metadata = ItemMetadata(id: 2, name: "test.txt", type: .file, size: 100, parentID: NSFileProviderItemIdentifier.rootContainerDatabaseValue, lastModifiedDate: nil, statusCode: .isUploaded, cloudPath: cloudPath, isPlaceholderItem: false) + let actualCapabilities = permissionProvider.getPermissions(for: metadata, at: .test) + XCTAssertEqual([.allowsWriting, .allowsReading, .allowsDeleting, .allowsRenaming, .allowsReparenting], actualCapabilities) + } + + // MARK: Cryptomator Hub + + func testUploadingItemRestrictsCapabilityToReadWithActiveHubSubscription() { + fullVersionCheckerMock.isFullVersion = false + hubRepositoryMock.getHubVaultVaultIDReturnValue = .init(vaultUID: "12345", subscriptionState: .active) + + let cloudPath = CloudPath("/test.txt") + let metadata = ItemMetadata(id: 2, name: "test.txt", type: .file, size: 100, parentID: NSFileProviderItemIdentifier.rootContainerDatabaseValue, lastModifiedDate: nil, statusCode: .isUploading, cloudPath: cloudPath, isPlaceholderItem: false) + let actualCapabilities = permissionProvider.getPermissions(for: metadata, at: .test) + XCTAssertEqual(NSFileProviderItemCapabilities.allowsReading, actualCapabilities) + } + + func testNoFullVersionNoActiveHubSubscriptionRestrictsToReadOnly() { + fullVersionCheckerMock.isFullVersion = false + hubRepositoryMock.getHubVaultVaultIDReturnValue = .init(vaultUID: "12345", subscriptionState: .inactive) + + let cloudPath = CloudPath("/test.txt") + let metadata = ItemMetadata(id: 2, name: "test.txt", type: .file, size: 100, parentID: NSFileProviderItemIdentifier.rootContainerDatabaseValue, lastModifiedDate: nil, statusCode: .isUploaded, cloudPath: cloudPath, isPlaceholderItem: false) + let actualCapabilities = permissionProvider.getPermissions(for: metadata, at: .test) + XCTAssertEqual(NSFileProviderItemCapabilities.allowsReading, actualCapabilities) + } + + func testFolderCapabilitiesNoFullVersionActiveHubSubscription() { + fullVersionCheckerMock.isFullVersion = false + hubRepositoryMock.getHubVaultVaultIDReturnValue = .init(vaultUID: "12345", subscriptionState: .active) + + let cloudPath = CloudPath("/test.txt") + let metadata = ItemMetadata(id: 2, name: "test.txt", type: .folder, size: 100, parentID: NSFileProviderItemIdentifier.rootContainerDatabaseValue, lastModifiedDate: nil, statusCode: .isUploaded, cloudPath: cloudPath, isPlaceholderItem: false) + let actualCapabilities = permissionProvider.getPermissions(for: metadata, at: .test) + XCTAssertEqual(Self.defaultFolderCapabilities, actualCapabilities) + } + + func testUploadingFolderDoesNotRestrictCapabilitiesForActiveHubSubsription() { + fullVersionCheckerMock.isFullVersion = false + hubRepositoryMock.getHubVaultVaultIDReturnValue = .init(vaultUID: "12345", subscriptionState: .active) + + let cloudPath = CloudPath("/test") + let metadata = ItemMetadata(id: 2, name: "test", type: .folder, size: nil, parentID: NSFileProviderItemIdentifier.rootContainerDatabaseValue, lastModifiedDate: nil, statusCode: .isUploading, cloudPath: cloudPath, isPlaceholderItem: false) + let actualCapabilities = permissionProvider.getPermissions(for: metadata, at: .test) + XCTAssertEqual(Self.defaultFolderCapabilities, actualCapabilities) + } + + func testNoFullVersionActiveHubScriptionReturnsFullPermissionsForFile() { + fullVersionCheckerMock.isFullVersion = false + hubRepositoryMock.getHubVaultVaultIDReturnValue = .init(vaultUID: "12345", subscriptionState: .active) + + let cloudPath = CloudPath("/test.txt") + let metadata = ItemMetadata(id: 2, name: "test.txt", type: .file, size: 100, parentID: NSFileProviderItemIdentifier.rootContainerDatabaseValue, lastModifiedDate: nil, statusCode: .isUploaded, cloudPath: cloudPath, isPlaceholderItem: false) + let actualCapabilities = permissionProvider.getPermissions(for: metadata, at: .test) + XCTAssertEqual([.allowsWriting, .allowsReading, .allowsDeleting, .allowsRenaming, .allowsReparenting], actualCapabilities) + } +} diff --git a/CryptomatorFileProviderTests/ServiceSource/CacheManagingServiceSourceTests.swift b/CryptomatorFileProviderTests/ServiceSource/CacheManagingServiceSourceTests.swift index 2772c4de2..eee1b96de 100644 --- a/CryptomatorFileProviderTests/ServiceSource/CacheManagingServiceSourceTests.swift +++ b/CryptomatorFileProviderTests/ServiceSource/CacheManagingServiceSourceTests.swift @@ -11,6 +11,7 @@ import Promises import XCTest @testable import CryptomatorCommonCore @testable import CryptomatorFileProvider +@testable import Dependencies class CacheManagingServiceSourceTests: XCTestCase { var serviceSource: CacheManagingServiceSource! @@ -57,6 +58,9 @@ class CacheManagingServiceSourceTests: XCTestCase { let expectation = XCTestExpectation() let cacheManagerMock = CachedFileManagerMock() cacheManagerFactoryMock.createCachedFileManagerForReturnValue = cacheManagerMock + let permissionProviderMock = PermissionProviderMock() + DependencyValues.mockDependency(\.permissionProvider, with: permissionProviderMock) + permissionProviderMock.getPermissionsForAtReturnValue = .allowsReading let domainIdentifier = NSFileProviderDomainIdentifier("Test-Domain") let itemID: Int64 = 2 let itemIdentifier = NSFileProviderItemIdentifier(domainIdentifier: domainIdentifier, itemID: itemID) diff --git a/CryptomatorFileProviderTests/WorkingSetObserverTests.swift b/CryptomatorFileProviderTests/WorkingSetObserverTests.swift index 034a0bca1..728b31357 100644 --- a/CryptomatorFileProviderTests/WorkingSetObserverTests.swift +++ b/CryptomatorFileProviderTests/WorkingSetObserverTests.swift @@ -10,6 +10,7 @@ import CryptomatorCloudAccessCore import GRDB import XCTest @testable import CryptomatorFileProvider +@testable import Dependencies class WorkingSetObserverTests: XCTestCase { var observer: WorkingSetObserver! @@ -31,6 +32,9 @@ class WorkingSetObserverTests: XCTestCase { XCTAssertEqual(1, notificatorMock.updateWorkingSetItemsCallsCount) let actualUpdatedItems = notificatorMock.updateWorkingSetItemsReceivedItems as? [FileProviderItem] + let permissionProviderMock = PermissionProviderMock() + DependencyValues.mockDependency(\.permissionProvider, with: permissionProviderMock) + permissionProviderMock.getPermissionsForAtReturnValue = .allowsReading XCTAssertEqual(updatedItems.sorted(), actualUpdatedItems?.sorted()) XCTAssertEqual(1, notificatorMock.refreshWorkingSetCallsCount) } diff --git a/CryptomatorIntents/GetFolderIntentHandler.swift b/CryptomatorIntents/GetFolderIntentHandler.swift index 0b822f0b5..14653985f 100644 --- a/CryptomatorIntents/GetFolderIntentHandler.swift +++ b/CryptomatorIntents/GetFolderIntentHandler.swift @@ -9,12 +9,14 @@ import CocoaLumberjackSwift import CryptomatorCloudAccessCore import CryptomatorCommonCore +import Dependencies import Foundation import Intents import Promises class GetFolderIntentHandler: NSObject, GetFolderIntentHandling { let vaultOptionsProvider: VaultOptionsProvider + @Dependency(\.fileProviderConnector) private var fileProviderConnector init(vaultOptionsProvider: VaultOptionsProvider) { self.vaultOptionsProvider = vaultOptionsProvider @@ -69,7 +71,7 @@ class GetFolderIntentHandler: NSObject, GetFolderIntentHandling { // MARK: Internal private func getIdentifierForFolder(at cloudPath: CloudPath, domainIdentifier: NSFileProviderDomainIdentifier) async throws -> String { - let getXPCPromise: Promise> = FileProviderXPCConnector.shared.getXPC(serviceName: .fileImporting, domainIdentifier: domainIdentifier) + let getXPCPromise: Promise> = fileProviderConnector.getXPC(serviceName: .fileImporting, domainIdentifier: domainIdentifier) return try await withCheckedThrowingContinuation({ continuation in getXPCPromise.then { xpc in xpc.proxy.getIdentifierForItem(at: cloudPath.path) @@ -77,8 +79,8 @@ class GetFolderIntentHandler: NSObject, GetFolderIntentHandling { continuation.resume(returning: $0 as String) }.catch { continuation.resume(throwing: $0) - }.always { - FileProviderXPCConnector.shared.invalidateXPC(getXPCPromise) + }.always { [fileProviderConnector] in + fileProviderConnector.invalidateXPC(getXPCPromise) } }) } diff --git a/CryptomatorIntents/IsVaultUnlockedIntentHandler.swift b/CryptomatorIntents/IsVaultUnlockedIntentHandler.swift index 0e21a8c3a..dd5aa81f2 100644 --- a/CryptomatorIntents/IsVaultUnlockedIntentHandler.swift +++ b/CryptomatorIntents/IsVaultUnlockedIntentHandler.swift @@ -7,6 +7,7 @@ // import CryptomatorCommonCore +import Dependencies import FileProvider import Foundation import Intents @@ -14,6 +15,7 @@ import Promises class IsVaultUnlockedIntentHandler: NSObject, IsVaultUnlockedIntentHandling { let vaultOptionsProvider: VaultOptionsProvider + @Dependency(\.fileProviderConnector) private var fileProviderConnector init(vaultOptionsProvider: VaultOptionsProvider) { self.vaultOptionsProvider = vaultOptionsProvider @@ -46,7 +48,7 @@ class IsVaultUnlockedIntentHandler: NSObject, IsVaultUnlockedIntentHandling { // MARK: Internal private func getIsUnlockedVault(domainIdentifier: NSFileProviderDomainIdentifier) async throws -> Bool { - let getXPCPromise: Promise> = FileProviderXPCConnector.shared.getXPC(serviceName: .vaultLocking, domainIdentifier: domainIdentifier) + let getXPCPromise: Promise> = fileProviderConnector.getXPC(serviceName: .vaultLocking, domainIdentifier: domainIdentifier) return try await withCheckedThrowingContinuation({ continuation in getXPCPromise.then { xpc in xpc.proxy.getIsUnlockedVault(domainIdentifier: domainIdentifier) @@ -54,8 +56,8 @@ class IsVaultUnlockedIntentHandler: NSObject, IsVaultUnlockedIntentHandling { continuation.resume(returning: $0) }.catch { continuation.resume(throwing: $0) - }.always { - FileProviderXPCConnector.shared.invalidateXPC(getXPCPromise) + }.always { [fileProviderConnector] in + fileProviderConnector.invalidateXPC(getXPCPromise) } }) } diff --git a/CryptomatorIntents/LockVaultIntentHandler.swift b/CryptomatorIntents/LockVaultIntentHandler.swift index aea7e3ead..a3065ce08 100644 --- a/CryptomatorIntents/LockVaultIntentHandler.swift +++ b/CryptomatorIntents/LockVaultIntentHandler.swift @@ -8,12 +8,14 @@ import CocoaLumberjackSwift import CryptomatorCommonCore +import Dependencies import Foundation import Intents import Promises class LockVaultIntentHandler: NSObject, LockVaultIntentHandling { let vaultOptionsProvider: VaultOptionsProvider + @Dependency(\.fileProviderConnector) private var fileProviderConnector init(vaultOptionsProvider: VaultOptionsProvider) { self.vaultOptionsProvider = vaultOptionsProvider @@ -45,7 +47,7 @@ class LockVaultIntentHandler: NSObject, LockVaultIntentHandling { // MARK: Internal private func lockVault(with domainIdentifier: NSFileProviderDomainIdentifier) async throws { - let getXPCPromise: Promise> = FileProviderXPCConnector.shared.getXPC(serviceName: .vaultLocking, domainIdentifier: domainIdentifier) + let getXPCPromise: Promise> = fileProviderConnector.getXPC(serviceName: .vaultLocking, domainIdentifier: domainIdentifier) return try await withCheckedThrowingContinuation({ continuation in getXPCPromise.then { xpc in xpc.proxy.gracefulLockVault(domainIdentifier: domainIdentifier) @@ -53,8 +55,8 @@ class LockVaultIntentHandler: NSObject, LockVaultIntentHandling { continuation.resume(returning: ()) }.catch { continuation.resume(throwing: $0) - }.always { - FileProviderXPCConnector.shared.invalidateXPC(getXPCPromise) + }.always { [fileProviderConnector] in + fileProviderConnector.invalidateXPC(getXPCPromise) } }) } diff --git a/CryptomatorIntents/SaveFileIntentHandler.swift b/CryptomatorIntents/SaveFileIntentHandler.swift index 400f1c064..00dafa1d9 100644 --- a/CryptomatorIntents/SaveFileIntentHandler.swift +++ b/CryptomatorIntents/SaveFileIntentHandler.swift @@ -9,12 +9,15 @@ import CocoaLumberjackSwift import CryptomatorCloudAccessCore import CryptomatorCommonCore +import Dependencies import FileProvider import Foundation import Intents import Promises class SaveFileIntentHandler: NSObject, SaveFileIntentHandling { + @Dependency(\.fileProviderConnector) private var fileProviderConnector + func handle(intent: SaveFileIntent) async -> SaveFileIntentResponse { guard let vaultFolder = intent.folder, let vaultIdentifier = vaultFolder.vaultIdentifier, let folderIdentifier = vaultFolder.identifier else { return SaveFileIntentResponse(failureReason: LocalizedString.getValue("intents.saveFile.invalidFolder")) @@ -85,7 +88,7 @@ class SaveFileIntentHandler: NSObject, SaveFileIntentHandling { } private func importFile(at localURL: URL, toParentItemIdentifier parentItemIdentifier: String, domainIdentifier: NSFileProviderDomainIdentifier) async throws { - let getXPCPromise: Promise> = FileProviderXPCConnector.shared.getXPC(serviceName: .fileImporting, domainIdentifier: domainIdentifier) + let getXPCPromise: Promise> = fileProviderConnector.getXPC(serviceName: .fileImporting, domainIdentifier: domainIdentifier) try await withCheckedThrowingContinuation({ continuation in getXPCPromise.then { xpc in xpc.proxy.importFile(at: localURL, toParentItemIdentifier: parentItemIdentifier) @@ -93,8 +96,8 @@ class SaveFileIntentHandler: NSObject, SaveFileIntentHandling { continuation.resume() }.catch { continuation.resume(throwing: $0) - }.always { - FileProviderXPCConnector.shared.invalidateXPC(getXPCPromise) + }.always { [fileProviderConnector] in + fileProviderConnector.invalidateXPC(getXPCPromise) } }) } diff --git a/CryptomatorTests/ChangePasswordViewModelTests.swift b/CryptomatorTests/ChangePasswordViewModelTests.swift index bf12f6add..0210d8267 100644 --- a/CryptomatorTests/ChangePasswordViewModelTests.swift +++ b/CryptomatorTests/ChangePasswordViewModelTests.swift @@ -14,6 +14,7 @@ import Promises import XCTest @testable import Cryptomator @testable import CryptomatorCommonCore +@testable import Dependencies class ChangePasswordViewModelTests: XCTestCase { private var vaultManagerMock: VaultManagerMock! @@ -27,7 +28,8 @@ class ChangePasswordViewModelTests: XCTestCase { setupMocks() vaultAccount = VaultAccount(vaultUID: UUID().uuidString, delegateAccountUID: UUID().uuidString, vaultPath: CloudPath("/Foo/Bar"), vaultName: "Bar") let domain = NSFileProviderDomain(vaultUID: vaultAccount.vaultUID, displayName: vaultAccount.vaultName) - viewModel = ChangePasswordViewModel(vaultAccount: vaultAccount, domain: domain, vaultManager: vaultManagerMock, fileProviderConnector: fileProviderConnectorMock) + DependencyValues.mockDependency(\.fileProviderConnector, with: fileProviderConnectorMock) + viewModel = ChangePasswordViewModel(vaultAccount: vaultAccount, domain: domain, vaultManager: vaultManagerMock) } private func setupMocks() { @@ -70,7 +72,7 @@ class ChangePasswordViewModelTests: XCTestCase { try await viewModel.changePassword() - wait(for: [maintenanceModeEnabled, maintenanceModeDisabled], timeout: 1.0, enforceOrder: true) + await fulfillment(of: [maintenanceModeEnabled, maintenanceModeDisabled], timeout: 1.0, enforceOrder: true) XCTAssertEqual(1, vaultManagerMock.changePassphraseOldPassphraseNewPassphraseForVaultUIDCallsCount) XCTAssertEqual(oldPassword, vaultManagerMock.changePassphraseOldPassphraseNewPassphraseForVaultUIDReceivedArguments?.oldPassphrase) @@ -125,7 +127,7 @@ class ChangePasswordViewModelTests: XCTestCase { XCTAssertEqual(1, vaultLockingMock.lockedVaults.count) XCTAssertTrue(vaultLockingMock.lockedVaults.contains(NSFileProviderDomainIdentifier(vaultAccount.vaultUID))) - wait(for: [maintenanceModeEnabled, maintenanceModeDisabled], timeout: 1.0, enforceOrder: true) + await fulfillment(of: [maintenanceModeEnabled, maintenanceModeDisabled], timeout: 1.0, enforceOrder: true) } func testChangePasswordFailForEmptyOldPassword() async throws { diff --git a/CryptomatorTests/CreateNewVaultPasswordViewModelTests.swift b/CryptomatorTests/CreateNewVaultPasswordViewModelTests.swift index 9b92fa039..72ca2b8ab 100644 --- a/CryptomatorTests/CreateNewVaultPasswordViewModelTests.swift +++ b/CryptomatorTests/CreateNewVaultPasswordViewModelTests.swift @@ -186,6 +186,14 @@ private class PasswordVaultManagerMock: VaultManager { func createVaultProvider(withUID vaultUID: String, masterkey: Masterkey) throws -> CloudProvider { throw MockError.notMocked } + + func addExistingHubVault(_ vault: ExistingHubVault) -> Promise { + return Promise(MockError.notMocked) + } + + func manualUnlockVault(withUID vaultUID: String, rawKey: [UInt8]) throws -> CloudProvider { + throw MockError.notMocked + } } private struct CreatedVault { diff --git a/CryptomatorTests/MoveVaultViewModelTests.swift b/CryptomatorTests/MoveVaultViewModelTests.swift index d68a9da4c..a0d1a4b09 100644 --- a/CryptomatorTests/MoveVaultViewModelTests.swift +++ b/CryptomatorTests/MoveVaultViewModelTests.swift @@ -13,6 +13,7 @@ import Promises import XCTest @testable import Cryptomator @testable import CryptomatorCommonCore +@testable import Dependencies class MoveVaultViewModelTests: XCTestCase { private var vaultManagerMock: VaultManagerMock! @@ -36,6 +37,8 @@ class MoveVaultViewModelTests: XCTestCase { maintenanceHelperMock = MaintenanceModeHelperMock() vaultLockingMock = VaultLockingMock() + DependencyValues.mockDependency(\.fileProviderConnector, with: fileProviderConnectorMock) + fileProviderConnectorMock.getXPCServiceNameDomainClosure = { serviceName, _ in switch serviceName { case .maintenanceModeHelper: @@ -71,7 +74,7 @@ class MoveVaultViewModelTests: XCTestCase { XCTAssertEqual(1, vaultLockingMock.lockedVaults.count) XCTAssertTrue(vaultLockingMock.lockedVaults.contains(NSFileProviderDomainIdentifier(vaultAccount.vaultUID))) - wait(for: [maintenanceModeEnabled, maintenanceModeDisabled], timeout: 1.0, enforceOrder: true) + await fulfillment(of: [maintenanceModeEnabled, maintenanceModeDisabled], timeout: 1.0, enforceOrder: true) } func testRejectVaultsInTheLocalFileSystem() async throws { @@ -173,7 +176,6 @@ class MoveVaultViewModelTests: XCTestCase { currentFolderChoosingCloudPath: currentFolderChoosingCloudPath, vaultInfo: vaultInfo, domain: domain, - vaultManager: vaultManagerMock, - fileProviderConnector: fileProviderConnectorMock) + vaultManager: vaultManagerMock) } } diff --git a/CryptomatorTests/Purchase/StoreObserverTests.swift b/CryptomatorTests/Purchase/StoreObserverTests.swift index 3e129d245..a8a6adfc0 100644 --- a/CryptomatorTests/Purchase/StoreObserverTests.swift +++ b/CryptomatorTests/Purchase/StoreObserverTests.swift @@ -42,32 +42,25 @@ class StoreObserverTests: XCTestCase { // MARK: Buy Product - func testBuyFreeTrial() throws { - let expectation = XCTestExpectation() - storeManager.fetchProducts(with: [.thirtyDayTrial]).then { response -> Promise in - XCTAssertEqual(1, response.products.count) - return self.storeObserver.buy(response.products[0]) - }.then { purchaseTransaction in - try self.assertTrialStarted(purchaseTransaction: purchaseTransaction) - }.catch { error in - XCTFail("Promise failed with error: \(error)") - }.always { - expectation.fulfill() - } - wait(for: [expectation], timeout: 1.0) + func testBuyFreeTrial() async throws { + let response = try await storeManager.fetchProducts(with: [.thirtyDayTrial]).getValue() + XCTAssertEqual(1, response.products.count) + + let purchaseTransaction = try await storeObserver.buy(response.products[0]).getValue() + try assertTrialStarted(purchaseTransaction: purchaseTransaction) } - func testBuyFullVersion() throws { - assertFullVersionUnlockedWhenBuying(product: .fullVersion) - assertFullVersionUnlockedWhenBuying(product: .paidUpgrade) - assertFullVersionUnlockedWhenBuying(product: .freeUpgrade) + func testBuyFullVersion() async throws { + try await assertFullVersionUnlockedWhenBuying(product: .fullVersion) + try await assertFullVersionUnlockedWhenBuying(product: .paidUpgrade) + try await assertFullVersionUnlockedWhenBuying(product: .freeUpgrade) } // MARK: Deferred Transactions (Ask to buy) // Only test the approved case as there is no transaction state changes if the transaction gets declined // see https://developer.apple.com/forums/thread/685183 - func testAskToBuy() throws { + func testAskToBuy() async throws { session.askToBuyEnabled = true XCTAssert(session.allTransactions().isEmpty) @@ -84,42 +77,42 @@ class StoreObserverTests: XCTestCase { } storeObserver.fallbackDelegate = fallbackDelegateMock - assertBuyFailsWithDeferredTransactionError() + try await assertBuyFailsWithDeferredTransactionError() try approveAskToBuyTransaction() - wait(for: [fallbackCalledExpectation], timeout: 1.0) + await fulfillment(of: [fallbackCalledExpectation]) XCTAssertEqual(1, fallbackDelegateMock.purchaseDidSucceedTransactionCallsCount) } - func testRestoreRunningSubscription() { + func testRestoreRunningSubscription() async throws { let cryptomatorSettingsMock = CryptomatorSettingsMock() cryptomatorSettingsMock.hasRunningSubscription = true - assertRestored(with: .restoredFullVersion, cryptomatorSettings: cryptomatorSettingsMock) + try await assertRestored(with: .restoredFullVersion, cryptomatorSettings: cryptomatorSettingsMock) } - func testRestoreLifetimePremium() { + func testRestoreLifetimePremium() async throws { let cryptomatorSettingsMock = CryptomatorSettingsMock() cryptomatorSettingsMock.fullVersionUnlocked = true - assertRestored(with: .restoredFullVersion, cryptomatorSettings: cryptomatorSettingsMock) + try await assertRestored(with: .restoredFullVersion, cryptomatorSettings: cryptomatorSettingsMock) } - func testRestoreTrial() { + func testRestoreTrial() async throws { let cryptomatorSettingsMock = CryptomatorSettingsMock() let trialExpirationDate = Date.distantFuture cryptomatorSettingsMock.trialExpirationDate = trialExpirationDate - assertRestored(with: .restoredFreeTrial(expiresOn: trialExpirationDate), cryptomatorSettings: cryptomatorSettingsMock) + try await assertRestored(with: .restoredFreeTrial(expiresOn: trialExpirationDate), cryptomatorSettings: cryptomatorSettingsMock) } - func testRestoreExpiredTrial() { + func testRestoreExpiredTrial() async throws { let cryptomatorSettingsMock = CryptomatorSettingsMock() let trialExpirationDate = Date.distantPast cryptomatorSettingsMock.trialExpirationDate = trialExpirationDate - assertRestored(with: .noRestorablePurchases, cryptomatorSettings: cryptomatorSettingsMock) + try await assertRestored(with: .noRestorablePurchases, cryptomatorSettings: cryptomatorSettingsMock) } - func testRestoreNothing() { + func testRestoreNothing() async throws { let cryptomatorSettingsMock = CryptomatorSettingsMock() - assertRestored(with: .noRestorablePurchases, cryptomatorSettings: cryptomatorSettingsMock) + try await assertRestored(with: .noRestorablePurchases, cryptomatorSettings: cryptomatorSettingsMock) } // MARK: - Internal @@ -134,20 +127,14 @@ class StoreObserverTests: XCTestCase { try session.approveAskToBuyTransaction(identifier: deferredTransaction.identifier) } - private func assertFullVersionUnlockedWhenBuying(product: ProductIdentifier) { - let expectation = XCTestExpectation() - storeManager.fetchProducts(with: [product]).then { response -> Promise in - XCTAssertEqual(1, response.products.count) - return self.storeObserver.buy(response.products[0]) - }.then { purchaseTransaction in - XCTAssertEqual(.fullVersion, purchaseTransaction) - XCTAssert(self.cryptomatorSettingsMock.fullVersionUnlocked) - }.catch { error in - XCTFail("Promise failed with error: \(error)") - }.always { - expectation.fulfill() - } - wait(for: [expectation], timeout: 1.0) + private func assertFullVersionUnlockedWhenBuying(product: ProductIdentifier, file: StaticString = #filePath, line: UInt = #line) async throws { + let response = try await storeManager.fetchProducts(with: [product]).getValue() + XCTAssertEqual(1, response.products.count) + + let purchaseTransaction = try await storeObserver.buy(response.products[0]).getValue() + + XCTAssertEqual(.fullVersion, purchaseTransaction) + XCTAssert(cryptomatorSettingsMock.fullVersionUnlocked) } private func assertTrialStarted(purchaseTransaction: PurchaseTransaction) throws { @@ -156,45 +143,37 @@ class StoreObserverTests: XCTestCase { XCTFail("Wrong purchaseTransaction: \(purchaseTransaction)") return } - XCTAssertEqual(expectedDate.timeIntervalSinceReferenceDate, expiresOn.timeIntervalSinceReferenceDate, accuracy: 2.0) + + // decrease the accuracy to 2 minutes to increase stability of the unit tests in the CI. + XCTAssertEqual(expectedDate.timeIntervalSinceReferenceDate, expiresOn.timeIntervalSinceReferenceDate, accuracy: 120.0) let actualDate = try XCTUnwrap(cryptomatorSettingsMock.trialExpirationDate, "trialExpirationDate was not set") - XCTAssertEqual(expectedDate.timeIntervalSinceReferenceDate, actualDate.timeIntervalSinceReferenceDate, accuracy: 2.0) - } - - private func assertBuyFailsWithDeferredTransactionError() { - let askToBuyExpectation = XCTestExpectation() - let fetchProductPromise = storeManager.fetchProducts(with: [.thirtyDayTrial]) - fetchProductPromise.then { response -> Promise in - XCTAssertEqual(1, response.products.count) - return self.storeObserver.buy(response.products[0]) - }.then { _ in - XCTFail("Promise fulfilled") - }.catch { error in + XCTAssertEqual(expectedDate.timeIntervalSinceReferenceDate, actualDate.timeIntervalSinceReferenceDate, accuracy: 120.0) + } + + private func assertBuyFailsWithDeferredTransactionError(file: StaticString = #filePath, line: UInt = #line) async throws { + let response = try await storeManager.fetchProducts(with: [.thirtyDayTrial]).getValue() + + XCTAssertEqual(1, response.products.count) + + do { + _ = try await storeObserver.buy(response.products[0]).getValue() + XCTFail("Buy did not fail", file: file, line: line) + } catch { XCTAssertEqual(.deferredTransaction, error as? StoreObserverError) - }.always { - askToBuyExpectation.fulfill() } - wait(for: [askToBuyExpectation], timeout: 1.0) } - private func assertRestored(with expectedResult: RestoreTransactionsResult, cryptomatorSettings: CryptomatorSettings) { - let expectation = XCTestExpectation() + private func assertRestored(with expectedResult: RestoreTransactionsResult, cryptomatorSettings: CryptomatorSettings, file: StaticString = #filePath, line: UInt = #line) async throws { let premiumManagerMock = PremiumManagerTypeMock() let storeObserver = StoreObserver(cryptomatorSettings: cryptomatorSettings, premiumManager: premiumManagerMock) SKPaymentQueue.default().add(storeObserver) SKPaymentQueue.default().remove(self.storeObserver) - storeObserver.restore().then { result in - XCTAssertEqual(expectedResult, result) - XCTAssert(premiumManagerMock.refreshStatusCalled) - }.catch { error in - XCTFail("Promise failed with error: \(error)") - }.always { - expectation.fulfill() - } - wait(for: [expectation], timeout: 1.0) + let result = try await storeObserver.restore().getValue() + XCTAssertEqual(expectedResult, result) + XCTAssert(premiumManagerMock.refreshStatusCalled) } } diff --git a/CryptomatorTests/RenameVaultViewModelTests.swift b/CryptomatorTests/RenameVaultViewModelTests.swift index 59390496d..331c51270 100644 --- a/CryptomatorTests/RenameVaultViewModelTests.swift +++ b/CryptomatorTests/RenameVaultViewModelTests.swift @@ -13,6 +13,7 @@ import Promises import XCTest @testable import Cryptomator @testable import CryptomatorCommonCore +@testable import Dependencies class RenameVaultViewModelTests: SetVaultNameViewModelTests { private var vaultManagerMock: VaultManagerMock! @@ -32,6 +33,8 @@ class RenameVaultViewModelTests: SetVaultNameViewModelTests { maintenanceHelperMock = MaintenanceModeHelperMock() vaultLockingMock = VaultLockingMock() + DependencyValues.mockDependency(\.fileProviderConnector, with: fileProviderConnectorMock) + fileProviderConnectorMock.getXPCServiceNameDomainClosure = { serviceName, _ in switch serviceName { case .maintenanceModeHelper: @@ -101,7 +104,7 @@ class RenameVaultViewModelTests: SetVaultNameViewModelTests { XCTAssertEqual(1, vaultLockingMock.lockedVaults.count) XCTAssertTrue(vaultLockingMock.lockedVaults.contains(NSFileProviderDomainIdentifier(vaultAccount.vaultUID))) - wait(for: [maintenanceModeEnabled, maintenanceModeDisabled], timeout: 1.0, enforceOrder: true) + await fulfillment(of: [maintenanceModeEnabled, maintenanceModeDisabled], timeout: 1.0, enforceOrder: true) } func testRenameVaultWithOldNameAsSubstring() async throws { @@ -191,7 +194,7 @@ class RenameVaultViewModelTests: SetVaultNameViewModelTests { XCTAssertEqual(1, vaultLockingMock.lockedVaults.count) XCTAssertTrue(vaultLockingMock.lockedVaults.contains(NSFileProviderDomainIdentifier(vaultAccount.vaultUID))) XCTAssertFalse(vaultManagerMock.moveVaultAccountToCalled) - wait(for: [maintenanceModeEnabled, maintenanceModeDisabled], timeout: 1.0, enforceOrder: true) + await fulfillment(of: [maintenanceModeEnabled, maintenanceModeDisabled], timeout: 1.0, enforceOrder: true) } private func createViewModel(vaultAccount: VaultAccount, cloudProviderType: CloudProviderType, viewControllerTitle: String? = nil) -> RenameVaultViewModel { @@ -199,7 +202,7 @@ class RenameVaultViewModelTests: SetVaultNameViewModelTests { let vaultListPosition = VaultListPosition(id: 1, position: 1, vaultUID: vaultAccount.vaultUID) let vaultInfo = VaultInfo(vaultAccount: vaultAccount, cloudProviderAccount: cloudProviderAccount, vaultListPosition: vaultListPosition) let domain = NSFileProviderDomain(vaultUID: vaultInfo.vaultUID, displayName: vaultInfo.vaultName) - return RenameVaultViewModel(provider: CloudProviderMock(), vaultInfo: vaultInfo, domain: domain, vaultManager: vaultManagerMock, fileProviderConnector: fileProviderConnectorMock) + return RenameVaultViewModel(provider: CloudProviderMock(), vaultInfo: vaultInfo, domain: domain, vaultManager: vaultManagerMock) } private func checkMaintenanceModeEnabledThenDisabled() { diff --git a/CryptomatorTests/S3AuthenticationViewModelTests.swift b/CryptomatorTests/S3AuthenticationViewModelTests.swift index 8043f3a25..8e529122a 100644 --- a/CryptomatorTests/S3AuthenticationViewModelTests.swift +++ b/CryptomatorTests/S3AuthenticationViewModelTests.swift @@ -113,7 +113,7 @@ class S3AuthenticationViewModelTests: XCTestCase { let recorder = viewModel.$loginState.recordNext(2) prepareViewModelWithDefaultValues() - viewModel.endpoint = "example invalid endpoint" + viewModel.endpoint = "https://example invalid endpoint" credentialVerifierMock.verifyCredentialReturnValue = Promise(()) viewModel.saveS3Credential() diff --git a/CryptomatorTests/SettingsViewModelTests.swift b/CryptomatorTests/SettingsViewModelTests.swift index 848651685..f2ed8ff85 100644 --- a/CryptomatorTests/SettingsViewModelTests.swift +++ b/CryptomatorTests/SettingsViewModelTests.swift @@ -11,6 +11,7 @@ import XCTest @testable import Cryptomator @testable import CryptomatorCommonCore @testable import CryptomatorFileProvider +@testable import Dependencies class SettingsViewModelTests: XCTestCase { private var cryptomatorSettingsMock: CryptomatorSettingsMock! @@ -25,7 +26,8 @@ class SettingsViewModelTests: XCTestCase { } cryptomatorSettingsMock = CryptomatorSettingsMock() fileProviderConnectorMock = FileProviderConnectorMock() - settingsViewModel = SettingsViewModel(cryptomatorSettings: cryptomatorSettingsMock, fileProviderConnector: fileProviderConnectorMock) + DependencyValues.mockDependency(\.fileProviderConnector, with: fileProviderConnectorMock) + settingsViewModel = SettingsViewModel(cryptomatorSettings: cryptomatorSettingsMock) } // - MARK: Cache Section diff --git a/CryptomatorTests/VaultKeepUnlockedViewModelTests.swift b/CryptomatorTests/VaultKeepUnlockedViewModelTests.swift index d3284f401..7ddb6d789 100644 --- a/CryptomatorTests/VaultKeepUnlockedViewModelTests.swift +++ b/CryptomatorTests/VaultKeepUnlockedViewModelTests.swift @@ -10,6 +10,7 @@ import CryptomatorCloudAccessCore import XCTest @testable import Cryptomator @testable import CryptomatorCommonCore +@testable import Dependencies class VaultKeepUnlockedViewModelTests: XCTestCase { var vaultKeepUnlockedSettingsMock: VaultKeepUnlockedSettingsMock! @@ -26,6 +27,7 @@ class VaultKeepUnlockedViewModelTests: XCTestCase { masterkeyCacheManagerMock = MasterkeyCacheManagerMock() fileProviderConnectorMock = FileProviderConnectorMock() vaultLockingMock = VaultLockingMock() + DependencyValues.mockDependency(\.fileProviderConnector, with: fileProviderConnectorMock) } func testDefaultConfiguration() throws { @@ -203,8 +205,7 @@ class VaultKeepUnlockedViewModelTests: XCTestCase { return VaultKeepUnlockedViewModel(currentKeepUnlockedDuration: currentKeepUnlockedDuration, vaultInfo: vaultInfo, vaultKeepUnlockedSettings: vaultKeepUnlockedSettingsMock, - masterkeyCacheManager: masterkeyCacheManagerMock, - fileProviderConnector: fileProviderConnectorMock) + masterkeyCacheManager: masterkeyCacheManagerMock) } private func assertSectionsAreCorrect(selectedKeepUnlockedDuration: KeepUnlockedDuration, viewModel: VaultKeepUnlockedViewModel) { diff --git a/CryptomatorTests/VaultListViewModelTests.swift b/CryptomatorTests/VaultListViewModelTests.swift index afcac5d0f..0decfa5c1 100644 --- a/CryptomatorTests/VaultListViewModelTests.swift +++ b/CryptomatorTests/VaultListViewModelTests.swift @@ -13,6 +13,7 @@ import Promises import XCTest @testable import Cryptomator @testable import CryptomatorCommonCore +@testable import Dependencies class VaultListViewModelTests: XCTestCase { private var vaultManagerMock: VaultDBManagerMock! @@ -28,11 +29,12 @@ class VaultListViewModelTests: XCTestCase { vaultCacheMock = VaultCacheMock() vaultManagerMock = VaultDBManagerMock(providerManager: cloudProviderManager, vaultAccountManager: vaultAccountManagerMock, vaultCache: vaultCacheMock, passwordManager: passwordManagerMock, masterkeyCacheManager: MasterkeyCacheManagerMock(), masterkeyCacheHelper: MasterkeyCacheHelperMock()) fileProviderConnectorMock = FileProviderConnectorMock() + DependencyValues.mockDependency(\.fileProviderConnector, with: fileProviderConnectorMock) } func testRefreshVaultsIsSorted() throws { let dbManagerMock = DatabaseManagerMock() - let vaultListViewModel = VaultListViewModel(dbManager: dbManagerMock, vaultManager: vaultManagerMock, fileProviderConnector: fileProviderConnectorMock) + let vaultListViewModel = VaultListViewModel(dbManager: dbManagerMock, vaultManager: vaultManagerMock) XCTAssert(vaultListViewModel.getVaults().isEmpty) try vaultListViewModel.refreshItems() XCTAssertEqual(2, vaultListViewModel.getVaults().count) @@ -45,7 +47,7 @@ class VaultListViewModelTests: XCTestCase { func testMoveRow() throws { let dbManagerMock = DatabaseManagerMock() - let vaultListViewModel = VaultListViewModel(dbManager: dbManagerMock, vaultManager: vaultManagerMock, fileProviderConnector: fileProviderConnectorMock) + let vaultListViewModel = VaultListViewModel(dbManager: dbManagerMock, vaultManager: vaultManagerMock) try vaultListViewModel.refreshItems() XCTAssertEqual(0, vaultListViewModel.getVaults()[0].listPosition) @@ -68,7 +70,7 @@ class VaultListViewModelTests: XCTestCase { try vaultCacheMock.cache(cachedVault) let dbManagerMock = DatabaseManagerMock() - let vaultListViewModel = VaultListViewModel(dbManager: dbManagerMock, vaultManager: vaultManagerMock, fileProviderConnector: fileProviderConnectorMock) + let vaultListViewModel = VaultListViewModel(dbManager: dbManagerMock, vaultManager: vaultManagerMock) try vaultListViewModel.refreshItems() XCTAssertEqual(0, vaultListViewModel.getVaults()[0].listPosition) @@ -91,7 +93,7 @@ class VaultListViewModelTests: XCTestCase { func testLockVault() throws { let expectation = XCTestExpectation() let dbManagerMock = DatabaseManagerMock() - let vaultListViewModel = VaultListViewModel(dbManager: dbManagerMock, vaultManager: vaultManagerMock, fileProviderConnector: fileProviderConnectorMock) + let vaultListViewModel = VaultListViewModel(dbManager: dbManagerMock, vaultManager: vaultManagerMock) let vaultInfo = VaultInfo(vaultAccount: VaultAccount(vaultUID: "vault1", delegateAccountUID: "1", vaultPath: CloudPath("/vault1"), vaultName: "vault1"), cloudProviderAccount: CloudProviderAccount(accountUID: "1", cloudProviderType: .dropbox), vaultListPosition: VaultListPosition(position: 1, vaultUID: "vault1")) @@ -117,7 +119,7 @@ class VaultListViewModelTests: XCTestCase { func testRefreshVaultLockedStates() throws { let expectation = XCTestExpectation() let dbManagerMock = DatabaseManagerMock() - let vaultListViewModel = VaultListViewModel(dbManager: dbManagerMock, vaultManager: vaultManagerMock, fileProviderConnector: fileProviderConnectorMock) + let vaultListViewModel = VaultListViewModel(dbManager: dbManagerMock, vaultManager: vaultManagerMock) try vaultListViewModel.refreshItems() XCTAssertTrue(vaultListViewModel.getVaults().allSatisfy({ !$0.vaultIsUnlocked.value })) diff --git a/FileProviderExtension/FileProviderExtension.swift b/FileProviderExtension/FileProviderExtension.swift index ee8f385b9..2458a4f48 100644 --- a/FileProviderExtension/FileProviderExtension.swift +++ b/FileProviderExtension/FileProviderExtension.swift @@ -69,7 +69,7 @@ class FileProviderExtension: NSFileProviderExtension { // resolve the given identifier to a record in the model DDLogDebug("FPExt: item(for: \(identifier)) called") if identifier == .rootContainer || identifier.rawValue == "File Provider Storage" || identifier.rawValue == domain?.identifier.rawValue { - return RootFileProviderItem() + return RootFileProviderItem(domain: domain) } let adapter = try getAdapterWithWrappedError() return try adapter.item(for: identifier) diff --git a/FileProviderExtensionUI/FileProviderCoordinator.swift b/FileProviderExtensionUI/FileProviderCoordinator.swift index 41b645a0b..40bc0ff09 100644 --- a/FileProviderExtensionUI/FileProviderCoordinator.swift +++ b/FileProviderExtensionUI/FileProviderCoordinator.swift @@ -7,13 +7,16 @@ // import CocoaLumberjackSwift +import CryptomatorCloudAccessCore +import CryptomatorCommon import CryptomatorCommonCore import CryptomatorFileProvider import FileProviderUI import LocalAuthentication import UIKit -class FileProviderCoordinator { +class FileProviderCoordinator: Coordinator { + lazy var childCoordinators = [Coordinator]() lazy var navigationController: UINavigationController = { let appearance = UINavigationBarAppearance() appearance.configureWithOpaqueBackground() @@ -39,6 +42,8 @@ class FileProviderCoordinator { extensionContext.cancelRequest(withError: NSError(domain: FPUIErrorDomain, code: Int(FPUIExtensionErrorCode.userCancelled.rawValue), userInfo: nil)) } + func start() {} + func startWith(error: Error) { let error = error as NSError let userInfo = error.userInfo @@ -91,7 +96,7 @@ class FileProviderCoordinator { if unlockError == .defaultLock, viewModel.canQuickUnlock { performQuickUnlock(viewModel: viewModel) } else { - showManualPasswordScreen(viewModel: viewModel) + showManualLogin(for: domain, unlockError: unlockError) } } @@ -113,6 +118,57 @@ class FileProviderCoordinator { } } + func showManualLogin(for domain: NSFileProviderDomain, unlockError: UnlockError) { + let vaultUID = domain.identifier.rawValue + let vaultCache = VaultDBCache() + let vaultAccount: VaultAccount + let provider: CloudProvider + do { + vaultAccount = try VaultAccountDBManager.shared.getAccount(with: vaultUID) + provider = try LocalizedCloudProviderDecorator(delegate: CloudProviderDBManager.shared.getProvider(with: vaultAccount.delegateAccountUID)) + } catch { + handleError(error) + return + } + vaultCache.refreshVaultCache(for: vaultAccount, with: provider).recover { error -> Void in + switch error { + case CloudProviderError.noInternetConnection, LocalizedCloudProviderError.itemNotFound: + break + default: + throw error + } + }.then { + let cachedVault = try vaultCache.getCachedVault(withVaultUID: vaultUID) + if let vaultConfigToken = cachedVault.vaultConfigToken { + let unverifiedVaultConfig = try UnverifiedVaultConfig(token: vaultConfigToken) + switch VaultConfigHelper.getType(for: unverifiedVaultConfig) { + case .hub: + self.showHubLoginScreen(vaultConfig: unverifiedVaultConfig, domain: domain) + case .masterkeyFile: + let viewModel = UnlockVaultViewModel(domain: domain, wrongBiometricalPassword: unlockError == .biometricalUnlockWrongPassword) + self.showManualPasswordScreen(viewModel: viewModel) + case .unknown: + fatalError("TODO: throw error") + } + } else { + let viewModel = UnlockVaultViewModel(domain: domain, wrongBiometricalPassword: unlockError == .biometricalUnlockWrongPassword) + self.showManualPasswordScreen(viewModel: viewModel) + } + }.catch { + self.handleError($0) + } + } + + func showHubLoginScreen(vaultConfig: UnverifiedVaultConfig, domain: NSFileProviderDomain) { + let child = HubXPCLoginCoordinator(navigationController: navigationController, + domain: domain, + vaultConfig: vaultConfig, + onUnlocked: { [weak self] in self?.done() }, + onErrorAlertDismissed: { [weak self] in self?.done() }) + childCoordinators.append(child) + child.start() + } + func showManualPasswordScreen(viewModel: UnlockVaultViewModel) { let unlockVaultVC = UnlockVaultViewController(viewModel: viewModel) unlockVaultVC.coordinator = self @@ -129,4 +185,11 @@ class FileProviderCoordinator { hostViewController.view.addSubview(viewController.view) viewController.didMove(toParent: hostViewController) } + + private func handleError(_ error: Error) { + guard let hostViewController = hostViewController else { + return + } + handleError(error, for: hostViewController) + } } diff --git a/FileProviderExtensionUI/RootViewController.swift b/FileProviderExtensionUI/RootViewController.swift index 1da46a054..99f427bbf 100644 --- a/FileProviderExtensionUI/RootViewController.swift +++ b/FileProviderExtensionUI/RootViewController.swift @@ -10,6 +10,7 @@ import CocoaLumberjackSwift import CryptomatorCloudAccessCore import CryptomatorCommonCore import CryptomatorFileProvider +import Dependencies import FileProviderUI import MSAL import Promises @@ -24,6 +25,8 @@ class RootViewController: FPUIActionExtensionViewController { #endif }() + @Dependency(\.fileProviderConnector) private var fileProviderConnector + override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) { super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) NotificationCenter.default.addObserver(self, @@ -72,7 +75,7 @@ class RootViewController: FPUIActionExtensionViewController { }() func retryUpload(for itemIdentifiers: [NSFileProviderItemIdentifier], domainIdentifier: NSFileProviderDomainIdentifier) { - let getXPCPromise: Promise> = FileProviderXPCConnector.shared.getXPC(serviceName: .uploadRetryingService, domainIdentifier: domainIdentifier) + let getXPCPromise: Promise> = fileProviderConnector.getXPC(serviceName: .uploadRetryingService, domainIdentifier: domainIdentifier) getXPCPromise.then { xpc in return wrap { xpc.proxy.retryUpload(for: itemIdentifiers, reply: $0) @@ -85,8 +88,8 @@ class RootViewController: FPUIActionExtensionViewController { }.catch { error in DDLogError("Retry upload failed with error: \(error)") self.extensionContext.cancelRequest(withError: NSError(domain: FPUIErrorDomain, code: Int(FPUIExtensionErrorCode.failed.rawValue), userInfo: nil)) - }.always { - FileProviderXPCConnector.shared.invalidateXPC(getXPCPromise) + }.always { [fileProviderConnector] in + fileProviderConnector.invalidateXPC(getXPCPromise) } } @@ -98,7 +101,7 @@ class RootViewController: FPUIActionExtensionViewController { } func showUploadProgressAlert(for itemIdentifiers: [NSFileProviderItemIdentifier], domainIdentifier: NSFileProviderDomainIdentifier) { - let getXPCPromise: Promise> = FileProviderXPCConnector.shared.getXPC(serviceName: .uploadRetryingService, domainIdentifier: domainIdentifier) + let getXPCPromise: Promise> = fileProviderConnector.getXPC(serviceName: .uploadRetryingService, domainIdentifier: domainIdentifier) let progressAlert = RetryUploadAlertControllerFactory.createUploadProgressAlert(dismissAction: { [weak self] in self?.cancel() }, retryAction: { [weak self] in @@ -108,9 +111,9 @@ class RootViewController: FPUIActionExtensionViewController { let observeProgressPromise = progressAlert.observeProgress(itemIdentifier: itemIdentifiers[0], proxy: xpc.proxy) let alertActionPromise = progressAlert.alertActionTriggered return race([observeProgressPromise, alertActionPromise]) - }.always { + }.always { [fileProviderConnector] in self.extensionContext.completeRequest() - FileProviderXPCConnector.shared.invalidateXPC(getXPCPromise) + fileProviderConnector.invalidateXPC(getXPCPromise) } present(progressAlert, animated: true) } @@ -135,7 +138,7 @@ class RootViewController: FPUIActionExtensionViewController { } func evictFilesFromCache(with itemIdentifiers: [NSFileProviderItemIdentifier], domainIdentifier: NSFileProviderDomainIdentifier) { - let getXPCPromise: Promise> = FileProviderXPCConnector.shared.getXPC(serviceName: .cacheManaging, domainIdentifier: domainIdentifier) + let getXPCPromise: Promise> = fileProviderConnector.getXPC(serviceName: .cacheManaging, domainIdentifier: domainIdentifier) getXPCPromise.then { xpc in xpc.proxy.evictFilesFromCache(with: itemIdentifiers) }.catch { error in @@ -150,8 +153,8 @@ class RootViewController: FPUIActionExtensionViewController { self.present(alertController, animated: true) }.then { self.extensionContext.completeRequest() - }.always { - FileProviderXPCConnector.shared.invalidateXPC(getXPCPromise) + }.always { [fileProviderConnector] in + fileProviderConnector.invalidateXPC(getXPCPromise) } } diff --git a/FileProviderExtensionUI/UnlockVaultViewModel.swift b/FileProviderExtensionUI/UnlockVaultViewModel.swift index 1654283a4..5e4a87c5f 100644 --- a/FileProviderExtensionUI/UnlockVaultViewModel.swift +++ b/FileProviderExtensionUI/UnlockVaultViewModel.swift @@ -11,6 +11,7 @@ import CryptomatorCloudAccessCore import CryptomatorCommonCore import CryptomatorCryptoLib import CryptomatorFileProvider +import Dependencies import FileProvider import FileProviderUI import Foundation @@ -106,7 +107,7 @@ class UnlockVaultViewModel { } }() - private let fileProviderConnector: FileProviderConnector + @Dependency(\.fileProviderConnector) private var fileProviderConnector private let vaultAccountManager: VaultAccountManager private let providerManager: CloudProviderManager private let vaultCache: VaultCache @@ -115,17 +116,15 @@ class UnlockVaultViewModel { public convenience init(domain: NSFileProviderDomain, wrongBiometricalPassword: Bool) { self.init(domain: domain, wrongBiometricalPassword: wrongBiometricalPassword, - fileProviderConnector: FileProviderXPCConnector.shared, passwordManager: VaultPasswordKeychainManager(), vaultAccountManager: VaultAccountDBManager.shared, providerManager: CloudProviderDBManager.shared, vaultCache: VaultDBCache()) } - init(domain: NSFileProviderDomain, wrongBiometricalPassword: Bool, fileProviderConnector: FileProviderConnector, passwordManager: VaultPasswordManager, vaultAccountManager: VaultAccountManager, providerManager: CloudProviderManager, vaultCache: VaultCache) { + init(domain: NSFileProviderDomain, wrongBiometricalPassword: Bool, passwordManager: VaultPasswordManager, vaultAccountManager: VaultAccountManager, providerManager: CloudProviderManager, vaultCache: VaultCache) { self.domain = domain self.wrongBiometricalPassword = wrongBiometricalPassword - self.fileProviderConnector = fileProviderConnector let context = LAContext() if #unavailable(iOS 16) { // Remove fallback title because "Enter password" also closes FileProviderExtensionUI (prior to iOS 16) and does not display the password input diff --git a/SharedResources/en.lproj/Localizable.strings b/SharedResources/en.lproj/Localizable.strings index 69293b1d4..f98e2720a 100644 --- a/SharedResources/en.lproj/Localizable.strings +++ b/SharedResources/en.lproj/Localizable.strings @@ -21,6 +21,8 @@ "common.button.enable" = "Enable"; "common.button.next" = "Next"; "common.button.ok" = "OK"; +"common.button.refresh" = "Refresh"; +"common.button.register" = "Register"; "common.button.remove" = "Remove"; "common.button.retry" = "Retry"; "common.button.signOut" = "Sign Out"; @@ -58,6 +60,7 @@ "addVault.openExistingVault.chooseCloud.header" = "Where is the vault located?"; "addVault.openExistingVault.detectedMasterkey.text" = "Cryptomator detected the vault \"%@\".\nWould you like to add this vault?"; "addVault.openExistingVault.detectedMasterkey.add" = "Add This Vault"; +"addVault.openExistingVault.downloadVault.progress" = "Downloading Vault…"; "addVault.openExistingVault.password.footer" = "Enter password for \"%@\"."; "addVault.openExistingVault.progress" = "Adding Vault…"; "addVault.success.info" = "Successfully added vault \"%@\".\nAccess this vault via the Files app."; @@ -110,6 +113,15 @@ "getFolderIntent.error.missingPath" = "No path was provided. Please provide a valid path for which a folder should be returned."; "getFolderIntent.error.noVaultSelected" = "No vault has been selected."; + +"hubAuthentication.title" = "Hub Vault"; +"hubAuthentication.accessNotGranted" = "Your device has not yet been authorized to access this vault. Ask the vault owner to authorize it."; +"hubAuthentication.licenseExceeded" = "Your Cryptomator Hub instance has an invalid license. Please inform a Hub administrator to upgrade or renew the license."; +"hubAuthentication.deviceRegistration.deviceName.cells.name" = "Device Name"; +"hubAuthentication.deviceRegistration.deviceName.footer.title" = "This seems to be the first Hub access from this device. In order to identify it for access authorization, you need to name this device."; +"hubAuthentication.deviceRegistration.needsAuthorization.alert.title" = "Register Device Successful"; +"hubAuthentication.deviceRegistration.needsAuthorization.alert.message" = "To access the vault, your device needs to be authorized by the vault owner."; + "intents.saveFile.missingFile" = "The provided file is not valid."; "intents.saveFile.invalidFolder" = "The provided folder is not valid."; "intents.saveFile.missingTemporaryFolder" = "Failed to create temporary folder.";