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/AddVault/Hub/HubAddVaultCoordinator.swift b/Cryptomator/AddVault/Hub/HubAddVaultCoordinator.swift index 9b8591d88..92cb4af15 100644 --- a/Cryptomator/AddVault/Hub/HubAddVaultCoordinator.swift +++ b/Cryptomator/AddVault/Hub/HubAddVaultCoordinator.swift @@ -23,7 +23,6 @@ class AddHubVaultCoordinator: Coordinator { let vaultUID: String let accountUID: String let vaultItem: VaultItem - let hubAuthenticator: HubAuthenticating let vaultManager: VaultManager weak var parentCoordinator: Coordinator? weak var delegate: (VaultInstalling & AnyObject)? @@ -33,54 +32,49 @@ class AddHubVaultCoordinator: Coordinator { vaultUID: String, accountUID: String, vaultItem: VaultItem, - hubAuthenticator: HubAuthenticating, vaultManager: VaultManager = VaultDBManager.shared) { self.navigationController = navigationController self.downloadedVaultConfig = downloadedVaultConfig self.vaultUID = vaultUID self.accountUID = accountUID self.vaultItem = vaultItem - self.hubAuthenticator = hubAuthenticator self.vaultManager = vaultManager } func start() { - let viewModel = HubAuthenticationViewModel(vaultConfig: downloadedVaultConfig.vaultConfig, - hubUserAuthenticator: self, - delegate: self) - let viewController = HubAuthenticationViewController(viewModel: viewModel) - navigationController.pushViewController(viewController, animated: true) + 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: HubAuthenticationFlowDelegate { - 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() - childDidFinish(self) - await showSuccessfullyAddedVault() - } catch { - DDLogError("Add existing Hub vault failed: \(error)") - handleError(error, for: navigationController) - } +extension AddHubVaultCoordinator: HubVaultUnlockHandlerDelegate { + func successfullyProcessedUnlockedVault() { + delegate?.showSuccessfullyAddedVault(withName: vaultItem.name, vaultUID: vaultUID) } - @MainActor - private func showSuccessfullyAddedVault() { - 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: HubUserLogin { - public func authenticate(with hubConfig: HubConfig) async throws -> OIDAuthState { - try await hubAuthenticator.authenticate(with: hubConfig, from: navigationController) +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/OpenExistingVaultCoordinator.swift b/Cryptomator/AddVault/OpenExistingVault/OpenExistingVaultCoordinator.swift index cf698a9f9..aa2d9be69 100644 --- a/Cryptomator/AddVault/OpenExistingVault/OpenExistingVaultCoordinator.swift +++ b/Cryptomator/AddVault/OpenExistingVault/OpenExistingVaultCoordinator.swift @@ -201,8 +201,7 @@ private class AuthenticatedOpenExistingVaultCoordinator: VaultInstalling, Folder downloadedVaultConfig: downloadedVaultConfig, vaultUID: UUID().uuidString, accountUID: account.accountUID, - vaultItem: vaultItem, - hubAuthenticator: CryptomatorHubAuthenticator.shared) + vaultItem: vaultItem) child.parentCoordinator = self child.delegate = self childCoordinators.append(child) 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/CryptomatorCommon/Sources/CryptomatorCommon/CryptomatorHubAuthenticator+HubAuthenticating.swift b/CryptomatorCommon/Sources/CryptomatorCommon/CryptomatorHubAuthenticator+HubAuthenticating.swift index 7e23bcc15..f34c331bd 100644 --- a/CryptomatorCommon/Sources/CryptomatorCommon/CryptomatorHubAuthenticator+HubAuthenticating.swift +++ b/CryptomatorCommon/Sources/CryptomatorCommon/CryptomatorHubAuthenticator+HubAuthenticating.swift @@ -10,6 +10,7 @@ import Base32 import CryptoKit import CryptomatorCloudAccessCore import CryptomatorCommonCore +import Dependencies import UIKit enum HubAuthenticationError: Error { @@ -52,3 +53,7 @@ extension CryptomatorHubAuthenticator: HubAuthenticating { }) } } + +extension HubAuthenticatingKey: DependencyKey { + public static var liveValue: HubAuthenticating = CryptomatorHubAuthenticator() +} 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/Hub/CryptomatorHubAuthenticator.swift b/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/CryptomatorHubAuthenticator.swift index d7bea476a..f32906807 100644 --- a/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/CryptomatorHubAuthenticator.swift +++ b/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/CryptomatorHubAuthenticator.swift @@ -9,6 +9,7 @@ import AppAuthCore import CryptoKit import CryptomatorCloudAccessCore +import Dependencies import Foundation public enum HubAuthenticationFlow { @@ -18,14 +19,6 @@ public enum HubAuthenticationFlow { case licenseExceeded } -public protocol HubDeviceRegistering { - func registerDevice(withName name: String, hubConfig: HubConfig, authState: OIDAuthState) async throws -} - -public protocol HubKeyReceiving { - func receiveKey(authState: OIDAuthState, vaultConfig: UnverifiedVaultConfig) async throws -> HubAuthenticationFlow -} - public enum CryptomatorHubAuthenticatorError: Error { case unexpectedError case unexpectedResponse @@ -38,7 +31,9 @@ public enum CryptomatorHubAuthenticatorError: Error { public class CryptomatorHubAuthenticator: HubDeviceRegistering, HubKeyReceiving { private static let scheme = "hub+" - public static let shared = CryptomatorHubAuthenticator() + @Dependency(\.cryptomatorHubKeyProvider) private var cryptomatorHubKeyProvider + + public init() {} public func receiveKey(authState: OIDAuthState, vaultConfig: UnverifiedVaultConfig) async throws -> HubAuthenticationFlow { guard let baseURL = createBaseURL(vaultConfig: vaultConfig) else { @@ -70,7 +65,7 @@ public class CryptomatorHubAuthenticator: HubDeviceRegistering, HubKeyReceiving public func registerDevice(withName name: String, hubConfig: HubConfig, authState: OIDAuthState) async throws { let deviceID = try getDeviceID() - let publicKey = try CryptomatorHubKeyProvider.shared.getPublicKey() + 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 { @@ -106,7 +101,7 @@ public class CryptomatorHubAuthenticator: HubDeviceRegistering, HubKeyReceiving } func getDeviceID() throws -> String { - let publicKey = try CryptomatorHubKeyProvider.shared.getPublicKey() + let publicKey = try cryptomatorHubKeyProvider.getPublicKey() let digest = SHA256.hash(data: publicKey.derRepresentation) return digest.data.base16EncodedString } diff --git a/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/CryptomatorHubKeyProvider.swift b/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/CryptomatorHubKeyProvider.swift index eb87de68f..845a96232 100644 --- a/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/CryptomatorHubKeyProvider.swift +++ b/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/CryptomatorHubKeyProvider.swift @@ -7,10 +7,31 @@ // import CryptoKit +import Dependencies import Foundation -public struct CryptomatorHubKeyProvider { - public static let shared: CryptomatorHubKeyProvider = .init(keychain: CryptomatorKeychain.hub) +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" @@ -38,3 +59,50 @@ public struct CryptomatorHubKeyProvider { 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 index 2b074a44b..c43627ce5 100644 --- a/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubAuthenticating.swift +++ b/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubAuthenticating.swift @@ -1,7 +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/HubAuthenticationView.swift b/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubAuthenticationView.swift index ac150cef4..adc7c43ff 100644 --- a/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubAuthenticationView.swift +++ b/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubAuthenticationView.swift @@ -20,15 +20,12 @@ public struct HubAuthenticationView: View { ) case .accessNotGranted: HubAccessNotGrantedView(onRefresh: { Task { await viewModel.refresh() }}) - case .loading: - ProgressView() - Text(LocalizedString.getValue("hubAuthentication.loading")) - case .userLogin: - HubLoginView(onLogin: { Task { await viewModel.login() }}) case .licenseExceeded: CryptomatorErrorView(text: LocalizedString.getValue("hubAuthentication.licenseExceeded")) case let .error(description): CryptomatorErrorView(text: description) + case .none: + EmptyView() } } .padding() diff --git a/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubAuthenticationViewController.swift b/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubAuthenticationViewController.swift index e08a46a17..25152feb3 100644 --- a/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubAuthenticationViewController.swift +++ b/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubAuthenticationViewController.swift @@ -24,13 +24,9 @@ public class HubAuthenticationViewController: UIViewController { override public func viewDidLoad() { super.viewDidLoad() + title = LocalizedString.getValue("hubAuthentication.title") - viewModel.$authenticationFlowState - .receive(on: DispatchQueue.main) - .sink(receiveValue: { [weak self] in - self?.updateToolbar(state: $0) - }) - .store(in: &cancellables) + setupToolBar() setupSwiftUIView() } @@ -43,6 +39,20 @@ public class HubAuthenticationViewController: UIViewController { 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`. diff --git a/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubAuthenticationViewModel.swift b/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubAuthenticationViewModel.swift index 449b10bd3..197f351f3 100644 --- a/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubAuthenticationViewModel.swift +++ b/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubAuthenticationViewModel.swift @@ -2,6 +2,7 @@ import AppAuthCore import CocoaLumberjackSwift import CryptoKit import CryptomatorCloudAccessCore +import Dependencies import Foundation import JOSESwift import UIKit @@ -13,13 +14,19 @@ public enum HubAuthenticationViewModelError: Error { case unexpectedSubscriptionHeader } -public class HubAuthenticationViewModel: ObservableObject { +public protocol HubAuthenticationViewModelDelegate: AnyObject { + @MainActor + func hubAuthenticationViewModelWantsToShowLoadingIndicator() + + @MainActor + func hubAuthenticationViewModelWantsToHideLoadingIndicator() async +} + +public final class HubAuthenticationViewModel: ObservableObject { public enum State: Equatable { - case userLogin case accessNotGranted case licenseExceeded case deviceRegistration(DeviceRegistration) - case loading case error(description: String) } @@ -32,53 +39,33 @@ public class HubAuthenticationViewModel: ObservableObject { static var subscriptionState: String { "hub-subscription-state" } } - @Published var authenticationFlowState: State = .userLogin + @Published var authenticationFlowState: State? @Published public var deviceName: String = UIDevice.current.name + private(set) var isLoggedIn = false private let vaultConfig: UnverifiedVaultConfig - private let deviceRegisteringService: HubDeviceRegistering - private let hubKeyService: HubKeyReceiving - private let hubUserAuthenticator: HubUserLogin - - private var authState: OIDAuthState? - private weak var delegate: HubAuthenticationFlowDelegate? - - public init(vaultConfig: UnverifiedVaultConfig, - deviceRegisteringService: HubDeviceRegistering = CryptomatorHubAuthenticator.shared, - hubUserAuthenticator: HubUserLogin, - hubKeyService: HubKeyReceiving = CryptomatorHubAuthenticator.shared, - delegate: HubAuthenticationFlowDelegate?) { + 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.deviceRegisteringService = deviceRegisteringService - self.hubUserAuthenticator = hubUserAuthenticator - self.hubKeyService = hubKeyService + self.unlockHandler = unlockHandler self.delegate = delegate } - public func login() async { - guard let hubConfig = vaultConfig.allegedHubConfig else { - await setStateToErrorState(with: HubAuthenticationViewModelError.missingHubConfig) - return - } - do { - authState = try await hubUserAuthenticator.authenticate(with: hubConfig) - await continueToAccessCheck() - } catch let error as NSError where error.domain == OIDGeneralErrorDomain && error.code == OIDErrorCode.userCanceledAuthorizationFlow.rawValue { - // ignore user cancellation - } catch { - await setStateToErrorState(with: error) - } - } - public func register() async { guard let hubConfig = vaultConfig.allegedHubConfig else { await setStateToErrorState(with: HubAuthenticationViewModelError.missingHubConfig) return } - guard let authState = authState else { - await setStateToErrorState(with: HubAuthenticationViewModelError.missingAuthState) - return - } do { try await deviceRegisteringService.registerDevice(withName: deviceName, hubConfig: hubConfig, authState: authState) @@ -94,11 +81,7 @@ public class HubAuthenticationViewModel: ObservableObject { } public func continueToAccessCheck() async { - guard let authState = authState else { - await setStateToErrorState(with: HubAuthenticationViewModelError.missingAuthState) - return - } - await setState(to: .loading) + await delegate?.hubAuthenticationViewModelWantsToShowLoadingIndicator() let authFlow: HubAuthenticationFlow do { @@ -107,6 +90,8 @@ public class HubAuthenticationViewModel: ObservableObject { await setStateToErrorState(with: error) return } + await delegate?.hubAuthenticationViewModelWantsToHideLoadingIndicator() + switch authFlow { case let .success(data, header): await receivedExistingKey(data: data, header: header) @@ -124,7 +109,7 @@ public class HubAuthenticationViewModel: ObservableObject { let jwe: JWE let subscriptionState: HubSubscriptionState do { - privateKey = try CryptomatorHubKeyProvider.shared.getPrivateKey() + privateKey = try cryptomatorHubKeyProvider.getPrivateKey() jwe = try JWE(compactSerialization: data) subscriptionState = try getSubscriptionState(from: header) } catch { @@ -134,7 +119,8 @@ public class HubAuthenticationViewModel: ObservableObject { let response = HubUnlockResponse(jwe: jwe, privateKey: privateKey, subscriptionState: subscriptionState) - await delegate?.didSuccessfullyRemoteUnlock(response) + await MainActor.run { isLoggedIn = true } + await unlockHandler.didSuccessfullyRemoteUnlock(response) } @MainActor @@ -143,6 +129,7 @@ public class HubAuthenticationViewModel: ObservableObject { } private func setStateToErrorState(with error: Error) async { + await delegate?.hubAuthenticationViewModelWantsToHideLoadingIndicator() await setState(to: .error(description: error.localizedDescription)) } 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/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/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/HubXPCLoginCoordinator.swift b/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubXPCLoginCoordinator.swift index 9249a61ec..3f58f2a85 100644 --- a/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubXPCLoginCoordinator.swift +++ b/CryptomatorCommon/Sources/CryptomatorCommonCore/Hub/HubXPCLoginCoordinator.swift @@ -12,64 +12,59 @@ public final class HubXPCLoginCoordinator: Coordinator { public var navigationController: UINavigationController let domain: NSFileProviderDomain let vaultConfig: UnverifiedVaultConfig - let fileProviderConnector: FileProviderConnector - let hubAuthenticator: HubAuthenticating 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, - fileProviderConnector: FileProviderConnector = FileProviderXPCConnector.shared, - hubAuthenticator: HubAuthenticating, onUnlocked: @escaping () -> Void, onErrorAlertDismissed: @escaping () -> Void) { self.navigationController = navigationController self.domain = domain self.vaultConfig = vaultConfig - self.fileProviderConnector = fileProviderConnector - self.hubAuthenticator = hubAuthenticator self.onUnlocked = onUnlocked self.onErrorAlertDismissed = onErrorAlertDismissed } public func start() { - let viewModel = HubAuthenticationViewModel(vaultConfig: vaultConfig, - hubUserAuthenticator: self, - delegate: self) - let viewController = HubAuthenticationViewController(viewModel: viewModel) - navigationController.pushViewController(viewController, animated: true) + 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: HubAuthenticationFlowDelegate { - public func didSuccessfullyRemoteUnlock(_ response: HubUnlockResponse) async { - let masterkey: Masterkey - do { - masterkey = try JWEHelper.decrypt(jwe: response.jwe, with: response.privateKey) - } catch { - handleError(error, for: navigationController, onOKTapped: onErrorAlertDismissed) - 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) - onUnlocked() - } catch { - handleError(error, for: navigationController, onOKTapped: onErrorAlertDismissed) - return - } +extension HubXPCLoginCoordinator: HubVaultUnlockHandlerDelegate { + public func successfullyProcessedUnlockedVault() { + onUnlocked() + } + + public func failedToProcessUnlockedVault(error: Error) { + handleError(error, for: navigationController, onOKTapped: onErrorAlertDismissed) } } -extension HubXPCLoginCoordinator: HubUserLogin { - public func authenticate(with hubConfig: HubConfig) async throws -> OIDAuthState { - try await hubAuthenticator.authenticate(with: hubConfig, from: navigationController) +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/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/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/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/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/FileProviderExtensionUI/FileProviderCoordinator.swift b/FileProviderExtensionUI/FileProviderCoordinator.swift index 9683ca67d..40bc0ff09 100644 --- a/FileProviderExtensionUI/FileProviderCoordinator.swift +++ b/FileProviderExtensionUI/FileProviderCoordinator.swift @@ -163,7 +163,6 @@ class FileProviderCoordinator: Coordinator { let child = HubXPCLoginCoordinator(navigationController: navigationController, domain: domain, vaultConfig: vaultConfig, - hubAuthenticator: CryptomatorHubAuthenticator.shared, onUnlocked: { [weak self] in self?.done() }, onErrorAlertDismissed: { [weak self] in self?.done() }) childCoordinators.append(child) 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 95b58e62b..f98e2720a 100644 --- a/SharedResources/en.lproj/Localizable.strings +++ b/SharedResources/en.lproj/Localizable.strings @@ -115,10 +115,8 @@ "getFolderIntent.error.noVaultSelected" = "No vault has been selected."; "hubAuthentication.title" = "Hub Vault"; -"hubAuthentication.loading" = "Cryptomator is receiving and processing the response from Hub. Please wait."; "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." = ""; "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";