diff --git a/DuckDuckGo/Common/Extensions/URLExtension.swift b/DuckDuckGo/Common/Extensions/URLExtension.swift index 3d95ae07f1..f33fc8a364 100644 --- a/DuckDuckGo/Common/Extensions/URLExtension.swift +++ b/DuckDuckGo/Common/Extensions/URLExtension.swift @@ -524,6 +524,8 @@ extension URL { static var fullDiskAccess = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles")! + static var touchIDAndPassword = URL(string: "x-apple.systempreferences:com.apple.preferences.password")! + // MARK: - Blob URLs var isBlobURL: Bool { diff --git a/DuckDuckGo/DeviceAuthentication/DeviceAuthenticationService.swift b/DuckDuckGo/DeviceAuthentication/DeviceAuthenticationService.swift index 307a1bc3bd..3cc09f250d 100644 --- a/DuckDuckGo/DeviceAuthentication/DeviceAuthenticationService.swift +++ b/DuckDuckGo/DeviceAuthentication/DeviceAuthenticationService.swift @@ -21,6 +21,7 @@ import Foundation enum DeviceAuthenticationResult { case success case failure + case noAuthAvailable var authenticated: Bool { return self == .success diff --git a/DuckDuckGo/DeviceAuthentication/LocalAuthenticationService.swift b/DuckDuckGo/DeviceAuthentication/LocalAuthenticationService.swift index 474d53c750..b7ddbe7416 100644 --- a/DuckDuckGo/DeviceAuthentication/LocalAuthenticationService.swift +++ b/DuckDuckGo/DeviceAuthentication/LocalAuthenticationService.swift @@ -24,6 +24,12 @@ final class LocalAuthenticationService: DeviceAuthenticationService { func authenticateDevice(reason: String, result: @escaping DeviceAuthenticationResultHandler) { let context = LAContext() + var error: NSError? + guard context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) else { + result(.noAuthAvailable) + return + } + context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { authenticated, _ in DispatchQueue.main.async { let authenticationResult: DeviceAuthenticationResult = authenticated ? .success : .failure diff --git a/DuckDuckGo/Preferences/Model/SyncPreferences.swift b/DuckDuckGo/Preferences/Model/SyncPreferences.swift index 28be6ba74d..d631e55f21 100644 --- a/DuckDuckGo/Preferences/Model/SyncPreferences.swift +++ b/DuckDuckGo/Preferences/Model/SyncPreferences.swift @@ -559,15 +559,19 @@ extension SyncPreferences: ManagementDialogModelDelegate { return } - let data = RecoveryPDFGenerator() - .generate(recoveryCode) - Task { @MainActor in let authenticationResult = await userAuthenticator.authenticateUser(reason: .syncSettings) guard authenticationResult.authenticated else { + if authenticationResult == .noAuthAvailable { + presentDialog(for: .empty) + managementDialogModel.syncErrorMessage = SyncErrorMessage(type: .unableToAuthenticateOnDevice, description: "") + } return } + let data = RecoveryPDFGenerator() + .generate(recoveryCode) + let panel = NSSavePanel.savePanelWithFileTypeChooser(fileTypes: [.pdf], suggestedFilename: "Sync Data Recovery - DuckDuckGo.pdf") let response = await panel.begin() @@ -607,7 +611,12 @@ extension SyncPreferences: ManagementDialogModelDelegate { @MainActor func syncWithAnotherDevicePressed() async { - guard await userAuthenticator.authenticateUser(reason: .syncSettings).authenticated else { + let authenticationResult = await userAuthenticator.authenticateUser(reason: .syncSettings) + guard authenticationResult.authenticated else { + if authenticationResult == .noAuthAvailable { + presentDialog(for: .empty) + managementDialogModel.syncErrorMessage = SyncErrorMessage(type: .unableToAuthenticateOnDevice, description: "") + } return } if isSyncEnabled { @@ -619,7 +628,12 @@ extension SyncPreferences: ManagementDialogModelDelegate { @MainActor func syncWithServerPressed() async { - guard await userAuthenticator.authenticateUser(reason: .syncSettings).authenticated else { + let authenticationResult = await userAuthenticator.authenticateUser(reason: .syncSettings) + guard authenticationResult.authenticated else { + if authenticationResult == .noAuthAvailable { + presentDialog(for: .empty) + managementDialogModel.syncErrorMessage = SyncErrorMessage(type: .unableToAuthenticateOnDevice, description: "") + } return } presentDialog(for: .syncWithServer) @@ -627,7 +641,12 @@ extension SyncPreferences: ManagementDialogModelDelegate { @MainActor func recoverDataPressed() async { - guard await userAuthenticator.authenticateUser(reason: .syncSettings).authenticated else { + let authenticationResult = await userAuthenticator.authenticateUser(reason: .syncSettings) + guard authenticationResult.authenticated else { + if authenticationResult == .noAuthAvailable { + presentDialog(for: .empty) + managementDialogModel.syncErrorMessage = SyncErrorMessage(type: .unableToAuthenticateOnDevice, description: "") + } return } presentDialog(for: .recoverSyncedData) @@ -652,6 +671,11 @@ extension SyncPreferences: ManagementDialogModelDelegate { showDevicesSynced() } + @MainActor + func openSystemPasswordSettings() { + NSWorkspace.shared.open(URL.touchIDAndPassword) + } + @MainActor private func showDevicesSynced() { presentDialog(for: .nowSyncing) diff --git a/LocalPackages/SyncUI/Sources/SyncUI/Resources/Localizable.xcstrings b/LocalPackages/SyncUI/Sources/SyncUI/Resources/Localizable.xcstrings index a899610e5c..b901be9fae 100644 --- a/LocalPackages/SyncUI/Sources/SyncUI/Resources/Localizable.xcstrings +++ b/LocalPackages/SyncUI/Sources/SyncUI/Resources/Localizable.xcstrings @@ -119,6 +119,30 @@ } } }, + "alert.sync-device-auth-error" : { + "comment" : "Title for an error alert", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Sync & Backup Error" + } + } + } + }, + "alert.sync-device-auth-error-button" : { + "comment" : "Button Title of an error alert", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Go to Settings" + } + } + } + }, "alert.sync-error" : { "comment" : "Title for sync error alert", "extractionState" : "extracted_with_value", @@ -179,6 +203,18 @@ } } }, + "alert.unable-to-authenticate-device" : { + "comment" : "Description for unable to authenticate error", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "A device password is required to use Sync & Backup." + } + } + } + }, "alert.unable-to-create-recovery-pdf-description" : { "comment" : "Description for unable to create recovery pdf error", "extractionState" : "extracted_with_value", diff --git a/LocalPackages/SyncUI/Sources/SyncUI/ViewModels/ManagementDialogModel.swift b/LocalPackages/SyncUI/Sources/SyncUI/ViewModels/ManagementDialogModel.swift index eaf0aa2808..39d22256e3 100644 --- a/LocalPackages/SyncUI/Sources/SyncUI/ViewModels/ManagementDialogModel.swift +++ b/LocalPackages/SyncUI/Sources/SyncUI/ViewModels/ManagementDialogModel.swift @@ -32,6 +32,7 @@ public protocol ManagementDialogModelDelegate: AnyObject { func recoveryCodePasted(_ code: String) func enterRecoveryCodePressed() func copyCode() + func openSystemPasswordSettings() } public final class ManagementDialogModel: ObservableObject { @@ -54,6 +55,8 @@ public final class ManagementDialogModel: ObservableObject { } public func endFlow() { + syncErrorMessage?.type.onButtonPressed(delegate: delegate) + syncErrorMessage = nil currentDialog = nil } diff --git a/LocalPackages/SyncUI/Sources/SyncUI/ViewModels/ManagementViewModel.swift b/LocalPackages/SyncUI/Sources/SyncUI/ViewModels/ManagementViewModel.swift index 0ad1a50478..9c17db0f43 100644 --- a/LocalPackages/SyncUI/Sources/SyncUI/ViewModels/ManagementViewModel.swift +++ b/LocalPackages/SyncUI/Sources/SyncUI/ViewModels/ManagementViewModel.swift @@ -68,9 +68,15 @@ public enum SyncErrorType { case unableToRemoveDevice case invalidCode case unableCreateRecoveryPDF + case unableToAuthenticateOnDevice var title: String { - return UserText.syncErrorAlertTitle + switch self { + case .unableToAuthenticateOnDevice: + return UserText.syncDeviceAuthenticationErrorAlertTitle + default: + return UserText.syncErrorAlertTitle + } } var description: String { @@ -93,6 +99,26 @@ public enum SyncErrorType { return UserText.invalidCodeDescription case .unableCreateRecoveryPDF: return UserText.unableCreateRecoveryPdfDescription + case .unableToAuthenticateOnDevice: + return UserText.unableToAuthenticateDevice + } + } + + var buttonTitle: String { + switch self { + case .unableToAuthenticateOnDevice: + return UserText.syncDeviceAuthenticationErrorAlertButton + default: + return UserText.ok + } + } + + func onButtonPressed(delegate: ManagementDialogModelDelegate?) { + switch self { + case .unableToAuthenticateOnDevice: + delegate?.openSystemPasswordSettings() + default: + break } } } diff --git a/LocalPackages/SyncUI/Sources/SyncUI/Views/ManagementDialog.swift b/LocalPackages/SyncUI/Sources/SyncUI/Views/ManagementDialog.swift index 6af2621ee1..fb8fa0b623 100644 --- a/LocalPackages/SyncUI/Sources/SyncUI/Views/ManagementDialog.swift +++ b/LocalPackages/SyncUI/Sources/SyncUI/Views/ManagementDialog.swift @@ -30,6 +30,7 @@ public enum ManagementDialogKind: Equatable { case syncWithServer case enterRecoveryCode(code: String) case recoverSyncedData + case empty } public struct ManagementDialog: View { @@ -49,6 +50,10 @@ public struct ManagementDialog: View { return typeDescription + "\n" + errorDescription } + var buttonTitle: String { + return model.syncErrorMessage?.type.buttonTitle ?? UserText.ok + } + public init(model: ManagementDialogModel, recoveryCodeModel: RecoveryCodeViewModel = .init()) { self.model = model self.recoveryCodeModel = recoveryCodeModel @@ -60,7 +65,7 @@ public struct ManagementDialog: View { Alert( title: Text(errorTitle), message: Text(errorDescription), - dismissButton: .default(Text(UserText.ok)) { + dismissButton: .default(Text(buttonTitle)) { model.endFlow() } ) diff --git a/LocalPackages/SyncUI/Sources/SyncUI/internal/UserText.swift b/LocalPackages/SyncUI/Sources/SyncUI/internal/UserText.swift index ad9b47b13e..0bff100318 100644 --- a/LocalPackages/SyncUI/Sources/SyncUI/internal/UserText.swift +++ b/LocalPackages/SyncUI/Sources/SyncUI/internal/UserText.swift @@ -190,6 +190,9 @@ enum UserText { } static let syncErrorAlertTitle = NSLocalizedString("alert.sync-error", bundle: Bundle.module, value: "Sync & Backup Error", comment: "Title for sync error alert") + static let syncDeviceAuthenticationErrorAlertTitle = NSLocalizedString("alert.sync-device-auth-error", bundle: Bundle.module, value: "Sync & Backup Error", comment: "Title for an error alert") + static let syncDeviceAuthenticationErrorAlertButton = NSLocalizedString("alert.sync-device-auth-error-button", bundle: Bundle.module, value: "Go to Settings", comment: "Button Title of an error alert") + static let unableToAuthenticateDevice = NSLocalizedString("alert.unable-to-authenticate-device", bundle: Bundle.module, value: "A device password is required to use Sync & Backup.", comment: "Description for unable to authenticate error") static let unableToSyncToServerDescription = NSLocalizedString("alert.unable-to-sync-to-server-description", bundle: Bundle.module, value: "Unable to connect to the server.", comment: "Description for unable to sync to server error") static let unableToSyncWithAnotherDeviceDescription = NSLocalizedString("alert.unable-to-sync-with-another-device-description", bundle: Bundle.module, value: "Unable to Sync with another device.", comment: "Description for unable to sync with another device error") static let unableToMergeTwoAccountsDescription = NSLocalizedString("alert.unable-to-merge-two-accounts-description", bundle: Bundle.module, value: "To pair these devices, turn off Sync & Backup on one device then tap \"Sync With Another Device\" on the other device.", comment: "Description for unable to merge two accounts error")