diff --git a/Sources/NetworkProtection/Notifications/NetworkProtectionNotification.swift b/Sources/NetworkProtection/Notifications/NetworkProtectionNotification.swift index 829296ff5..86dedc1cc 100644 --- a/Sources/NetworkProtection/Notifications/NetworkProtectionNotification.swift +++ b/Sources/NetworkProtection/Notifications/NetworkProtectionNotification.swift @@ -116,6 +116,7 @@ public enum NetworkProtectionNotification: String { // Error Events case tunnelErrorChanged case controllerErrorChanged + case knownFailureUpdated // New Status Observer case requestStatusUpdate diff --git a/Sources/NetworkProtection/PacketTunnelProvider.swift b/Sources/NetworkProtection/PacketTunnelProvider.swift index 533cdb2b9..8e4c0cea2 100644 --- a/Sources/NetworkProtection/PacketTunnelProvider.swift +++ b/Sources/NetworkProtection/PacketTunnelProvider.swift @@ -63,7 +63,7 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { // MARK: - Error Handling - enum TunnelError: LocalizedError, CustomNSError { + public enum TunnelError: LocalizedError, CustomNSError, SilentErrorConvertible { // Tunnel Setup Errors - 0+ case startingTunnelWithoutAuthToken case couldNotGenerateTunnelConfiguration(internalError: Error) @@ -72,7 +72,7 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { // Subscription Errors - 100+ case vpnAccessRevoked - var errorDescription: String? { + public var errorDescription: String? { switch self { case .startingTunnelWithoutAuthToken: return "Missing auth token at startup" @@ -85,7 +85,7 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { } } - var errorCode: Int { + public var errorCode: Int { switch self { // Tunnel Setup Errors - 0+ case .startingTunnelWithoutAuthToken: return 0 @@ -96,7 +96,7 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { } } - var errorUserInfo: [String: Any] { + public var errorUserInfo: [String: Any] { switch self { case .startingTunnelWithoutAuthToken, .simulateTunnelFailureError, @@ -106,6 +106,16 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { return [NSUnderlyingErrorKey: underlyingError] } } + + public var asSilentError: KnownFailure.SilentError? { + guard case .couldNotGenerateTunnelConfiguration(let internalError) = self, + let clientError = internalError as? NetworkProtectionClientError, + case .failedToFetchRegisteredServers = clientError else { + return nil + } + + return .registeredServerFetchingFailed + } } // MARK: - WireGuard @@ -335,6 +345,7 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { private let bandwidthAnalyzer = NetworkProtectionConnectionBandwidthAnalyzer() private let tunnelHealth: NetworkProtectionTunnelHealthStore private let controllerErrorStore: NetworkProtectionTunnelErrorStore + private let knownFailureStore: NetworkProtectionKnownFailureStore // MARK: - Cancellables @@ -352,6 +363,7 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { public init(notificationsPresenter: NetworkProtectionNotificationsPresenter, tunnelHealthStore: NetworkProtectionTunnelHealthStore, controllerErrorStore: NetworkProtectionTunnelErrorStore, + knownFailureStore: NetworkProtectionKnownFailureStore = NetworkProtectionKnownFailureStore(), keychainType: KeychainType, tokenStore: NetworkProtectionTokenStore, debugEvents: EventMapping?, @@ -369,6 +381,7 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { self.providerEvents = providerEvents self.tunnelHealth = tunnelHealthStore self.controllerErrorStore = controllerErrorStore + self.knownFailureStore = knownFailureStore self.settings = settings self.defaults = defaults self.isSubscriptionEnabled = isSubscriptionEnabled @@ -563,6 +576,7 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { os_log("Tunnel startup error: %{public}@", type: .error, errorDescription) self?.controllerErrorStore.lastErrorMessage = errorDescription self?.connectionStatus = .disconnected + self?.knownFailureStore.lastKnownFailure = KnownFailure(error) providerEvents.fire(.tunnelStartAttempt(.failure(error))) completionHandler(error) diff --git a/Sources/NetworkProtection/Status/KnownFailure.swift b/Sources/NetworkProtection/Status/KnownFailure.swift new file mode 100644 index 000000000..53ec740f8 --- /dev/null +++ b/Sources/NetworkProtection/Status/KnownFailure.swift @@ -0,0 +1,50 @@ +// +// KnownFailure.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +public protocol SilentErrorConvertible: Error { + var asSilentError: KnownFailure.SilentError? { get } +} + +@objc +final public class KnownFailure: NSObject, Codable { + public typealias SilentErrorCode = Int + + public enum SilentError: SilentErrorCode { + case operationNotPermitted + case loginItemVersionMismatched + case registeredServerFetchingFailed + } + + public let error: SilentErrorCode + + public init?(_ error: Error?) { + if let nsError = error as? NSError, nsError.domain == "SMAppServiceErrorDomain", nsError.code == 1 { + self.error = SilentError.operationNotPermitted.rawValue + return + } + + if let error = error as? SilentErrorConvertible, let silentError = error.asSilentError { + self.error = silentError.rawValue + return + } + + return nil + } +} diff --git a/Sources/NetworkProtection/Status/KnownFailureObserver/KnownFailureObserver.swift b/Sources/NetworkProtection/Status/KnownFailureObserver/KnownFailureObserver.swift new file mode 100644 index 000000000..b1ba8d686 --- /dev/null +++ b/Sources/NetworkProtection/Status/KnownFailureObserver/KnownFailureObserver.swift @@ -0,0 +1,26 @@ +// +// KnownFailureObserver.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import Foundation +import NetworkExtension + +public protocol KnownFailureObserver { + var publisher: AnyPublisher { get } + var recentValue: KnownFailure? { get } +} diff --git a/Sources/NetworkProtection/Status/KnownFailureObserver/KnownFailureObserverThroughDistributedNotifications.swift b/Sources/NetworkProtection/Status/KnownFailureObserver/KnownFailureObserverThroughDistributedNotifications.swift new file mode 100644 index 000000000..5ea43c300 --- /dev/null +++ b/Sources/NetworkProtection/Status/KnownFailureObserver/KnownFailureObserverThroughDistributedNotifications.swift @@ -0,0 +1,60 @@ +// +// KnownFailureObserverThroughDistributedNotifications.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +#if os(macOS) + +import Combine +import Foundation +import NetworkExtension +import NotificationCenter + +public class KnownFailureObserverThroughDistributedNotifications: KnownFailureObserver { + public lazy var publisher = subject.eraseToAnyPublisher() + public var recentValue: KnownFailure? { + subject.value + } + + private let subject = CurrentValueSubject(nil) + + private let distributedNotificationCenter: DistributedNotificationCenter + private var cancellable: AnyCancellable? + + public init(distributedNotificationCenter: DistributedNotificationCenter = .default()) { + self.distributedNotificationCenter = distributedNotificationCenter + + start() + } + + private func start() { + cancellable = distributedNotificationCenter.publisher(for: .knownFailureUpdated).sink { [weak self] notification in + self?.handleKnownFailureUpdated(notification) + } + } + + private func handleKnownFailureUpdated(_ notification: Notification) { + if let object = notification.object as? String, + let data = object.data(using: .utf8), + let failure = try? JSONDecoder().decode(KnownFailure.self, from: data) { + subject.send(failure) + } else { + subject.send(nil) + } + } +} + +#endif diff --git a/Sources/NetworkProtection/Status/NetworkProtectionStatusReporter.swift b/Sources/NetworkProtection/Status/NetworkProtectionStatusReporter.swift index a83e09891..f030141a4 100644 --- a/Sources/NetworkProtection/Status/NetworkProtectionStatusReporter.swift +++ b/Sources/NetworkProtection/Status/NetworkProtectionStatusReporter.swift @@ -29,6 +29,7 @@ public protocol NetworkProtectionStatusReporter { var connectivityIssuesObserver: ConnectivityIssueObserver { get } var controllerErrorMessageObserver: ControllerErrorMesssageObserver { get } var dataVolumeObserver: DataVolumeObserver { get } + var knownFailureObserver: KnownFailureObserver { get } func forceRefresh() } @@ -70,6 +71,7 @@ public final class DefaultNetworkProtectionStatusReporter: NetworkProtectionStat public let connectivityIssuesObserver: ConnectivityIssueObserver public let controllerErrorMessageObserver: ControllerErrorMesssageObserver public let dataVolumeObserver: DataVolumeObserver + public let knownFailureObserver: KnownFailureObserver // MARK: - Init & deinit @@ -79,6 +81,7 @@ public final class DefaultNetworkProtectionStatusReporter: NetworkProtectionStat connectivityIssuesObserver: ConnectivityIssueObserver, controllerErrorMessageObserver: ControllerErrorMesssageObserver, dataVolumeObserver: DataVolumeObserver, + knownFailureObserver: KnownFailureObserver, distributedNotificationCenter: DistributedNotificationCenter = .default()) { self.statusObserver = statusObserver @@ -87,6 +90,7 @@ public final class DefaultNetworkProtectionStatusReporter: NetworkProtectionStat self.connectivityIssuesObserver = connectivityIssuesObserver self.controllerErrorMessageObserver = controllerErrorMessageObserver self.dataVolumeObserver = dataVolumeObserver + self.knownFailureObserver = knownFailureObserver self.distributedNotificationCenter = distributedNotificationCenter start() diff --git a/Sources/NetworkProtection/Storage/NetworkProtectionKnownFailureStore.swift b/Sources/NetworkProtection/Storage/NetworkProtectionKnownFailureStore.swift new file mode 100644 index 000000000..294571ecf --- /dev/null +++ b/Sources/NetworkProtection/Storage/NetworkProtectionKnownFailureStore.swift @@ -0,0 +1,76 @@ +// +// NetworkProtectionKnownFailureStore.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import Common + +final public class NetworkProtectionKnownFailureStore { + private static let lastKnownFailureKey = "com.duckduckgo.NetworkProtectionKnownFailureStore.knownFailure" + private let userDefaults: UserDefaults + +#if os(macOS) + private let distributedNotificationCenter: DistributedNotificationCenter + + public init(userDefaults: UserDefaults = .standard, + distributedNotificationCenter: DistributedNotificationCenter = .default()) { + self.userDefaults = userDefaults + self.distributedNotificationCenter = distributedNotificationCenter + } +#else + public init(userDefaults: UserDefaults = .standard) { + self.userDefaults = userDefaults + } +#endif + + public var lastKnownFailure: KnownFailure? { + get { + guard let data = userDefaults.data(forKey: Self.lastKnownFailureKey) else { return nil } + return try? JSONDecoder().decode(KnownFailure.self, from: data) + } + + set { + if let newValue, let data = try? JSONEncoder().encode(newValue) { + userDefaults.set(data, forKey: Self.lastKnownFailureKey) +#if os(macOS) + postKnownFailureUpdatedNotification(data: data) +#endif + } else { + userDefaults.removeObject(forKey: Self.lastKnownFailureKey) +#if os(macOS) + postKnownFailureUpdatedNotification() +#endif + } + } + } + + public func reset() { + lastKnownFailure = nil + } + + // MARK: - Posting Notifications + +#if os(macOS) + private func postKnownFailureUpdatedNotification(data: Data? = nil) { + let object: String? = { + guard let data else { return nil } + return String(data: data, encoding: .utf8) + }() + distributedNotificationCenter.post(.knownFailureUpdated, object: object) + } +#endif +} diff --git a/Sources/NetworkProtectionTestUtils/Status/MockKnownFailureObserver.swift b/Sources/NetworkProtectionTestUtils/Status/MockKnownFailureObserver.swift new file mode 100644 index 000000000..5fadc4db5 --- /dev/null +++ b/Sources/NetworkProtectionTestUtils/Status/MockKnownFailureObserver.swift @@ -0,0 +1,30 @@ +// +// MockKnownFailureObserver.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import Foundation +import NetworkProtection + +public final class MockKnownFailureObserver: KnownFailureObserver { + public init() {} + public let subject = CurrentValueSubject(nil) + public lazy var publisher = subject.eraseToAnyPublisher() + public var recentValue: KnownFailure? { + subject.value + } +} diff --git a/Sources/NetworkProtectionTestUtils/Status/MockNetworkProtectionStatusReporter.swift b/Sources/NetworkProtectionTestUtils/Status/MockNetworkProtectionStatusReporter.swift index 9df4f1460..4656e0597 100644 --- a/Sources/NetworkProtectionTestUtils/Status/MockNetworkProtectionStatusReporter.swift +++ b/Sources/NetworkProtectionTestUtils/Status/MockNetworkProtectionStatusReporter.swift @@ -35,6 +35,7 @@ public final class MockNetworkProtectionStatusReporter: NetworkProtectionStatusR public let connectivityIssuesObserver: ConnectivityIssueObserver public let controllerErrorMessageObserver: ControllerErrorMesssageObserver public let dataVolumeObserver: DataVolumeObserver + public let knownFailureObserver: KnownFailureObserver // MARK: - Init & deinit @@ -44,6 +45,7 @@ public final class MockNetworkProtectionStatusReporter: NetworkProtectionStatusR connectivityIssuesObserver: ConnectivityIssueObserver = MockConnectivityIssueObserver(), controllerErrorMessageObserver: ControllerErrorMesssageObserver = MockControllerErrorMesssageObserver(), dataVolumeObserver: DataVolumeObserver = MockDataVolumeObserver(), + knownFailureObserver: KnownFailureObserver = MockKnownFailureObserver(), distributedNotificationCenter: DistributedNotificationCenter = .default()) { self.statusObserver = statusObserver @@ -51,6 +53,7 @@ public final class MockNetworkProtectionStatusReporter: NetworkProtectionStatusR self.connectionErrorObserver = connectionErrorObserver self.connectivityIssuesObserver = connectivityIssuesObserver self.dataVolumeObserver = dataVolumeObserver + self.knownFailureObserver = knownFailureObserver self.controllerErrorMessageObserver = controllerErrorMessageObserver } diff --git a/Tests/NetworkProtectionTests/KnownFailureTests.swift b/Tests/NetworkProtectionTests/KnownFailureTests.swift new file mode 100644 index 000000000..41f06648f --- /dev/null +++ b/Tests/NetworkProtectionTests/KnownFailureTests.swift @@ -0,0 +1,36 @@ +// +// KnownFailureTests.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +import Foundation +@testable import NetworkProtection + +final class KnownFailureTests: XCTestCase { + func testHardCodedErrorInitializer() { + let error = NSError(domain: "SMAppServiceErrorDomain", code: 1) + XCTAssertEqual(KnownFailure(error)!.error, KnownFailure.SilentError.operationNotPermitted.rawValue) + } + + func testNonHardCodedErrorInitializer() { + let internalError = NetworkProtectionClientError.failedToFetchRegisteredServers("404") + let error = PacketTunnelProvider.TunnelError.couldNotGenerateTunnelConfiguration(internalError: internalError) + XCTAssertEqual(KnownFailure(error)!.error, KnownFailure.SilentError.registeredServerFetchingFailed.rawValue) + } +} + +extension String: Error {}