From a390dfd0692d178627ac6463f46c1472c8b9888a Mon Sep 17 00:00:00 2001 From: Anh Do <18567+quanganhdo@users.noreply.github.com> Date: Tue, 4 Jun 2024 11:34:35 -0400 Subject: [PATCH] Surface specific XPC & login item errors (#2773) Task/Issue URL: https://app.asana.com/0/72649045549333/1207013105069620/f Tech Design URL: CC: BSK PR: https://github.com/duckduckgo/BrowserServicesKit/pull/819 **Description**: **Steps to test this PR**: 1. For the UI, go to Debug > VPN > Simulate known failure and check the warning banner in the VPN popover 2. To check if XPC errors are surfaced, go to `NetworkProtectionIPCTunnelController.start()`, and add a simulated error like `handleFailure(NSError(domain: "SMAppServiceErrorDomain", code: 1))` inside `handleFailure(_:)` 3. To check if login item version mismatch is handled, go to `TunnelControllerIPCService.register(version:bundlePath:completion)` and change the `DefaultIPCMetadataCollector.version != version` to `DefaultIPCMetadataCollector.version == version` 4. To check if ClientError code 5 is handled, block access to NetP endpoint (at router level or using Proxyman), then try to start the VPN. 5. Use Debug > VPN > Log metadata to console to check if lastKnownFailureDescription is recorded --- DuckDuckGo.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- .../UserText+NetworkProtection.swift | 2 +- .../MainWindow/MainViewController.swift | 7 ++- .../View/NetPPopoverManagerMock.swift | 6 ++ .../NetworkProtectionDebugMenu.swift | 7 +++ ...etworkProtectionNavBarPopoverManager.swift | 3 +- .../NetworkProtectionTunnelController.swift | 3 + .../TunnelControllerProvider.swift | 5 +- ...NetworkProtectionIPCTunnelController.swift | 8 ++- .../VPNMetadataCollector.swift | 8 ++- DuckDuckGoVPN/DuckDuckGoVPNAppDelegate.swift | 3 +- .../TunnelControllerIPCService.swift | 55 +++++++++++++++++- .../DataBrokerProtection/Package.swift | 2 +- .../NetworkProtectionMac/Package.swift | 2 +- .../IPCMetadataCollector.swift | 44 +++++++++++++++ .../KnownFailureObserverThroughIPC.swift | 39 +++++++++++++ .../TunnelControllerIPCClient.swift | 56 +++++++++++++------ .../TunnelControllerIPCServer.swift | 30 ++++++++-- .../UserText+NetworkProtectionUI.swift | 4 ++ .../NetworkProtectionStatusView.swift | 32 ++--------- .../NetworkProtectionStatusViewModel.swift | 50 +++++++++++++++++ .../Views/WarningView/WarningView.swift | 54 ++++++++++++++++++ .../Views/WarningView/WarningViewModel.swift | 35 ++++++++++++ .../TunnelControllerViewModelTests.swift | 8 ++- LocalPackages/SubscriptionUI/Package.swift | 2 +- .../SwiftUIExtensions/ButtonStyles.swift | 9 ++- .../VPNFeedbackFormViewModelTests.swift | 1 + 28 files changed, 412 insertions(+), 69 deletions(-) create mode 100644 LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionIPC/IPCMetadataCollector.swift create mode 100644 LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionIPC/Observers/KnownFailureObserverThroughIPC.swift create mode 100644 LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/WarningView/WarningView.swift create mode 100644 LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/WarningView/WarningViewModel.swift diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 678168e0eb..537548fe8a 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -12993,7 +12993,7 @@ repositoryURL = "https://github.com/duckduckgo/BrowserServicesKit"; requirement = { kind = exactVersion; - version = 150.0.0; + version = 150.1.0; }; }; 9FF521422BAA8FF300B9819B /* XCRemoteSwiftPackageReference "lottie-spm" */ = { diff --git a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 8d596f9f94..6fa7ef0118 100644 --- a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/duckduckgo/BrowserServicesKit", "state" : { - "revision" : "03e6b719671c5baaa2afa474d447b707bf595820", - "version" : "150.0.0" + "revision" : "79fe0c99e43c6c1bf2c0a4d397368033fd37eae9", + "version" : "150.1.0" } }, { diff --git a/DuckDuckGo/Common/Localizables/UserText+NetworkProtection.swift b/DuckDuckGo/Common/Localizables/UserText+NetworkProtection.swift index d908735e23..27cb828025 100644 --- a/DuckDuckGo/Common/Localizables/UserText+NetworkProtection.swift +++ b/DuckDuckGo/Common/Localizables/UserText+NetworkProtection.swift @@ -65,7 +65,7 @@ extension UserText { } // "network.protection.system.extension.unknown.activation.error" - Message shown to users when they try to enable NetP and there is an unexpected activation error. - static let networkProtectionUnknownActivationError = "There as an unexpected error. Please try again." + static let networkProtectionUnknownActivationError = "There was an unexpected error. Please try again." // "network.protection.system.extension.please.reboot" - Message shown to users when they try to enable NetP and they need to reboot the computer to complete the installation static let networkProtectionPleaseReboot = "VPN update available. Restart your Mac to reconnect." } diff --git a/DuckDuckGo/MainWindow/MainViewController.swift b/DuckDuckGo/MainWindow/MainViewController.swift index 8d4c5852a9..4624b91d14 100644 --- a/DuckDuckGo/MainWindow/MainViewController.swift +++ b/DuckDuckGo/MainWindow/MainViewController.swift @@ -71,7 +71,9 @@ final class MainViewController: NSViewController { #endif let ipcClient = TunnelControllerIPCClient() - ipcClient.register() + ipcClient.register { error in + NetworkProtectionKnownFailureStore().lastKnownFailure = KnownFailure(error) + } let vpnUninstaller = VPNUninstaller(ipcClient: ipcClient) return NetworkProtectionNavBarPopoverManager( @@ -97,7 +99,8 @@ final class MainViewController: NSViewController { connectionErrorObserver: ipcClient.ipcConnectionErrorObserver, connectivityIssuesObserver: connectivityIssuesObserver, controllerErrorMessageObserver: controllerErrorMessageObserver, - dataVolumeObserver: ipcClient.ipcDataVolumeObserver + dataVolumeObserver: ipcClient.ipcDataVolumeObserver, + knownFailureObserver: KnownFailureObserverThroughDistributedNotifications() ) }() diff --git a/DuckDuckGo/NavigationBar/View/NetPPopoverManagerMock.swift b/DuckDuckGo/NavigationBar/View/NetPPopoverManagerMock.swift index 033f795005..3b0d6efd9a 100644 --- a/DuckDuckGo/NavigationBar/View/NetPPopoverManagerMock.swift +++ b/DuckDuckGo/NavigationBar/View/NetPPopoverManagerMock.swift @@ -74,6 +74,12 @@ final class IPCClientMock: NetworkProtectionIPCClient { } var ipcDataVolumeObserver: any NetworkProtection.DataVolumeObserver = DataVolumeObserverMock() + final class KnownFailureObserverMock: NetworkProtection.KnownFailureObserver { + var publisher: AnyPublisher = PassthroughSubject().eraseToAnyPublisher() + var recentValue: KnownFailure? + } + var ipcKnownFailureObserver: any NetworkProtection.KnownFailureObserver = KnownFailureObserverMock() + func start(completion: @escaping (Error?) -> Void) { completion(nil) } diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugMenu.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugMenu.swift index 23fb71b7ab..418ba71d9f 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugMenu.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugMenu.swift @@ -405,6 +405,13 @@ final class NetworkProtectionDebugMenu: NSMenu { } + func menuItem(title: String, action: Selector, representedObject: Any?) -> NSMenuItem { + let menuItem = NSMenuItem(title: title, action: action, keyEquivalent: "") + menuItem.target = self + menuItem.representedObject = representedObject + return menuItem + } + // MARK: - Menu State Update override func update() { diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionNavBarPopoverManager.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionNavBarPopoverManager.swift index 82cd98ab95..9b4946913e 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionNavBarPopoverManager.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionNavBarPopoverManager.swift @@ -69,7 +69,8 @@ final class NetworkProtectionNavBarPopoverManager: NetPPopoverManager { connectionErrorObserver: ipcClient.ipcConnectionErrorObserver, connectivityIssuesObserver: ConnectivityIssueObserverThroughDistributedNotifications(), controllerErrorMessageObserver: ControllerErrorMesssageObserverThroughDistributedNotifications(), - dataVolumeObserver: ipcClient.ipcDataVolumeObserver + dataVolumeObserver: ipcClient.ipcDataVolumeObserver, + knownFailureObserver: KnownFailureObserverThroughDistributedNotifications() ) let onboardingStatusPublisher = UserDefaults.netP.networkProtectionOnboardingStatusPublisher diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionTunnelController.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionTunnelController.swift index 8610b020c7..62e32dc0a6 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionTunnelController.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionTunnelController.swift @@ -68,6 +68,8 @@ final class NetworkProtectionTunnelController: TunnelController, TunnelSessionPr /// private let controllerErrorStore = NetworkProtectionControllerErrorStore() + private let knownFailureStore = NetworkProtectionKnownFailureStore() + // MARK: - VPN Tunnel & Configuration /// Auth token store @@ -597,6 +599,7 @@ final class NetworkProtectionTunnelController: TunnelController, TunnelSessionPr } } catch { VPNOperationErrorRecorder().recordControllerStartFailure(error) + knownFailureStore.lastKnownFailure = KnownFailure(error) if case StartError.cancelled = error { PixelKit.fire( diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/TunnelControllerProvider.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/TunnelControllerProvider.swift index 1d0c8efa53..1b50a1156c 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/TunnelControllerProvider.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/TunnelControllerProvider.swift @@ -17,6 +17,7 @@ // import Foundation +import NetworkProtection import NetworkProtectionIPC final class TunnelControllerProvider { @@ -26,7 +27,9 @@ final class TunnelControllerProvider { private init() { let ipcClient = TunnelControllerIPCClient() - ipcClient.register() + ipcClient.register { error in + NetworkProtectionKnownFailureStore().lastKnownFailure = KnownFailure(error) + } tunnelController = NetworkProtectionIPCTunnelController(ipcClient: ipcClient) } diff --git a/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionIPCTunnelController.swift b/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionIPCTunnelController.swift index 028b3ba4cc..9a883ab588 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionIPCTunnelController.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/NetworkProtectionIPCTunnelController.swift @@ -52,18 +52,21 @@ final class NetworkProtectionIPCTunnelController { private let ipcClient: NetworkProtectionIPCClient private let pixelKit: PixelFiring? private let errorRecorder: VPNOperationErrorRecorder + private let knownFailureStore: NetworkProtectionKnownFailureStore init(featureVisibility: NetworkProtectionFeatureVisibility = DefaultNetworkProtectionVisibility(subscriptionManager: Application.appDelegate.subscriptionManager), loginItemsManager: LoginItemsManaging = LoginItemsManager(), ipcClient: NetworkProtectionIPCClient, pixelKit: PixelFiring? = PixelKit.shared, - errorRecorder: VPNOperationErrorRecorder = VPNOperationErrorRecorder()) { + errorRecorder: VPNOperationErrorRecorder = VPNOperationErrorRecorder(), + knownFailureStore: NetworkProtectionKnownFailureStore = NetworkProtectionKnownFailureStore()) { self.featureVisibility = featureVisibility self.loginItemsManager = loginItemsManager self.ipcClient = ipcClient self.pixelKit = pixelKit self.errorRecorder = errorRecorder + self.knownFailureStore = knownFailureStore } // MARK: - Login Items Manager @@ -91,6 +94,7 @@ extension NetworkProtectionIPCTunnelController: TunnelController { pixelKit?.fire(StartAttempt.begin) func handleFailure(_ error: Error) { + knownFailureStore.lastKnownFailure = KnownFailure(error) errorRecorder.recordIPCStartFailure(error) log(error) pixelKit?.fire(StartAttempt.failure(error), frequency: .dailyAndCount) @@ -99,6 +103,8 @@ extension NetworkProtectionIPCTunnelController: TunnelController { do { try await enableLoginItems() + knownFailureStore.reset() + ipcClient.start { [pixelKit] error in if let error { handleFailure(error) diff --git a/DuckDuckGo/VPNFeedbackForm/VPNMetadataCollector.swift b/DuckDuckGo/VPNFeedbackForm/VPNMetadataCollector.swift index e69c0c4a41..51f64b8912 100644 --- a/DuckDuckGo/VPNFeedbackForm/VPNMetadataCollector.swift +++ b/DuckDuckGo/VPNFeedbackForm/VPNMetadataCollector.swift @@ -52,6 +52,7 @@ struct VPNMetadata: Encodable { let connectionState: String let lastStartErrorDescription: String let lastTunnelErrorDescription: String + let lastKnownFailureDescription: String let connectedServer: String let connectedServerIP: String } @@ -127,7 +128,7 @@ final class DefaultVPNMetadataCollector: VPNMetadataCollector { init(defaults: UserDefaults = .netP, accountManager: AccountManaging) { let ipcClient = TunnelControllerIPCClient() - ipcClient.register() + ipcClient.register { _ in } self.accountManager = accountManager self.ipcClient = ipcClient self.defaults = defaults @@ -138,7 +139,8 @@ final class DefaultVPNMetadataCollector: VPNMetadataCollector { connectionErrorObserver: ipcClient.connectionErrorObserver, connectivityIssuesObserver: ConnectivityIssueObserverThroughDistributedNotifications(), controllerErrorMessageObserver: ControllerErrorMesssageObserverThroughDistributedNotifications(), - dataVolumeObserver: ipcClient.dataVolumeObserver + dataVolumeObserver: ipcClient.dataVolumeObserver, + knownFailureObserver: KnownFailureObserverThroughDistributedNotifications() ) // Force refresh just in case. A refresh is requested when the IPC client is created, but distributed notifications don't guarantee delivery @@ -266,12 +268,14 @@ final class DefaultVPNMetadataCollector: VPNMetadataCollector { let connectionState = String(describing: statusReporter.statusObserver.recentValue) let lastTunnelErrorDescription = await errorHistory.lastTunnelErrorDescription + let lastKnownFailureDescription = NetworkProtectionKnownFailureStore().lastKnownFailure?.description ?? "none" let connectedServer = statusReporter.serverInfoObserver.recentValue.serverLocation?.serverLocation ?? "none" let connectedServerIP = statusReporter.serverInfoObserver.recentValue.serverAddress ?? "none" return .init(onboardingState: onboardingState, connectionState: connectionState, lastStartErrorDescription: errorHistory.lastStartErrorDescription, lastTunnelErrorDescription: lastTunnelErrorDescription, + lastKnownFailureDescription: lastKnownFailureDescription, connectedServer: connectedServer, connectedServerIP: connectedServerIP) } diff --git a/DuckDuckGoVPN/DuckDuckGoVPNAppDelegate.swift b/DuckDuckGoVPN/DuckDuckGoVPNAppDelegate.swift index de78d86b57..406272e733 100644 --- a/DuckDuckGoVPN/DuckDuckGoVPNAppDelegate.swift +++ b/DuckDuckGoVPN/DuckDuckGoVPNAppDelegate.swift @@ -266,7 +266,8 @@ final class DuckDuckGoVPNAppDelegate: NSObject, NSApplicationDelegate { connectionErrorObserver: errorObserver, connectivityIssuesObserver: DisabledConnectivityIssueObserver(), controllerErrorMessageObserver: ControllerErrorMesssageObserverThroughDistributedNotifications(), - dataVolumeObserver: dataVolumeObserver + dataVolumeObserver: dataVolumeObserver, + knownFailureObserver: KnownFailureObserverThroughDistributedNotifications() ) }() diff --git a/DuckDuckGoVPN/TunnelControllerIPCService.swift b/DuckDuckGoVPN/TunnelControllerIPCService.swift index 2b23d6c658..c79aedc986 100644 --- a/DuckDuckGoVPN/TunnelControllerIPCService.swift +++ b/DuckDuckGoVPN/TunnelControllerIPCService.swift @@ -37,6 +37,16 @@ final class TunnelControllerIPCService { private var cancellables = Set() private let defaults: UserDefaults + enum IPCError: SilentErrorConvertible { + case versionMismatched + + var asSilentError: KnownFailure.SilentError? { + switch self { + case .versionMismatched: return .loginItemVersionMismatched + } + } + } + init(tunnelController: NetworkProtectionTunnelController, uninstaller: VPNUninstalling, networkExtensionController: NetworkExtensionController, @@ -53,6 +63,7 @@ final class TunnelControllerIPCService { subscribeToErrorChanges() subscribeToStatusUpdates() subscribeToServerChanges() + subscribeToKnownFailureUpdates() subscribeToDataVolumeUpdates() server.serverDelegate = self @@ -89,6 +100,15 @@ final class TunnelControllerIPCService { .store(in: &cancellables) } + private func subscribeToKnownFailureUpdates() { + statusReporter.knownFailureObserver.publisher + .subscribe(on: DispatchQueue.main) + .sink { [weak self] failure in + self?.server.knownFailureUpdated(failure) + } + .store(in: &cancellables) + } + private func subscribeToDataVolumeUpdates() { statusReporter.dataVolumeObserver.publisher .subscribe(on: DispatchQueue.main) @@ -103,9 +123,20 @@ final class TunnelControllerIPCService { extension TunnelControllerIPCService: IPCServerInterface { - func register() { + func register(completion: @escaping (Error?) -> Void) { + register(version: version, bundlePath: bundlePath, completion: completion) + } + + func register(version: String, bundlePath: String, completion: @escaping (Error?) -> Void) { server.serverInfoChanged(statusReporter.serverInfoObserver.recentValue) server.statusChanged(statusReporter.statusObserver.recentValue) + if self.version != version { + let error = TunnelControllerIPCService.IPCError.versionMismatched + NetworkProtectionKnownFailureStore().lastKnownFailure = KnownFailure(error) + completion(error) + } else { + completion(nil) + } } func start(completion: @escaping (Error?) -> Void) { @@ -169,3 +200,25 @@ extension TunnelControllerIPCService: IPCServerInterface { } } } + +// MARK: - Error Handling + +extension TunnelControllerIPCService.IPCError: LocalizedError, CustomNSError { + var errorDescription: String? { + switch self { + case .versionMismatched: return "Login item version mismatched" + } + } + + var errorCode: Int { + switch self { + case .versionMismatched: return 0 + } + } + + var errorUserInfo: [String: Any] { + switch self { + case .versionMismatched: return [:] + } + } +} diff --git a/LocalPackages/DataBrokerProtection/Package.swift b/LocalPackages/DataBrokerProtection/Package.swift index 5e03b7e95c..745a4c468d 100644 --- a/LocalPackages/DataBrokerProtection/Package.swift +++ b/LocalPackages/DataBrokerProtection/Package.swift @@ -29,7 +29,7 @@ let package = Package( targets: ["DataBrokerProtection"]) ], dependencies: [ - .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "150.0.0"), + .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "150.1.0"), .package(path: "../SwiftUIExtensions"), .package(path: "../XPCHelper"), ], diff --git a/LocalPackages/NetworkProtectionMac/Package.swift b/LocalPackages/NetworkProtectionMac/Package.swift index bf3e2340cf..12be7911f0 100644 --- a/LocalPackages/NetworkProtectionMac/Package.swift +++ b/LocalPackages/NetworkProtectionMac/Package.swift @@ -31,7 +31,7 @@ let package = Package( .library(name: "NetworkProtectionUI", targets: ["NetworkProtectionUI"]), ], dependencies: [ - .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "150.0.0"), + .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "150.1.0"), .package(url: "https://github.com/airbnb/lottie-spm", exact: "4.4.1"), .package(path: "../XPCHelper"), .package(path: "../SwiftUIExtensions"), diff --git a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionIPC/IPCMetadataCollector.swift b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionIPC/IPCMetadataCollector.swift new file mode 100644 index 0000000000..c1b5129370 --- /dev/null +++ b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionIPC/IPCMetadataCollector.swift @@ -0,0 +1,44 @@ +// +// IPCMetadataCollector.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 IPCMetadataCollector { + static var version: String { get } + static var bundlePath: String { get } +} + +final public class DefaultIPCMetadataCollector: IPCMetadataCollector { + public static var version: String { + shortVersion + "/" + buildNumber + } + + public static var bundlePath: String { + Bundle.main.bundlePath + } + + // swiftlint:disable force_cast + private static var shortVersion: String { + Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as! String + } + + private static var buildNumber: String { + Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as! String + } + // swiftlint:enable force_cast +} diff --git a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionIPC/Observers/KnownFailureObserverThroughIPC.swift b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionIPC/Observers/KnownFailureObserverThroughIPC.swift new file mode 100644 index 0000000000..ab95ad66fe --- /dev/null +++ b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionIPC/Observers/KnownFailureObserverThroughIPC.swift @@ -0,0 +1,39 @@ +// +// KnownFailureObserverThroughIPC.swift +// +// Copyright © 2023 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 Combine +import NetworkProtection + +public final class KnownFailureObserverThroughIPC: KnownFailureObserver { + private let subject = CurrentValueSubject(nil) + + // MARK: - KnownFailureObserver + + public lazy var publisher = subject.eraseToAnyPublisher() + + public var recentValue: KnownFailure? { + subject.value + } + + // MARK: - Publishing Updates + + func publish(_ error: KnownFailure?) { + subject.send(error) + } +} diff --git a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionIPC/TunnelControllerIPCClient.swift b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionIPC/TunnelControllerIPCClient.swift index 58e060f78a..84a68750d0 100644 --- a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionIPC/TunnelControllerIPCClient.swift +++ b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionIPC/TunnelControllerIPCClient.swift @@ -27,6 +27,7 @@ public protocol IPCClientInterface: AnyObject { func serverInfoChanged(_ serverInfo: NetworkProtectionStatusServerInfo) func statusChanged(_ status: ConnectionStatus) func dataVolumeUpdated(_ dataVolume: DataVolume) + func knownFailureUpdated(_ failure: KnownFailure?) } /// This is the XPC interface with parameters that can be packed properly @@ -36,6 +37,7 @@ protocol XPCClientInterface { func serverInfoChanged(payload: Data) func statusChanged(payload: Data) func dataVolumeUpdated(payload: Data) + func knownFailureUpdated(failure: KnownFailure?) } public final class TunnelControllerIPCClient { @@ -50,6 +52,7 @@ public final class TunnelControllerIPCClient { public var connectionErrorObserver = ConnectionErrorObserverThroughIPC() public var connectionStatusObserver = ConnectionStatusObserverThroughIPC() public var dataVolumeObserver = DataVolumeObserverThroughIPC() + public var knownFailureObserver = KnownFailureObserverThroughIPC() /// The delegate. /// @@ -69,7 +72,8 @@ public final class TunnelControllerIPCClient { serverInfoObserver: self.serverInfoObserver, connectionErrorObserver: self.connectionErrorObserver, connectionStatusObserver: self.connectionStatusObserver, - dataVolumeObserver: self.dataVolumeObserver + dataVolumeObserver: self.dataVolumeObserver, + knownFailureObserver: self.knownFailureObserver ) xpc = XPCClient( @@ -87,11 +91,11 @@ public final class TunnelControllerIPCClient { // By calling register we make sure that XPC will connect as soon as it // becomes available again, as requests are queued. This helps ensure // that the client app will always be connected to XPC. - self.register() + self.register { _ in } } } - self.register() + self.register { _ in } } } @@ -102,17 +106,20 @@ private final class TunnelControllerXPCClientDelegate: XPCClientInterface { let connectionErrorObserver: ConnectionErrorObserverThroughIPC let connectionStatusObserver: ConnectionStatusObserverThroughIPC let dataVolumeObserver: DataVolumeObserverThroughIPC + let knownFailureObserver: KnownFailureObserverThroughIPC init(clientDelegate: IPCClientInterface?, serverInfoObserver: ConnectionServerInfoObserverThroughIPC, connectionErrorObserver: ConnectionErrorObserverThroughIPC, connectionStatusObserver: ConnectionStatusObserverThroughIPC, - dataVolumeObserver: DataVolumeObserverThroughIPC) { + dataVolumeObserver: DataVolumeObserverThroughIPC, + knownFailureObserver: KnownFailureObserverThroughIPC) { self.clientDelegate = clientDelegate self.serverInfoObserver = serverInfoObserver self.connectionErrorObserver = connectionErrorObserver self.connectionStatusObserver = connectionStatusObserver self.dataVolumeObserver = dataVolumeObserver + self.knownFailureObserver = knownFailureObserver } func errorChanged(error: String?) { @@ -146,30 +153,43 @@ private final class TunnelControllerXPCClientDelegate: XPCClientInterface { dataVolumeObserver.publish(dataVolume) clientDelegate?.dataVolumeUpdated(dataVolume) } + + func knownFailureUpdated(failure: KnownFailure?) { + knownFailureObserver.publish(failure) + clientDelegate?.knownFailureUpdated(failure) + } } // MARK: - Outgoing communication to the server extension TunnelControllerIPCClient: IPCServerInterface { - public func register() { + public func register(completion: @escaping (Error?) -> Void) { + register(version: version, bundlePath: bundlePath, completion: self.onComplete(completion)) + } + + public func register(version: String, bundlePath: String, completion: @escaping (Error?) -> Void) { xpc.execute(call: { server in - server.register() - }, xpcReplyErrorHandler: { _ in - // Intentional no-op as there's no completion block - // If you add a completion block, please remember to call it here too! - }) + server.register(version: version, bundlePath: bundlePath, completion: self.onComplete(completion)) + }, xpcReplyErrorHandler: self.onComplete(completion)) + } + + public func onComplete(_ completion: @escaping (Error?) -> Void) -> (Error?) -> Void { + { [weak self] error in + self?.xpcDelegate.knownFailureUpdated(failure: .init(error)) + completion(error) + } } public func start(completion: @escaping (Error?) -> Void) { xpc.execute(call: { server in - server.start(completion: completion) - }, xpcReplyErrorHandler: completion) + server.start(completion: self.onComplete(completion)) + }, xpcReplyErrorHandler: self.onComplete(completion)) } public func stop(completion: @escaping (Error?) -> Void) { xpc.execute(call: { server in - server.stop(completion: completion) - }, xpcReplyErrorHandler: completion) + server.stop(completion: self.onComplete(completion)) + }, xpcReplyErrorHandler: self.onComplete(completion)) } public func fetchLastError(completion: @escaping (Error?) -> Void) { @@ -185,16 +205,16 @@ extension TunnelControllerIPCClient: IPCServerInterface { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in xpc.execute(call: { server in - server.command(payload) { error in + server.command(payload) { [weak self] error in if let error { + self?.xpcDelegate.knownFailureUpdated(failure: .init(error)) continuation.resume(throwing: error) } else { continuation.resume() } } - }, xpcReplyErrorHandler: { error in - // Intentional no-op as there's no completion block - // If you add a completion block, please remember to call it here too! + }, xpcReplyErrorHandler: { [weak self] error in + self?.xpcDelegate.knownFailureUpdated(failure: .init(error)) continuation.resume(throwing: error) }) } diff --git a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionIPC/TunnelControllerIPCServer.swift b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionIPC/TunnelControllerIPCServer.swift index e1103a3592..576e270415 100644 --- a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionIPC/TunnelControllerIPCServer.swift +++ b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionIPC/TunnelControllerIPCServer.swift @@ -23,11 +23,16 @@ import XPCHelper /// This protocol describes the server-side IPC interface for controlling the tunnel /// public protocol IPCServerInterface: AnyObject { + var version: String { get } + var bundlePath: String { get } + /// Registers a connection with the server. /// /// This is the point where the server will start sending status updates to the client. /// - func register() + func register(completion: @escaping (Error?) -> Void) + + func register(version: String, bundlePath: String, completion: @escaping (Error?) -> Void) /// Start the VPN tunnel. /// @@ -54,6 +59,11 @@ public protocol IPCServerInterface: AnyObject { func command(_ command: VPNCommand) async throws } +public extension IPCServerInterface { + var version: String { DefaultIPCMetadataCollector.version } + var bundlePath: String { DefaultIPCMetadataCollector.bundlePath } +} + /// This protocol describes the server-side XPC interface. /// /// The object that implements this interface takes care of unpacking any encoded data and forwarding @@ -65,7 +75,9 @@ protocol XPCServerInterface { /// /// This is the point where the server will start sending status updates to the client. /// - func register() + func register(completion: @escaping (Error?) -> Void) + + func register(version: String, bundlePath: String, completion: @escaping (Error?) -> Void) /// Start the VPN tunnel. /// @@ -165,13 +177,23 @@ extension TunnelControllerIPCServer: IPCClientInterface { client.dataVolumeUpdated(payload: payload) } } + + public func knownFailureUpdated(_ failure: KnownFailure?) { + xpc.forEachClient { client in + client.knownFailureUpdated(failure: failure) + } + } } // MARK: - Incoming communication from a client extension TunnelControllerIPCServer: XPCServerInterface { - func register() { - serverDelegate?.register() + func register(completion: @escaping (Error?) -> Void) { + serverDelegate?.register(completion: completion) + } + + func register(version: String, bundlePath: String, completion: @escaping (Error?) -> Void) { + serverDelegate?.register(version: version, bundlePath: bundlePath, completion: completion) } func start(completion: @escaping (Error?) -> Void) { diff --git a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Extensions/UserText+NetworkProtectionUI.swift b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Extensions/UserText+NetworkProtectionUI.swift index 0236302a96..7332cefe86 100644 --- a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Extensions/UserText+NetworkProtectionUI.swift +++ b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Extensions/UserText+NetworkProtectionUI.swift @@ -33,6 +33,10 @@ final class UserText { static let vpnLocationConnected = NSLocalizedString("network.protection.vpn.location.connected", value: "Connected Location", comment: "Description of the location type in the VPN status view") static let vpnLocationSelected = NSLocalizedString("network.protection.vpn.location.selected", value: "Selected Location", comment: "Description of the location type in the VPN status view") static let vpnDataVolume = NSLocalizedString("network.protection.vpn.data-volume", value: "Data Volume", comment: "Title for the data volume section in the VPN status view") + static let vpnShareFeedback = NSLocalizedString("network.protection.vpn.share-feedback", value: "Share VPN Feedback…", comment: "Action button title for the Share VPN feedback option") + static let vpnOperationNotPermittedMessage = NSLocalizedString("network.protection.vpn.failure.operation-not-permitted", value: "Unable to connect due to an unexpected error. Restarting your Mac can usually fix the issue.", comment: "Error message for the Operation not permitted error") + static let vpnLoginItemVersionMismatchedMessage = NSLocalizedString("network.protection.vpn.failure.login-item-version-mismatched", value: "Unable to connect due to versioning conflict. If you have multiple versions of the browser installed, remove all but the most recent version of DuckDuckGo and restart your Mac.", comment: "Error message for the Login item version mismatched error") + static let vpnRegisteredServerFetchingFailedMessage = NSLocalizedString("network.protection.vpn.failure.registered-server-fetching-failed", value: "Unable to connect. Double check your internet connection. Make sure other software or services aren't blocking DuckDuckGo VPN servers.", comment: "Error message for the Failed to fetch registered server error") // MARK: - Onboarding diff --git a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/StatusView/NetworkProtectionStatusView.swift b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/StatusView/NetworkProtectionStatusView.swift index bc3caa84c3..39a63eec04 100644 --- a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/StatusView/NetworkProtectionStatusView.swift +++ b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/StatusView/NetworkProtectionStatusView.swift @@ -50,6 +50,11 @@ public struct NetworkProtectionStatusView: View { public var body: some View { VStack(spacing: 0) { + if let warning = model.warningViewModel { + WarningView(model: warning) + .transition(.slide) + } + if model.shouldShowSubscriptionExpired { SubscriptionExpiredView { model.openPrivacyPro() @@ -62,11 +67,6 @@ public struct NetworkProtectionStatusView: View { .padding(.horizontal, 5) .padding(.top, 5) .transition(.slide) - } else { - if let healthWarning = model.issueDescription { - connectionHealthWarningView(message: healthWarning) - .transition(.slide) - } } Spacer() @@ -88,28 +88,6 @@ public struct NetworkProtectionStatusView: View { // MARK: - Composite Views - private func connectionHealthWarningView(message: String) -> some View { - VStack(spacing: 0) { - HStack(alignment: .top, spacing: 12) { - Image(.warningColored) - - /// Text elements in SwiftUI don't expand horizontally more than needed, so we're adding an "optional" spacer at the end so that - /// the alert bubble won't shrink if there's not enough text. - HStack(spacing: 0) { - Text(message) - .makeSelectable() - .multilineText() - .foregroundColor(Color(.defaultText)) - - Spacer() - } - } - .padding(16) - .background(RoundedRectangle(cornerRadius: 8).fill(Color(.alertBubbleBackground))) - } - .padding(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8)) - } - private func bottomMenuView() -> some View { VStack(spacing: 0) { ForEach(model.menuItems(), id: \.name) { menuItem in diff --git a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/StatusView/NetworkProtectionStatusViewModel.swift b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/StatusView/NetworkProtectionStatusViewModel.swift index eb5509a68f..5e6fc27f86 100644 --- a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/StatusView/NetworkProtectionStatusViewModel.swift +++ b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/StatusView/NetworkProtectionStatusViewModel.swift @@ -22,6 +22,7 @@ import NetworkExtension import NetworkProtection import ServiceManagement import SwiftUI +import Common /// This view can be shown from any location where we want the user to be able to interact with VPN. /// This view shows status information about the VPN, and offers a chance to toggle it ON and OFF. @@ -106,6 +107,7 @@ extension NetworkProtectionStatusView { private static let serverInfoDispatchQueue = DispatchQueue(label: "com.duckduckgo.NetworkProtectionStatusView.serverInfoDispatchQueue", qos: .userInteractive) private static let tunnelErrorDispatchQueue = DispatchQueue(label: "com.duckduckgo.NetworkProtectionStatusView.tunnelErrorDispatchQueue", qos: .userInteractive) private static let controllerErrorDispatchQueue = DispatchQueue(label: "com.duckduckgo.NetworkProtectionStatusView.controllerErrorDispatchQueue", qos: .userInteractive) + private static let knownFailureDispatchQueue = DispatchQueue(label: "com.duckduckgo.NetworkProtectionStatusView.knownFailureDispatchQueue", qos: .userInteractive) // MARK: - Initialization & Deinitialization @@ -144,6 +146,7 @@ extension NetworkProtectionStatusView { isHavingConnectivityIssues = statusReporter.connectivityIssuesObserver.recentValue lastTunnelErrorMessage = statusReporter.connectionErrorObserver.recentValue lastControllerErrorMessage = statusReporter.controllerErrorMessageObserver.recentValue + knownFailure = statusReporter.knownFailureObserver.recentValue showDebugInformation = false // Particularly useful when unit testing with an initial status of our choosing. @@ -151,6 +154,7 @@ extension NetworkProtectionStatusView { subscribeToConnectivityIssues() subscribeToTunnelErrorMessages() subscribeToControllerErrorMessages() + subscribeToKnownFailures() subscribeToDebugInformationChanges() refreshLoginItemStatus() @@ -187,6 +191,12 @@ extension NetworkProtectionStatusView { } } + func openFeedbackForm() { + Task { + await appLauncher.launchApp(withCommand: .shareFeedback) + } + } + func uninstallVPN() { Task { await uninstallHandler() @@ -237,6 +247,14 @@ extension NetworkProtectionStatusView { }.store(in: &cancellables) } + private func subscribeToKnownFailures() { + statusReporter.knownFailureObserver.publisher + .removeDuplicates() + .subscribe(on: Self.knownFailureDispatchQueue) + .assign(to: \.knownFailure, onWeaklyHeld: self) + .store(in: &cancellables) + } + private func subscribeToDebugInformationChanges() { debugInformationPublisher .removeDuplicates() @@ -341,5 +359,37 @@ extension NetworkProtectionStatusView { } } + + @Published + private var knownFailure: KnownFailure? + + var warningViewModel: WarningView.Model? { + if let warningMessage = warningMessage(for: knownFailure) { + return WarningView.Model(message: warningMessage, + actionTitle: UserText.vpnShareFeedback, + action: openFeedbackForm) + } + + if let issueDescription { + return WarningView.Model(message: issueDescription, actionTitle: nil, action: nil) + } + + return nil + } + + func warningMessage(for knownFailure: KnownFailure?) -> String? { + guard let knownFailure else { return nil } + + switch KnownFailure.SilentError(rawValue: knownFailure.error) { + case .operationNotPermitted: + return UserText.vpnOperationNotPermittedMessage + case .loginItemVersionMismatched: + return UserText.vpnLoginItemVersionMismatchedMessage + case .registeredServerFetchingFailed: + return UserText.vpnRegisteredServerFetchingFailedMessage + default: + return nil + } + } } } diff --git a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/WarningView/WarningView.swift b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/WarningView/WarningView.swift new file mode 100644 index 0000000000..b6e48ba836 --- /dev/null +++ b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/WarningView/WarningView.swift @@ -0,0 +1,54 @@ +// +// WarningView.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 SwiftUI +import SwiftUIExtensions + +struct WarningView: View { + let model: Model + + public var body: some View { + VStack(spacing: 0) { + HStack(alignment: .top, spacing: 12) { + Image(.warningColored) + + HStack(spacing: 0) { + VStack(alignment: .leading, spacing: 5) { + Text(model.message) + .makeSelectable() + .multilineText() + .foregroundColor(Color(.defaultText)) + + if let actionTitle = model.actionTitle, + let action = model.action { + Button(actionTitle, action: action) + .buttonStyle(DismissActionButtonStyle(textColor: Color(.defaultText))) + .keyboardShortcut(.defaultAction) + .padding(.top, 3) + } + } + + Spacer() + } + } + .padding(16) + .background(RoundedRectangle(cornerRadius: 8).fill(Color(.alertBubbleBackground))) + } + .padding(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8)) + } +} diff --git a/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/WarningView/WarningViewModel.swift b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/WarningView/WarningViewModel.swift new file mode 100644 index 0000000000..1e6e720904 --- /dev/null +++ b/LocalPackages/NetworkProtectionMac/Sources/NetworkProtectionUI/Views/WarningView/WarningViewModel.swift @@ -0,0 +1,35 @@ +// +// WarningViewModel.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 SwiftUI + +extension WarningView { + final class Model: ObservableObject { + var message: String + var actionTitle: String? + var action: (() -> Void)? + + init(message: String, + actionTitle: String? = nil, + action: (() -> Void)?) { + self.message = message + self.actionTitle = actionTitle + self.action = action + } + } +} diff --git a/LocalPackages/NetworkProtectionMac/Tests/NetworkProtectionUITests/TunnelControllerViewModelTests.swift b/LocalPackages/NetworkProtectionMac/Tests/NetworkProtectionUITests/TunnelControllerViewModelTests.swift index e7785da3f4..b6853541fb 100644 --- a/LocalPackages/NetworkProtectionMac/Tests/NetworkProtectionUITests/TunnelControllerViewModelTests.swift +++ b/LocalPackages/NetworkProtectionMac/Tests/NetworkProtectionUITests/TunnelControllerViewModelTests.swift @@ -37,13 +37,15 @@ final class TunnelControllerViewModelTests: XCTestCase { let connectivityIssuesObserver: ConnectivityIssueObserver let controllerErrorMessageObserver: ControllerErrorMesssageObserver let dataVolumeObserver: DataVolumeObserver + let knownFailureObserver: KnownFailureObserver init(status: ConnectionStatus, isHavingConnectivityIssues: Bool = false, serverInfo: NetworkProtectionStatusServerInfo = MockStatusReporter.defaultServerInfo, tunnelErrorMessage: String? = nil, controllerErrorMessage: String? = nil, - dataVolume: DataVolume = .init()) { + dataVolume: DataVolume = .init(), + failure: KnownFailure? = nil) { let mockStatusObserver = MockConnectionStatusObserver() mockStatusObserver.subject.send(status) @@ -68,6 +70,10 @@ final class TunnelControllerViewModelTests: XCTestCase { let mockDataVolumeObserver = MockDataVolumeObserver() mockDataVolumeObserver.subject.send(dataVolume) dataVolumeObserver = mockDataVolumeObserver + + let mockKnownFailureObserver = MockKnownFailureObserver() + mockKnownFailureObserver.subject.send(failure) + knownFailureObserver = mockKnownFailureObserver } func forceRefresh() { diff --git a/LocalPackages/SubscriptionUI/Package.swift b/LocalPackages/SubscriptionUI/Package.swift index 3e38b79343..d57838d088 100644 --- a/LocalPackages/SubscriptionUI/Package.swift +++ b/LocalPackages/SubscriptionUI/Package.swift @@ -12,7 +12,7 @@ let package = Package( targets: ["SubscriptionUI"]), ], dependencies: [ - .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "150.0.0"), + .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "150.1.0"), .package(path: "../SwiftUIExtensions") ], targets: [ diff --git a/LocalPackages/SwiftUIExtensions/Sources/SwiftUIExtensions/ButtonStyles.swift b/LocalPackages/SwiftUIExtensions/Sources/SwiftUIExtensions/ButtonStyles.swift index e5c7c83424..1254693f51 100644 --- a/LocalPackages/SwiftUIExtensions/Sources/SwiftUIExtensions/ButtonStyles.swift +++ b/LocalPackages/SwiftUIExtensions/Sources/SwiftUIExtensions/ButtonStyles.swift @@ -100,11 +100,14 @@ public struct TransparentActionButtonStyle: ButtonStyle { public struct DismissActionButtonStyle: ButtonStyle { @Environment(\.colorScheme) var colorScheme - public init() {} + public let textColor: Color + + public init(textColor: Color = .primary) { + self.textColor = textColor + } public func makeBody(configuration: Self.Configuration) -> some View { let backgroundColor = configuration.isPressed ? Color(.windowBackgroundColor) : Color(.controlColor) - let labelColor = Color.primary let outerShadowOpacity = colorScheme == .dark ? 0.8 : 0.0 configuration.label @@ -124,7 +127,7 @@ public struct DismissActionButtonStyle: ButtonStyle { RoundedRectangle(cornerRadius: 5) .stroke(Color.black.opacity(0.1), lineWidth: 1) ) - .foregroundColor(labelColor) + .foregroundColor(textColor) } } diff --git a/UnitTests/VPNFeedbackForm/VPNFeedbackFormViewModelTests.swift b/UnitTests/VPNFeedbackForm/VPNFeedbackFormViewModelTests.swift index d9a45fda0c..746ebca9ee 100644 --- a/UnitTests/VPNFeedbackForm/VPNFeedbackFormViewModelTests.swift +++ b/UnitTests/VPNFeedbackForm/VPNFeedbackFormViewModelTests.swift @@ -105,6 +105,7 @@ private class MockVPNMetadataCollector: VPNMetadataCollector { connectionState: "connected", lastStartErrorDescription: "none", lastTunnelErrorDescription: "none", + lastKnownFailureDescription: "none", connectedServer: "Paoli, PA", connectedServerIP: "123.123.123.123" )