diff --git a/.github/workflows/alpha.yml b/.github/workflows/alpha.yml index 3d3ba3c8af..01ec4a7540 100644 --- a/.github/workflows/alpha.yml +++ b/.github/workflows/alpha.yml @@ -74,8 +74,9 @@ jobs: restore-keys: | ${{ runner.os }}-spm- + # Using Xcode 15 as the alpha build uses iOS 17 APIs - name: Select Xcode - run: sudo xcode-select -s /Applications/Xcode_$(<.xcode-version).app/Contents/Developer + run: sudo xcode-select -s /Applications/Xcode_15.0.1.app/Contents/Developer - name: Prepare fastlane run: bundle install diff --git a/Core/AppDeepLinkSchemes.swift b/Core/AppDeepLinkSchemes.swift index c88f697cbc..38fa976b99 100644 --- a/Core/AppDeepLinkSchemes.swift +++ b/Core/AppDeepLinkSchemes.swift @@ -31,6 +31,8 @@ public enum AppDeepLinkSchemes: String, CaseIterable { case addFavorite = "ddgAddFavorite" + case openVPN = "ddgOpenVPN" + public var url: URL { URL(string: rawValue + "://")! } diff --git a/Core/UserDefaults+NetworkProtection.swift b/Core/UserDefaults+NetworkProtection.swift index fb9def1004..e137aa51c2 100644 --- a/Core/UserDefaults+NetworkProtection.swift +++ b/Core/UserDefaults+NetworkProtection.swift @@ -31,4 +31,10 @@ public extension UserDefaults { } } +public enum NetworkProtectionUserDefaultKeys { + + public static let lastSelectedServer = "com.duckduckgo.network-protection.last-selected-server" + +} + #endif diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index aace48e673..0a6878724b 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -267,6 +267,7 @@ 37FCAAC029930E26000E420A /* FailedAssertionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FCAABF29930E26000E420A /* FailedAssertionView.swift */; }; 37FD780F2A29E28B00B36DB1 /* SyncErrorHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FD780E2A29E28B00B36DB1 /* SyncErrorHandler.swift */; }; 4B0295192537BC6700E00CEF /* ConfigurationDebugViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0295182537BC6700E00CEF /* ConfigurationDebugViewController.swift */; }; + 4B274F602AFEAECC003F0745 /* NetworkProtectionWidgetRefreshModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B274F5F2AFEAECC003F0745 /* NetworkProtectionWidgetRefreshModel.swift */; }; 4B2754EC29E8C7DF00394032 /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 4B2754EB29E8C7DF00394032 /* Lottie */; }; 4B470ED6299C49800086EBDC /* AppTrackingProtectionDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B470ED5299C49800086EBDC /* AppTrackingProtectionDatabase.swift */; }; 4B470ED9299C4AED0086EBDC /* AppTrackingProtectionModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 4B470ED7299C4AED0086EBDC /* AppTrackingProtectionModel.xcdatamodeld */; }; @@ -275,6 +276,8 @@ 4B470EE4299C6DFB0086EBDC /* Core.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F143C2E41E4A4CD400CFDE3A /* Core.framework */; }; 4B52648B25F9613B00CB4C24 /* trackerData.json in Resources */ = {isa = PBXBuildFile; fileRef = 4B52648A25F9613B00CB4C24 /* trackerData.json */; }; 4B53648A26718D0E001AA041 /* EmailWaitlist.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B53648926718D0E001AA041 /* EmailWaitlist.swift */; }; + 4B5C462A2AF2A6E6002A4432 /* VPNIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B5C46292AF2A6E6002A4432 /* VPNIntents.swift */; }; + 4B5C462B2AF2BDC4002A4432 /* VPNIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B5C46292AF2A6E6002A4432 /* VPNIntents.swift */; }; 4B60AC97252EC07B00E8D219 /* fullscreenvideo.js in Resources */ = {isa = PBXBuildFile; fileRef = 4B60AC96252EC07B00E8D219 /* fullscreenvideo.js */; }; 4B60ACA1252EC0B100E8D219 /* FullScreenVideoUserScript.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B60ACA0252EC0B100E8D219 /* FullScreenVideoUserScript.swift */; }; 4B62C4BA25B930DD008912C6 /* AppConfigurationFetchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B62C4B925B930DD008912C6 /* AppConfigurationFetchTests.swift */; }; @@ -291,6 +294,8 @@ 4B83397329AFB8D2003F7EA9 /* AppTrackingProtectionFeedbackModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B83397229AFB8D2003F7EA9 /* AppTrackingProtectionFeedbackModel.swift */; }; 4B83397529AFBCE6003F7EA9 /* AppTrackingProtectionFeedbackModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B83397429AFBCE6003F7EA9 /* AppTrackingProtectionFeedbackModelTests.swift */; }; 4B948E2629DCCDB9002531FA /* Persistence in Frameworks */ = {isa = PBXBuildFile; productRef = 4B948E2529DCCDB9002531FA /* Persistence */; }; + 4BB7CBB02AF59C310014A35F /* VPNWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BB7CBAF2AF59C310014A35F /* VPNWidget.swift */; }; + 4BBBBA872B02E85400D965DA /* DesignResourcesKit in Frameworks */ = {isa = PBXBuildFile; productRef = 4BBBBA862B02E85400D965DA /* DesignResourcesKit */; }; 4BC21A2F27238B7500229F0E /* RunLoopExtensionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BC21A2C272388BD00229F0E /* RunLoopExtensionTests.swift */; }; 4BC6DD1C2A60E6AD001EC129 /* ReportBrokenSiteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BC6DD1B2A60E6AD001EC129 /* ReportBrokenSiteView.swift */; }; 4BE2756827304F57006B20B0 /* URLRequestExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BE27566272F878F006B20B0 /* URLRequestExtension.swift */; }; @@ -1282,6 +1287,7 @@ 37FCAACB2993149A000E420A /* Waitlist */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Waitlist; sourceTree = ""; }; 37FD780E2A29E28B00B36DB1 /* SyncErrorHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncErrorHandler.swift; sourceTree = ""; }; 4B0295182537BC6700E00CEF /* ConfigurationDebugViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationDebugViewController.swift; sourceTree = ""; }; + 4B274F5F2AFEAECC003F0745 /* NetworkProtectionWidgetRefreshModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkProtectionWidgetRefreshModel.swift; sourceTree = ""; }; 4B470ED5299C49800086EBDC /* AppTrackingProtectionDatabase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTrackingProtectionDatabase.swift; sourceTree = ""; }; 4B470ED8299C4AED0086EBDC /* AppTrackingProtectionModel.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = AppTrackingProtectionModel.xcdatamodel; sourceTree = ""; }; 4B470EDA299C4FB20086EBDC /* AppTrackingProtectionListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTrackingProtectionListViewModel.swift; sourceTree = ""; }; @@ -1289,6 +1295,7 @@ 4B470EE2299C6DD10086EBDC /* AppTrackingProtectionStoringModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTrackingProtectionStoringModel.swift; sourceTree = ""; }; 4B52648A25F9613B00CB4C24 /* trackerData.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = trackerData.json; sourceTree = ""; }; 4B53648926718D0E001AA041 /* EmailWaitlist.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmailWaitlist.swift; sourceTree = ""; }; + 4B5C46292AF2A6E6002A4432 /* VPNIntents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNIntents.swift; sourceTree = ""; }; 4B60AC96252EC07B00E8D219 /* fullscreenvideo.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = fullscreenvideo.js; sourceTree = ""; }; 4B60ACA0252EC0B100E8D219 /* FullScreenVideoUserScript.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullScreenVideoUserScript.swift; sourceTree = ""; }; 4B62C4B925B930DD008912C6 /* AppConfigurationFetchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConfigurationFetchTests.swift; sourceTree = ""; }; @@ -1303,6 +1310,7 @@ 4B83397029AC18C9003F7EA9 /* AppTrackingProtectionStoringModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTrackingProtectionStoringModelTests.swift; sourceTree = ""; }; 4B83397229AFB8D2003F7EA9 /* AppTrackingProtectionFeedbackModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTrackingProtectionFeedbackModel.swift; sourceTree = ""; }; 4B83397429AFBCE6003F7EA9 /* AppTrackingProtectionFeedbackModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTrackingProtectionFeedbackModelTests.swift; sourceTree = ""; }; + 4BB7CBAF2AF59C310014A35F /* VPNWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNWidget.swift; sourceTree = ""; }; 4BC21A2C272388BD00229F0E /* RunLoopExtensionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunLoopExtensionTests.swift; sourceTree = ""; }; 4BC6DD1B2A60E6AD001EC129 /* ReportBrokenSiteView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReportBrokenSiteView.swift; sourceTree = ""; }; 4BE27566272F878F006B20B0 /* URLRequestExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = URLRequestExtension.swift; path = ../DuckDuckGo/URLRequestExtension.swift; sourceTree = ""; }; @@ -2586,6 +2594,7 @@ 8512EA5124ED30D20073EE19 /* SwiftUI.framework in Frameworks */, 85DF714624F7FE6100C89288 /* Core.framework in Frameworks */, 8512EA4F24ED30D20073EE19 /* WidgetKit.framework in Frameworks */, + 4BBBBA872B02E85400D965DA /* DesignResourcesKit in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -3379,6 +3388,14 @@ name = WindowsBrowser; sourceTree = ""; }; + 4B274F5E2AFEAEB3003F0745 /* Widget */ = { + isa = PBXGroup; + children = ( + 4B274F5F2AFEAECC003F0745 /* NetworkProtectionWidgetRefreshModel.swift */, + ); + name = Widget; + sourceTree = ""; + }; 4B470ED4299C484B0086EBDC /* AppTrackingProtection */ = { isa = PBXGroup; children = ( @@ -3394,6 +3411,14 @@ name = AppTrackingProtection; sourceTree = ""; }; + 4B5C46282AF2A6DB002A4432 /* Intents */ = { + isa = PBXGroup; + children = ( + 4B5C46292AF2A6E6002A4432 /* VPNIntents.swift */, + ); + name = Intents; + sourceTree = ""; + }; 4B6484F427FD1E390050A7A1 /* Waitlist */ = { isa = PBXGroup; children = ( @@ -3701,6 +3726,7 @@ 8512EA5324ED30D20073EE19 /* Widgets.swift */, 853273AF24FEFE4600E3C778 /* WidgetsExtension.entitlements */, 853273A924FEF24300E3C778 /* WidgetViews.swift */, + 4BB7CBAF2AF59C310014A35F /* VPNWidget.swift */, ); path = Widgets; sourceTree = ""; @@ -4496,6 +4522,8 @@ EE0153DF2A6EABAF002A8B26 /* Helpers */, EEFD562D2A65B68B00DAEC48 /* Invite */, EECD94B32A28B96C0085C66E /* Status */, + 4B5C46282AF2A6DB002A4432 /* Intents */, + 4B274F5E2AFEAEB3003F0745 /* Widget */, EE8594982A44791C008A6D06 /* NetworkProtectionTunnelController.swift */, ); name = NetworkProtection; @@ -5417,6 +5445,9 @@ 85DF714924F7FE6100C89288 /* PBXTargetDependency */, ); name = WidgetsExtension; + packageProductDependencies = ( + 4BBBBA862B02E85400D965DA /* DesignResourcesKit */, + ); productName = WidgetsExtension; productReference = 8512EA4D24ED30D20073EE19 /* WidgetsExtension.appex */; productType = "com.apple.product-type.app-extension"; @@ -6379,6 +6410,7 @@ 85C861E628FF1B5F00189466 /* HomeViewSectionRenderersExtension.swift in Sources */, F1D477C61F2126CC0031ED49 /* OmniBarState.swift in Sources */, 85F2FFCD2211F615006BB258 /* MainViewController+KeyCommands.swift in Sources */, + 4B274F602AFEAECC003F0745 /* NetworkProtectionWidgetRefreshModel.swift in Sources */, 0268FC132A449F04000EE6A2 /* OnboardingContainerView.swift in Sources */, 858650D9246B0D3C00C36F8A /* DaxOnboardingViewController.swift in Sources */, 312E5746283BB04A00C18FA0 /* AutofillEmptySearchView.swift in Sources */, @@ -6437,6 +6469,7 @@ 020108A729A6ABF600644F9D /* AppTPToggleView.swift in Sources */, 02A54A982A093126000C8FED /* AppTPHomeViewModel.swift in Sources */, F1617C191E573EA800DEDCAF /* TabSwitcherDelegate.swift in Sources */, + 4B5C462A2AF2A6E6002A4432 /* VPNIntents.swift in Sources */, 310742A62848CD780012660B /* BackForwardMenuHistoryItem.swift in Sources */, 858566FB252E55D6007501B8 /* ImageCacheDebugViewController.swift in Sources */, 0290472E29E99A2F0008FE3C /* GenericIconView.swift in Sources */, @@ -6667,6 +6700,8 @@ 853273B324FF114700E3C778 /* DeepLinks.swift in Sources */, 853273B424FFB36100E3C778 /* UIColorExtension.swift in Sources */, 853273AB24FEF27500E3C778 /* WidgetViews.swift in Sources */, + 4B5C462B2AF2BDC4002A4432 /* VPNIntents.swift in Sources */, + 4BB7CBB02AF59C310014A35F /* VPNWidget.swift in Sources */, 8512EA5424ED30D20073EE19 /* Widgets.swift in Sources */, 85DB12EB2A1FE2A4000A4A72 /* LockScreenWidgets.swift in Sources */, 8544C37C250B827300A0FE73 /* UserText.swift in Sources */, @@ -8499,7 +8534,7 @@ MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG NETWORK_PROTECTION"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG NETWORK_PROTECTION ALPHA"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; TARGETED_DEVICE_FAMILY = "1,2"; VALID_ARCHS = "$(ARCHS_STANDARD_64_BIT)"; @@ -9197,6 +9232,11 @@ package = 98A16C2928A11BDE00A6C003 /* XCRemoteSwiftPackageReference "BrowserServicesKit" */; productName = Persistence; }; + 4BBBBA862B02E85400D965DA /* DesignResourcesKit */ = { + isa = XCSwiftPackageProductDependency; + package = F42D541B29DCA40B004C4FF1 /* XCRemoteSwiftPackageReference "DesignResourcesKit" */; + productName = DesignResourcesKit; + }; 851481872A600EFC00ABC65F /* RemoteMessaging */ = { isa = XCSwiftPackageProductDependency; package = 98A16C2928A11BDE00A6C003 /* XCRemoteSwiftPackageReference "BrowserServicesKit" */; diff --git a/DuckDuckGo/AppDelegate+AppDeepLinks.swift b/DuckDuckGo/AppDelegate+AppDeepLinks.swift index 60788a6abc..ec8d49dbac 100644 --- a/DuckDuckGo/AppDelegate+AppDeepLinks.swift +++ b/DuckDuckGo/AppDelegate+AppDeepLinks.swift @@ -22,6 +22,7 @@ import Core extension AppDelegate { + // swiftlint:disable:next cyclomatic_complexity func handleAppDeepLink(_ app: UIApplication, _ mainViewController: MainViewController?, _ url: URL) -> Bool { guard let mainViewController else { return false } @@ -50,6 +51,11 @@ extension AppDelegate { case .newEmail: mainViewController.newEmailAddress() + case .openVPN: +#if NETWORK_PROTECTION + presentNetworkProtectionStatusSettingsModal() +#endif + default: guard app.applicationState == .active, let currentTab = mainViewController.currentTab else { diff --git a/DuckDuckGo/AppDelegate.swift b/DuckDuckGo/AppDelegate.swift index 9b3a32e512..3732a3fd5e 100644 --- a/DuckDuckGo/AppDelegate.swift +++ b/DuckDuckGo/AppDelegate.swift @@ -67,6 +67,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate { private var appTrackingProtectionDatabase: CoreDataDatabase = AppTrackingProtectionDatabase.make() #endif +#if NETWORK_PROTECTION + private let widgetRefreshModel = NetworkProtectionWidgetRefreshModel() +#endif + private var autoClear: AutoClear? private var showKeyboardIfSettingOn = true private var lastBackgroundDate: Date? @@ -278,6 +282,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { syncDataProviders: syncDataProviders, appSettings: AppDependencyProvider.shared.appSettings) #endif + main.loadViewIfNeeded() window = UIWindow(frame: UIScreen.main.bounds) @@ -316,6 +321,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate { AppDependencyProvider.shared.appSettings.setAutofillIsNewInstallForOnByDefault() } +#if NETWORK_PROTECTION + widgetRefreshModel.beginObservingVPNStatus() +#endif + return true } @@ -411,6 +420,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate { syncService.scheduler.notifyAppLifecycleEvent() fireFailedCompilationsPixelIfNeeded() refreshShortcuts() + +#if NETWORK_PROTECTION + widgetRefreshModel.refreshVPNWidget() +#endif } func applicationWillResignActive(_ application: UIApplication) { @@ -566,7 +579,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } NotificationCenter.default.post(name: AutofillLoginListAuthenticator.Notifications.invalidateContext, object: nil) - mainViewController?.clearNavigationStack() + + // The openVPN action handles the navigation stack on its own and does not need it to be cleared + if url != AppDeepLinkSchemes.openVPN.url { + mainViewController?.clearNavigationStack() + } + autoClear?.applicationWillMoveToForeground() showKeyboardIfSettingOn = false @@ -814,7 +832,7 @@ extension AppDelegate: UNUserNotificationCenterDelegate { } #if NETWORK_PROTECTION - private func presentNetworkProtectionStatusSettingsModal() { + func presentNetworkProtectionStatusSettingsModal() { if #available(iOS 15, *) { let networkProtectionRoot = NetworkProtectionRootViewController() presentSettings(with: networkProtectionRoot) diff --git a/DuckDuckGo/NetworkProtectionStatusViewModel.swift b/DuckDuckGo/NetworkProtectionStatusViewModel.swift index dc8924c38b..6d6a817072 100644 --- a/DuckDuckGo/NetworkProtectionStatusViewModel.swift +++ b/DuckDuckGo/NetworkProtectionStatusViewModel.swift @@ -22,6 +22,7 @@ import Foundation import Combine import NetworkProtection +import WidgetKit final class NetworkProtectionStatusViewModel: ObservableObject { private static var dateFormatter: DateComponentsFormatter = { @@ -178,6 +179,8 @@ final class NetworkProtectionStatusViewModel: ObservableObject { } else { await disableNetP() } + + WidgetCenter.shared.reloadTimelines(ofKind: "VPNStatusWidget") } @MainActor diff --git a/DuckDuckGo/NetworkProtectionWidgetRefreshModel.swift b/DuckDuckGo/NetworkProtectionWidgetRefreshModel.swift new file mode 100644 index 0000000000..b9b5871ffc --- /dev/null +++ b/DuckDuckGo/NetworkProtectionWidgetRefreshModel.swift @@ -0,0 +1,46 @@ +// +// NetworkProtectionWidgetRefreshModel.swift +// DuckDuckGo +// +// 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 NETWORK_PROTECTION + +import Foundation +import Combine +import NetworkExtension +import WidgetKit + +class NetworkProtectionWidgetRefreshModel { + + private var cancellable: AnyCancellable? + + public func beginObservingVPNStatus() { + cancellable = NotificationCenter.default.publisher(for: .NEVPNStatusDidChange) + .debounce(for: .seconds(0.5), scheduler: RunLoop.main) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.refreshVPNWidget() + } + } + + public func refreshVPNWidget() { + WidgetCenter.shared.reloadTimelines(ofKind: "VPNStatusWidget") + } + +} + +#endif diff --git a/DuckDuckGo/VPNIntents.swift b/DuckDuckGo/VPNIntents.swift new file mode 100644 index 0000000000..716396607f --- /dev/null +++ b/DuckDuckGo/VPNIntents.swift @@ -0,0 +1,102 @@ +// +// VPNIntents.swift +// DuckDuckGo +// +// 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 AppIntents +import NetworkExtension +import WidgetKit + +@available(iOS 17.0, *) +struct DisableVPNIntent: AppIntent { + + static let title: LocalizedStringResource = "Disable VPN" + static let description: LocalizedStringResource = "Disables the DuckDuckGo VPN" + static let openAppWhenRun: Bool = false + + @MainActor + func perform() async throws -> some IntentResult { + do { + let managers = try await NETunnelProviderManager.loadAllFromPreferences() + guard let manager = managers.first else { + return .result() + } + + manager.isOnDemandEnabled = false + try await manager.saveToPreferences() + manager.connection.stopVPNTunnel() + + WidgetCenter.shared.reloadTimelines(ofKind: "VPNStatusWidget") + var iterations = 0 + + while iterations <= 10 { + try? await Task.sleep(interval: .seconds(0.5)) + + if manager.connection.status == .disconnected { + return .result() + } + + iterations += 1 + } + + return .result() + } catch { + return .result() + } + } + +} + +@available(iOS 17.0, *) +struct EnableVPNIntent: AppIntent { + + static let title: LocalizedStringResource = "Enable VPN" + static let description: LocalizedStringResource = "Enables the DuckDuckGo VPN" + static let openAppWhenRun: Bool = false + + @MainActor + func perform() async throws -> some IntentResult { + do { + let managers = try await NETunnelProviderManager.loadAllFromPreferences() + guard let manager = managers.first else { + return .result() + } + + manager.isOnDemandEnabled = true + try await manager.saveToPreferences() + try manager.connection.startVPNTunnel() + + WidgetCenter.shared.reloadTimelines(ofKind: "VPNStatusWidget") + var iterations = 0 + + while iterations <= 10 { + try? await Task.sleep(interval: .seconds(0.5)) + + if manager.connection.status == .connected { + return .result() + } + + iterations += 1 + } + + return .result() + } catch { + return .result() + } + } + +} diff --git a/PacketTunnelProvider/NetworkProtection/NetworkProtectionPacketTunnelProvider.swift b/PacketTunnelProvider/NetworkProtection/NetworkProtectionPacketTunnelProvider.swift index 5db8d2c3c5..d3ca118411 100644 --- a/PacketTunnelProvider/NetworkProtection/NetworkProtectionPacketTunnelProvider.swift +++ b/PacketTunnelProvider/NetworkProtection/NetworkProtectionPacketTunnelProvider.swift @@ -20,15 +20,18 @@ #if NETWORK_PROTECTION import Foundation -import NetworkProtection import Common +import Combine import Core import Networking import NetworkExtension +import NetworkProtection // Initial implementation for initial Network Protection tests. Will be fleshed out with https://app.asana.com/0/1203137811378537/1204630829332227/f final class NetworkProtectionPacketTunnelProvider: PacketTunnelProvider { + private var cancellables = Set() + // MARK: - PacketTunnelProvider.Event reporting private static var packetTunnelProviderEvents: EventMapping = .init { event, _, _, _ in @@ -183,6 +186,7 @@ final class NetworkProtectionPacketTunnelProvider: PacketTunnelProvider { debugEvents: Self.networkProtectionDebugEvents(controllerErrorStore: errorStore), providerEvents: Self.packetTunnelProviderEvents) startMonitoringMemoryPressureEvents() + observeServerChanges() APIRequest.Headers.setUserAgent(DefaultUserAgentManager.duckDuckGoUserAgent) } @@ -209,6 +213,14 @@ final class NetworkProtectionPacketTunnelProvider: PacketTunnelProvider { source.resume() } } + + private func observeServerChanges() { + lastSelectedServerInfoPublisher.sink { server in + let location = server?.serverLocation ?? "Unknown Location" + UserDefaults.networkProtectionGroupDefaults.set(location, forKey: NetworkProtectionUserDefaultKeys.lastSelectedServer) + } + .store(in: &cancellables) + } } #endif diff --git a/Widgets/Assets.xcassets/vpn-off.imageset/Contents.json b/Widgets/Assets.xcassets/vpn-off.imageset/Contents.json new file mode 100644 index 0000000000..839c6e2214 --- /dev/null +++ b/Widgets/Assets.xcassets/vpn-off.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "vpn-off.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Widgets/Assets.xcassets/vpn-off.imageset/vpn-off.pdf b/Widgets/Assets.xcassets/vpn-off.imageset/vpn-off.pdf new file mode 100644 index 0000000000..8438e0ef8e Binary files /dev/null and b/Widgets/Assets.xcassets/vpn-off.imageset/vpn-off.pdf differ diff --git a/Widgets/Assets.xcassets/vpn-on.imageset/Contents.json b/Widgets/Assets.xcassets/vpn-on.imageset/Contents.json new file mode 100644 index 0000000000..ec33d453f4 --- /dev/null +++ b/Widgets/Assets.xcassets/vpn-on.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "vpn-on.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Widgets/Assets.xcassets/vpn-on.imageset/vpn-on.pdf b/Widgets/Assets.xcassets/vpn-on.imageset/vpn-on.pdf new file mode 100644 index 0000000000..09c2cae805 Binary files /dev/null and b/Widgets/Assets.xcassets/vpn-on.imageset/vpn-on.pdf differ diff --git a/Widgets/DeepLinks.swift b/Widgets/DeepLinks.swift index c3b6a4798d..0bdf850738 100644 --- a/Widgets/DeepLinks.swift +++ b/Widgets/DeepLinks.swift @@ -29,4 +29,5 @@ struct DeepLinks { static let addFavorite = AppDeepLinkSchemes.addFavorite.url + static let openVPN = AppDeepLinkSchemes.openVPN.url } diff --git a/Widgets/VPNWidget.swift b/Widgets/VPNWidget.swift new file mode 100644 index 0000000000..c7a7681d5b --- /dev/null +++ b/Widgets/VPNWidget.swift @@ -0,0 +1,301 @@ +// +// VPNWidget.swift +// DuckDuckGo +// +// 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 AppIntents +import Core +import DesignResourcesKit +import SwiftUI +import WidgetKit +import NetworkExtension +import NetworkProtection + +#if ALPHA + +enum VPNStatus { + case status(NEVPNStatus) + case error + case notConfigured +} +struct VPNStatusTimelineEntry: TimelineEntry { + let date: Date + let status: VPNStatus + let location: String + + internal init(date: Date, status: VPNStatus = .notConfigured, location: String) { + self.date = date + self.status = status + self.location = location + } +} + +class VPNStatusTimelineProvider: TimelineProvider { + + typealias Entry = VPNStatusTimelineEntry + + func placeholder(in context: Context) -> VPNStatusTimelineEntry { + return VPNStatusTimelineEntry(date: Date(), status: .status(.connected), location: "Los Angeles, CA") + } + + func getSnapshot(in context: Context, completion: @escaping (VPNStatusTimelineEntry) -> Void) { + let entry = VPNStatusTimelineEntry(date: Date(), status: .status(.connected), location: "Los Angeles, CA") + completion(entry) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + NETunnelProviderManager.loadAllFromPreferences { managers, error in + let defaults = UserDefaults.networkProtectionGroupDefaults + let location = defaults.string(forKey: NetworkProtectionUserDefaultKeys.lastSelectedServer) ?? "Unknown Location" + let expiration = Date().addingTimeInterval(TimeInterval.minutes(5)) + + if error != nil { + let entry = VPNStatusTimelineEntry(date: expiration, status: .error, location: location) + let timeline = Timeline(entries: [entry], policy: .atEnd) + completion(timeline) + return + } + + guard let manager = managers?.first else { + let entry = VPNStatusTimelineEntry(date: expiration, status: .notConfigured, location: location) + let timeline = Timeline(entries: [entry], policy: .atEnd) + completion(timeline) + return + } + + let status = manager.connection.status + let entry = VPNStatusTimelineEntry(date: expiration, status: .status(status), location: location) + let timeline = Timeline(entries: [entry], policy: .atEnd) + + completion(timeline) + } + } +} + +extension NEVPNStatus { + var description: String { + switch self { + case .connected: return "Connected" + case .connecting: return "Connecting" + case .disconnected: return "Disconnected" + case .disconnecting: return "Disconnecting" + case .invalid: return "Invalid" + case .reasserting: return "Reasserting" + default: return "Unknown Status" + } + } + + var isConnected: Bool { + switch self { + case .connected, .connecting, .reasserting: return true + case .disconnecting, .disconnected: return false + default: return false + } + } +} + +@available(iOSApplicationExtension 17.0, *) +struct VPNStatusView: View { + @Environment(\.widgetFamily) var family: WidgetFamily + var entry: VPNStatusTimelineProvider.Entry + + @ViewBuilder + var body: some View { + Group { + switch entry.status { + case .status(let status): + HStack { + connectionView(with: status) + .padding([.leading, .trailing], 16) + + Spacer() + } + case .error: + Text("Error") + .foregroundStyle(Color.black) + case .notConfigured: + Text("VPN Not Configured") + .foregroundStyle(Color.black) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .containerBackground(for: .widget) { + switch entry.status { + case .status(let status): + switch status { + case .connecting, .connected, .reasserting: + Color.vpnWidgetBackgroundColor + case .disconnecting, .disconnected, .invalid: + Color.white + @unknown default: + Color.white + } + case .error, .notConfigured: + Color.white + } + } + } + + private func connectionView(with status: NEVPNStatus) -> some View { + HStack { + VStack(alignment: .leading, spacing: 0) { + Image(headerImageName(with: status)) + .frame(width: 50, height: 54) + .padding(.top, 15) + + Spacer() + + Text(title(with: status)) + .font(.system(size: 16, weight: .semibold)) + .fontWeight(.semibold) + .foregroundStyle(status.isConnected ? Color.white : Color.black) + + Text(status.isConnected ? entry.location : "VPN is Off") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(status.isConnected ? Color.white : Color.black) + .opacity(status.isConnected ? 0.8 : 0.6) + + switch status { + case .connected, .connecting, .reasserting: + Button(intent: DisableVPNIntent()) { + Text("Disconnect") + .font(.system(size: 15, weight: .medium)) + .fontWeight(.semibold) + } + .foregroundStyle(Color.vpnWidgetBackgroundColor) + .buttonStyle(.borderedProminent) + .tint(.white) + .disabled(status != .connected) + .padding(.top, 6) + .padding(.bottom, 16) + case .disconnected, .disconnecting: + Button(intent: EnableVPNIntent()) { + Text("Connect") + .font(.system(size: 15, weight: .medium)) + .fontWeight(.semibold) + } + .foregroundStyle(.white) + .buttonStyle(.borderedProminent) + .tint(Color.vpnWidgetBackgroundColor) + .disabled(status != .disconnected) + .padding(.top, 6) + .padding(.bottom, 16) + default: + Spacer() + } + } + } + } + + private func headerImageName(with status: NEVPNStatus) -> String { + switch status { + case .connecting, .connected, .reasserting: return "vpn-on" + case .disconnecting, .disconnected: return "vpn-off" + case .invalid: return "vpn-off" + @unknown default: return "vpn-off" + } + } + + private func title(with status: NEVPNStatus) -> String { + switch status { + case .connecting, .connected, .reasserting: return "Protected" + case .disconnecting, .disconnected: return "Unprotected" + case .invalid: return "Invalid" + @unknown default: return "Unknown" + } + } + +} + +@available(iOSApplicationExtension 17.0, *) +struct VPNStatusWidget: Widget { + let kind: String = "VPNStatusWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: VPNStatusTimelineProvider()) { entry in + VPNStatusView(entry: entry).widgetURL(DeepLinks.openVPN) + } + .configurationDisplayName("VPN Status") + .description("View and manage the VPN connection") + .supportedFamilies([.systemSmall]) + .contentMarginsDisabled() + } +} + +struct VPNStatusView_Previews: PreviewProvider { + + static let connectedState = VPNStatusTimelineProvider.Entry( + date: Date(), + status: .status(.connected), + location: "Paoli, PA" + ) + + static let disconnectedState = VPNStatusTimelineProvider.Entry( + date: Date(), + status: .status(.disconnected), + location: "Paoli, PA" + ) + + static let notConfiguredState = VPNStatusTimelineProvider.Entry( + date: Date(), + status: .notConfigured, + location: "Paoli, PA" + ) + + static var previews: some View { + if #available(iOSApplicationExtension 17.0, *) { + VPNStatusView(entry: connectedState) + .previewContext(WidgetPreviewContext(family: .systemSmall)) + .environment(\.colorScheme, .light) + + VPNStatusView(entry: connectedState) + .previewContext(WidgetPreviewContext(family: .systemSmall)) + .environment(\.colorScheme, .dark) + + VPNStatusView(entry: disconnectedState) + .previewContext(WidgetPreviewContext(family: .systemSmall)) + .environment(\.colorScheme, .light) + + VPNStatusView(entry: disconnectedState) + .previewContext(WidgetPreviewContext(family: .systemSmall)) + .environment(\.colorScheme, .dark) + + VPNStatusView(entry: notConfiguredState) + .previewContext(WidgetPreviewContext(family: .systemSmall)) + .environment(\.colorScheme, .light) + + VPNStatusView(entry: notConfiguredState) + .previewContext(WidgetPreviewContext(family: .systemSmall)) + .environment(\.colorScheme, .dark) + } else { + Text("iOS 17 required") + } + } + +} + +extension Color { + + static var vpnWidgetBackgroundColor: Color { + let color = UIColor(designSystemColor: .accent).resolvedColor(with: UITraitCollection(userInterfaceStyle: .light)) + return Color(color) + } + +} + +#endif diff --git a/Widgets/Widgets.swift b/Widgets/Widgets.swift index ae5ae2b1d2..39ab7b0057 100644 --- a/Widgets/Widgets.swift +++ b/Widgets/Widgets.swift @@ -25,6 +25,7 @@ import CoreData import Kingfisher import Bookmarks import Persistence +import NetworkExtension struct Favorite { @@ -202,6 +203,12 @@ struct Widgets: WidgetBundle { SearchWidget() FavoritesWidget() +#if ALPHA + if #available(iOSApplicationExtension 17.0, *) { + VPNStatusWidget() + } +#endif + if #available(iOSApplicationExtension 16.0, *) { SearchLockScreenWidget() VoiceSearchLockScreenWidget() diff --git a/Widgets/WidgetsExtension.entitlements b/Widgets/WidgetsExtension.entitlements index 2dd2c82001..4d39b3e562 100644 --- a/Widgets/WidgetsExtension.entitlements +++ b/Widgets/WidgetsExtension.entitlements @@ -6,6 +6,7 @@ $(GROUP_ID_PREFIX).bookmarks $(GROUP_ID_PREFIX).database + $(GROUP_ID_PREFIX).netp diff --git a/WidgetsExtensionAlpha.entitlements b/WidgetsExtensionAlpha.entitlements index d5324a15bd..4d39b3e562 100644 --- a/WidgetsExtensionAlpha.entitlements +++ b/WidgetsExtensionAlpha.entitlements @@ -4,8 +4,9 @@ com.apple.security.application-groups - group.com.duckduckgo.alpha.database - group.com.duckduckgo.alpha.bookmarks + $(GROUP_ID_PREFIX).bookmarks + $(GROUP_ID_PREFIX).database + $(GROUP_ID_PREFIX).netp