diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index fa8a00f0d8..e080aa1d38 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -1108,6 +1108,8 @@ 372A0FED2B2379310033BF7F /* SyncMetricsEventsHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 372A0FEB2B2379310033BF7F /* SyncMetricsEventsHandler.swift */; }; 372BC2A12A4AFA47001D8FD5 /* SyncCredentialsAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 372BC2A02A4AFA47001D8FD5 /* SyncCredentialsAdapter.swift */; }; 372BC2A22A4AFA47001D8FD5 /* SyncCredentialsAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 372BC2A02A4AFA47001D8FD5 /* SyncCredentialsAdapter.swift */; }; + 372ED7C22CDD481B002287EC /* NewTabPageUserContentController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 372ED7C12CDD4815002287EC /* NewTabPageUserContentController.swift */; }; + 372ED7C32CDD481B002287EC /* NewTabPageUserContentController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 372ED7C12CDD4815002287EC /* NewTabPageUserContentController.swift */; }; 3739326529AE4B39009346AE /* DDGSync in Frameworks */ = {isa = PBXBuildFile; productRef = 3739326429AE4B39009346AE /* DDGSync */; }; 3739326729AE4B42009346AE /* DDGSync in Frameworks */ = {isa = PBXBuildFile; productRef = 3739326629AE4B42009346AE /* DDGSync */; }; 373A1AA8283ED1B900586521 /* BookmarkHTMLReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373A1AA7283ED1B900586521 /* BookmarkHTMLReader.swift */; }; @@ -1133,6 +1135,8 @@ 37534CA3281132CB002621E7 /* TabLazyLoaderDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37534CA2281132CB002621E7 /* TabLazyLoaderDataSource.swift */; }; 37534CA52811987D002621E7 /* AdjacentItemEnumeratorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37534CA42811987D002621E7 /* AdjacentItemEnumeratorTests.swift */; }; 37534CA8281198CD002621E7 /* AdjacentItemEnumerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37534CA62811988E002621E7 /* AdjacentItemEnumerator.swift */; }; + 3758CBAB2CE63D540089FC2D /* NewTabPageWebViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3758CBAA2CE63D510089FC2D /* NewTabPageWebViewModel.swift */; }; + 3758CBAC2CE63D540089FC2D /* NewTabPageWebViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3758CBAA2CE63D510089FC2D /* NewTabPageWebViewModel.swift */; }; 376113CC2B29CD5B00E794BB /* CriticalPathsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 565E46DF2B2725DD0013AC2A /* CriticalPathsTests.swift */; }; 376705AF27EB488600DD8D76 /* RoundedSelectionRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0511B3262CAA5A00F6079C /* RoundedSelectionRowView.swift */; }; 376731822C7E226A00EB097B /* HomePageViewBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376731812C7E226A00EB097B /* HomePageViewBackground.swift */; }; @@ -1271,6 +1275,10 @@ 37F44A5F298C17830025E7FE /* Navigation in Frameworks */ = {isa = PBXBuildFile; productRef = 37F44A5E298C17830025E7FE /* Navigation */; }; 37F8ABD32CE3EE5B00CB0294 /* FeatureFlagOverridesMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F8ABD22CE3EE5B00CB0294 /* FeatureFlagOverridesMenu.swift */; }; 37F8ABD42CE3EE5B00CB0294 /* FeatureFlagOverridesMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F8ABD22CE3EE5B00CB0294 /* FeatureFlagOverridesMenu.swift */; }; + 37FB430E2CDB84A500479A1E /* NewTabPageUserScript.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FB430D2CDB84A200479A1E /* NewTabPageUserScript.swift */; }; + 37FB430F2CDB84A500479A1E /* NewTabPageUserScript.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FB430D2CDB84A200479A1E /* NewTabPageUserScript.swift */; }; + 37FB43112CDB883B00479A1E /* NewTabPageActionsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FB43102CDB883700479A1E /* NewTabPageActionsManager.swift */; }; + 37FB43122CDB883B00479A1E /* NewTabPageActionsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FB43102CDB883700479A1E /* NewTabPageActionsManager.swift */; }; 37FD78112A29EBD100B36DB1 /* SyncErrorHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FD78102A29EBD100B36DB1 /* SyncErrorHandler.swift */; }; 37FD78122A29EBD100B36DB1 /* SyncErrorHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FD78102A29EBD100B36DB1 /* SyncErrorHandler.swift */; }; 4B0135CE2729F1AA00D54834 /* NSPasteboardExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0135CD2729F1AA00D54834 /* NSPasteboardExtension.swift */; }; @@ -3590,6 +3598,7 @@ 37219B3C2CC27DB300C9D7A8 /* NewTabPageSearchBoxExperimentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTabPageSearchBoxExperimentTests.swift; sourceTree = ""; }; 372A0FEB2B2379310033BF7F /* SyncMetricsEventsHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncMetricsEventsHandler.swift; sourceTree = ""; }; 372BC2A02A4AFA47001D8FD5 /* SyncCredentialsAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncCredentialsAdapter.swift; sourceTree = ""; }; + 372ED7C12CDD4815002287EC /* NewTabPageUserContentController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTabPageUserContentController.swift; sourceTree = ""; }; 373A1AA7283ED1B900586521 /* BookmarkHTMLReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkHTMLReader.swift; sourceTree = ""; }; 373A1AA9283ED86C00586521 /* BookmarksHTMLReaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksHTMLReaderTests.swift; sourceTree = ""; }; 373A1AAF2842C4EA00586521 /* BookmarkHTMLImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkHTMLImporter.swift; sourceTree = ""; }; @@ -3607,6 +3616,7 @@ 37534CA2281132CB002621E7 /* TabLazyLoaderDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabLazyLoaderDataSource.swift; sourceTree = ""; }; 37534CA42811987D002621E7 /* AdjacentItemEnumeratorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdjacentItemEnumeratorTests.swift; sourceTree = ""; }; 37534CA62811988E002621E7 /* AdjacentItemEnumerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdjacentItemEnumerator.swift; sourceTree = ""; }; + 3758CBAA2CE63D510089FC2D /* NewTabPageWebViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTabPageWebViewModel.swift; sourceTree = ""; }; 376113C52B29BCD600E794BB /* SyncE2EUITests.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = SyncE2EUITests.xcconfig; sourceTree = ""; }; 376113D42B29CD5B00E794BB /* SyncE2EUITests App Store.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "SyncE2EUITests App Store.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; 376113D72B29D0F800E794BB /* SyncE2EUITestsAppStore.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = SyncE2EUITestsAppStore.xcconfig; sourceTree = ""; }; @@ -3715,6 +3725,8 @@ 37F19A6628E1B43200740DC6 /* DuckPlayerPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DuckPlayerPreferences.swift; sourceTree = ""; }; 37F19A6928E2F2D000740DC6 /* DuckPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DuckPlayer.swift; sourceTree = ""; }; 37F8ABD22CE3EE5B00CB0294 /* FeatureFlagOverridesMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureFlagOverridesMenu.swift; sourceTree = ""; }; + 37FB430D2CDB84A200479A1E /* NewTabPageUserScript.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTabPageUserScript.swift; sourceTree = ""; }; + 37FB43102CDB883700479A1E /* NewTabPageActionsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTabPageActionsManager.swift; sourceTree = ""; }; 37FD78102A29EBD100B36DB1 /* SyncErrorHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncErrorHandler.swift; sourceTree = ""; }; 4B0135CD2729F1AA00D54834 /* NSPasteboardExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSPasteboardExtension.swift; sourceTree = ""; }; 4B02197F25E05FAC00ED7DEA /* FireproofingURLExtensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FireproofingURLExtensions.swift; sourceTree = ""; }; @@ -8752,6 +8764,10 @@ AAE71DB225F66A0900D74437 /* HomePage */ = { isa = PBXGroup; children = ( + 37FB43102CDB883700479A1E /* NewTabPageActionsManager.swift */, + 372ED7C12CDD4815002287EC /* NewTabPageUserContentController.swift */, + 37FB430D2CDB84A200479A1E /* NewTabPageUserScript.swift */, + 3758CBAA2CE63D510089FC2D /* NewTabPageWebViewModel.swift */, 85589E8527BBB8DD0038AD11 /* Model */, AAE71DB325F66A3F00D74437 /* View */, 85AC7ADA27BD628400FFB69B /* HomePage.swift */, @@ -11220,6 +11236,7 @@ EED4D3D92C874AE200C79EEA /* PopoverInfoViewController.swift in Sources */, 3707C722294B5D2900682A9F /* WKWebViewExtension.swift in Sources */, 3706FAD9293F65D500E42796 /* FirefoxFaviconsReader.swift in Sources */, + 37FB430E2CDB84A500479A1E /* NewTabPageUserScript.swift in Sources */, 3706FADB293F65D500E42796 /* ContentBlockingRulesUpdateObserver.swift in Sources */, 3706FADC293F65D500E42796 /* FirefoxLoginReader.swift in Sources */, 3706FADD293F65D500E42796 /* AtbParser.swift in Sources */, @@ -11377,6 +11394,7 @@ 4B9DB0362A983B24000927DB /* WaitlistTermsAndConditionsView.swift in Sources */, 37197EA82942443D00394917 /* BrowserTabViewController.swift in Sources */, 3706FB39293F65D500E42796 /* PrivacyDashboardPopover.swift in Sources */, + 372ED7C32CDD481B002287EC /* NewTabPageUserContentController.swift in Sources */, 3706FB3B293F65D500E42796 /* RootView.swift in Sources */, 3706FB3C293F65D500E42796 /* AddressBarTextField.swift in Sources */, 3706FB3D293F65D500E42796 /* FocusRingView.swift in Sources */, @@ -11643,6 +11661,7 @@ 3706FBCD293F65D500E42796 /* LocaleExtension.swift in Sources */, 37D0469E2C7D0EDD00AEAA50 /* CustomBackground.swift in Sources */, 3706FBCE293F65D500E42796 /* SavePaymentMethodViewController.swift in Sources */, + 3758CBAB2CE63D540089FC2D /* NewTabPageWebViewModel.swift in Sources */, 9FA5A0A62BC8F34900153786 /* UserDefaultsBookmarkFoldersStore.swift in Sources */, 1DDC85002B835BC000670238 /* SearchPreferences.swift in Sources */, 3706FBD0293F65D500E42796 /* WebKitVersionProvider.swift in Sources */, @@ -11783,6 +11802,7 @@ 3706FC16293F65D500E42796 /* PasswordManagementLoginModel.swift in Sources */, 3706FC17293F65D500E42796 /* TabViewModel.swift in Sources */, 3706FC18293F65D500E42796 /* TabDragAndDropManager.swift in Sources */, + 37FB43112CDB883B00479A1E /* NewTabPageActionsManager.swift in Sources */, 1DC669712B6CF0D700AA0645 /* TabSnapshotStore.swift in Sources */, 3706FC19293F65D500E42796 /* NSNotificationName+Favicons.swift in Sources */, 3706FC1A293F65D500E42796 /* PinningManager.swift in Sources */, @@ -13020,6 +13040,7 @@ 37054FCE2876472D00033B6F /* WebViewSnapshotView.swift in Sources */, 560EB9392C789A450080DBC8 /* OnboardingSuggestedSearchesProvider.swift in Sources */, 4BBC16A027C4859400E00A38 /* DeviceAuthenticationService.swift in Sources */, + 37FB43122CDB883B00479A1E /* NewTabPageActionsManager.swift in Sources */, C181945C2C7CDCC700381092 /* PromotionView.swift in Sources */, CB24F70C29A3D9CB006DCC58 /* AppConfigurationURLProvider.swift in Sources */, 1DEF3BAD2BD145A9004A2FBA /* AutoClearHandler.swift in Sources */, @@ -13061,6 +13082,7 @@ AA68C3D32490ED62001B8783 /* NavigationBarViewController.swift in Sources */, AA585DAF2490E6E600E9A3E2 /* MainViewController.swift in Sources */, F1D43AEE2B98D8DF00BAB743 /* MainMenuActions+VanillaBrowser.swift in Sources */, + 3758CBAC2CE63D540089FC2D /* NewTabPageWebViewModel.swift in Sources */, 37F19A6A28E2F2D000740DC6 /* DuckPlayer.swift in Sources */, AA5FA69A275F91C700DCE9C9 /* Favicon.swift in Sources */, AABEE69A24A902A90043105B /* SuggestionContainerViewModel.swift in Sources */, @@ -13189,6 +13211,7 @@ 56D6A3D629DB2BAB0055215A /* ContinueSetUpView.swift in Sources */, B6B5F5842B03580A008DB58A /* RequestFilePermissionView.swift in Sources */, 4B1E6EEE27AB5E5100F51793 /* PasswordManagementListSection.swift in Sources */, + 372ED7C22CDD481B002287EC /* NewTabPageUserContentController.swift in Sources */, 31C9ADE52AF0564500CEF57D /* WaitlistFeatureSetupHandler.swift in Sources */, AA222CB92760F74E00321475 /* FaviconReferenceCache.swift in Sources */, 4B9292A126670D2A00AD2C21 /* BookmarkTreeController.swift in Sources */, @@ -13424,6 +13447,7 @@ CB63DECB2CDC0BBE0097986A /* PageRefreshMonitor.swift in Sources */, B6040856274B830F00680351 /* DictionaryExtension.swift in Sources */, 3199AF772C80734A003AEBDC /* DuckPlayerOnboardingViewController.swift in Sources */, + 37FB430F2CDB84A500479A1E /* NewTabPageUserScript.swift in Sources */, B684592725C93C0500DC17B6 /* Publishers.NestedObjectChanges.swift in Sources */, B6DA06E62913F39400225DE2 /* MenuItemSelectors.swift in Sources */, 3712092C2C23383C003ADF3D /* RemoteMessagingStoreErrorHandling.swift in Sources */, diff --git a/DuckDuckGo/Application/AppDelegate.swift b/DuckDuckGo/Application/AppDelegate.swift index 38ab9f6654..460932024b 100644 --- a/DuckDuckGo/Application/AppDelegate.swift +++ b/DuckDuckGo/Application/AppDelegate.swift @@ -92,6 +92,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { let bookmarksManager = LocalBookmarkManager.shared var privacyDashboardWindow: NSWindow? + let newTabPageActionsManager: NewTabPageActionsManaging let activeRemoteMessageModel: ActiveRemoteMessageModel let homePageSettingsModel = HomePage.Models.SettingsModel() let remoteMessagingClient: RemoteMessagingClient! @@ -308,6 +309,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { freemiumDBPUserStateManager: freemiumDBPUserStateManager) freemiumDBPPromotionViewCoordinator = FreemiumDBPPromotionViewCoordinator(freemiumDBPUserStateManager: freemiumDBPUserStateManager, freemiumDBPFeature: freemiumDBPFeature) + + newTabPageActionsManager = NewTabPageActionsManager(appearancePreferences: .shared) } func applicationWillFinishLaunching(_ notification: Notification) { diff --git a/DuckDuckGo/HomePage/NewTabPageActionsManager.swift b/DuckDuckGo/HomePage/NewTabPageActionsManager.swift new file mode 100644 index 0000000000..2325e2cf85 --- /dev/null +++ b/DuckDuckGo/HomePage/NewTabPageActionsManager.swift @@ -0,0 +1,185 @@ +// +// NewTabPageActionsManager.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import Combine +import PixelKit +import Common +import os.log + +protocol NewTabPageActionsManaging: AnyObject { + var configuration: NewTabPageUserScript.NewTabPageConfiguration { get } + + func registerUserScript(_ userScript: NewTabPageUserScript) + + func getFavorites() -> NewTabPageUserScript.FavoritesData + func getFavoritesConfig() -> NewTabPageUserScript.WidgetConfig + + func getPrivacyStats() -> NewTabPageUserScript.PrivacyStatsData + func getPrivacyStatsConfig() -> NewTabPageUserScript.WidgetConfig + + /// It is called in case of error loading the pages + func reportException(with params: [String: String]) + func showContextMenu(with params: [String: Any]) + func updateWidgetConfigs(with params: [[String: String]]) +} + +final class NewTabPageActionsManager: NewTabPageActionsManaging { + + private let appearancePreferences: AppearancePreferences + private var cancellables = Set() + private var userScripts = NSHashTable.weakObjects() + + init(appearancePreferences: AppearancePreferences) { + self.appearancePreferences = appearancePreferences + + appearancePreferences.$isFavoriteVisible.dropFirst().removeDuplicates().asVoid() + .receive(on: DispatchQueue.main) + .sink { [weak self] in + self?.notifyWidgetConfigsDidChange() + } + .store(in: &cancellables) + + appearancePreferences.$isRecentActivityVisible.dropFirst().removeDuplicates().asVoid() + .receive(on: DispatchQueue.main) + .sink { [weak self] in + self?.notifyWidgetConfigsDidChange() + } + .store(in: &cancellables) + } + + func registerUserScript(_ userScript: NewTabPageUserScript) { + userScripts.add(userScript) + } + + private func notifyWidgetConfigsDidChange() { + userScripts.allObjects.forEach { userScript in + userScript.notifyWidgetConfigsDidChange(widgetConfigs: [ + .init(id: "favorites", isVisible: appearancePreferences.isFavoriteVisible), + .init(id: "privacyStats", isVisible: appearancePreferences.isRecentActivityVisible) + ]) + } + } + + var configuration: NewTabPageUserScript.NewTabPageConfiguration { +#if DEBUG || REVIEW + let env = "development" +#else + let env = "production" +#endif + return .init( + widgets: [ + .init(id: "rmf"), + .init(id: "favorites"), + .init(id: "privacyStats") + ], + widgetConfigs: [ + .init(id: "favorites", isVisible: appearancePreferences.isFavoriteVisible), + .init(id: "privacyStats", isVisible: appearancePreferences.isRecentActivityVisible) + ], + env: env, + locale: Bundle.main.preferredLocalizations.first ?? "en", + platform: .init(name: "macos") + ) + } + + func getFavorites() -> NewTabPageUserScript.FavoritesData { + // implementation TBD + .init(favorites: []) + } + + func getFavoritesConfig() -> NewTabPageUserScript.WidgetConfig { + // implementation TBD + .init(animation: .auto, expansion: .collapsed) + } + + func getPrivacyStats() -> NewTabPageUserScript.PrivacyStatsData { + // implementation TBD + .init(totalCount: 0, trackerCompanies: []) + } + + func getPrivacyStatsConfig() -> NewTabPageUserScript.WidgetConfig { + // implementation TBD + .init(animation: .auto, expansion: .collapsed) + } + + func showContextMenu(with params: [String: Any]) { + guard let menuItems = params["visibilityMenuItems"] as? [[String: String]] else { + return + } + let menu = NSMenu() + + for menuItem in menuItems { + guard let title = menuItem["title"], let id = menuItem["id"] else { + continue + } + switch id { + case "favorites": + let item = NSMenuItem(title: title, action: #selector(toggleVisibility(_:)), representedObject: id) + .targetting(self) + item.state = appearancePreferences.isFavoriteVisible ? .on : .off + menu.addItem(item) + case "privacyStats": + let item = NSMenuItem(title: title, action: #selector(toggleVisibility(_:)), representedObject: id) + .targetting(self) + item.state = appearancePreferences.isRecentActivityVisible ? .on : .off + menu.addItem(item) + default: + break + } + } + + if !menu.items.isEmpty { + menu.popUp(positioning: nil, at: NSEvent.mouseLocation, in: nil) + } + } + + @objc private func toggleVisibility(_ sender: NSMenuItem) { + switch sender.representedObject as? String { + case "favorites": + appearancePreferences.isFavoriteVisible.toggle() + case "privacyStats": + appearancePreferences.isRecentActivityVisible.toggle() + default: + break + } + } + + func updateWidgetConfigs(with params: [[String: String]]) { + for param in params { + guard let id = param["id"], let visibility = param["visibility"] else { + continue + } + let isVisible = NewTabPageUserScript.NewTabPageConfiguration.WidgetConfig.WidgetVisibility(rawValue: visibility)?.isVisible == true + switch id { + case "favorites": + appearancePreferences.isFavoriteVisible = isVisible + case "privacyStats": + appearancePreferences.isRecentActivityVisible = isVisible + default: + break + } + } + } + + func reportException(with params: [String: String]) { + let message = params["message"] ?? "" + let id = params["id"] ?? "" + Logger.general.error("New Tab Page error: \("\(id): \(message)", privacy: .public)") + } +} diff --git a/DuckDuckGo/HomePage/NewTabPageUserContentController.swift b/DuckDuckGo/HomePage/NewTabPageUserContentController.swift new file mode 100644 index 0000000000..7c70f45efe --- /dev/null +++ b/DuckDuckGo/HomePage/NewTabPageUserContentController.swift @@ -0,0 +1,93 @@ +// +// NewTabPageUserContentController.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import WebKit +import BrowserServicesKit +import UserScript + +final class NewTabPageUserContentController: WKUserContentController { + + let newTabPageUserScriptProvider: NewTabPageUserScriptProvider + + @MainActor + init(newTabPageUserScript: NewTabPageUserScript) { + newTabPageUserScriptProvider = NewTabPageUserScriptProvider(newTabPageUserScript: newTabPageUserScript) + + super.init() + + newTabPageUserScriptProvider.userScripts.forEach { + let userScript = $0.makeWKUserScriptSync() + self.installUserScripts([userScript], handlers: [$0]) + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @MainActor + private func installUserScripts(_ wkUserScripts: [WKUserScript], handlers: [UserScript]) { + handlers.forEach { self.addHandler($0) } + wkUserScripts.forEach(self.addUserScript) + } +} + +@MainActor +final class NewTabPageUserScriptProvider: UserScriptsProvider { + lazy var userScripts: [UserScript] = [specialPagesUserScript] + + let specialPagesUserScript: SpecialPagesUserScript + + init(newTabPageUserScript: NewTabPageUserScript) { + specialPagesUserScript = SpecialPagesUserScript() + specialPagesUserScript.registerSubfeature(delegate: newTabPageUserScript) + } + + @MainActor + func loadWKUserScripts() async -> [WKUserScript] { + return await withTaskGroup(of: WKUserScriptBox.self) { @MainActor group in + var wkUserScripts = [WKUserScript]() + userScripts.forEach { userScript in + group.addTask { @MainActor in + await userScript.makeWKUserScript() + } + } + for await result in group { + wkUserScripts.append(result.wkUserScript) + } + + return wkUserScripts + } + } +} + +extension WKWebViewConfiguration { + + @MainActor + func applyNewTabPageWebViewConfiguration(with featureFlagger: FeatureFlagger, newTabPageUserScript: NewTabPageUserScript) { + if urlSchemeHandler(forURLScheme: URL.NavigationalScheme.duck.rawValue) == nil { + setURLSchemeHandler( + DuckURLSchemeHandler(featureFlagger: featureFlagger), + forURLScheme: URL.NavigationalScheme.duck.rawValue + ) + } + preferences[.developerExtrasEnabled] = true + self.userContentController = NewTabPageUserContentController(newTabPageUserScript: newTabPageUserScript) + } +} diff --git a/DuckDuckGo/HomePage/NewTabPageUserScript.swift b/DuckDuckGo/HomePage/NewTabPageUserScript.swift new file mode 100644 index 0000000000..0a3f5ddc77 --- /dev/null +++ b/DuckDuckGo/HomePage/NewTabPageUserScript.swift @@ -0,0 +1,212 @@ +// +// NewTabPageUserScript.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import UserScript +import WebKit + +final class NewTabPageUserScript: NSObject, Subfeature { + + let actionsManager: NewTabPageActionsManaging + var messageOriginPolicy: MessageOriginPolicy = .only(rules: [.exact(hostname: "newtab")]) + let featureName: String = "newTabPage" + weak var broker: UserScriptMessageBroker? + weak var webView: WKWebView? + + // MARK: - MessageNames + enum MessageNames: String, CaseIterable { + case contextMenu + case favoritesGetConfig = "favorites_getConfig" + case favoritesGetData = "favorites_getData" + case initialSetup + case reportInitException + case reportPageException + case statsGetConfig = "stats_getConfig" + case statsGetData = "stats_getData" + case widgetsSetConfig = "widgets_setConfig" + } + + init(actionsManager: NewTabPageActionsManaging) { + self.actionsManager = actionsManager + super.init() + actionsManager.registerUserScript(self) + } + + public func with(broker: UserScriptMessageBroker) { + self.broker = broker + } + + private lazy var methodHandlers: [MessageNames: Handler] = [ + .contextMenu: { [weak self] in try await self?.showContextMenu(params: $0, original: $1) }, + .favoritesGetConfig: { [weak self] in try await self?.favoritesGetConfig(params: $0, original: $1) }, + .favoritesGetData: { [weak self] in try await self?.favoritesGetData(params: $0, original: $1) }, + .initialSetup: { [weak self] in try await self?.initialSetup(params: $0, original: $1) }, + .reportInitException: { [weak self] in try await self?.reportException(params: $0, original: $1) }, + .reportPageException: { [weak self] in try await self?.reportException(params: $0, original: $1) }, + .statsGetConfig: { [weak self] in try await self?.statsGetConfig(params: $0, original: $1) }, + .statsGetData: { [weak self] in try await self?.statsGetData(params: $0, original: $1) }, + .widgetsSetConfig: { [weak self] in try await self?.widgetsSetConfig(params: $0, original: $1) } + ] + + @MainActor + func handler(forMethodNamed methodName: String) -> Handler? { + guard let messageName = MessageNames(rawValue: methodName) else { return nil } + return methodHandlers[messageName] + } + + func notifyWidgetConfigsDidChange(widgetConfigs: [NewTabPageConfiguration.WidgetConfig]) { + guard let webView else { + return + } + broker?.push(method: "widgets_onConfigUpdated", params: widgetConfigs, for: self, into: webView) + } +} + +extension NewTabPageUserScript { + @MainActor + private func showContextMenu(params: Any, original: WKScriptMessage) async throws -> Encodable? { + guard let params = params as? [String: Any] else { return nil } + actionsManager.showContextMenu(with: params) + return nil + } + + @MainActor + private func favoritesGetConfig(params: Any, original: WKScriptMessage) async throws -> Encodable? { + actionsManager.getFavoritesConfig() + } + + @MainActor + private func favoritesGetData(params: Any, original: WKScriptMessage) async throws -> Encodable? { + actionsManager.getFavorites() + } + + @MainActor + private func initialSetup(params: Any, original: WKScriptMessage) async throws -> Encodable? { + actionsManager.configuration + } + + @MainActor + private func statsGetConfig(params: Any, original: WKScriptMessage) async throws -> Encodable? { + actionsManager.getPrivacyStatsConfig() + } + + @MainActor + private func statsGetData(params: Any, original: WKScriptMessage) async throws -> Encodable? { + actionsManager.getPrivacyStats() + } + + @MainActor + private func widgetsSetConfig(params: Any, original: WKScriptMessage) async throws -> Encodable? { + guard let params = params as? [[String: String]] else { return nil } + actionsManager.updateWidgetConfigs(with: params) + return nil + } + + private func reportException(params: Any, original: WKScriptMessage) async throws -> Encodable? { + guard let params = params as? [String: String] else { return nil } + actionsManager.reportException(with: params) + return nil + } +} + +extension NewTabPageUserScript { + + struct NewTabPageConfiguration: Encodable { + var widgets: [Widget] + var widgetConfigs: [WidgetConfig] + var env: String + var locale: String + var platform: Platform + + struct Widget: Encodable { + var id: String + } + + struct WidgetConfig: Encodable { + + enum WidgetVisibility: String, Encodable { + case visible, hidden + + var isVisible: Bool { + self == .visible + } + } + + init(id: String, isVisible: Bool) { + self.id = id + self.visibility = isVisible ? .visible : .hidden + } + + var id: String + var visibility: WidgetVisibility + } + + struct Platform: Encodable { + var name: String + } + } + + struct WidgetConfig: Encodable { + let animation: Animation? + let expansion: Expansion + } + + enum Expansion: String, Encodable { + case collapsed, expanded + } + + struct Animation: Encodable { + let kind: AnimationKind + + static let none = Animation(kind: .none) + static let viewTransitions = Animation(kind: .viewTransitions) + static let auto = Animation(kind: .auto) + + enum AnimationKind: String, Encodable { + case none + case viewTransitions = "view-transitions" + case auto = "auto-animate" + } + } + + struct FavoritesData: Encodable { + let favorites: [Favorite] + } + + struct Favorite: Encodable { + let favicon: FavoriteFavicon? + let id: String + let title: String + let url: String + } + + struct FavoriteFavicon: Encodable { + let maxAvailableSize: Int + let src: String + } + + struct PrivacyStatsData: Encodable { + let totalCount: Int + let trackerCompanies: [TrackerCompany] + } + + struct TrackerCompany: Encodable { + let count: Int + let displayName: String + } +} diff --git a/DuckDuckGo/HomePage/NewTabPageWebViewModel.swift b/DuckDuckGo/HomePage/NewTabPageWebViewModel.swift new file mode 100644 index 0000000000..d61fa97f76 --- /dev/null +++ b/DuckDuckGo/HomePage/NewTabPageWebViewModel.swift @@ -0,0 +1,56 @@ +// +// NewTabPageWebViewModel.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import BrowserServicesKit +import WebKit + +/** + * This class manages a dedicated web view for displaying New Tab Page. + * + * It initializes NTP user script, the NTP-specific web view configuration + * and then sets up a new web view with that configuration. It also serves + * as a navigation delegate for the web view, blocking all navigations other than + * to the New Tab Page. + * + * This class is inspired by `DBPUIViewModel`. + */ +@MainActor +final class NewTabPageWebViewModel: NSObject { + let newTabPageUserScript: NewTabPageUserScript + let webView: WebView + + init(featureFlagger: FeatureFlagger, actionsManager: NewTabPageActionsManaging) { + newTabPageUserScript = NewTabPageUserScript(actionsManager: actionsManager) + + let configuration = WKWebViewConfiguration() + configuration.applyNewTabPageWebViewConfiguration(with: featureFlagger, newTabPageUserScript: newTabPageUserScript) + webView = WebView(frame: .zero, configuration: configuration) + + super.init() + + webView.navigationDelegate = self + webView.load(URLRequest(url: URL.newtab)) + newTabPageUserScript.webView = webView + } +} + +extension NewTabPageWebViewModel: WKNavigationDelegate { + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy { + navigationAction.request.url == .newtab ? .allow : .cancel + } +} diff --git a/DuckDuckGo/MainWindow/MainViewController.swift b/DuckDuckGo/MainWindow/MainViewController.swift index 7d8df888c9..39232b9ec6 100644 --- a/DuckDuckGo/MainWindow/MainViewController.swift +++ b/DuckDuckGo/MainWindow/MainViewController.swift @@ -16,6 +16,7 @@ // limitations under the License. // +import BrowserServicesKit import Cocoa import Carbon.HIToolbox import Combine @@ -34,6 +35,7 @@ final class MainViewController: NSViewController { let findInPageViewController: FindInPageViewController let fireViewController: FireViewController let bookmarksBarViewController: BookmarksBarViewController + let featureFlagger: FeatureFlagger private let bookmarksBarVisibilityManager: BookmarksBarVisibilityManager let tabCollectionViewModel: TabCollectionViewModel @@ -63,12 +65,15 @@ final class MainViewController: NSViewController { autofillPopoverPresenter: AutofillPopoverPresenter, vpnXPCClient: VPNControllerXPCClient = .shared, aiChatMenuConfig: AIChatMenuVisibilityConfigurable = AIChatMenuConfiguration(), - brokenSitePromptLimiter: BrokenSitePromptLimiter = .shared) { + brokenSitePromptLimiter: BrokenSitePromptLimiter = .shared, + featureFlagger: FeatureFlagger = NSApp.delegateTyped.featureFlagger + ) { self.aiChatMenuConfig = aiChatMenuConfig let tabCollectionViewModel = tabCollectionViewModel ?? TabCollectionViewModel() self.tabCollectionViewModel = tabCollectionViewModel self.isBurner = tabCollectionViewModel.isBurner + self.featureFlagger = featureFlagger tabBarViewController = TabBarViewController.create(tabCollectionViewModel: tabCollectionViewModel) bookmarksBarVisibilityManager = BookmarksBarVisibilityManager(selectedTabPublisher: tabCollectionViewModel.$selectedTabViewModel.eraseToAnyPublisher()) diff --git a/DuckDuckGo/Menus/MainMenuActions.swift b/DuckDuckGo/Menus/MainMenuActions.swift index 7598c6b7b8..c563f3db8e 100644 --- a/DuckDuckGo/Menus/MainMenuActions.swift +++ b/DuckDuckGo/Menus/MainMenuActions.swift @@ -21,6 +21,7 @@ import Cocoa import Common import Configuration import Crashes +import FeatureFlags import History import PixelKit import Subscription @@ -1018,7 +1019,9 @@ extension MainViewController { // MARK: - Developer Tools @objc func toggleDeveloperTools(_ sender: Any?) { - guard let webView = getActiveTabAndIndex()?.tab.webView else { return } + guard let webView = browserTabViewController.webView else { + return + } if webView.isInspectorShown == true { webView.closeDeveloperTools() @@ -1028,15 +1031,15 @@ extension MainViewController { } @objc func openJavaScriptConsole(_ sender: Any?) { - getActiveTabAndIndex()?.tab.webView.openJavaScriptConsole() + browserTabViewController.webView?.openJavaScriptConsole() } @objc func showPageSource(_ sender: Any?) { - getActiveTabAndIndex()?.tab.webView.showPageSource() + browserTabViewController.webView?.showPageSource() } @objc func showPageResources(_ sender: Any?) { - getActiveTabAndIndex()?.tab.webView.showPageSource() + browserTabViewController.webView?.showPageSource() } } @@ -1133,7 +1136,7 @@ extension MainViewController: NSMenuItemValidation { case #selector(MainViewController.openJavaScriptConsole(_:)), #selector(MainViewController.showPageSource(_:)), #selector(MainViewController.showPageResources(_:)): - return activeTabViewModel?.canReload == true + return activeTabViewModel?.canReload == true || (featureFlagger.isFeatureOn(.htmlNewTabPage) && activeTabViewModel?.tab.content == .newtab) case #selector(MainViewController.toggleDownloads(_:)): let isDownloadsPopoverShown = self.navigationBarViewController.isDownloadsPopoverShown diff --git a/DuckDuckGo/Tab/View/BrowserTabViewController.swift b/DuckDuckGo/Tab/View/BrowserTabViewController.swift index 74f97fb45a..300eb63804 100644 --- a/DuckDuckGo/Tab/View/BrowserTabViewController.swift +++ b/DuckDuckGo/Tab/View/BrowserTabViewController.swift @@ -28,6 +28,7 @@ import PixelKit import os.log import Onboarding import Freemium +import UserScript protocol BrowserTabViewControllerDelegate: AnyObject { func highlightFireButton() @@ -41,7 +42,9 @@ final class BrowserTabViewController: NSViewController { private lazy var hoverLabel = NSTextField(string: URL.duckDuckGo.absoluteString) private lazy var hoverLabelContainer = ColorView(frame: .zero, backgroundColor: .browserTabBackground, borderWidth: 0) - private weak var webView: WebView? + private let newTabPageActionsManager: NewTabPageActionsManaging + private(set) lazy var newTabPageWebViewModel: NewTabPageWebViewModel = NewTabPageWebViewModel(featureFlagger: featureFlagger, actionsManager: newTabPageActionsManager) + private(set) weak var webView: WebView? private weak var webViewContainer: NSView? private weak var webViewSnapshot: NSView? private var containerStackView: NSStackView @@ -83,12 +86,15 @@ final class BrowserTabViewController: NSViewController { bookmarkManager: BookmarkManager = LocalBookmarkManager.shared, onboardingDialogTypeProvider: ContextualOnboardingDialogTypeProviding & ContextualOnboardingStateUpdater = Application.appDelegate.onboardingStateMachine, onboardingDialogFactory: ContextualDaxDialogsFactory = DefaultContextualDaxDialogViewFactory(), - featureFlagger: FeatureFlagger = NSApp.delegateTyped.featureFlagger) { + featureFlagger: FeatureFlagger = NSApp.delegateTyped.featureFlagger, + newTabPageActionsManager: NewTabPageActionsManaging = NSApp.delegateTyped.newTabPageActionsManager + ) { self.tabCollectionViewModel = tabCollectionViewModel self.bookmarkManager = bookmarkManager self.onboardingDialogTypeProvider = onboardingDialogTypeProvider self.onboardingDialogFactory = onboardingDialogFactory self.featureFlagger = featureFlagger + self.newTabPageActionsManager = newTabPageActionsManager containerStackView = NSStackView() super.init(nibName: nil, bundle: nil) @@ -537,12 +543,11 @@ final class BrowserTabViewController: NSViewController { } func displayWebView(of tabViewModel: TabViewModel) { - let newWebView = tabViewModel.tab.webView + let newWebView = tabViewModel.tab.content == .newtab ? newTabPageWebViewModel.webView : tabViewModel.tab.webView cleanUpRemoteWebViewIfNeeded(newWebView) webView = newWebView addWebViewToViewHierarchy(newWebView, tab: tabViewModel.tab) - } guard let tabViewModel = tabViewModel else { @@ -798,7 +803,7 @@ final class BrowserTabViewController: NSViewController { updateTabIfNeeded(tabViewModel: tabViewModel) case .newtab: - if NSApp.delegateTyped.featureFlagger.isFeatureOn(.htmlNewTabPage) { + if featureFlagger.isFeatureOn(.htmlNewTabPage) { updateTabIfNeeded(tabViewModel: tabViewModel) } else { removeAllTabContent() @@ -840,11 +845,13 @@ final class BrowserTabViewController: NSViewController { return false } + let newWebView = tabViewModel.tab.content == .newtab ? newTabPageWebViewModel.webView : tabViewModel.tab.webView + let isPinnedTab = tabCollectionViewModel.pinnedTabsCollection?.tabs.contains(tabViewModel.tab) == true let isKeyWindow = view.window?.isKeyWindow == true let tabIsNotOnScreen = webView?.tabContentView.superview == nil - let isDifferentTabDisplayed = webView !== tabViewModel.tab.webView + let isDifferentTabDisplayed = webView !== newWebView return isDifferentTabDisplayed || tabIsNotOnScreen @@ -861,7 +868,7 @@ final class BrowserTabViewController: NSViewController { case .onboarding: return case .newtab: - containsHostingView = !NSApp.delegateTyped.featureFlagger.isFeatureOn(.htmlNewTabPage) + containsHostingView = !featureFlagger.isFeatureOn(.htmlNewTabPage) case .settings: containsHostingView = true default: @@ -1450,7 +1457,7 @@ extension BrowserTabViewController { guard let self, self.tabViewModel === tabViewModel else { return } - // only make web view first responder after replacing the + // only make web view first responder after replacing the // snapshot if the address bar is not the first responder if view.window?.firstResponder === view.window { viewToMakeFirstResponderAfterAdding = { [weak self] in diff --git a/DuckDuckGo/YoutubePlayer/DuckURLSchemeHandler.swift b/DuckDuckGo/YoutubePlayer/DuckURLSchemeHandler.swift index acd03cc599..a4bed48993 100644 --- a/DuckDuckGo/YoutubePlayer/DuckURLSchemeHandler.swift +++ b/DuckDuckGo/YoutubePlayer/DuckURLSchemeHandler.swift @@ -141,7 +141,7 @@ private extension DuckURLSchemeHandler { directoryURL = URL(fileURLWithPath: "/pages/onboarding") } else if url.isReleaseNotesScheme { directoryURL = URL(fileURLWithPath: "/pages/release-notes") - } else if url.isNewTab { + } else if url.isNewTabPage { directoryURL = URL(fileURLWithPath: "/pages/new-tab") } else { assertionFailure("Unknown scheme") @@ -237,7 +237,7 @@ extension URL { return .phishingErrorPage } else if self.isReleaseNotesScheme { return .releaseNotes - } else if self.isNewTab { + } else if self.isNewTabPage { return .newTab } else { return nil @@ -248,7 +248,7 @@ extension URL { return isDuckURLScheme && host == "onboarding" } - var isNewTab: Bool { + var isNewTabPage: Bool { return isDuckURLScheme && host == "newtab" }