diff --git a/Configuration/App/NetworkProtection/DuckDuckGoAgent.xcconfig b/Configuration/App/NetworkProtection/DuckDuckGoAgent.xcconfig index 56fbe6f393..d6302e3951 100644 --- a/Configuration/App/NetworkProtection/DuckDuckGoAgent.xcconfig +++ b/Configuration/App/NetworkProtection/DuckDuckGoAgent.xcconfig @@ -33,9 +33,8 @@ INFOPLIST_KEY_NSPrincipalClass = Application //CODE_SIGN_STYLE[config=Debug][sdk=*] = Manual //CODE_SIGN_STYLE[config=Release][sdk=*] = Manual -// Left empty intentionally as we currently only support debug and release builds -CODE_SIGN_ENTITLEMENTS[config=Review][sdk=macosx*] = -CODE_SIGN_ENTITLEMENTS[config=CI][sdk=macosx*] = +CODE_SIGN_ENTITLEMENTS[config=Review][sdk=macosx*] = DuckDuckGoAgent/DuckDuckGoAgent.entitlements +CODE_SIGN_ENTITLEMENTS[config=CI][sdk=macosx*] = DuckDuckGoAgent/DuckDuckGoAgent.entitlements CODE_SIGN_ENTITLEMENTS[config=Debug][sdk=macosx*] = DuckDuckGoAgent/DuckDuckGoAgent.entitlements CODE_SIGN_ENTITLEMENTS[config=Release][sdk=macosx*] = DuckDuckGoAgent/DuckDuckGoAgent.entitlements diff --git a/Configuration/App/NetworkProtection/DuckDuckGoNotifications.xcconfig b/Configuration/App/NetworkProtection/DuckDuckGoNotifications.xcconfig index c1b60b0bc1..66720d8ca6 100644 --- a/Configuration/App/NetworkProtection/DuckDuckGoNotifications.xcconfig +++ b/Configuration/App/NetworkProtection/DuckDuckGoNotifications.xcconfig @@ -29,9 +29,8 @@ GENERATE_INFOPLIST_FILE = YES INFOPLIST_KEY_LSUIElement = YES INFOPLIST_KEY_NSPrincipalClass = Application -// Left empty intentionally as we currently only support debug and release builds -CODE_SIGN_ENTITLEMENTS[config=Review][sdk=macosx*] = -CODE_SIGN_ENTITLEMENTS[config=CI][sdk=macosx*] = +CODE_SIGN_ENTITLEMENTS[config=Review][sdk=macosx*] = DuckDuckGoNotifications/DuckDuckGoNotifications.entitlements +CODE_SIGN_ENTITLEMENTS[config=CI][sdk=macosx*] = DuckDuckGoNotifications/DuckDuckGoNotifications.entitlements CODE_SIGN_ENTITLEMENTS[config=Debug][sdk=macosx*] = DuckDuckGoNotifications/DuckDuckGoNotifications.entitlements CODE_SIGN_ENTITLEMENTS[config=Release][sdk=macosx*] = DuckDuckGoNotifications/DuckDuckGoNotifications.entitlements diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 89bdce7194..3f22489b51 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -1319,6 +1319,8 @@ 56D145F229E6F06D00E3488A /* MockBookmarkManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56D145F029E6F06D00E3488A /* MockBookmarkManager.swift */; }; 56D6A3D629DB2BAB0055215A /* ContinueSetUpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56D6A3D529DB2BAB0055215A /* ContinueSetUpView.swift */; }; 56D6A3D729DB2BAB0055215A /* ContinueSetUpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56D6A3D529DB2BAB0055215A /* ContinueSetUpView.swift */; }; + 7B05829E2A812AC000AC3F7C /* NetworkProtectionOnboardingMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B05829D2A812AC000AC3F7C /* NetworkProtectionOnboardingMenu.swift */; }; + 7B05829F2A812AC000AC3F7C /* NetworkProtectionOnboardingMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B05829D2A812AC000AC3F7C /* NetworkProtectionOnboardingMenu.swift */; }; 7B1E819E27C8874900FF0E60 /* ContentOverlayPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1E819B27C8874900FF0E60 /* ContentOverlayPopover.swift */; }; 7B1E819F27C8874900FF0E60 /* ContentOverlay.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 7B1E819C27C8874900FF0E60 /* ContentOverlay.storyboard */; }; 7B1E81A027C8874900FF0E60 /* ContentOverlayViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1E819D27C8874900FF0E60 /* ContentOverlayViewController.swift */; }; @@ -1337,7 +1339,13 @@ 7B736E582A4A22B700F9922A /* Main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B5F14C52A145D6A0060320F /* Main.swift */; }; 7B736E6A2A4A22FC00F9922A /* enableOnDemand.app in Embed NetP Controller Apps */ = {isa = PBXBuildFile; fileRef = 7B736E5F2A4A22B700F9922A /* enableOnDemand.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 7B838C382A1DD8DD00E05A13 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4B2D06522A11D19B00DE1F49 /* Assets.xcassets */; }; + 7B934C3E2A866CFF00FC8F9C /* NetworkProtectionOnboardingMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B934C3D2A866CFF00FC8F9C /* NetworkProtectionOnboardingMenu.swift */; }; + 7B934C3F2A866CFF00FC8F9C /* NetworkProtectionOnboardingMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B934C3D2A866CFF00FC8F9C /* NetworkProtectionOnboardingMenu.swift */; }; + 7B934C412A866DD400FC8F9C /* UserDefaults+NetworkProtectionShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B934C402A866DD400FC8F9C /* UserDefaults+NetworkProtectionShared.swift */; }; 7BA4727D26F01BC400EAA165 /* CoreDataTestUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B9292C42667104B00AD2C21 /* CoreDataTestUtilities.swift */; }; + 7BAF9E4B2A8A3CC9002D3B6E /* UserDefaults+NetworkProtectionShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B934C402A866DD400FC8F9C /* UserDefaults+NetworkProtectionShared.swift */; }; + 7BAF9E4C2A8A3CCA002D3B6E /* UserDefaults+NetworkProtectionShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B934C402A866DD400FC8F9C /* UserDefaults+NetworkProtectionShared.swift */; }; + 7BAF9E4D2A8A3CCB002D3B6E /* UserDefaults+NetworkProtectionShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B934C402A866DD400FC8F9C /* UserDefaults+NetworkProtectionShared.swift */; }; 7BB108592A43375D000AB95F /* PFMoveApplication.m in Sources */ = {isa = PBXBuildFile; fileRef = 7BB108582A43375D000AB95F /* PFMoveApplication.m */; settings = {COMPILER_FLAGS = "-fno-objc-arc"; }; }; 7BBD45B12A691AB500C83CA9 /* NetworkProtectionDebugUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BBD45B02A691AB500C83CA9 /* NetworkProtectionDebugUtilities.swift */; }; 7BBD45B22A691AB500C83CA9 /* NetworkProtectionDebugUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BBD45B02A691AB500C83CA9 /* NetworkProtectionDebugUtilities.swift */; }; @@ -2591,6 +2599,7 @@ 56D145ED29E6DAD900E3488A /* DataImportProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataImportProviderTests.swift; sourceTree = ""; }; 56D145F029E6F06D00E3488A /* MockBookmarkManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBookmarkManager.swift; sourceTree = ""; }; 56D6A3D529DB2BAB0055215A /* ContinueSetUpView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContinueSetUpView.swift; sourceTree = ""; }; + 7B05829D2A812AC000AC3F7C /* NetworkProtectionOnboardingMenu.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkProtectionOnboardingMenu.swift; sourceTree = ""; }; 7B1E819B27C8874900FF0E60 /* ContentOverlayPopover.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContentOverlayPopover.swift; sourceTree = ""; }; 7B1E819C27C8874900FF0E60 /* ContentOverlay.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = ContentOverlay.storyboard; sourceTree = ""; }; 7B1E819D27C8874900FF0E60 /* ContentOverlayViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContentOverlayViewController.swift; sourceTree = ""; }; @@ -2607,6 +2616,8 @@ 7B5291882A1697680022E406 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 7B5291892A169BC90022E406 /* NetworkProtectionDeveloperID.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = NetworkProtectionDeveloperID.xcconfig; sourceTree = ""; }; 7B736E5F2A4A22B700F9922A /* enableOnDemand.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = enableOnDemand.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 7B934C3D2A866CFF00FC8F9C /* NetworkProtectionOnboardingMenu.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NetworkProtectionOnboardingMenu.swift; sourceTree = ""; }; + 7B934C402A866DD400FC8F9C /* UserDefaults+NetworkProtectionShared.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UserDefaults+NetworkProtectionShared.swift"; sourceTree = ""; }; 7B9459632A4A5BAF0012535A /* NetworkProtectionEnableOnDemand.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = NetworkProtectionEnableOnDemand.xcconfig; sourceTree = ""; }; 7BB108572A43375D000AB95F /* PFMoveApplication.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = PFMoveApplication.h; sourceTree = ""; }; 7BB108582A43375D000AB95F /* PFMoveApplication.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = PFMoveApplication.m; sourceTree = ""; }; @@ -4012,8 +4023,10 @@ children = ( 4B4D60642A0B29FA00BCD287 /* LoginItem.swift */, 7BE146062A6A83C700C313B8 /* NetworkProtectionDebugMenu.swift */, + 7B05829D2A812AC000AC3F7C /* NetworkProtectionOnboardingMenu.swift */, 7B430EA02A71411A00BAC4A1 /* NetworkProtectionSimulateFailureMenu.swift */, 7BBD45B02A691AB500C83CA9 /* NetworkProtectionDebugUtilities.swift */, + 7B934C3D2A866CFF00FC8F9C /* NetworkProtectionOnboardingMenu.swift */, 7BBD45B32A691C3A00C83CA9 /* NetworkProtectionLoginItemsManager.swift */, B602E81C2A1E25B0006D261F /* NEOnDemandRuleExtension.swift */, 4B4D60652A0B29FA00BCD287 /* NetworkProtectionNavBarButtonModel.swift */, @@ -6551,6 +6564,7 @@ children = ( EEAD7A6E2A1D3E1F002A24E7 /* AppLauncher.swift */, B6F92BA42A691A44002ABA6B /* NetworkProtectionUserDefaultsConstants.swift */, + 7B934C402A866DD400FC8F9C /* UserDefaults+NetworkProtectionShared.swift */, ); path = AppAndExtensionAndAgentTargets; sourceTree = ""; @@ -7685,6 +7699,7 @@ 85774B042A71CDD000DE0561 /* BlockMenuItem.swift in Sources */, 3706FB19293F65D500E42796 /* FireViewController.swift in Sources */, 4B4D60D42A0C84F700BCD287 /* UserText+NetworkProtection.swift in Sources */, + 7B05829F2A812AC000AC3F7C /* NetworkProtectionOnboardingMenu.swift in Sources */, 3707C71F294B5D2900682A9F /* WKUserContentControllerExtension.swift in Sources */, 3706FB1A293F65D500E42796 /* OutlineSeparatorViewCell.swift in Sources */, 3706FB1B293F65D500E42796 /* SafariDataImporter.swift in Sources */, @@ -7802,6 +7817,7 @@ 3706FB80293F65D500E42796 /* NSAlert+ActiveDownloadsTermination.swift in Sources */, 3707C717294B5D0F00682A9F /* FindInPageTabExtension.swift in Sources */, 3706FB81293F65D500E42796 /* IndexPathExtension.swift in Sources */, + 7BAF9E4B2A8A3CC9002D3B6E /* UserDefaults+NetworkProtectionShared.swift in Sources */, B6685E3D29A602D90043D2EE /* ExternalAppSchemeHandler.swift in Sources */, B6E1491029A5C30500AAFBE8 /* ContentBlockingTabExtension.swift in Sources */, 3706FB82293F65D500E42796 /* PasswordManagementNoteItemView.swift in Sources */, @@ -7891,6 +7907,7 @@ 85774B002A713D3B00DE0561 /* BookmarksBarMenuFactory.swift in Sources */, B602E81E2A1E25B1006D261F /* NEOnDemandRuleExtension.swift in Sources */, 3706FBC7293F65D500E42796 /* HistoryStore.swift in Sources */, + 7B934C3F2A866CFF00FC8F9C /* NetworkProtectionOnboardingMenu.swift in Sources */, 3706FBC8293F65D500E42796 /* FirePopoverCollectionViewItem.swift in Sources */, 3706FBC9293F65D500E42796 /* ArrayExtension.swift in Sources */, 3706FBCA293F65D500E42796 /* CrashReportSender.swift in Sources */, @@ -8464,6 +8481,7 @@ 7B2DDD052A93BEE20039D884 /* FeatureProtectedTunnelController.swift in Sources */, B6F92BA22A691580002ABA6B /* UserDefaultsWrapper.swift in Sources */, 4B2D065B2A11D1FF00DE1F49 /* Logging.swift in Sources */, + 7BAF9E4C2A8A3CCA002D3B6E /* UserDefaults+NetworkProtectionShared.swift in Sources */, 4B2D06572A11D19B00DE1F49 /* DuckDuckGoAgentAppDelegate.swift in Sources */, 7B2DDCFA2A93B25F0039D884 /* KeychainType+ClientDefault.swift in Sources */, 7B2DDD072A93C17D0039D884 /* UserText.swift in Sources */, @@ -8490,6 +8508,7 @@ EEC589DC2A4F1CE800BCD60C /* AppLauncher.swift in Sources */, 7B2DDD032A93BBEC0039D884 /* NetworkProtectionBouncer.swift in Sources */, B65DA5F02A77CC3C00CBEE8D /* Bundle+NetworkProtectionExtensions.swift in Sources */, + 7BAF9E4D2A8A3CCB002D3B6E /* UserDefaults+NetworkProtectionShared.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -9076,6 +9095,7 @@ B64C84E32692DC9F0048FEBE /* PermissionAuthorizationViewController.swift in Sources */, 4B92929D26670D2A00AD2C21 /* BookmarkNode.swift in Sources */, B693955226F04BEB0015B914 /* LongPressButton.swift in Sources */, + 7B934C3E2A866CFF00FC8F9C /* NetworkProtectionOnboardingMenu.swift in Sources */, B6085D062743905F00A9C456 /* CoreDataStore.swift in Sources */, B6DB3AF6278EA0130024C5C4 /* BundleExtension.swift in Sources */, 4B0511E1262CAA8600F6079C /* NSOpenPanelExtensions.swift in Sources */, @@ -9154,6 +9174,7 @@ 4B139AFD26B60BD800894F82 /* NSImageExtensions.swift in Sources */, 85625996269C953C00EE44BC /* PasswordManagementViewController.swift in Sources */, 4BB99D0226FE191E001E4761 /* ImportedBookmarks.swift in Sources */, + 7B934C412A866DD400FC8F9C /* UserDefaults+NetworkProtectionShared.swift in Sources */, B626A75A29921FAA00053070 /* NavigationActionPolicyExtension.swift in Sources */, B603FD9E2A02712E00F3FCA9 /* CIImageExtension.swift in Sources */, AA6EF9B3250785D5004754E6 /* NSMenuExtension.swift in Sources */, @@ -9189,6 +9210,7 @@ 4B92928B26670D1700AD2C21 /* BookmarksOutlineView.swift in Sources */, 4BF01C00272AE74C00884A61 /* CountryList.swift in Sources */, 37CD54CC27F2FDD100F1F7B9 /* PreferencesSection.swift in Sources */, + 7B05829E2A812AC000AC3F7C /* NetworkProtectionOnboardingMenu.swift in Sources */, 4B4D60B62A0C847D00BCD287 /* NetworkProtectionNavBarButtonModel.swift in Sources */, FD23FD2D2886A81D007F6985 /* AutoconsentManagement.swift in Sources */, 4B4D60D32A0C84F700BCD287 /* UserText+NetworkProtection.swift in Sources */, diff --git a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index a1e77c3c7a..44491f03c9 100644 --- a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -129,7 +129,7 @@ { "identity" : "trackerradarkit", "kind" : "remoteSourceControl", - "location" : "https://github.com/duckduckgo/TrackerRadarKit", + "location" : "https://github.com/duckduckgo/TrackerRadarKit.git", "state" : { "revision" : "4684440d03304e7638a2c8086895367e90987463", "version" : "1.2.1" diff --git a/DuckDuckGo/Common/Utilities/UserDefaultsWrapper.swift b/DuckDuckGo/Common/Utilities/UserDefaultsWrapper.swift index d0fb89fa28..fb65d1e2f8 100644 --- a/DuckDuckGo/Common/Utilities/UserDefaultsWrapper.swift +++ b/DuckDuckGo/Common/Utilities/UserDefaultsWrapper.swift @@ -21,7 +21,7 @@ import Foundation extension UserDefaults { /// The app group's shared UserDefaults - static let shared = UserDefaults(suiteName: Bundle.main.appGroupName) + static let shared = UserDefaults(suiteName: Bundle.main.appGroupName)! } @propertyWrapper @@ -129,6 +129,7 @@ public struct UserDefaultsWrapper { case firstLaunchDate = "first.app.launch.date" // Network Protection + case networkProtectionOnDemandActivation = "netp.ondemand" case networkProtectionShouldEnforceRoutes = "netp.enforce-routes" case networkProtectionShouldIncludeAllNetworks = "netp.include-all-networks" @@ -143,6 +144,13 @@ public struct UserDefaultsWrapper { case agentLaunchTime = "netp.agent.launch-time" + // Network Protection: Shared Defaults + // --- + // Please note that shared defaults MUST have a name that matches exactly their value, + // or else KVO will just not work as of 2023-08-07 + + case networkProtectionOnboardingStatusRawValue = "networkProtectionOnboardingStatusRawValue" + // Experiments case pixelExperimentInstalled = "pixel.experiment.installed" case pixelExperimentCohort = "pixel.experiment.cohort" diff --git a/DuckDuckGo/Menus/MainMenu.storyboard b/DuckDuckGo/Menus/MainMenu.storyboard index 0dc91ac2aa..3c3182476b 100644 --- a/DuckDuckGo/Menus/MainMenu.storyboard +++ b/DuckDuckGo/Menus/MainMenu.storyboard @@ -895,6 +895,51 @@ CQ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DuckDuckGo/NavigationBar/View/NavigationBarPopovers.swift b/DuckDuckGo/NavigationBar/View/NavigationBarPopovers.swift index 6897020e39..b07b7cfd4e 100644 --- a/DuckDuckGo/NavigationBar/View/NavigationBarPopovers.swift +++ b/DuckDuckGo/NavigationBar/View/NavigationBarPopovers.swift @@ -21,6 +21,7 @@ import BrowserServicesKit import AppKit #if NETWORK_PROTECTION +import Combine import NetworkProtection import NetworkProtectionUI #endif @@ -303,7 +304,9 @@ final class NavigationBarPopovers { }) ] - let popover = NetworkProtectionPopover(controller: controller, statusReporter: statusReporter, menuItems: menuItems) + let onboardingStatusPublisher = UserDefaults.shared.networkProtectionOnboardingStatusPublisher + + let popover = NetworkProtectionPopover(controller: controller, onboardingStatusPublisher: onboardingStatusPublisher, statusReporter: statusReporter, menuItems: menuItems) popover.delegate = delegate networkProtectionPopover = popover @@ -312,7 +315,6 @@ final class NavigationBarPopovers { show(popover: popover, usingView: view, preferredEdge: .maxY) } #endif - } extension Notification.Name { diff --git a/DuckDuckGo/NetworkProtection/AppAndExtensionAndAgentTargets/UserDefaults+NetworkProtectionShared.swift b/DuckDuckGo/NetworkProtection/AppAndExtensionAndAgentTargets/UserDefaults+NetworkProtectionShared.swift new file mode 100644 index 0000000000..97925a857f --- /dev/null +++ b/DuckDuckGo/NetworkProtection/AppAndExtensionAndAgentTargets/UserDefaults+NetworkProtectionShared.swift @@ -0,0 +1,69 @@ +// +// UserDefaults+NetworkProtectionShared.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. +// + +#if NETP_SYSTEM_EXTENSION + +import Combine +import Foundation +import NetworkProtectionUI + +extension UserDefaults { + // Convenience declaration + var networkProtectionOnboardingStatusRawValueKey: String { + UserDefaultsWrapper.Key.networkProtectionOnboardingStatusRawValue.rawValue + } + + /// For KVO to work across processes (Menu App + Main App) we need to declare this dynamic var in a `UserDefaults` + /// extension, and the key for this property must match its name exactly. + /// + @objc + dynamic var networkProtectionOnboardingStatusRawValue: String { + get { + value(forKey: networkProtectionOnboardingStatusRawValueKey) as? String ?? OnboardingStatus.default.rawValue + } + + set { + set(newValue, forKey: networkProtectionOnboardingStatusRawValueKey) + } + } + + var networkProtectionOnboardingStatusPublisher: AnyPublisher { + publisher(for: \.networkProtectionOnboardingStatusRawValue).map { value in + OnboardingStatus(rawValue: value) ?? .default + }.eraseToAnyPublisher() + } +} + +extension NetworkProtectionUI.OnboardingStatus { + /// The default onboarding status. + /// + /// This default is defined in our browser app because it's inherently tied to the specific build-configuration of the browser + /// app: + /// - For AppStore builds the default is asking the user to allow the VPN configuration. + /// - For DeveloperID builds the default is asking the user to allow the System Extension. + /// + public static let `default`: OnboardingStatus = { +#if NETP_SYSTEM_EXTENSION + .isOnboarding(step: .userNeedsToAllowExtension) +#else + .isOnboarding(step: .userNeedsToAllowVPNConfiguration) +#endif + }() +} + +#endif diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugUtilities.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugUtilities.swift index 1fd2cdfd7d..41cfb9c5eb 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugUtilities.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionDebugUtilities.swift @@ -21,6 +21,7 @@ import Foundation #if NETWORK_PROTECTION import NetworkProtection +import NetworkProtectionUI import NetworkExtension import SystemExtensions @@ -39,6 +40,9 @@ final class NetworkProtectionDebugUtilities { } } + @UserDefaultsWrapper(key: .networkProtectionOnboardingStatusRawValue, defaultValue: OnboardingStatus.default.rawValue, defaults: .shared) + private(set) var onboardingStatusRawValue: OnboardingStatus.RawValue + // MARK: - Login Items Management private let loginItemsManager: NetworkProtectionLoginItemsManager @@ -75,9 +79,14 @@ final class NetworkProtectionDebugUtilities { } } + // We reset the onboarding status incrementally to stay aligned with the actual + // status of things. + onboardingStatusRawValue = OnboardingStatus.isOnboarding(step: .userNeedsToAllowVPNConfiguration).rawValue + NetworkProtectionSelectedServerUserDefaultsStore().reset() try await removeSystemExtensionAndAgents() + onboardingStatusRawValue = OnboardingStatus.default.rawValue } func removeSystemExtensionAndAgents() async throws { diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionOnboardingMenu.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionOnboardingMenu.swift new file mode 100644 index 0000000000..e74702224d --- /dev/null +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionOnboardingMenu.swift @@ -0,0 +1,68 @@ +// +// NetworkProtectionOnboardingMenu.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 AppKit +import Foundation + +#if !NETWORK_PROTECTION + +@objc +final class NetworkProtectionOnboardingMenu: NSMenu { +} + +#else + +import NetworkProtection +import NetworkProtectionUI + +/// Implements the logic for Network Protection's simulate failures menu. +/// +@available(macOS 11.4, *) +@objc +@MainActor +final class NetworkProtectionOnboardingMenu: NSMenu { + @IBOutlet weak var resetMenuItem: NSMenuItem! + @IBOutlet weak var setStatusCompletedMenuItem: NSMenuItem! + @IBOutlet weak var setStatusAllowSystemExtensionMenuItem: NSMenuItem! + @IBOutlet weak var setStatusAllowVPNConfigurationMenuItem: NSMenuItem! + + @UserDefaultsWrapper(key: .networkProtectionOnboardingStatusRawValue, defaultValue: OnboardingStatus.default.rawValue, defaults: .shared) + var onboardingStatus: OnboardingStatus.RawValue + + @IBAction + func reset(sender: NSMenuItem) { + onboardingStatus = OnboardingStatus.default.rawValue + } + + @IBAction + func setStatusCompleted(sender: NSMenuItem) { + onboardingStatus = OnboardingStatus.completed.rawValue + } + + @IBAction + func setStatusAllowSystemExtension(sender: NSMenuItem) { + onboardingStatus = OnboardingStatus.isOnboarding(step: .userNeedsToAllowExtension).rawValue + } + + @IBAction + func setStatusAllowVPNConfiguration(sender: NSMenuItem) { + onboardingStatus = OnboardingStatus.isOnboarding(step: .userNeedsToAllowVPNConfiguration).rawValue + } +} + +#endif diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionTunnelController.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionTunnelController.swift index 3db492ae2f..cae0efdddd 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionTunnelController.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionTunnelController.swift @@ -24,6 +24,7 @@ import SwiftUI import Common import NetworkExtension import NetworkProtection +import NetworkProtectionUI import SystemExtensions import Networking @@ -96,6 +97,9 @@ final class NetworkProtectionTunnelController: NetworkProtection.TunnelControlle @UserDefaultsWrapper(key: .networkProtectionConnectionTesterEnabled, defaultValue: NetworkProtectionUserDefaultsConstants.isConnectionTesterEnabled, defaults: .shared) private(set) var isConnectionTesterEnabled: Bool + @UserDefaultsWrapper(key: .networkProtectionOnboardingStatusRawValue, defaultValue: OnboardingStatus.default.rawValue, defaults: .shared) + private(set) var onboardingStatusRawValue: OnboardingStatus.RawValue + // MARK: - Connection Status private let statusTransitionAwaiter = ConnectionStatusTransitionAwaiter(statusObserver: ConnectionStatusObserverThroughSession(platformNotificationCenter: NSWorkspace.shared.notificationCenter, platformDidWakeNotification: NSWorkspace.didWakeNotification), transitionTimeout: .seconds(4)) @@ -218,7 +222,7 @@ final class NetworkProtectionTunnelController: NetworkProtection.TunnelControlle for try await event in SystemExtensionManager().activate() { switch event { case .waitingForUserApproval: - self.controllerErrorStore.lastErrorMessage = UserText.networkProtectionSystemSettings + onboardingStatusRawValue = OnboardingStatus.isOnboarding(step: .userNeedsToAllowExtension).rawValue case .activated: self.controllerErrorStore.lastErrorMessage = nil activated = true @@ -261,18 +265,41 @@ final class NetworkProtectionTunnelController: NetworkProtection.TunnelControlle func start(enableLoginItems: Bool) async { controllerErrorStore.lastErrorMessage = nil - if enableLoginItems { - loginItemsManager.enableLoginItems() - } - do { #if NETP_SYSTEM_EXTENSION guard try await ensureSystemExtensionIsActivated() else { return } + + // We'll only update to completed if we were showing the onboarding step to + // allow the system extension. Otherwise we may override the allow-VPN + // onboarding step. + // + // Additionally if the onboarding step was allowing the system extension, we won't + // start the tunnel at once, and instead require that the user enables the toggle. + // + if onboardingStatusRawValue == OnboardingStatus.isOnboarding(step: .userNeedsToAllowExtension).rawValue { + onboardingStatusRawValue = OnboardingStatus.completed.rawValue + return + } #endif - let tunnelManager = try await loadOrMakeTunnelManager() + let tunnelManager: NETunnelProviderManager + + do { + tunnelManager = try await loadOrMakeTunnelManager() + } catch { + if case NEVPNError.configurationReadWriteFailed = error { + onboardingStatusRawValue = OnboardingStatus.isOnboarding(step: .userNeedsToAllowVPNConfiguration).rawValue + } + + throw error + } + onboardingStatusRawValue = OnboardingStatus.completed.rawValue + + if enableLoginItems { + loginItemsManager.enableLoginItems() + } switch tunnelManager.connection.status { case .invalid: diff --git a/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/SystemExtensionManager.swift b/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/SystemExtensionManager.swift index 817da6c871..c3198e08d2 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/SystemExtensionManager.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/DeveloperIDTarget/SystemExtensionManager.swift @@ -29,22 +29,76 @@ struct SystemExtensionManager { case willActivateAfterReboot } - let bundleID: String - let manager: OSSystemExtensionManager + private static let systemSettingsSecurityURL = "x-apple.systempreferences:com.apple.preference.security?Security" + + private let bundleID: String + private let manager: OSSystemExtensionManager + private let workspace: NSWorkspace init(bundleID: String = NetworkProtectionBundle.extensionBundle().bundleIdentifier!, - manager: OSSystemExtensionManager = .shared) { + manager: OSSystemExtensionManager = .shared, + workspace: NSWorkspace = .shared) { + self.bundleID = bundleID self.manager = manager + self.workspace = workspace } func activate() -> AsyncThrowingStream { + /// Documenting a workaround for the issue discussed in https://app.asana.com/0/0/1205275221447702/f + /// Background: For a lot of users, the system won't show the system-extension-blocked alert if there's a previous request + /// to activate the extension. You can see active requests in your console using command `systemextensionsctl list`. + /// + /// Proposed workaround: Just open system settings into the right section when we detect a previous activation request already exists. + /// + /// Tradeoffs: Unfortunately we don't know if the previous request was sent out by the currently runing-instance of this App + /// or if an activation request was made, and then the App was reopened. + /// This means we don't know if we'll be notified when the previous activation request completes or fails. Because we + /// need to update our UI once the extension is allowed, we can't avoid sending a new activation request every time. + /// For the users that don't see the alert come up more than once this should be invisible. For users (like myself) that + /// see the alert every single time, they'll see both the alert and system settings being opened automatically. + /// + if hasPendingActivationRequests() { + openSystemSettingsSecurity() + } + return SystemExtensionRequest.activationRequest(forExtensionWithIdentifier: bundleID, manager: manager).submit() } func deactivate() async throws { for try await _ in SystemExtensionRequest.deactivationRequest(forExtensionWithIdentifier: bundleID, manager: manager).submit() {} } + + // MARK: - Activation: Checking if there are pending requests + + /// Checks if there are pending activation requests for the system extension. + /// + /// This implementation should work well for all macOS 11+ releases. A better implementation for macOS 12+ + /// would be to use a properties request, but that option requires bigger changes and some rethinking of these + /// classes which I'd rather avoid right now. In short this solution was picked as a quick solution with the best + /// ROI to avoid getting blocked. + /// + private func hasPendingActivationRequests() -> Bool { + let task = Process() + let pipe = Pipe() + + task.standardOutput = pipe + task.launchPath = "/bin/bash" // Specify the shell to use + task.arguments = ["-c", "$(which systemextensionsctl) list | $(which egrep) -c '(?:\(bundleID)).+(?:activated waiting for user)+'"] + + task.launch() + task.waitUntilExit() + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) + + return (Int(output ?? "0") ?? 0) > 0 + } + + private func openSystemSettingsSecurity() { + let url = URL(string: Self.systemSettingsSecurityURL)! + workspace.open(url) + } } final class SystemExtensionRequest: NSObject { diff --git a/DuckDuckGoAgent/DuckDuckGoAgentAppDelegate.swift b/DuckDuckGoAgent/DuckDuckGoAgentAppDelegate.swift index f315cfb046..db7478fc3f 100644 --- a/DuckDuckGoAgent/DuckDuckGoAgentAppDelegate.swift +++ b/DuckDuckGoAgent/DuckDuckGoAgentAppDelegate.swift @@ -56,6 +56,7 @@ final class DuckDuckGoAgentAppDelegate: NSObject, NSApplicationDelegate { /// Agent launch time saved by the main app to distinguish between Log In launch and Main App launch to prevent connection when started by the Main App @UserDefaultsWrapper(key: .agentLaunchTime, defaultValue: .distantPast, defaults: .shared) private var agentLaunchTime: Date + private static let recentThreshold: TimeInterval = 5.0 private let appLauncher = AppLauncher() @@ -87,7 +88,11 @@ final class DuckDuckGoAgentAppDelegate: NSObject, NSApplicationDelegate { }) ] - return StatusBarMenu(controller: tunnelController, iconProvider: iconProvider, menuItems: menuItems) + let onboardingStatusPublisher = UserDefaults.shared.publisher(for: \.networkProtectionOnboardingStatusRawValue).map { rawValue in + OnboardingStatus(rawValue: rawValue) ?? .default + }.eraseToAnyPublisher() + + return StatusBarMenu(onboardingStatusPublisher: onboardingStatusPublisher, controller: tunnelController, iconProvider: iconProvider, menuItems: menuItems) }() func applicationDidFinishLaunching(_ aNotification: Notification) { diff --git a/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Configuration/OnboardingStatus.swift b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Configuration/OnboardingStatus.swift new file mode 100644 index 0000000000..2c1a23564d --- /dev/null +++ b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Configuration/OnboardingStatus.swift @@ -0,0 +1,76 @@ +// +// OnboardingStatus.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 Combine +import Foundation + +public typealias OnboardingStatusPublisher = AnyPublisher + +/// Whether the user is onboarding. +/// +@frozen +public enum OnboardingStatus: RawRepresentable, Equatable { + /// The onboarding has been completed at least once + /// + case completed + + case isOnboarding(step: OnboardingStep) + + static let completedRawValue = "completed" + static let isOnboardingRawValue = "isOnboarding." + + public init?(rawValue: String) { + if rawValue == Self.completedRawValue { + self = .completed + return + } else if rawValue.hasPrefix(Self.isOnboardingRawValue) { + let stepRawValue = rawValue.dropping(prefix: Self.isOnboardingRawValue) + + guard let step = OnboardingStep(rawValue: stepRawValue) else { + return nil + } + + self = .isOnboarding(step: step) + return + } + + return nil + } + + public var rawValue: String { + switch self { + case .completed: + return Self.completedRawValue + case .isOnboarding(let step): + return Self.isOnboardingRawValue + step.rawValue + } + } +} + +/// A specific step in the onboarding process. +/// +@frozen +public enum OnboardingStep: String, Equatable { + /// The user needs to allow the system extension in macOS + /// + case userNeedsToAllowExtension + + /// The user needs to allow the VPN Configuration creation + /// + case userNeedsToAllowVPNConfiguration +} diff --git a/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Extensions/UserText+NetworkProtectionUI.swift b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Extensions/UserText+NetworkProtectionUI.swift index b5f58e7910..5a35ae2b4d 100644 --- a/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Extensions/UserText+NetworkProtectionUI.swift +++ b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Extensions/UserText+NetworkProtectionUI.swift @@ -28,6 +28,20 @@ final class UserText { static let networkProtectionStatusViewFeatureOn = NSLocalizedString("network.protection.status.view.feature.on", value: "Network Protection is ON", comment: "Text shown in NetworkProtection's status view when NetP is ON.") static let networkProtectionStatusViewTimerZero = "00:00:00" + // MARK: - Onboarding + + static let networkProtectionOnboardingAllowExtensionTitle = NSLocalizedString("network.protection.onboarding.allow.extension.title", value: "Allow System Extension", comment: "Title for the onboarding allow-extension step") + static let networkProtectionOnboardingAllowExtensionDescPrefix = NSLocalizedString("network.protection.onboarding.allow.extension.desc.prefix", value: "Open System Settings to Privacy & Security. Scroll and select ", comment: "Non-bold prefix for the onboarding allow-extension description") + static let networkProtectionOnboardingAllowExtensionDescAllow = NSLocalizedString("network.protection.onboarding.allow.extension.desc.allow", value: "Allow", comment: "'Allow' word between the prefix and suffix for the onboarding allow-extension description") + static let networkProtectionOnboardingAllowExtensionDescSuffix = NSLocalizedString("network.protection.onboarding.allow.extension.desc.suffix", value: " for DuckDuckGo software.", comment: "Non-bold suffix for the onboarding allow-extension description") + static let networkProtectionOnboardingAllowExtensionAction = NSLocalizedString("network.protection.onboarding.allow.extension.action", value: "Open System Settings...", comment: "Action button title for the onboarding allow-extension view") + + static let networkProtectionOnboardingAllowVPNTitle = NSLocalizedString("network.protection.onboarding.allow.vpn.title", value: "Add VPN Configuration", comment: "Title for the onboarding allow-VPN step") + static let networkProtectionOnboardingAllowVPNDescPrefix = NSLocalizedString("network.protection.onboarding.allow.vpn.desc.prefix", value: "Select ", comment: "Non-bold prefix for the onboarding allow-VPN description") + static let networkProtectionOnboardingAllowVPNDescAllow = NSLocalizedString("network.protection.onboarding.allow.vpn.desc.allow", value: "Allow", comment: "'Allow' word between the prefix and suffix for the onboarding allow-VPN description") + static let networkProtectionOnboardingAllowVPNDescSuffix = NSLocalizedString("network.protection.onboarding.allow.vpn.desc.suffix", value: " when prompted to finish setting up Network Protection.", comment: "Non-bold suffix for the onboarding allow-VPN description") + static let networkProtectionOnboardingAllowVPNAction = NSLocalizedString("network.protection.onboarding.allow.vpn.action", value: "Add VPN Configuration...", comment: "Action button title for the onboarding allow-VPN view") + // MARK: - Connection Status static let networkProtectionStatusDisconnected = NSLocalizedString("network.protection.status.disconnected", value: "Not connected", comment: "The label for the NetP VPN when disconnected") diff --git a/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Menu/NetworkProtectionStatusBarMenu.swift b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Menu/NetworkProtectionStatusBarMenu.swift index 73acb6e040..b434f0c20c 100644 --- a/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Menu/NetworkProtectionStatusBarMenu.swift +++ b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Menu/NetworkProtectionStatusBarMenu.swift @@ -43,6 +43,7 @@ public final class StatusBarMenu { /// - statusItem: (meant for testing) this allows us to inject our own status `NSStatusItem` to make automated testing easier.. /// public init(statusItem: NSStatusItem? = nil, + onboardingStatusPublisher: OnboardingStatusPublisher, statusReporter: NetworkProtectionStatusReporter? = nil, controller: TunnelController, iconProvider: IconProvider, @@ -59,7 +60,7 @@ public final class StatusBarMenu { self.statusItem = statusItem ?? NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) self.iconPublisher = NetworkProtectionIconPublisher(statusReporter: statusReporter, iconProvider: iconProvider) - popover = NetworkProtectionPopover(controller: controller, statusReporter: statusReporter, menuItems: menuItems) + popover = NetworkProtectionPopover(controller: controller, onboardingStatusPublisher: onboardingStatusPublisher, statusReporter: statusReporter, menuItems: menuItems) popover.behavior = .transient self.statusItem.button?.image = .image(for: iconPublisher.icon) diff --git a/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/NetworkProtectionAsset.swift b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/NetworkProtectionAsset.swift index 711ebf4627..31e0d6eaa0 100644 --- a/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/NetworkProtectionAsset.swift +++ b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/NetworkProtectionAsset.swift @@ -18,13 +18,17 @@ import Foundation -public enum NetworkProtectionAsset: String { +public enum NetworkProtectionAsset: String, CaseIterable { case ipAddressIcon = "IP-16" case serverLocationIcon = "Server-Location-16" case vpnDisabledImage = "VPN-Disabled-128" case vpnEnabledImage = "VPN-128" case vpnIcon = "VPN-16" + // Apple Icons + case appleVaultIcon = "apple-vault-icon" + case appleVPNIcon = "apple-vpn-icon" + // App Specific case appVPNOnIcon = "app-vpn-on" case appVPNOffIcon = "app-vpn-off" @@ -40,4 +44,8 @@ public enum NetworkProtectionAsset: String { case statusbarDebugVPNOnIcon = "statusbar-debug-vpn-on" case statusbarBrandedVPNOffIcon = "statusbar-branded-vpn-off" case statusbarBrandedVPNIssueIcon = "statusbar-branded-vpn-issue" + + // Images: + case allowSysexScreenshot = "allow-sysex-screenshot" + case allowSysexScreenshotBigSur = "allow-sysex-screenshot-bigsur" } diff --git a/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/NetworkProtectionColor.swift b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/NetworkProtectionColor.swift new file mode 100644 index 0000000000..264fcb2ab8 --- /dev/null +++ b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/NetworkProtectionColor.swift @@ -0,0 +1,43 @@ +// +// NetworkProtectionColor.swift +// +// Copyright © 2022 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 SwiftUI + +extension Color { + /// Convenience initializer to make it easier to use our custom colors. + /// + init(_ networkProtectionColor: NetworkProtectionColor) { + self = networkProtectionColor.asColor + } +} + +/// NetworkProtectionUI bundled color definitions +/// +enum NetworkProtectionColor: String { + case alertBubbleBackground = "AlertBubbleBackground" + case defaultText = "TextColor" + case linkColor = "LinkBlueColor" + case onboardingButtonBackgroundColor = "OnboardingButtonBackgroundColor" + case onboardingStepBorder = "OnboardingStepBorderColor" + case onboardingStepBackground = "OnboardingStepBackgroundColor" + + var asColor: Color { + Color(rawValue, bundle: .module) + } +} diff --git a/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/NetworkProtectionPopover.swift b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/NetworkProtectionPopover.swift index 3453f5ced7..47f25e7192 100644 --- a/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/NetworkProtectionPopover.swift +++ b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/NetworkProtectionPopover.swift @@ -45,7 +45,10 @@ public final class NetworkProtectionPopover: NSPopover { private let statusReporter: NetworkProtectionStatusReporter - public required init(controller: TunnelController, statusReporter: NetworkProtectionStatusReporter, menuItems: [MenuItem]) { + public required init(controller: TunnelController, + onboardingStatusPublisher: OnboardingStatusPublisher, + statusReporter: NetworkProtectionStatusReporter, + menuItems: [MenuItem]) { self.statusReporter = statusReporter @@ -54,16 +57,20 @@ public final class NetworkProtectionPopover: NSPopover { self.animates = false self.behavior = .semitransient - setupContentController(controller: controller, statusReporter: statusReporter, menuItems: menuItems) + setupContentController(controller: controller, onboardingStatusPublisher: onboardingStatusPublisher, statusReporter: statusReporter, menuItems: menuItems) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - private func setupContentController(controller: TunnelController, statusReporter: NetworkProtectionStatusReporter, menuItems: [MenuItem]) { + private func setupContentController(controller: TunnelController, + onboardingStatusPublisher: OnboardingStatusPublisher, + statusReporter: NetworkProtectionStatusReporter, + menuItems: [MenuItem]) { let model = NetworkProtectionStatusView.Model(controller: controller, + onboardingStatusPublisher: onboardingStatusPublisher, statusReporter: statusReporter, menuItems: menuItems) diff --git a/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/NetworkProtectionStatusViewModel.swift b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/NetworkProtectionStatusViewModel.swift deleted file mode 100644 index 22d6109008..0000000000 --- a/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/NetworkProtectionStatusViewModel.swift +++ /dev/null @@ -1,550 +0,0 @@ -// -// NetworkProtectionStatusViewModel.swift -// -// Copyright © 2022 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 Combine -import NetworkExtension -import NetworkProtection - -/// This view can be shown from any location where we want the user to be able to interact with NetP. -/// This view shows status information about Network Protection, and offers a chance to toggle it ON and OFF. -/// -extension NetworkProtectionStatusView { - - /// The view model definition for ``NetworkProtectionStatusView`` - /// - @MainActor - public final class Model: ObservableObject { - - public struct MenuItem { - let name: String - let action: () async -> Void - - public init(name: String, action: @escaping () async -> Void) { - self.name = name - self.action = action - } - } - - /// The NetP service. - /// - private let tunnelController: TunnelController - - /// The NetP status reporter - /// - private let statusReporter: NetworkProtectionStatusReporter - - // MARK: - Extra Menu Items - - public let menuItems: [MenuItem] - - // MARK: - Misc - - /// The `RunLoop` for the timer. - /// - private let runLoopMode: RunLoop.Mode? - - private var statusChangeCancellable: AnyCancellable? - private var connectivityIssuesCancellable: AnyCancellable? - private var serverInfoCancellable: AnyCancellable? - private var tunnelErrorMessageCancellable: AnyCancellable? - private var controllerErrorMessageCancellable: AnyCancellable? - - // MARK: - Dispatch Queues - - private static let statusDispatchQueue = DispatchQueue(label: "com.duckduckgo.NetworkProtectionStatusView.statusDispatchQueue", qos: .userInteractive) - private static let connectivityIssuesDispatchQueue = DispatchQueue(label: "com.duckduckgo.NetworkProtectionStatusView.connectivityIssuesDispatchQueue", qos: .userInteractive) - 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) - - // MARK: - Feature Image - - var mainImageAsset: NetworkProtectionAsset { - switch connectionStatus { - case .connected: - return .vpnEnabledImage - case .disconnecting: - if case .connected = previousConnectionStatus { - return .vpnEnabledImage - } else { - return .vpnDisabledImage - } - default: - return .vpnDisabledImage - } - } - - // MARK: - Initialization & Deinitialization - - public init(controller: TunnelController, - statusReporter: NetworkProtectionStatusReporter, - menuItems: [MenuItem], - runLoopMode: RunLoop.Mode? = nil) { - - self.tunnelController = controller - self.statusReporter = statusReporter - self.menuItems = menuItems - self.runLoopMode = runLoopMode - - connectionStatus = statusReporter.statusObserver.recentValue - isHavingConnectivityIssues = statusReporter.connectivityIssuesObserver.recentValue - internalServerAddress = statusReporter.serverInfoObserver.recentValue.serverAddress - internalServerLocation = statusReporter.serverInfoObserver.recentValue.serverLocation - lastTunnelErrorMessage = statusReporter.connectionErrorObserver.recentValue - lastControllerErrorMessage = statusReporter.controllerErrorMessageObserver.recentValue - - // Particularly useful when unit testing with an initial status of our choosing. - refreshInternalIsRunning() - - subscribeToStatusChanges() - subscribeToConnectivityIssues() - subscribeToTunnelErrorMessages() - subscribeToControllerErrorMessages() - subscribeToServerInfoChanges() - } - - deinit { - timer?.invalidate() - timer = nil - } - - // MARK: - Subscriptions - - private func subscribeToStatusChanges() { - statusChangeCancellable = statusReporter.statusObserver.publisher - .subscribe(on: Self.statusDispatchQueue) - .sink { [weak self] status in - - guard let self else { - return - } - - Task { @MainActor in - self.connectionStatus = status - } - } - } - - private func subscribeToConnectivityIssues() { - connectivityIssuesCancellable = statusReporter.connectivityIssuesObserver.publisher - .subscribe(on: Self.connectivityIssuesDispatchQueue) - .sink { [weak self] isHavingConnectivityIssues in - - guard let self else { - return - } - - Task { @MainActor in - self.isHavingConnectivityIssues = isHavingConnectivityIssues - } - } - } - - private func subscribeToTunnelErrorMessages() { - tunnelErrorMessageCancellable = statusReporter.connectionErrorObserver.publisher - .subscribe(on: Self.tunnelErrorDispatchQueue) - .sink { [weak self] errorMessage in - - guard let self else { - return - } - - Task { @MainActor in - self.lastTunnelErrorMessage = errorMessage - } - } - } - - private func subscribeToControllerErrorMessages() { - controllerErrorMessageCancellable = statusReporter.controllerErrorMessageObserver.publisher - .subscribe(on: Self.controllerErrorDispatchQueue) - .sink { [weak self] errorMessage in - - guard let self else { - return - } - - Task { @MainActor in - self.lastControllerErrorMessage = errorMessage - } - } - } - - private func subscribeToServerInfoChanges() { - serverInfoCancellable = statusReporter.serverInfoObserver.publisher - .subscribe(on: Self.serverInfoDispatchQueue) - .sink { [weak self] serverInfo in - - guard let self else { - return - } - - Task { @MainActor in - self.internalServerAddress = serverInfo.serverAddress - self.internalServerLocation = serverInfo.serverLocation - } - } - } - - // MARK: - ON/OFF Toggle - - private func startTimer() { - guard timer == nil else { - return - } - - refreshTimeLapsed() - let call = refreshTimeLapsed - - let newTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in - Task { @MainActor in - call() - } - } - - timer = newTimer - - if let runLoopMode = runLoopMode { - RunLoop.current.add(newTimer, forMode: runLoopMode) - } - } - - private func stopTimer() { - timer?.invalidate() - timer = nil - } - - /// Whether NetP is actually running. - /// - @Published - private var internalIsRunning = false { - didSet { - if internalIsRunning { - startTimer() - } else { - stopTimer() - } - } - } - - @MainActor - private func refreshInternalIsRunning() { - switch connectionStatus { - case .connected, .connecting, .reasserting: - guard internalIsRunning == false else { - return - } - - internalIsRunning = true - case .disconnected, .disconnecting: - guard internalIsRunning == true else { - return - } - - internalIsRunning = false - default: - break - } - } - - /// Convenience binding to be able to both query and toggle NetP. - /// - @MainActor - var isToggleOn: Binding { - .init { - switch self.toggleTransition { - case .idle: - break - case .switchingOn: - return true - case .switchingOff: - return false - } - - return self.internalIsRunning - } set: { newValue in - guard newValue != self.internalIsRunning else { - return - } - - self.internalIsRunning = newValue - - if newValue { - self.startNetworkProtection() - } else { - self.stopNetworkProtection() - } - } - } - - // MARK: - Status & health - - private weak var timer: Timer? - - private var previousConnectionStatus: NetworkProtection.ConnectionStatus = .disconnected - - @MainActor - @Published - private var connectionStatus: NetworkProtection.ConnectionStatus = .disconnected { - didSet { - detectAndRefreshExternalToggleSwitching() - previousConnectionStatus = oldValue - refreshInternalIsRunning() - refreshTimeLapsed() - } - } - - /// This method serves as a simple mechanism to detect when the toggle is controlled by the agent app, or by another - /// external event causing the tunnel to start or stop, so we can disable the toggle as it's transitioning.. - /// - private func detectAndRefreshExternalToggleSwitching() { - switch toggleTransition { - case .idle: - // When the toggle transition is idle, if the status changes to connecting or disconnecting - // it means the tunnel is being controlled from elsewhere. - if connectionStatus == .connecting { - toggleTransition = .switchingOn(locallyInitiated: false) - } else if connectionStatus == .disconnecting { - toggleTransition = .switchingOff(locallyInitiated: false) - } - case .switchingOn(let locallyInitiated), .switchingOff(let locallyInitiated): - guard !locallyInitiated else { break } - - if connectionStatus == .connecting { - toggleTransition = .switchingOn(locallyInitiated: false) - } else if connectionStatus == .disconnecting { - toggleTransition = .switchingOff(locallyInitiated: false) - } else { - toggleTransition = .idle - } - } - } - - // MARK: - Connection Status: Toggle State - - @frozen - enum ToggleTransition: Equatable { - case idle - case switchingOn(locallyInitiated: Bool) - case switchingOff(locallyInitiated: Bool) - } - - /// Specifies a transition the toggle is undergoing, which will make sure the toggle stays in a position (either ON or OFF) - /// and ignores intermediate status updates until the transition completes and this is set back to .idle. - @Published - private(set) var toggleTransition = ToggleTransition.idle - - /// The toggle is disabled while transitioning due to user interaction. - /// - var isToggleDisabled: Bool { - if case .idle = toggleTransition { - return false - } - - return true - } - - // MARK: - Connection Status: Errors - - @Published - private var isHavingConnectivityIssues: Bool = false - - @Published - private var lastControllerErrorMessage: String? - - @Published - private var lastTunnelErrorMessage: String? - - // MARK: - Connection Status: Timer - - /// The description for the current connection status. - /// When the status is `connected` this description will also show the time lapsed since connection. - /// - @Published var timeLapsed = UserText.networkProtectionStatusViewTimerZero - - private func refreshTimeLapsed() { - switch connectionStatus { - case .connected(let connectedDate): - timeLapsed = timeLapsedString(since: connectedDate) - case .disconnecting: - timeLapsed = UserText.networkProtectionStatusViewTimerZero - default: - timeLapsed = UserText.networkProtectionStatusViewTimerZero - } - } - - /// The description for the current connection status. - /// When the status is `connected` this description will also show the time lapsed since connection. - /// - var connectionStatusDescription: String { - // If the user is toggling NetP ON or OFF we'll respect the toggle state - // until it's idle again - switch toggleTransition { - case .idle: - break - case .switchingOn: - return UserText.networkProtectionStatusConnecting - case .switchingOff: - return UserText.networkProtectionStatusDisconnecting - } - - switch connectionStatus { - case .connected: - return "\(UserText.networkProtectionStatusConnected) · \(timeLapsed)" - case .connecting, .reasserting: - return UserText.networkProtectionStatusConnecting - case .disconnected, .notConfigured: - return UserText.networkProtectionStatusDisconnected - case .disconnecting: - return UserText.networkProtectionStatusDisconnecting - } - } - - var issueDescription: String? { - if let lastControllerErrorMessage = lastControllerErrorMessage { - return lastControllerErrorMessage - } - - if let lastTunnelErrorMessage = lastTunnelErrorMessage { - return lastTunnelErrorMessage - } - - if isHavingConnectivityIssues { - switch connectionStatus { - case .reasserting, .connecting, .connected: - return UserText.networkProtectionInterruptedReconnecting - case .disconnecting, .disconnected: - return UserText.networkProtectionInterrupted - default: - return nil - } - } else { - return nil - } - } - - private func timeLapsedString(since date: Date) -> String { - let secondsLapsed = Date().timeIntervalSince(date) - - let hours = Int(secondsLapsed) / 3600 - let minutes = Int(secondsLapsed) / 60 % 60 - let seconds = Int(secondsLapsed) % 60 - - return String(format: "%02i:%02i:%02i", hours, minutes, seconds) - } - - /// The feature status (ON/OFF) right below the main icon. - /// - var featureStatusDescription: String { - switch connectionStatus { - case .connected, .disconnecting: - return UserText.networkProtectionStatusViewFeatureOn - default: - return UserText.networkProtectionStatusViewFeatureOff - } - } - - // MARK: - Server Information - - var showServerDetails: Bool { - switch connectionStatus { - case .connected: - return true - case .disconnecting: - if case .connected = previousConnectionStatus { - return true - } else { - return false - } - default: - return false - } - } - - @Published - private var internalServerAddress: String? - - var serverAddress: String { - guard let internalServerAddress = internalServerAddress else { - return UserText.networkProtectionServerAddressUnknown - } - - switch connectionStatus { - case .connected: - return internalServerAddress - case .disconnecting: - if case .connected = previousConnectionStatus { - return internalServerAddress - } else { - return UserText.networkProtectionServerAddressUnknown - } - default: - return UserText.networkProtectionServerAddressUnknown - } - } - - @Published - var internalServerLocation: String? - - var serverLocation: String { - guard let internalServerLocation = internalServerLocation else { - return UserText.networkProtectionServerLocationUnknown - } - - switch connectionStatus { - case .connected: - return internalServerLocation - case .disconnecting: - if case .connected = previousConnectionStatus { - return internalServerLocation - } else { - return UserText.networkProtectionServerLocationUnknown - } - default: - return UserText.networkProtectionServerLocationUnknown - } - } - - // MARK: - Toggling Network Protection - - /// Start network protection. - /// - private func startNetworkProtection() { - toggleTransition = .switchingOn(locallyInitiated: true) - - Task { @MainActor in - await tunnelController.start() - toggleTransition = .idle - refreshInternalIsRunning() - } - } - - /// Stop network protection. - /// - private func stopNetworkProtection() { - toggleTransition = .switchingOff(locallyInitiated: true) - - Task { @MainActor in - await tunnelController.stop() - toggleTransition = .idle - refreshInternalIsRunning() - } - } - } -} diff --git a/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Colors/OnboardingButtonBackgroundColor.colorset/Contents.json b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Colors/OnboardingButtonBackgroundColor.colorset/Contents.json new file mode 100644 index 0000000000..9ce44647e8 --- /dev/null +++ b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Colors/OnboardingButtonBackgroundColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFF" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.250", + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFF" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Colors/OnboardingStepBackgroundColor.colorset/Contents.json b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Colors/OnboardingStepBackgroundColor.colorset/Contents.json new file mode 100644 index 0000000000..d18777e9b1 --- /dev/null +++ b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Colors/OnboardingStepBackgroundColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.010", + "blue" : "0x00", + "green" : "0x00", + "red" : "0x00" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.030", + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFF" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Colors/OnboardingStepBorderColor.colorset/Contents.json b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Colors/OnboardingStepBorderColor.colorset/Contents.json new file mode 100644 index 0000000000..524f806670 --- /dev/null +++ b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Colors/OnboardingStepBorderColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.090", + "blue" : "0x00", + "green" : "0x00", + "red" : "0x00" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.090", + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFF" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Colors/TextColor.colorset/Contents.json b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Colors/TextColor.colorset/Contents.json index 0c600f92f1..8eb21037b0 100644 --- a/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Colors/TextColor.colorset/Contents.json +++ b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Colors/TextColor.colorset/Contents.json @@ -22,7 +22,7 @@ "color" : { "color-space" : "srgb", "components" : { - "alpha" : "1.000", + "alpha" : "0.850", "blue" : "0xFF", "green" : "0xFF", "red" : "0xFF" diff --git a/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Icons/apple-vault-icon.imageset/Contents.json b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Icons/apple-vault-icon.imageset/Contents.json new file mode 100644 index 0000000000..408b00eb0c --- /dev/null +++ b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Icons/apple-vault-icon.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "exention-icon.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "exention-icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "exention-icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Icons/apple-vault-icon.imageset/exention-icon.png b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Icons/apple-vault-icon.imageset/exention-icon.png new file mode 100644 index 0000000000..73c6dda6e8 Binary files /dev/null and b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Icons/apple-vault-icon.imageset/exention-icon.png differ diff --git a/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Icons/apple-vault-icon.imageset/exention-icon@2x.png b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Icons/apple-vault-icon.imageset/exention-icon@2x.png new file mode 100644 index 0000000000..2ce308b1d2 Binary files /dev/null and b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Icons/apple-vault-icon.imageset/exention-icon@2x.png differ diff --git a/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Icons/apple-vault-icon.imageset/exention-icon@3x.png b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Icons/apple-vault-icon.imageset/exention-icon@3x.png new file mode 100644 index 0000000000..6640539ba3 Binary files /dev/null and b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Icons/apple-vault-icon.imageset/exention-icon@3x.png differ diff --git a/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Icons/apple-vpn-icon.imageset/Contents.json b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Icons/apple-vpn-icon.imageset/Contents.json new file mode 100644 index 0000000000..0352b014d4 --- /dev/null +++ b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Icons/apple-vpn-icon.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "vpn-icon.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "vpn-icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "vpn-icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Icons/apple-vpn-icon.imageset/vpn-icon.png b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Icons/apple-vpn-icon.imageset/vpn-icon.png new file mode 100644 index 0000000000..259d35d2ae Binary files /dev/null and b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Icons/apple-vpn-icon.imageset/vpn-icon.png differ diff --git a/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Icons/apple-vpn-icon.imageset/vpn-icon@2x.png b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Icons/apple-vpn-icon.imageset/vpn-icon@2x.png new file mode 100644 index 0000000000..581b7ecd2a Binary files /dev/null and b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Icons/apple-vpn-icon.imageset/vpn-icon@2x.png differ diff --git a/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Icons/apple-vpn-icon.imageset/vpn-icon@3x.png b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Icons/apple-vpn-icon.imageset/vpn-icon@3x.png new file mode 100644 index 0000000000..30ba908cc8 Binary files /dev/null and b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Icons/apple-vpn-icon.imageset/vpn-icon@3x.png differ diff --git a/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Images/Contents.json b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Images/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Images/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Images/allow-sysex-screenshot-bigsur.imageset/Contents.json b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Images/allow-sysex-screenshot-bigsur.imageset/Contents.json new file mode 100644 index 0000000000..2d1067d0d3 --- /dev/null +++ b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Images/allow-sysex-screenshot-bigsur.imageset/Contents.json @@ -0,0 +1,56 @@ +{ + "images" : [ + { + "filename" : "theme=light, mac=big sur.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "theme=dark, mac=big sur.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "theme=light, mac=big sur@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "theme=dark, mac=big sur@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "theme=light, mac=big sur@3x.png", + "idiom" : "universal", + "scale" : "3x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "theme=dark, mac=big sur@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Images/allow-sysex-screenshot-bigsur.imageset/theme=dark, mac=big sur.png b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Images/allow-sysex-screenshot-bigsur.imageset/theme=dark, mac=big sur.png new file mode 100644 index 0000000000..e3194e0a9f Binary files /dev/null and b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Images/allow-sysex-screenshot-bigsur.imageset/theme=dark, mac=big sur.png differ diff --git a/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Images/allow-sysex-screenshot-bigsur.imageset/theme=dark, mac=big sur@2x.png b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Images/allow-sysex-screenshot-bigsur.imageset/theme=dark, mac=big sur@2x.png new file mode 100644 index 0000000000..1c9bd65aad Binary files /dev/null and b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Images/allow-sysex-screenshot-bigsur.imageset/theme=dark, mac=big sur@2x.png differ diff --git a/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Images/allow-sysex-screenshot-bigsur.imageset/theme=dark, mac=big sur@3x.png b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Images/allow-sysex-screenshot-bigsur.imageset/theme=dark, mac=big sur@3x.png new file mode 100644 index 0000000000..cba0df3d15 Binary files /dev/null and b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Images/allow-sysex-screenshot-bigsur.imageset/theme=dark, mac=big sur@3x.png differ diff --git a/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Images/allow-sysex-screenshot-bigsur.imageset/theme=light, mac=big sur.png b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Images/allow-sysex-screenshot-bigsur.imageset/theme=light, mac=big sur.png new file mode 100644 index 0000000000..070f408762 Binary files /dev/null and b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Images/allow-sysex-screenshot-bigsur.imageset/theme=light, mac=big sur.png differ diff --git a/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Images/allow-sysex-screenshot-bigsur.imageset/theme=light, mac=big sur@2x.png b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Images/allow-sysex-screenshot-bigsur.imageset/theme=light, mac=big sur@2x.png new file mode 100644 index 0000000000..22a5891286 Binary files /dev/null and b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Images/allow-sysex-screenshot-bigsur.imageset/theme=light, mac=big sur@2x.png differ diff --git a/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Images/allow-sysex-screenshot-bigsur.imageset/theme=light, mac=big sur@3x.png b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Images/allow-sysex-screenshot-bigsur.imageset/theme=light, mac=big sur@3x.png new file mode 100644 index 0000000000..b7e968ff5a Binary files /dev/null and b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Images/allow-sysex-screenshot-bigsur.imageset/theme=light, mac=big sur@3x.png differ diff --git a/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Images/allow-sysex-screenshot.imageset/Contents.json b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Images/allow-sysex-screenshot.imageset/Contents.json new file mode 100644 index 0000000000..d3f0564280 --- /dev/null +++ b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Images/allow-sysex-screenshot.imageset/Contents.json @@ -0,0 +1,56 @@ +{ + "images" : [ + { + "filename" : "theme=light, mac=ventura.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "theme=dark, mac=ventura.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "theme=light, mac=ventura@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "theme=dark, mac=ventura@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "theme=light, mac=ventura@3x.png", + "idiom" : "universal", + "scale" : "3x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "theme=dark, mac=ventura@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Images/allow-sysex-screenshot.imageset/theme=dark, mac=ventura.png b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Images/allow-sysex-screenshot.imageset/theme=dark, mac=ventura.png new file mode 100644 index 0000000000..6eacd39658 Binary files /dev/null and b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Images/allow-sysex-screenshot.imageset/theme=dark, mac=ventura.png differ diff --git a/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Images/allow-sysex-screenshot.imageset/theme=dark, mac=ventura@2x.png b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Images/allow-sysex-screenshot.imageset/theme=dark, mac=ventura@2x.png new file mode 100644 index 0000000000..59af04aad4 Binary files /dev/null and b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Images/allow-sysex-screenshot.imageset/theme=dark, mac=ventura@2x.png differ diff --git a/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Images/allow-sysex-screenshot.imageset/theme=dark, mac=ventura@3x.png b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Images/allow-sysex-screenshot.imageset/theme=dark, mac=ventura@3x.png new file mode 100644 index 0000000000..6fc843dd0f Binary files /dev/null and b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Images/allow-sysex-screenshot.imageset/theme=dark, mac=ventura@3x.png differ diff --git a/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Images/allow-sysex-screenshot.imageset/theme=light, mac=ventura.png b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Images/allow-sysex-screenshot.imageset/theme=light, mac=ventura.png new file mode 100644 index 0000000000..8b2f03a973 Binary files /dev/null and b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Images/allow-sysex-screenshot.imageset/theme=light, mac=ventura.png differ diff --git a/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Images/allow-sysex-screenshot.imageset/theme=light, mac=ventura@2x.png b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Images/allow-sysex-screenshot.imageset/theme=light, mac=ventura@2x.png new file mode 100644 index 0000000000..e2a930929b Binary files /dev/null and b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Images/allow-sysex-screenshot.imageset/theme=light, mac=ventura@2x.png differ diff --git a/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Images/allow-sysex-screenshot.imageset/theme=light, mac=ventura@3x.png b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Images/allow-sysex-screenshot.imageset/theme=light, mac=ventura@3x.png new file mode 100644 index 0000000000..9db138413a Binary files /dev/null and b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Resources/Assets.xcassets/Images/allow-sysex-screenshot.imageset/theme=light, mac=ventura@3x.png differ diff --git a/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/SwiftUI/ViewDisabler.swift b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/SwiftUI/ViewDisabler.swift new file mode 100644 index 0000000000..3dcc12d49f --- /dev/null +++ b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/SwiftUI/ViewDisabler.swift @@ -0,0 +1,51 @@ +// +// ViewDisabler.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 Combine +import Foundation +import SwiftUI + +/// Disables a view giving it opacity and making it impossible to interact with. Most useful on composite views. +/// +private struct ViewDisabler: ViewModifier { + static let disabledOpacity = 0.4 + static let enabledOpacity = 1.0 + + @State var disable: Bool + + func body(content: Content) -> some View { + content.opacity(disable ? Self.disabledOpacity : Self.enabledOpacity) + .disabled(disable ? true : false) + } +} + +extension View { + + /// Disables a view giving it opacity and making it impossible to interact with. Most useful on composite views. + /// + @ViewBuilder + func disabled(on condition: Bool) -> some View { + // This if condition may seem a bit silly and unnecessary, but it seems like the `ViewDisabler` + // won't be recreated / recalculated unless we split paths here for the condition. + if condition { + self.modifier(ViewDisabler(disable: condition)) + } else { + self.modifier(ViewDisabler(disable: condition)) + } + } +} diff --git a/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Views/OnboardingStepView/OnboardingStepView.swift b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Views/OnboardingStepView/OnboardingStepView.swift new file mode 100644 index 0000000000..778e4cb3d9 --- /dev/null +++ b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Views/OnboardingStepView/OnboardingStepView.swift @@ -0,0 +1,128 @@ +// +// OnboardingStepView.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 SwiftUI + +fileprivate extension View { + func applyStepTitleAttributes() -> some View { + self.font(.custom("SF Pro Text", size: 13) + .weight(.bold)) + .foregroundColor(Color(.defaultText)) + } + + func applyStepDescriptionAttributes() -> some View { + self.font(.custom("SF Pro Text", size: 13)) + .foregroundColor(Color(.defaultText)) + } + + @ViewBuilder + func applyStepButtonAttributes(colorScheme: ColorScheme) -> some View { + switch colorScheme { + case .dark: + self.buttonStyle(.plain) + .padding(.horizontal, 12) + .padding(.vertical, 0) + .frame(height: 20, alignment: .center) + .background(Color(.onboardingButtonBackgroundColor)) + .cornerRadius(5) + .shadow(color: .black.opacity(0.2), radius: 0.5, x: 0, y: 1) + .shadow(color: .black.opacity(0.05), radius: 0.5, x: 0, y: 0) + .shadow(color: .black.opacity(0.1), radius: 0, x: 0, y: 0) + default: + self.buttonStyle(.plain) + .padding(.horizontal, 12) + .padding(.vertical, 0) + .frame(height: 20, alignment: .center) + .background(Color(.onboardingButtonBackgroundColor)) + .cornerRadius(5) + .shadow(color: .black.opacity(0.1), radius: 0.5, x: 0, y: 1) + .shadow(color: .black.opacity(0.05), radius: 0.5, x: 0, y: 0) + .overlay( + RoundedRectangle(cornerRadius: 5) + .inset(by: -0.25) + .stroke(.black.opacity(0.1), lineWidth: 0.5) + ) + } + } +} + +struct OnboardingStepView: View { + + @Environment(\.colorScheme) var colorScheme + + // MARK: - Model + + private let model: Model + + // MARK: - Initializers + + public init(model: Model) { + self.model = model + } + + // MARK: - View + + public var body: some View { + VStack(spacing: 0) { + HStack(alignment: .top, spacing: 12) { + Image(model.icon) + + HStack { + VStack(alignment: .leading, spacing: 5) { + Text(model.title) + .applyStepTitleAttributes() + .multilineText() + + model.description.reduce(Text("")) { previous, fragment in + var newText = Text(fragment.text) + + if fragment.isEmphasized { + newText = newText.fontWeight(.semibold) + } + + return previous + newText + } + .applyStepDescriptionAttributes() + .multilineText() + + Button(model.actionTitle, action: model.action) + .applyStepButtonAttributes(colorScheme: colorScheme) + .padding(.top, 3) + } + + Spacer() + } + } + .padding(.vertical, 16) + .padding(.horizontal, 10) + + if let actionScreenshot = model.actionScreenshot { + Image(actionScreenshot) + } + } + .cornerRadius(8) + .background( + RoundedRectangle(cornerRadius: 6, style: .circular) + .stroke(Color(.onboardingStepBorder)) + .background( + RoundedRectangle(cornerRadius: 6, style: .circular) + .fill(Color(.onboardingStepBackground)) + )) + } +} diff --git a/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Views/OnboardingStepView/OnboardingStepViewModel.swift b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Views/OnboardingStepView/OnboardingStepViewModel.swift new file mode 100644 index 0000000000..de6d796351 --- /dev/null +++ b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Views/OnboardingStepView/OnboardingStepViewModel.swift @@ -0,0 +1,102 @@ +// +// OnboardingStepViewModel.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 AppKit +import Foundation + +extension OnboardingStepView { + + /// Model for AllowSystemExtensionView + /// + final class Model: ObservableObject { + struct StyledTextFragment { + let text: String + let isEmphasized: Bool + + init(text: String, isEmphasized: Bool = false) { + self.text = text + self.isEmphasized = isEmphasized + } + } + + private let step: OnboardingStep + let action: () -> Void + + init(step: OnboardingStep, action: @escaping () -> Void) { + self.step = step + self.action = action + } + + var icon: NetworkProtectionAsset { + switch step { + case .userNeedsToAllowExtension: + return .appleVaultIcon + case .userNeedsToAllowVPNConfiguration: + return .appleVPNIcon + } + } + + var title: String { + switch step { + case .userNeedsToAllowExtension: + return UserText.networkProtectionOnboardingAllowExtensionTitle + case .userNeedsToAllowVPNConfiguration: + return UserText.networkProtectionOnboardingAllowVPNTitle + } + } + + var description: [StyledTextFragment] { + switch step { + case .userNeedsToAllowExtension: + return [ + .init(text: UserText.networkProtectionOnboardingAllowExtensionDescPrefix), + .init(text: UserText.networkProtectionOnboardingAllowExtensionDescAllow, isEmphasized: true), + .init(text: UserText.networkProtectionOnboardingAllowExtensionDescSuffix), + ] + case .userNeedsToAllowVPNConfiguration: + return [ + .init(text: UserText.networkProtectionOnboardingAllowVPNDescPrefix), + .init(text: UserText.networkProtectionOnboardingAllowVPNDescAllow, isEmphasized: true), + .init(text: UserText.networkProtectionOnboardingAllowVPNDescSuffix), + ] + } + } + + var actionTitle: String { + switch step { + case .userNeedsToAllowExtension: + return UserText.networkProtectionOnboardingAllowExtensionAction + case .userNeedsToAllowVPNConfiguration: + return UserText.networkProtectionOnboardingAllowVPNAction + } + } + + var actionScreenshot: NetworkProtectionAsset? { + switch step { + case .userNeedsToAllowExtension: + if #available(macOS 12, *) { + return .allowSysexScreenshot + } else { + return .allowSysexScreenshotBigSur + } + case .userNeedsToAllowVPNConfiguration: + return nil + } + } + } +} diff --git a/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Views/StatusView/NetworkProtectionStatusView.swift b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Views/StatusView/NetworkProtectionStatusView.swift new file mode 100644 index 0000000000..a138e4544c --- /dev/null +++ b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Views/StatusView/NetworkProtectionStatusView.swift @@ -0,0 +1,108 @@ +// +// NetworkProtectionStatusView.swift +// +// Copyright © 2022 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 +import Combine +import NetworkProtection + +fileprivate extension View { + func applyMenuAttributes() -> some View { + opacity(0.9) + .font(.system(size: 13, weight: .regular, design: .default)) + .foregroundColor(Color(.defaultText)) + } +} + +public struct NetworkProtectionStatusView: View { + + @Environment(\.colorScheme) var colorScheme + @Environment(\.dismiss) private var dismiss + + // MARK: - Model + + /// The view model that this instance will use. + /// + @ObservedObject var model: Model + + // MARK: - Initializers + + public init(model: Model) { + self.model = model + } + + // MARK: - View Contents + + public var body: some View { + VStack(spacing: 0) { + if let onboardingStepViewModel = model.onboardingStepViewModel { + OnboardingStepView(model: onboardingStepViewModel) + .padding(.horizontal, 5) + .padding(.top, 5) + } else { + if let healthWarning = model.issueDescription { + connectionHealthWarningView(message: healthWarning) + } + } + + Spacer() + + TunnelControllerView(model: model.tunnelControllerViewModel) + .disabled(model.tunnelControllerViewDisabled) + + bottomMenuView() + } + .padding(5) + .frame(maxWidth: 350, alignment: .top) + } + + // MARK: - Composite Views + + private func connectionHealthWarningView(message: String) -> some View { + VStack(spacing: 0) { + HStack(alignment: .top, spacing: 12) { + Image("WarningColored", bundle: Bundle.module) + + /// 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 + MenuItemButton(menuItem.name, textColor: Color(.defaultText)) { + await menuItem.action() + dismiss() + }.applyMenuAttributes() + } + } + } +} diff --git a/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Views/StatusView/NetworkProtectionStatusViewModel.swift b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Views/StatusView/NetworkProtectionStatusViewModel.swift new file mode 100644 index 0000000000..df561d201d --- /dev/null +++ b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Views/StatusView/NetworkProtectionStatusViewModel.swift @@ -0,0 +1,240 @@ +// +// NetworkProtectionStatusViewModel.swift +// +// Copyright © 2022 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 Combine +import NetworkExtension +import NetworkProtection + +/// This view can be shown from any location where we want the user to be able to interact with NetP. +/// This view shows status information about Network Protection, and offers a chance to toggle it ON and OFF. +/// +extension NetworkProtectionStatusView { + + /// The view model definition for ``NetworkProtectionStatusView`` + /// + @MainActor + public final class Model: ObservableObject { + + public struct MenuItem { + let name: String + let action: () async -> Void + + public init(name: String, action: @escaping () async -> Void) { + self.name = name + self.action = action + } + } + + /// The NetP service. + /// + private let tunnelController: TunnelController + + @MainActor + @Published + private var connectionStatus: NetworkProtection.ConnectionStatus = .disconnected + + /// The type of extension that's being used for NetP + /// + @Published + private(set) var onboardingStatus: OnboardingStatus = .completed + + var tunnelControllerViewDisabled: Bool { + onboardingStatus != .completed + } + + /// The NetP onboarding status publisher + /// + private let onboardingStatusPublisher: OnboardingStatusPublisher + + /// The NetP status reporter + /// + private let statusReporter: NetworkProtectionStatusReporter + + // MARK: - Extra Menu Items + + public let menuItems: [MenuItem] + + // MARK: - Misc + + /// The `RunLoop` for the timer. + /// + private let runLoopMode: RunLoop.Mode? + + private var cancellables = Set() + + // MARK: - Dispatch Queues + + private static let statusDispatchQueue = DispatchQueue(label: "com.duckduckgo.NetworkProtectionStatusView.statusDispatchQueue", qos: .userInteractive) + private static let connectivityIssuesDispatchQueue = DispatchQueue(label: "com.duckduckgo.NetworkProtectionStatusView.connectivityIssuesDispatchQueue", qos: .userInteractive) + 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) + + // MARK: - Initialization & Deinitialization + + public init(controller: TunnelController, + onboardingStatusPublisher: OnboardingStatusPublisher, + statusReporter: NetworkProtectionStatusReporter, + menuItems: [MenuItem], + runLoopMode: RunLoop.Mode? = nil) { + + self.tunnelController = controller + self.onboardingStatusPublisher = onboardingStatusPublisher + self.statusReporter = statusReporter + self.menuItems = menuItems + self.runLoopMode = runLoopMode + + tunnelControllerViewModel = TunnelControllerViewModel(controller: tunnelController, + onboardingStatusPublisher: onboardingStatusPublisher, + statusReporter: statusReporter) + + connectionStatus = statusReporter.statusObserver.recentValue + isHavingConnectivityIssues = statusReporter.connectivityIssuesObserver.recentValue + lastTunnelErrorMessage = statusReporter.connectionErrorObserver.recentValue + lastControllerErrorMessage = statusReporter.controllerErrorMessageObserver.recentValue + + // Particularly useful when unit testing with an initial status of our choosing. + subscribeToConnectivityIssues() + subscribeToTunnelErrorMessages() + subscribeToControllerErrorMessages() + + onboardingStatusPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] status in + self?.onboardingStatus = status + } + .store(in: &cancellables) + } + + private func subscribeToConnectivityIssues() { + statusReporter.connectivityIssuesObserver.publisher + .subscribe(on: Self.connectivityIssuesDispatchQueue) + .sink { [weak self] isHavingConnectivityIssues in + + guard let self else { + return + } + + Task { @MainActor in + self.isHavingConnectivityIssues = isHavingConnectivityIssues + } + }.store(in: &cancellables) + } + + private func subscribeToTunnelErrorMessages() { + statusReporter.connectionErrorObserver.publisher + .subscribe(on: Self.tunnelErrorDispatchQueue) + .sink { [weak self] errorMessage in + + guard let self else { + return + } + + Task { @MainActor in + self.lastTunnelErrorMessage = errorMessage + } + }.store(in: &cancellables) + } + + private func subscribeToControllerErrorMessages() { + statusReporter.controllerErrorMessageObserver.publisher + .subscribe(on: Self.controllerErrorDispatchQueue) + .sink { [weak self] errorMessage in + + guard let self else { + return + } + + Task { @MainActor in + self.lastControllerErrorMessage = errorMessage + } + }.store(in: &cancellables) + } + + // MARK: - Connection Status: Errors + + @Published + private var isHavingConnectivityIssues: Bool = false + + @Published + private var lastControllerErrorMessage: String? + + @Published + private var lastTunnelErrorMessage: String? + + var issueDescription: String? { + if let lastControllerErrorMessage = lastControllerErrorMessage { + return lastControllerErrorMessage + } + + if let lastTunnelErrorMessage = lastTunnelErrorMessage { + return lastTunnelErrorMessage + } + + if isHavingConnectivityIssues { + switch connectionStatus { + case .reasserting, .connecting, .connected: + return UserText.networkProtectionInterruptedReconnecting + case .disconnecting, .disconnected: + return UserText.networkProtectionInterrupted + default: + return nil + } + } else { + return nil + } + } + + private func timeLapsedString(since date: Date) -> String { + let secondsLapsed = Date().timeIntervalSince(date) + + let hours = Int(secondsLapsed) / 3600 + let minutes = Int(secondsLapsed) / 60 % 60 + let seconds = Int(secondsLapsed) % 60 + + return String(format: "%02i:%02i:%02i", hours, minutes, seconds) + } + + /// The feature status (ON/OFF) right below the main icon. + /// + var featureStatusDescription: String { + switch connectionStatus { + case .connected, .disconnecting: + return UserText.networkProtectionStatusViewFeatureOn + default: + return UserText.networkProtectionStatusViewFeatureOff + } + } + + // MARK: - Child View Models + + let tunnelControllerViewModel: TunnelControllerViewModel + + var onboardingStepViewModel: OnboardingStepView.Model? { + switch onboardingStatus { + case .completed: + return nil + case .isOnboarding(let step): + return OnboardingStepView.Model(step: step) { [weak self] in + self?.tunnelControllerViewModel.startNetworkProtection() + } + } + } + } +} diff --git a/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/NetworkProtectionStatusView.swift b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Views/TunnelControllerView/TunnelControllerView.swift similarity index 73% rename from LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/NetworkProtectionStatusView.swift rename to LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Views/TunnelControllerView/TunnelControllerView.swift index dfe9e160d3..f7b0286ef6 100644 --- a/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/NetworkProtectionStatusView.swift +++ b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Views/TunnelControllerView/TunnelControllerView.swift @@ -21,8 +21,6 @@ import SwiftUIExtensions import Combine import NetworkProtection -private let defaultTextColor = Color("TextColor", bundle: .module) - fileprivate extension Font { enum NetworkProtection { static var connectionStatusDetail: Font { @@ -37,10 +35,6 @@ fileprivate extension Font { .system(size: 13, weight: .regular, design: .default) } - static var menu: Font { - .system(size: 13, weight: .regular, design: .default) - } - static var label: Font { .system(size: 13, weight: .regular, design: .default) } @@ -68,7 +62,6 @@ private enum Opacity { static let content = Double(0.58) static let label = Double(0.9) static let description = Double(0.9) - static let menu = Double(0.9) static let link = Double(1) static func sectionHeader(colorScheme: ColorScheme) -> Double { @@ -88,84 +81,71 @@ fileprivate extension View { func applyConnectionStatusDetailAttributes(colorScheme: ColorScheme) -> some View { opacity(Opacity.connectionStatusDetail(colorScheme: colorScheme)) .font(.NetworkProtection.connectionStatusDetail) - .foregroundColor(defaultTextColor) + .foregroundColor(Color(.defaultText)) } func applyContentAttributes(colorScheme: ColorScheme) -> some View { opacity(Opacity.content) .font(.NetworkProtection.content) - .foregroundColor(defaultTextColor) + .foregroundColor(Color(.defaultText)) } func applyDescriptionAttributes(colorScheme: ColorScheme) -> some View { opacity(Opacity.description) .font(.NetworkProtection.description) - .foregroundColor(defaultTextColor) - } - - func applyMenuAttributes() -> some View { - opacity(Opacity.menu) - .font(.NetworkProtection.menu) - .foregroundColor(defaultTextColor) - } - - func applyLinkAttributes(colorScheme: ColorScheme) -> some View { - opacity(Opacity.link) - .font(.NetworkProtection.content) - .foregroundColor(defaultTextColor) + .foregroundColor(Color(.defaultText)) } func applyLabelAttributes(colorScheme: ColorScheme) -> some View { opacity(Opacity.label) .font(.NetworkProtection.label) - .foregroundColor(defaultTextColor) + .foregroundColor(Color(.defaultText)) } func applySectionHeaderAttributes(colorScheme: ColorScheme) -> some View { opacity(Opacity.sectionHeader(colorScheme: colorScheme)) .font(.NetworkProtection.sectionHeader) - .foregroundColor(defaultTextColor) + .foregroundColor(Color(.defaultText)) } func applyTimerAttributes(colorScheme: ColorScheme) -> some View { opacity(Opacity.timer(colorScheme: colorScheme)) .font(.NetworkProtection.timer) - .foregroundColor(defaultTextColor) + .foregroundColor(Color(.defaultText)) } func applyTitleAttributes(colorScheme: ColorScheme) -> some View { opacity(Opacity.title(colorScheme: colorScheme)) .font(.NetworkProtection.title) - .foregroundColor(defaultTextColor) + .foregroundColor(Color(.defaultText)) } } -public struct NetworkProtectionStatusView: View { +public struct TunnelControllerView: View { - @Environment(\.colorScheme) var colorScheme + @Environment(\.colorScheme) private var colorScheme + @Environment(\.isEnabled) private var isEnabled @Environment(\.dismiss) private var dismiss // MARK: - Model /// The view model that this instance will use. /// - @ObservedObject var model: Model + @ObservedObject var model: TunnelControllerViewModel // MARK: - Initializers - public init(model: Model) { + public init(model: TunnelControllerViewModel) { self.model = model } // MARK: - View Contents public var body: some View { - VStack(spacing: 0) { - if let healthWarning = model.issueDescription { - connectionHealthWarningView(message: healthWarning) - } - + Group { headerView() + .disabled(on: !isEnabled) + featureToggleRow() Divider() @@ -173,38 +153,13 @@ public struct NetworkProtectionStatusView: View { if model.showServerDetails { connectionStatusView() + .disabled(on: !isEnabled) } - - bottomMenuView() } - .padding(EdgeInsets(top: 5, leading: 5, bottom: 5, trailing: 5)) - .frame(maxWidth: 350) } // MARK: - Composite Views - private func connectionHealthWarningView(message: String) -> some View { - VStack(spacing: 0) { - HStack(alignment: .top, spacing: 12) { - Image("WarningColored", bundle: Bundle.module) - - /// 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(defaultTextColor) - - Spacer() - } - } - .padding(16) - .background(RoundedRectangle(cornerRadius: 8).fill(Color("AlertBubbleBackground", bundle: Bundle.module))) - } - .padding(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8)) - } - /// Main image, feature ON/OFF and feature description /// private func headerView() -> some View { @@ -218,6 +173,7 @@ public struct NetworkProtectionStatusView: View { Text(model.featureStatusDescription) .applyTitleAttributes(colorScheme: colorScheme) .padding([.top], 8) + .multilineText() Text(UserText.networkProtectionStatusViewFeatureDesc) .multilineText() @@ -247,17 +203,6 @@ public struct NetworkProtectionStatusView: View { } } - private func bottomMenuView() -> some View { - VStack(spacing: 0) { - ForEach(model.menuItems, id: \.name) { menuItem in - MenuItemButton(menuItem.name, textColor: defaultTextColor) { - await menuItem.action() - dismiss() - } - } - } - } - // MARK: - Rows private func dividerRow() -> some View { @@ -272,18 +217,20 @@ public struct NetworkProtectionStatusView: View { .applyLabelAttributes(colorScheme: colorScheme) .frame(alignment: .leading) .fixedSize() + .disabled(on: !isEnabled) Spacer(minLength: 8) Text(model.connectionStatusDescription) .applyTimerAttributes(colorScheme: colorScheme) .fixedSize() + .disabled(on: !isEnabled) Spacer() .frame(width: 8) } } - .disabled(model.isToggleDisabled) + .disabled(!isEnabled || model.isToggleDisabled) .toggleStyle(.switch) .padding(EdgeInsets(top: 3, leading: 9, bottom: 3, trailing: 9)) } diff --git a/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Views/TunnelControllerView/TunnelControllerViewModel.swift b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Views/TunnelControllerView/TunnelControllerViewModel.swift new file mode 100644 index 0000000000..90294d616d --- /dev/null +++ b/LocalPackages/NetworkProtectionUI/Sources/NetworkProtectionUI/Views/TunnelControllerView/TunnelControllerViewModel.swift @@ -0,0 +1,464 @@ +// +// TunnelControllerViewModel.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 Combine +import Foundation +import NetworkProtection +import SwiftUI + +@MainActor +public final class TunnelControllerViewModel: ObservableObject { + + /// The NetP service. + /// + private let tunnelController: TunnelController + + /// The type of extension that's being used for NetP + /// + @Published + private(set) var onboardingStatus: OnboardingStatus = .completed + + var shouldFlipToggle: Bool { + // The toggle is not flipped when we're asking to allow a system extension + // because that step does not result in the tunnel being started. + onboardingStatus != .isOnboarding(step: .userNeedsToAllowExtension) + } + + /// The NetP onboarding status publisher + /// + private let onboardingStatusPublisher: OnboardingStatusPublisher + + /// The NetP status reporter + /// + private let statusReporter: NetworkProtectionStatusReporter + + // MARK: - Misc + + /// The `RunLoop` for the timer. + /// + private let runLoopMode: RunLoop.Mode? + private var cancellables = Set() + + // MARK: - Dispatch Queues + + private static let statusDispatchQueue = DispatchQueue(label: "com.duckduckgo.NetworkProtectionStatusView.statusDispatchQueue", qos: .userInteractive) + private static let connectivityIssuesDispatchQueue = DispatchQueue(label: "com.duckduckgo.NetworkProtectionStatusView.connectivityIssuesDispatchQueue", qos: .userInteractive) + private static let serverInfoDispatchQueue = DispatchQueue(label: "com.duckduckgo.NetworkProtectionStatusView.serverInfoDispatchQueue", qos: .userInteractive) + + // MARK: - Feature Image + + var mainImageAsset: NetworkProtectionAsset { + switch connectionStatus { + case .connected: + return .vpnEnabledImage + case .disconnecting: + if case .connected = previousConnectionStatus { + return .vpnEnabledImage + } else { + return .vpnDisabledImage + } + default: + return .vpnDisabledImage + } + } + + // MARK: - Initialization & Deinitialization + + public init(controller: TunnelController, + onboardingStatusPublisher: OnboardingStatusPublisher, + statusReporter: NetworkProtectionStatusReporter, + runLoopMode: RunLoop.Mode? = nil) { + + self.tunnelController = controller + self.onboardingStatusPublisher = onboardingStatusPublisher + self.statusReporter = statusReporter + self.runLoopMode = runLoopMode + + connectionStatus = statusReporter.statusObserver.recentValue + internalServerAddress = statusReporter.serverInfoObserver.recentValue.serverAddress + internalServerLocation = statusReporter.serverInfoObserver.recentValue.serverLocation + + // Particularly useful when unit testing with an initial status of our choosing. + refreshInternalIsRunning() + + subscribeToOnboardingStatusChanges() + subscribeToStatusChanges() + subscribeToServerInfoChanges() + } + + deinit { + timer?.invalidate() + timer = nil + } + + // MARK: - Subscriptions + + private func subscribeToOnboardingStatusChanges() { + onboardingStatusPublisher + .receive(on: DispatchQueue.main) + .assign(to: \.onboardingStatus, onWeaklyHeld: self) + .store(in: &cancellables) + } + + private func subscribeToStatusChanges() { + statusReporter.statusObserver.publisher + .subscribe(on: Self.statusDispatchQueue) + .sink { [weak self] status in + + guard let self else { + return + } + + Task { @MainActor in + self.connectionStatus = status + } + } + .store(in: &cancellables) + } + + private func subscribeToServerInfoChanges() { + statusReporter.serverInfoObserver.publisher + .subscribe(on: Self.serverInfoDispatchQueue) + .sink { [weak self] serverInfo in + + guard let self else { + return + } + + Task { @MainActor in + self.internalServerAddress = serverInfo.serverAddress + self.internalServerLocation = serverInfo.serverLocation + } + } + .store(in: &cancellables) + } + + // MARK: - ON/OFF Toggle + + private func startTimer() { + guard timer == nil else { + return + } + + refreshTimeLapsed() + let call = refreshTimeLapsed + + let newTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in + Task { @MainActor in + call() + } + } + + timer = newTimer + + if let runLoopMode = runLoopMode { + RunLoop.current.add(newTimer, forMode: runLoopMode) + } + } + + private func stopTimer() { + timer?.invalidate() + timer = nil + } + + /// Whether NetP is actually running. + /// + @Published + private var internalIsRunning = false { + didSet { + if internalIsRunning { + startTimer() + } else { + stopTimer() + } + } + } + + @MainActor + private func refreshInternalIsRunning() { + switch connectionStatus { + case .connected, .connecting, .reasserting: + guard internalIsRunning == false else { + return + } + + internalIsRunning = true + case .disconnected, .disconnecting: + guard internalIsRunning == true else { + return + } + + internalIsRunning = false + default: + break + } + } + + /// Convenience binding to be able to both query and toggle NetP. + /// + @MainActor + var isToggleOn: Binding { + .init { + switch self.toggleTransition { + case .idle: + break + case .switchingOn: + return true + case .switchingOff: + return false + } + + return self.internalIsRunning + } set: { newValue in + guard newValue != self.internalIsRunning else { + return + } + + self.internalIsRunning = newValue + + if newValue { + self.startNetworkProtection() + } else { + self.stopNetworkProtection() + } + } + } + + // MARK: - Status & health + + private weak var timer: Timer? + + private var previousConnectionStatus: NetworkProtection.ConnectionStatus = .disconnected + + @MainActor + @Published + private var connectionStatus: NetworkProtection.ConnectionStatus = .disconnected { + didSet { + detectAndRefreshExternalToggleSwitching() + previousConnectionStatus = oldValue + refreshInternalIsRunning() + refreshTimeLapsed() + } + } + + /// This method serves as a simple mechanism to detect when the toggle is controlled by the agent app, or by another + /// external event causing the tunnel to start or stop, so we can disable the toggle as it's transitioning.. + /// + private func detectAndRefreshExternalToggleSwitching() { + switch toggleTransition { + case .idle: + // When the toggle transition is idle, if the status changes to connecting or disconnecting + // it means the tunnel is being controlled from elsewhere. + if connectionStatus == .connecting { + toggleTransition = .switchingOn(locallyInitiated: false) + } else if connectionStatus == .disconnecting { + toggleTransition = .switchingOff(locallyInitiated: false) + } + case .switchingOn(let locallyInitiated), .switchingOff(let locallyInitiated): + guard !locallyInitiated else { break } + + if connectionStatus == .connecting { + toggleTransition = .switchingOn(locallyInitiated: false) + } else if connectionStatus == .disconnecting { + toggleTransition = .switchingOff(locallyInitiated: false) + } else { + toggleTransition = .idle + } + } + } + + // MARK: - Connection Status: Toggle State + + @frozen + enum ToggleTransition: Equatable { + case idle + case switchingOn(locallyInitiated: Bool) + case switchingOff(locallyInitiated: Bool) + } + + /// Specifies a transition the toggle is undergoing, which will make sure the toggle stays in a position (either ON or OFF) + /// and ignores intermediate status updates until the transition completes and this is set back to .idle. + @Published + private(set) var toggleTransition = ToggleTransition.idle + + /// The toggle is disabled while transitioning due to user interaction. + /// + var isToggleDisabled: Bool { + if case .idle = toggleTransition { + return false + } + + return true + } + + // MARK: - Connection Status: Timer + + /// The description for the current connection status. + /// When the status is `connected` this description will also show the time lapsed since connection. + /// + @Published var timeLapsed = UserText.networkProtectionStatusViewTimerZero + + private func refreshTimeLapsed() { + switch connectionStatus { + case .connected(let connectedDate): + timeLapsed = timeLapsedString(since: connectedDate) + case .disconnecting: + timeLapsed = UserText.networkProtectionStatusViewTimerZero + default: + timeLapsed = UserText.networkProtectionStatusViewTimerZero + } + } + + /// The description for the current connection status. + /// When the status is `connected` this description will also show the time lapsed since connection. + /// + var connectionStatusDescription: String { + // If the user is toggling NetP ON or OFF we'll respect the toggle state + // until it's idle again + switch toggleTransition { + case .idle: + break + case .switchingOn: + return UserText.networkProtectionStatusConnecting + case .switchingOff: + return UserText.networkProtectionStatusDisconnecting + } + + switch connectionStatus { + case .connected: + return "\(UserText.networkProtectionStatusConnected) · \(timeLapsed)" + case .connecting, .reasserting: + return UserText.networkProtectionStatusConnecting + case .disconnected, .notConfigured: + return UserText.networkProtectionStatusDisconnected + case .disconnecting: + return UserText.networkProtectionStatusDisconnecting + } + } + + private func timeLapsedString(since date: Date) -> String { + let secondsLapsed = Date().timeIntervalSince(date) + + let hours = Int(secondsLapsed) / 3600 + let minutes = Int(secondsLapsed) / 60 % 60 + let seconds = Int(secondsLapsed) % 60 + + return String(format: "%02i:%02i:%02i", hours, minutes, seconds) + } + + /// The feature status (ON/OFF) right below the main icon. + /// + var featureStatusDescription: String { + switch connectionStatus { + case .connected, .disconnecting: + return UserText.networkProtectionStatusViewFeatureOn + default: + return UserText.networkProtectionStatusViewFeatureOff + } + } + + // MARK: - Server Information + + var showServerDetails: Bool { + switch connectionStatus { + case .connected: + return true + case .disconnecting: + if case .connected = previousConnectionStatus { + return true + } else { + return false + } + default: + return false + } + } + + @Published + private var internalServerAddress: String? + + var serverAddress: String { + guard let internalServerAddress = internalServerAddress else { + return UserText.networkProtectionServerAddressUnknown + } + + switch connectionStatus { + case .connected: + return internalServerAddress + case .disconnecting: + if case .connected = previousConnectionStatus { + return internalServerAddress + } else { + return UserText.networkProtectionServerAddressUnknown + } + default: + return UserText.networkProtectionServerAddressUnknown + } + } + + @Published + var internalServerLocation: String? + + var serverLocation: String { + guard let internalServerLocation = internalServerLocation else { + return UserText.networkProtectionServerLocationUnknown + } + + switch connectionStatus { + case .connected: + return internalServerLocation + case .disconnecting: + if case .connected = previousConnectionStatus { + return internalServerLocation + } else { + return UserText.networkProtectionServerLocationUnknown + } + default: + return UserText.networkProtectionServerLocationUnknown + } + } + + // MARK: - Toggling Network Protection + + /// Start network protection. + /// + func startNetworkProtection() { + if shouldFlipToggle { + toggleTransition = .switchingOn(locallyInitiated: true) + } + + Task { @MainActor in + await tunnelController.start() + + toggleTransition = .idle + refreshInternalIsRunning() + } + } + + /// Stop network protection. + /// + func stopNetworkProtection() { + toggleTransition = .switchingOff(locallyInitiated: true) + + Task { @MainActor in + await tunnelController.stop() + toggleTransition = .idle + refreshInternalIsRunning() + } + } +} diff --git a/LocalPackages/NetworkProtectionUI/Tests/NetworkProtectionUITests/NetworkProtectionAssetTests.swift b/LocalPackages/NetworkProtectionUI/Tests/NetworkProtectionUITests/NetworkProtectionAssetTests.swift index 154a0d649d..07915bd363 100644 --- a/LocalPackages/NetworkProtectionUI/Tests/NetworkProtectionUITests/NetworkProtectionAssetTests.swift +++ b/LocalPackages/NetworkProtectionUI/Tests/NetworkProtectionUITests/NetworkProtectionAssetTests.swift @@ -17,25 +17,43 @@ // import Foundation +import SwiftUI import XCTest @testable import NetworkProtectionUI final class NetworkProtectionAssetTests: XCTestCase { + + /// This test validates that the asset names aren't changed by mistake, and that the assets + /// exist in the bundle. + /// func testAssetEnumValuesAreUnchanged() { - XCTAssertEqual(NetworkProtectionAsset.ipAddressIcon.rawValue, "IP-16") - XCTAssertEqual(NetworkProtectionAsset.serverLocationIcon.rawValue, "Server-Location-16") - XCTAssertEqual(NetworkProtectionAsset.vpnDisabledImage.rawValue, "VPN-Disabled-128") - XCTAssertEqual(NetworkProtectionAsset.vpnEnabledImage.rawValue, "VPN-128") - XCTAssertEqual(NetworkProtectionAsset.vpnIcon.rawValue, "VPN-16") - XCTAssertEqual(NetworkProtectionAsset.appVPNOnIcon.rawValue, "app-vpn-on") - XCTAssertEqual(NetworkProtectionAsset.appVPNOffIcon.rawValue, "app-vpn-off") - XCTAssertEqual(NetworkProtectionAsset.appVPNIssueIcon.rawValue, "app-vpn-issue") - XCTAssertEqual(NetworkProtectionAsset.statusbarVPNOnIcon.rawValue, "statusbar-vpn-on") - XCTAssertEqual(NetworkProtectionAsset.statusbarVPNOffIcon.rawValue, "statusbar-vpn-off") - XCTAssertEqual(NetworkProtectionAsset.statusbarVPNIssueIcon.rawValue, "statusbar-vpn-issue") - XCTAssertEqual(NetworkProtectionAsset.statusbarReviewVPNOnIcon.rawValue, "statusbar-review-vpn-on") - XCTAssertEqual(NetworkProtectionAsset.statusbarDebugVPNOnIcon.rawValue, "statusbar-debug-vpn-on") - XCTAssertEqual(NetworkProtectionAsset.statusbarBrandedVPNOffIcon.rawValue, "statusbar-branded-vpn-off") - XCTAssertEqual(NetworkProtectionAsset.statusbarBrandedVPNIssueIcon.rawValue, "statusbar-branded-vpn-issue") + let assetsAndExpectedRawValues: [NetworkProtectionAsset: String] = [ + .ipAddressIcon: "IP-16", + .serverLocationIcon: "Server-Location-16", + .vpnDisabledImage: "VPN-Disabled-128", + .vpnEnabledImage: "VPN-128", + .vpnIcon: "VPN-16", + .appleVaultIcon: "apple-vault-icon", + .appleVPNIcon: "apple-vpn-icon", + .appVPNOnIcon: "app-vpn-on", + .appVPNOffIcon: "app-vpn-off", + .appVPNIssueIcon: "app-vpn-issue", + .statusbarVPNOnIcon: "statusbar-vpn-on", + .statusbarVPNOffIcon: "statusbar-vpn-off", + .statusbarVPNIssueIcon: "statusbar-vpn-issue", + .statusbarReviewVPNOnIcon: "statusbar-review-vpn-on", + .statusbarDebugVPNOnIcon: "statusbar-debug-vpn-on", + .statusbarBrandedVPNOffIcon: "statusbar-branded-vpn-off", + .statusbarBrandedVPNIssueIcon: "statusbar-branded-vpn-issue", + .allowSysexScreenshot: "allow-sysex-screenshot", + .allowSysexScreenshotBigSur: "allow-sysex-screenshot-bigsur" + ] + + XCTAssertEqual(assetsAndExpectedRawValues.count, NetworkProtectionAsset.allCases.count) + + for (asset, rawValue) in assetsAndExpectedRawValues { + XCTAssertEqual(asset.rawValue, rawValue) + XCTAssertNotNil(Image(rawValue, bundle: .module)) + } } } diff --git a/LocalPackages/NetworkProtectionUI/Tests/NetworkProtectionUITests/NetworkProtectionStatusBarMenuTests.swift b/LocalPackages/NetworkProtectionUI/Tests/NetworkProtectionUITests/NetworkProtectionStatusBarMenuTests.swift index bdffe04051..6613f7faab 100644 --- a/LocalPackages/NetworkProtectionUI/Tests/NetworkProtectionUITests/NetworkProtectionStatusBarMenuTests.swift +++ b/LocalPackages/NetworkProtectionUI/Tests/NetworkProtectionUITests/NetworkProtectionStatusBarMenuTests.swift @@ -40,6 +40,7 @@ final class StatusBarMenuTests: XCTestCase { let item = NSStatusItem() let menu = StatusBarMenu( statusItem: item, + onboardingStatusPublisher: Just(OnboardingStatus.completed).eraseToAnyPublisher(), statusReporter: MockNetworkProtectionStatusReporter(), controller: TestTunnelController(), iconProvider: MenuIconProvider(), @@ -54,6 +55,7 @@ final class StatusBarMenuTests: XCTestCase { let item = NSStatusItem() let menu = StatusBarMenu( statusItem: item, + onboardingStatusPublisher: Just(OnboardingStatus.completed).eraseToAnyPublisher(), statusReporter: MockNetworkProtectionStatusReporter(), controller: TestTunnelController(), iconProvider: MenuIconProvider(), diff --git a/LocalPackages/NetworkProtectionUI/Tests/NetworkProtectionUITests/OnboardingStatusTests.swift b/LocalPackages/NetworkProtectionUI/Tests/NetworkProtectionUITests/OnboardingStatusTests.swift new file mode 100644 index 0000000000..40b4d34f34 --- /dev/null +++ b/LocalPackages/NetworkProtectionUI/Tests/NetworkProtectionUITests/OnboardingStatusTests.swift @@ -0,0 +1,42 @@ +// +// OnboardingStatusTests.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 XCTest +@testable import NetworkProtectionUI + +final class OnboardingStatusTests: XCTestCase { + + /// Since raw values for `OnboardingStatus` are calculated dynamically, this test + /// tests the the conversion from status to raw value and back. + /// + func testOnboardingStatusRawValues() { + let allStatus: [OnboardingStatus] = [ + .completed, + .isOnboarding(step: .userNeedsToAllowExtension), + .isOnboarding(step: .userNeedsToAllowVPNConfiguration) + ] + + for status in allStatus { + let rawValue = status.rawValue + let newStatus = OnboardingStatus(rawValue: rawValue) + + XCTAssertEqual(status, newStatus) + } + } +} diff --git a/LocalPackages/NetworkProtectionUI/Tests/NetworkProtectionUITests/NetworkProtectionStatusViewModelTests.swift b/LocalPackages/NetworkProtectionUI/Tests/NetworkProtectionUITests/TunnelControllerViewModelTests.swift similarity index 86% rename from LocalPackages/NetworkProtectionUI/Tests/NetworkProtectionUITests/NetworkProtectionStatusViewModelTests.swift rename to LocalPackages/NetworkProtectionUI/Tests/NetworkProtectionUITests/TunnelControllerViewModelTests.swift index 75bcece6df..2070272881 100644 --- a/LocalPackages/NetworkProtectionUI/Tests/NetworkProtectionUITests/NetworkProtectionStatusViewModelTests.swift +++ b/LocalPackages/NetworkProtectionUI/Tests/NetworkProtectionUITests/TunnelControllerViewModelTests.swift @@ -1,5 +1,5 @@ // -// NetworkProtectionStatusViewModelTests.swift +// TunnelControllerViewModelTests.swift // // Copyright © 2023 DuckDuckGo. All rights reserved. // @@ -24,7 +24,7 @@ import NetworkProtection @testable import NetworkProtectionUI import NetworkProtectionTestUtils -final class NetworkProtectionStatusViewModelTests: XCTestCase { +final class TunnelControllerViewModelTests: XCTestCase { private class MockStatusReporter: NetworkProtectionStatusReporter { static let defaultServerInfo = NetworkProtectionStatusServerInfo( @@ -100,10 +100,10 @@ final class NetworkProtectionStatusViewModelTests: XCTestCase { func testProperlyReflectsStatusDisconnected() async throws { let controller = MockTunnelController() let statusReporter = MockStatusReporter(status: .disconnected) - let model = NetworkProtectionStatusView.Model( + let model = TunnelControllerViewModel( controller: controller, - statusReporter: statusReporter, - menuItems: []) + onboardingStatusPublisher: Just(OnboardingStatus.completed).eraseToAnyPublisher(), + statusReporter: statusReporter) let isToggleOn = model.isToggleOn.wrappedValue XCTAssertFalse(isToggleOn) @@ -119,10 +119,10 @@ final class NetworkProtectionStatusViewModelTests: XCTestCase { func testProperlyReflectsStatusDisconnecting() async throws { let controller = MockTunnelController() let statusReporter = MockStatusReporter(status: .disconnecting) - let model = NetworkProtectionStatusView.Model( + let model = TunnelControllerViewModel( controller: controller, - statusReporter: statusReporter, - menuItems: []) + onboardingStatusPublisher: Just(OnboardingStatus.completed).eraseToAnyPublisher(), + statusReporter: statusReporter) XCTAssertEqual(model.connectionStatusDescription, UserText.networkProtectionStatusDisconnecting) XCTAssertEqual(model.timeLapsed, UserText.networkProtectionStatusViewTimerZero) @@ -146,10 +146,10 @@ final class NetworkProtectionStatusViewModelTests: XCTestCase { serverLocation: mockServerLocation, serverAddress: mockServerIP) let statusReporter = MockStatusReporter(status: .connected(connectedDate: mockDate), serverInfo: serverInfo) - let model = NetworkProtectionStatusView.Model( + let model = TunnelControllerViewModel( controller: controller, - statusReporter: statusReporter, - menuItems: []) + onboardingStatusPublisher: Just(OnboardingStatus.completed).eraseToAnyPublisher(), + statusReporter: statusReporter) let isToggleOn = model.isToggleOn.wrappedValue XCTAssertTrue(isToggleOn) @@ -167,8 +167,10 @@ final class NetworkProtectionStatusViewModelTests: XCTestCase { func testProperlyReflectsStatusConnecting() async throws { let controller = MockTunnelController() let statusReporter = MockStatusReporter(status: .connecting) - let model = NetworkProtectionStatusView.Model( - controller: controller, statusReporter: statusReporter, menuItems: []) + let model = TunnelControllerViewModel( + controller: controller, + onboardingStatusPublisher: Just(OnboardingStatus.completed).eraseToAnyPublisher(), + statusReporter: statusReporter) XCTAssertEqual(model.connectionStatusDescription, UserText.networkProtectionStatusConnecting) XCTAssertEqual(model.timeLapsed, UserText.networkProtectionStatusViewTimerZero) @@ -182,8 +184,10 @@ final class NetworkProtectionStatusViewModelTests: XCTestCase { func testStartsNetworkProtection() async throws { let controller = MockTunnelController() let statusReporter = MockStatusReporter(status: .disconnected) - let model = NetworkProtectionStatusView.Model( - controller: controller, statusReporter: statusReporter, menuItems: []) + let model = TunnelControllerViewModel( + controller: controller, + onboardingStatusPublisher: Just(OnboardingStatus.completed).eraseToAnyPublisher(), + statusReporter: statusReporter) let networkProtectionWasStarted = expectation(description: "The model started network protection when appropriate") controller.startCallback = { @@ -210,7 +214,10 @@ final class NetworkProtectionStatusViewModelTests: XCTestCase { let statusReporter = MockStatusReporter( status: .connected(connectedDate: mockDate), serverInfo: serverInfo) - let model = NetworkProtectionStatusView.Model(controller: controller, statusReporter: statusReporter, menuItems: []) + let model = TunnelControllerViewModel( + controller: controller, + onboardingStatusPublisher: Just(OnboardingStatus.completed).eraseToAnyPublisher(), + statusReporter: statusReporter) let networkProtectionWasStopped = expectation(description: "The model stopped network protection when appropriate")