diff --git a/Sources/Brave/Frontend/Browser/Helpers/LaunchHelper.swift b/Sources/Brave/Frontend/Browser/Helpers/LaunchHelper.swift index 0c435bf14aa..b7564264c3f 100644 --- a/Sources/Brave/Frontend/Browser/Helpers/LaunchHelper.swift +++ b/Sources/Brave/Frontend/Browser/Helpers/LaunchHelper.swift @@ -35,7 +35,8 @@ public actor LaunchHelper { // This is done first because compileResources need their results async let filterListCache: Void = FilterListResourceDownloader.shared.loadCachedData() async let adblockResourceCache: Void = AdblockResourceDownloader.shared.loadCachedAndBundledDataIfNeeded() - _ = await (filterListCache, adblockResourceCache) + async let filterListURLCache: Void = FilterListCustomURLDownloader.shared.loadCachedFilterLists() + _ = await (filterListCache, adblockResourceCache, filterListURLCache) // Compile some engines await AdBlockEngineManager.shared.compileResources() @@ -82,5 +83,9 @@ public actor LaunchHelper { .union( ContentBlockerManager.GenericBlocklistType.allCases.map { .generic($0) } ) + // All custom filter list urls + .union( + CustomFilterListStorage.shared.filterListsURLs.map { .customFilterList(uuid: $0.setting.uuid) } + ) } } diff --git a/Sources/Brave/Frontend/Settings/AdblockDebugMenuTableViewController.swift b/Sources/Brave/Frontend/Settings/AdblockDebugMenuTableViewController.swift index 55e2b2ca7e6..a82a7aa7f4f 100644 --- a/Sources/Brave/Frontend/Settings/AdblockDebugMenuTableViewController.swift +++ b/Sources/Brave/Frontend/Settings/AdblockDebugMenuTableViewController.swift @@ -43,6 +43,13 @@ class AdblockDebugMenuTableViewController: TableViewController { resources: FilterListResourceDownloader.shared.filterLists.map({ filterList in return filterList.makeResource(componentId: filterList.entry.componentId) }) + ), + self.downloadedResourcesSection( + header: "Filter list custom URLs", + footer: "Files downloaded using the FilterListURLResourceDownloader", + resources: CustomFilterListStorage.shared.filterListsURLs.map({ filterListURL in + return filterListURL.setting.resource + }) ) ] } diff --git a/Sources/Brave/Frontend/Settings/FilterListAddURLView.swift b/Sources/Brave/Frontend/Settings/FilterListAddURLView.swift new file mode 100644 index 00000000000..363c7e916af --- /dev/null +++ b/Sources/Brave/Frontend/Settings/FilterListAddURLView.swift @@ -0,0 +1,111 @@ +// Copyright 2023 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import SwiftUI +import Strings +import DesignSystem +import BraveUI + +struct FilterListAddURLView: View { + @ObservedObject private var customFilterListStorage = CustomFilterListStorage.shared + @Environment(\.presentationMode) @Binding private var presentationMode + @State private var newURLInput: String = "" + @State private var errorMessage: String? + + private var textField: some View { + TextField(Strings.filterListsEnterFilterListURL, text: $newURLInput) + .onChange(of: newURLInput) { newValue in + errorMessage = nil + } + .keyboardType(.URL) + .textContentType(.URL) + .autocapitalization(.none) + .autocorrectionDisabled() + } + + var body: some View { + NavigationView { + List { + Section(content: { + VStack(alignment: .leading) { + textField + .submitLabel(SubmitLabel.done) + + if let errorMessage = errorMessage { + Text(errorMessage) + .font(.caption) + .foregroundColor(.red) + } + } + }, header: { + Text(Strings.customFilterListURL) + }, footer: { + VStack(alignment: .leading, spacing: 8) { + Text(Strings.addCustomFilterListDescription) + Text(LocalizedStringKey(Strings.addCustomFilterListWarning)) + }.padding(.top, 8) + }) + } + .listBackgroundColor(Color(UIColor.braveGroupedBackground)) + .navigationTitle(Strings.customFilterList) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItemGroup(placement: .confirmationAction) { + Button(Strings.filterListsAdd) { + handleOnSubmit() + }.disabled(newURLInput.isEmpty) + } + + ToolbarItemGroup(placement: .cancellationAction) { + Button(Strings.CancelString) { + presentationMode.dismiss() + } + } + } + }.frame(idealWidth: 400, idealHeight: 400) + } + + private func handleOnSubmit() { + guard !newURLInput.isEmpty else { return } + guard let url = URL(string: newURLInput) else { + self.errorMessage = Strings.filterListAddInvalidURLError + return + } + guard url.scheme == "https" else { + self.errorMessage = Strings.filterListAddOnlyHTTPSAllowedError + return + } + guard !customFilterListStorage.filterListsURLs.contains(where: { filterListURL in + return filterListURL.setting.externalURL == url + }) else { + // Don't allow duplicates + self.presentationMode.dismiss() + return + } + + Task { + let customURL = FilterListCustomURL( + externalURL: url, isEnabled: true, + inMemory: !customFilterListStorage.persistChanges + ) + + customFilterListStorage.filterListsURLs.append(customURL) + + await FilterListCustomURLDownloader.shared.startFetching( + filterListCustomURL: customURL + ) + + self.presentationMode.dismiss() + } + } +} + +#if DEBUG +struct FilterListAddURLView_Previews: PreviewProvider { + static var previews: some View { + FilterListAddURLView() + } +} +#endif diff --git a/Sources/Brave/Frontend/Settings/FilterListsView.swift b/Sources/Brave/Frontend/Settings/FilterListsView.swift index 9439d75acb7..27ed477b382 100644 --- a/Sources/Brave/Frontend/Settings/FilterListsView.swift +++ b/Sources/Brave/Frontend/Settings/FilterListsView.swift @@ -8,13 +8,79 @@ import Strings import Data import DesignSystem import BraveUI +import BraveCore /// A view showing enabled and disabled community filter lists struct FilterListsView: View { @ObservedObject private var filterListDownloader = FilterListResourceDownloader.shared + @ObservedObject private var customFilterListStorage = CustomFilterListStorage.shared + @Environment(\.editMode) private var editMode + @State private var showingAddSheet = false + private let dateFormatter = RelativeDateTimeFormatter() var body: some View { List { + Section { + ForEach($customFilterListStorage.filterListsURLs) { $filterListURL in + VStack(alignment: .leading, spacing: 4) { + Toggle(isOn: $filterListURL.setting.isEnabled) { + VStack(alignment: .leading, spacing: 4) { + Text(filterListURL.title ?? filterListURL.setting.externalURL.origin.url?.absoluteString ?? filterListURL.setting.externalURL.absoluteString) + .foregroundColor(Color(.bravePrimary)) + .truncationMode(.middle) + .lineLimit(1) + + switch filterListURL.downloadStatus { + case .downloaded(let downloadDate): + Text(String.localizedStringWithFormat( + Strings.filterListsLastUpdated, + dateFormatter.localizedString(for: downloadDate, relativeTo: Date()))) + .font(.caption) + .foregroundColor(Color(.braveLabel)) + case .failure: + Text(Strings.filterListsDownloadFailed) + .font(.caption) + .foregroundColor(.red) + case .pending: + Text(Strings.filterListsDownloadPending) + .font(.caption) + .foregroundColor(Color(.braveLabel)) + } + } + } + .disabled(editMode?.wrappedValue.isEditing == true) + .toggleStyle(SwitchToggleStyle(tint: .accentColor)) + .onChange(of: filterListURL.setting.isEnabled) { value in + Task { + CustomFilterListSetting.save(inMemory: !customFilterListStorage.persistChanges) + await FilterListCustomURLDownloader.shared.handleUpdate(to: filterListURL, isEnabled: value) + } + } + + Text(filterListURL.setting.externalURL.absoluteDisplayString) + .font(.caption) + .foregroundColor(Color(.secondaryBraveLabel)) + .allowsTightening(true) + }.listRowBackground(Color(.secondaryBraveGroupedBackground)) + } + .onDelete(perform: onDeleteHandling) + + Button { + showingAddSheet = true + } label: { + Text(Strings.addCustomFilterList) + .foregroundColor(Color(.braveBlurple)) + } + .buttonStyle(PlainButtonStyle()) + .disabled(editMode?.wrappedValue.isEditing == true) + .listRowBackground(Color(.secondaryBraveGroupedBackground)) + .popover(isPresented: $showingAddSheet, content: { + FilterListAddURLView() + }) + } header: { + Text(Strings.customFilterLists) + } + Section { ForEach($filterListDownloader.filterLists) { $filterList in Toggle(isOn: $filterList.isEnabled) { @@ -29,13 +95,60 @@ struct FilterListsView: View { .listRowBackground(Color(.secondaryBraveGroupedBackground)) } } header: { - Text(Strings.filterListsDescription) - .textCase(.none) + VStack(alignment: .leading, spacing: 4) { + Text(Strings.defaultFilterLists) + .textCase(.uppercase) + Text(Strings.filterListsDescription) + .textCase(.none) + } } - } .listBackgroundColor(Color(UIColor.braveGroupedBackground)) + .listStyle(.insetGrouped) .navigationTitle(Strings.filterLists) + .toolbar { + EditButton().disabled( + customFilterListStorage.filterListsURLs.isEmpty && + editMode?.wrappedValue.isEditing == false + ) + } + } + + private func onDeleteHandling(offsets: IndexSet) { + let removedURLs = offsets.map { customFilterListStorage.filterListsURLs[$0] } + customFilterListStorage.filterListsURLs.remove(atOffsets: offsets) + + Task { + await removedURLs.asyncConcurrentForEach { removedURL in + // 1. Disable the filter list. + // It would be better to delete it but for some reason if we remove the rule list, + // it will not allow us to remove it from the tab + // So we don't remove it, only flag it as disabled and it will be removed on the next launch + // during the `cleaupInvalidRuleLists` step on `LaunchHelper` + await FilterListCustomURLDownloader.shared.handleUpdate( + to: removedURL, isEnabled: false + ) + + // 2. Stop downloading the file + await FilterListCustomURLDownloader.shared.stopFetching( + filterListCustomURL: removedURL + ) + + // 3. Remove the files + do { + try removedURL.setting.resource.removeCacheFolder() + } catch { + ContentBlockerManager.log.error( + "Failed to remove file for resource \(removedURL.setting.uuid)" + ) + } + + // 4. Remove the setting. + // This should always happen in the end + // because we need to access properties on the setting until then + removedURL.setting.delete(inMemory: !customFilterListStorage.persistChanges) + } + } } } diff --git a/Sources/Brave/WebFilters/AdBlockEngineManager.swift b/Sources/Brave/WebFilters/AdBlockEngineManager.swift index 033cf446256..c4318bf8b87 100644 --- a/Sources/Brave/WebFilters/AdBlockEngineManager.swift +++ b/Sources/Brave/WebFilters/AdBlockEngineManager.swift @@ -18,6 +18,7 @@ public actor AdBlockEngineManager: Sendable { case adBlock case cosmeticFilters case filterList(uuid: String) + case filterListURL(uuid: String) /// The order of this source relative to other sources. /// @@ -27,6 +28,7 @@ public actor AdBlockEngineManager: Sendable { case .adBlock: return 0 case .cosmeticFilters: return 3 case .filterList: return 100 + case .filterListURL: return 200 } } } @@ -224,6 +226,7 @@ extension AdBlockEngineManager.Source: CustomDebugStringConvertible { public var debugDescription: String { switch self { case .filterList(let uuid): return "filterList(\(uuid))" + case .filterListURL(let uuid): return "filterListURL(\(uuid))" case .adBlock: return "adBlock" case .cosmeticFilters: return "cosmeticFilters" } diff --git a/Sources/Brave/WebFilters/ContentBlocker/ContentBlockerManager.swift b/Sources/Brave/WebFilters/ContentBlocker/ContentBlockerManager.swift index cc786c95a53..f0f176f1e77 100644 --- a/Sources/Brave/WebFilters/ContentBlocker/ContentBlockerManager.swift +++ b/Sources/Brave/WebFilters/ContentBlocker/ContentBlockerManager.swift @@ -199,7 +199,14 @@ actor ContentBlockerManager { return .filterList(uuid: filterList.uuid) } - return Set(genericRuleLists).union(additionalRuleLists) + // Get rule lists for custom filter lists + let customFilterLists = CustomFilterListStorage.shared.filterListsURLs + let customRuleLists = customFilterLists.compactMap { customURL -> BlocklistType? in + guard customURL.setting.isEnabled else { return nil } + return .customFilterList(uuid: customURL.setting.uuid) + } + + return Set(genericRuleLists).union(additionalRuleLists).union(customRuleLists) } /// Return the enabled rule types for this domain and the enabled settings. diff --git a/Sources/Brave/WebFilters/CustomFilterListStorage.swift b/Sources/Brave/WebFilters/CustomFilterListStorage.swift new file mode 100644 index 00000000000..412bdd5f5f1 --- /dev/null +++ b/Sources/Brave/WebFilters/CustomFilterListStorage.swift @@ -0,0 +1,55 @@ +// Copyright 2023 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import Foundation +import Data + +@MainActor class CustomFilterListStorage: ObservableObject { + static let shared = CustomFilterListStorage(persistChanges: true) + /// Wether or not to store the data into disk or into memory + let persistChanges: Bool + /// A list of filter list URLs and their enabled statuses + @Published var filterListsURLs: [FilterListCustomURL] + + init(persistChanges: Bool) { + self.persistChanges = persistChanges + self.filterListsURLs = [] + } + + func loadCachedFilterLists() { + let settings = CustomFilterListSetting.loadAllSettings(fromMemory: !persistChanges) + + self.filterListsURLs = settings.map { setting in + let resource = setting.resource + let date = try? resource.creationDate() + + if let date = date { + return FilterListCustomURL(setting: setting, downloadStatus: .downloaded(date)) + } else { + return FilterListCustomURL(setting: setting, downloadStatus: .pending) + } + } + } + + func update(filterListId id: ObjectIdentifier, with result: Result) { + guard let index = filterListsURLs.firstIndex(where: { $0.id == id }) else { + return + } + + switch result { + case .failure(let error): + #if DEBUG + let externalURL = filterListsURLs[index].setting.externalURL.absoluteString + ContentBlockerManager.log.error( + "Failed to download resource \(externalURL): \(String(describing: error))" + ) + #endif + + filterListsURLs[index].downloadStatus = .failure + case .success(let date): + filterListsURLs[index].downloadStatus = .downloaded(date) + } + } +} diff --git a/Sources/Brave/WebFilters/FilterListCustomURL.swift b/Sources/Brave/WebFilters/FilterListCustomURL.swift new file mode 100644 index 00000000000..7debf335b53 --- /dev/null +++ b/Sources/Brave/WebFilters/FilterListCustomURL.swift @@ -0,0 +1,42 @@ +// Copyright 2023 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import Foundation +import Data + +struct FilterListCustomURL: Identifiable { + enum DownloadStatus { + case pending + case failure + case downloaded(Date) + } + + var id: ObjectIdentifier { + return setting.id + } + + var setting: CustomFilterListSetting + var downloadStatus: DownloadStatus = .pending + + @MainActor var title: String? { + let lastPathComponent = setting.externalURL.lastPathComponent + guard !lastPathComponent.isEmpty else { return nil } + return lastPathComponent + } + + public init(setting: CustomFilterListSetting, downloadStatus: DownloadStatus = .pending) { + self.setting = setting + self.downloadStatus = downloadStatus + } + + @MainActor public init(externalURL: URL, isEnabled: Bool, inMemory: Bool) { + let setting = CustomFilterListSetting.create( + externalURL: externalURL, isEnabled: isEnabled, + inMemory: inMemory + ) + + self.init(setting: setting) + } +} diff --git a/Sources/Brave/WebFilters/FilterListCustomURLDownloader.swift b/Sources/Brave/WebFilters/FilterListCustomURLDownloader.swift new file mode 100644 index 00000000000..84805bae3d2 --- /dev/null +++ b/Sources/Brave/WebFilters/FilterListCustomURLDownloader.swift @@ -0,0 +1,189 @@ +// Copyright 2023 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import Foundation +import Data +import Combine +import BraveCore + +/// An actor that handles the downloading of custom filter lists which are sourced via a URL +actor FilterListCustomURLDownloader: ObservableObject { + /// An object representing a downloadable custom filter list. + struct DownloadResource: Hashable, DownloadResourceInterface { + let uuid: String + let externalURL: URL + + var cacheFolderName: String { + return ["custom-filter-lists", uuid].joined(separator: "/") + } + + var cacheFileName: String { + return externalURL.lastPathComponent + } + + var headers: [String: String] { + return ["Accept": "text/plain"] + } + } + + /// A formatter that is used to format a version number + private let fileVersionDateFormatter: DateFormatter = { + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.dateFormat = "yyyy.MM.dd.HH.mm.ss" + dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) + return dateFormatter + }() + + static let shared = FilterListCustomURLDownloader() + + /// The resource downloader that downloads our resources + private let resourceDownloader: ResourceDownloader + /// Fetch content blocking tasks per filter list + private var fetchTasks: [DownloadResource: Task] + + init(networkManager: NetworkManager = NetworkManager()) { + self.resourceDownloader = ResourceDownloader(networkManager: networkManager) + self.fetchTasks = [:] + } + + /// Load any custom filter lists from cache so they are ready to use and start fetching updates. + func loadCachedFilterLists() async { + await CustomFilterListStorage.shared.loadCachedFilterLists() + + await CustomFilterListStorage.shared.filterListsURLs.asyncConcurrentForEach { customURL in + await self.handleUpdate(to: customURL, isEnabled: customURL.setting.isEnabled) + + // Always fetch this resource so it's ready if the user enables it. + await self.startFetching(filterListCustomURL: customURL) + } + } + + /// Handle the download results of a custom filter list. This will process the download by compiling iOS rule lists and adding the rule list to the `AdblockEngineManager`. + private func handle(downloadResult: ResourceDownloader.DownloadResult, for filterListCustomURL: FilterListCustomURL) async { + let uuid = await filterListCustomURL.setting.uuid + + // Compile this rule list if we haven't already or if the file has been modified + if downloadResult.isModified { + do { + let filterSet = try String(contentsOf: downloadResult.fileURL, encoding: .utf8) + let jsonRules = AdblockEngine.contentBlockerRules(fromFilterSet: filterSet) + + try await ContentBlockerManager.shared.compile( + encodedContentRuleList: jsonRules, + for: .customFilterList(uuid: uuid), + options: .all + ) + } catch { + ContentBlockerManager.log.error( + "Failed to convert custom filter list to content blockers: \(error.localizedDescription)" + ) + } + } + + // Add/remove the resource depending on if it is enabled/disabled + if await filterListCustomURL.setting.isEnabled { + let version = fileVersionDateFormatter.string(from: downloadResult.date) + + await AdBlockEngineManager.shared.add( + resource: AdBlockEngineManager.Resource(type: .ruleList, source: .filterListURL(uuid: uuid)), + fileURL: downloadResult.fileURL, version: version + ) + } else { + await AdBlockEngineManager.shared.removeResources( + for: .filterListURL(uuid: uuid) + ) + } + } + + /// Handle the update to a filter list enabled status which will will add or remove it from the engine. + /// This will not compile anything to iOS rule lists as we always do this on the download regardless of its enabled status. + @MainActor func handleUpdate(to filterListCustomURL: FilterListCustomURL, isEnabled: Bool) async { + if isEnabled { + let resource = filterListCustomURL.setting.resource + + do { + if let cachedResult = try resource.cachedResult() { + await self.handle(downloadResult: cachedResult, for: filterListCustomURL) + } + } catch { + ContentBlockerManager.log.error( + "Failed to cached data for resource \(filterListCustomURL.setting.externalURL): \(error)" + ) + } + } else { + // We need to remove this resource if we disable this filter list + // But we will keep the compiled rule lists since we can remove them from the web-view. + await AdBlockEngineManager.shared.removeResources( + for: .filterListURL(uuid: filterListCustomURL.setting.uuid) + ) + } + } + + /// Start fetching the resource for the given filter list. Once a new version is downloaded, the file will be processed using the `handle` method + func startFetching(filterListCustomURL: FilterListCustomURL) async { + let resource = await filterListCustomURL.setting.resource + + guard fetchTasks[resource] == nil else { + // We're already fetching for this filter list + return + } + + fetchTasks[resource] = Task { + do { + for try await result in await self.resourceDownloader.downloadStream(for: resource) { + switch result { + case .success(let downloadResult): + // Update the data for UI purposes + await CustomFilterListStorage.shared.update( + filterListId: filterListCustomURL.id, + with: .success(downloadResult.date) + ) + + // Handle the successful result so we parse the content blockers + await self.handle( + downloadResult: downloadResult, + for: filterListCustomURL + ) + case .failure(let error): + // We don't want to keep refetching these types of failures + let hardFailureCodes = [URLError.Code.badURL, .appTransportSecurityRequiresSecureConnection, .fileIsDirectory, .unsupportedURL] + if let urlError = error as? URLError, hardFailureCodes.contains(urlError.code) { + throw urlError + } + + await CustomFilterListStorage.shared.update( + filterListId: filterListCustomURL.id, + with: .failure(error) + ) + } + } + } catch is CancellationError { + self.fetchTasks.removeValue(forKey: resource) + } catch { + self.fetchTasks.removeValue(forKey: resource) + + await CustomFilterListStorage.shared.update( + filterListId: filterListCustomURL.id, + with: .failure(error) + ) + } + } + } + + /// Cancel all fetching tasks for the given filter list + func stopFetching(filterListCustomURL: FilterListCustomURL) async { + let resource = await filterListCustomURL.setting.resource + fetchTasks[resource]?.cancel() + fetchTasks.removeValue(forKey: resource) + } +} + +extension CustomFilterListSetting { + /// Return a download resource representing the given setting + @MainActor var resource: FilterListCustomURLDownloader.DownloadResource { + return FilterListCustomURLDownloader.DownloadResource(uuid: uuid, externalURL: externalURL) + } +} diff --git a/Sources/Brave/WebFilters/FilterListResourceDownloader.swift b/Sources/Brave/WebFilters/FilterListResourceDownloader.swift index 5abe613afe1..91910dafbf2 100644 --- a/Sources/Brave/WebFilters/FilterListResourceDownloader.swift +++ b/Sources/Brave/WebFilters/FilterListResourceDownloader.swift @@ -86,12 +86,13 @@ public class FilterListResourceDownloader: ObservableObject { allFilterListSettings[index].isEnabled = isEnabled allFilterListSettings[index].componentId = componentId + allFilterListSettings[index].order = NSNumber(value: order) FilterListSetting.save(inMemory: inMemory) } /// Create a filter list setting for the given UUID and enabled status @MainActor private func create(uuid: String, componentId: String?, isEnabled: Bool, order: Int) { - let setting = FilterListSetting.create(uuid: uuid, componentId: componentId, isEnabled: isEnabled, inMemory: inMemory) + let setting = FilterListSetting.create(uuid: uuid, componentId: componentId, isEnabled: isEnabled, order: order, inMemory: inMemory) allFilterListSettings.append(setting) } } @@ -153,7 +154,8 @@ public class FilterListResourceDownloader: ObservableObject { // Try to load the filter list folder. We always have to compile this at start if let folderURL = setting.folderURL, FileManager.default.fileExists(atPath: folderURL.path) { await self.addEngineResources( - forFilterListUUID: setting.uuid, downloadedFolderURL: folderURL, relativeOrder: 0 + forFilterListUUID: setting.uuid, downloadedFolderURL: folderURL, + relativeOrder: setting.order?.intValue ?? 0 ) } } diff --git a/Sources/Brave/WebFilters/ShieldStats/Adblock/CachedAdBlockEngine.swift b/Sources/Brave/WebFilters/ShieldStats/Adblock/CachedAdBlockEngine.swift index d4c79676d7e..dd17b759a86 100644 --- a/Sources/Brave/WebFilters/ShieldStats/Adblock/CachedAdBlockEngine.swift +++ b/Sources/Brave/WebFilters/ShieldStats/Adblock/CachedAdBlockEngine.swift @@ -119,7 +119,7 @@ public class CachedAdBlockEngine { /// This is determined by checking the source of the engine and checking the appropriate shields. @MainActor func isEnabled(for domain: Domain) -> Bool { switch source { - case .adBlock, .filterList: + case .adBlock, .filterList, .filterListURL: // This engine source type is enabled only if shields are enabled // for the given domain return domain.isShieldExpected(.AdblockAndTp, considerAllShieldsOption: true) diff --git a/Sources/BraveShared/BraveStrings.swift b/Sources/BraveShared/BraveStrings.swift index 701146b9b87..34cf66ea8c2 100644 --- a/Sources/BraveShared/BraveStrings.swift +++ b/Sources/BraveShared/BraveStrings.swift @@ -1062,14 +1062,6 @@ extension Strings { public static let blockPhishingAndMalware = NSLocalizedString("BlockPhishingAndMalware", tableName: "BraveShared", bundle: .module, value: "Block Phishing and Malware", comment: "") public static let googleSafeBrowsing = NSLocalizedString("GoogleSafeBrowsing", tableName: "BraveShared", bundle: .module, value: "Block Dangerous Sites", comment: "") public static let googleSafeBrowsingUsingWebKitDescription = NSLocalizedString("GoogleSafeBrowsingUsingWebKitDescription", tableName: "BraveShared", bundle: .module, value: "Sends obfuscated URLs of some pages you visit to the Google Safe Browsing service, when your security is at risk.", comment: "") - public static let contentFiltering = NSLocalizedString("ContentFiltering", tableName: "BraveShared", bundle: .module, value: "Content Filtering", comment: "A title to the content filtering page under global shield settings and the title on the Content filtering page") - public static let blockMobileAnnoyances = NSLocalizedString("blockMobileAnnoyances", tableName: "BraveShared", bundle: .module, value: "Block 'Switch to App' Notices", comment: "A title for setting which blocks 'switch to app' popups") - public static let contentFilteringDescription = NSLocalizedString("ContentFilteringDescription", tableName: "BraveShared", bundle: .module, value: "Enable custom filters that block regional and language-specific trackers and Annoyances", comment: "A description of the content filtering page.") - public static let filterLists = NSLocalizedString("FilterLists", tableName: "BraveShared", bundle: .module, value: "Filter Lists", comment: "A title on the content filtering screen that allows you to enable/disable filter lists") - public static let filterListsDescription = NSLocalizedString("FilterListsDescription", tableName: "BraveShared", bundle: .module, value: "Additional popular community lists. Note that enabling too many filters will degrade browsing speeds.", comment: "A description on the content filtering screen for the filter lists section.") - public static let filterListDownloadedLabel = NSLocalizedString("Downloaded", tableName: "BraveShared", bundle: .module, value: "Downloaded", comment: "A label representing the state of a filter list that is downloaded") - public static let filterListDownloadingLabel = NSLocalizedString("Downloading", tableName: "BraveShared", bundle: .module, value: "Downloading", comment: "A label representing the state of a filter list that is currently being downloaded") - public static let filterListNotDownloadedLabel = NSLocalizedString("NotDownloaded", tableName: "BraveShared", bundle: .module, value: "Not downloaded", comment: "A label representing the state of a filter list that is not downloaded") public static let blockScripts = NSLocalizedString("BlockScripts", tableName: "BraveShared", bundle: .module, value: "Block Scripts", comment: "") public static let blockScriptsDescription = NSLocalizedString("BlockScriptsDescription", tableName: "BraveShared", bundle: .module, value: "Blocks JavaScript (may break sites).", comment: "") public static let blockCookiesDescription = NSLocalizedString("BlockCookiesDescription", tableName: "BraveShared", bundle: .module, value: "Prevents websites from storing information about your previous visits.", comment: "") @@ -4549,3 +4541,29 @@ extension Strings { public static let switchToNonPBMKeyCodeTitle = NSLocalizedString("SwitchToNonPBMKeyCodeTitle", bundle: .module, value: "Normal Browsing Mode", comment: "Hardware shortcut for non-private tab or tab. Shown in the Discoverability overlay when the hardware Command Key is held down.") } } + +// MARK: - Filter lists + +extension Strings { + public static let contentFiltering = NSLocalizedString("ContentFiltering", tableName: "BraveShared", bundle: .module, value: "Content Filtering", comment: "A title to the content filtering page under global shield settings and the title on the Content filtering page") + public static let blockMobileAnnoyances = NSLocalizedString("blockMobileAnnoyances", tableName: "BraveShared", bundle: .module, value: "Block 'Switch to App' Notices", comment: "A title for setting which blocks 'switch to app' popups") + public static let contentFilteringDescription = NSLocalizedString("ContentFilteringDescription", tableName: "BraveShared", bundle: .module, value: "Enable custom filters that block regional and language-specific trackers and Annoyances", comment: "A description of the content filtering page.") + public static let filterLists = NSLocalizedString("FilterLists", tableName: "BraveShared", bundle: .module, value: "Filter Lists", comment: "A title on the content filtering screen that allows you to enable/disable filter lists") + public static let defaultFilterLists = NSLocalizedString("DefaultFilterLists", tableName: "BraveShared", bundle: .module, value: "Default Filter Lists", comment: "A section title that contains default (predefined) filter lists a user can enable/diable.") + public static let filterListsDescription = NSLocalizedString("FilterListsDescription", tableName: "BraveShared", bundle: .module, value: "Additional popular community lists. Note that enabling too many filters will degrade browsing speeds.", comment: "A description on the content filtering screen for the filter lists section.") + public static let addCustomFilterList = NSLocalizedString("AddCustomFilterList", tableName: "BraveShared", bundle: .module, value: "Add Custom Filter List", comment: "A title within a cell where a user can navigate to an add screen.") + public static let customFilterList = NSLocalizedString("CustomFilterList", tableName: "BraveShared", bundle: .module, value: "Custom Filter List", comment: "Title for the custom filter list add screen found in the navigation bar.") + public static let customFilterLists = NSLocalizedString("CustomFilterLists", tableName: "BraveShared", bundle: .module, value: "Custom Filter Lists", comment: "A title for a section that contains all custom filter lists") + public static let customFilterListURL = NSLocalizedString("CustomFilterListsURL", tableName: "BraveShared", bundle: .module, value: "Custom Filter List URL", comment: "A section heading above a cell that allows you to enter a filter list URL.") + public static let addCustomFilterListDescription = NSLocalizedString("AddCustomFilterListDescription", tableName: "BraveShared", bundle: .module, value: "Add additional lists created and maintained by your trusted community.", comment: "A description of a section in a list that allows you to add custom filter lists found in the footer of the add custom url screen") + public static let addCustomFilterListWarning = NSLocalizedString("AddCustomFilterListWarning", tableName: "BraveShared", bundle: .module, value: "**Only subscribe to lists from entities you trust**. Your browser will periodically check for list updates from the URL you enter.", comment: "Warning text found in the footer of the add custom filter list url screen.") + public static let filterListsLastUpdated = NSLocalizedString("FilterListsLastUpdatedLabel", tableName: "BraveShared", bundle: .module, value: "Last updated %@", comment: "A label that shows when the filter list was last updated. Do not translate the '%@' placeholder. The %@ will be replaced with a relative date. For example, '5 minutes ago' or '1 hour ago'. So the full string will read something like 'Last updated 5 minutes ago'.") + public static let filterListsDownloadPending = NSLocalizedString("FilterListsDownloadPending", tableName: "BraveShared", bundle: .module, value: "Pending download", comment: "If a filter list is not yet downloaded this label shows up instead of a last download date, signifying that the download is still pending.") + public static let filterListsEnterFilterListURL = NSLocalizedString("FilterListsEnterFilterListURL", tableName: "BraveShared", bundle: .module, value: "Enter filter list URL", comment: "This is a placeholder for an input field that takes a custom filter list URL.") + public static let filterListsAdd = NSLocalizedString("FilterListsAdd", tableName: "BraveShared", bundle: .module, value: "Add", comment: "This is a button on the top navigation that takes the user to an add custom filter list url to the list") + public static let filterListsEdit = NSLocalizedString("FilterListsEdit", tableName: "BraveShared", bundle: .module, value: "Edit", comment: "This is a button on the top navigation that takes the user to an add custom filter list url to the list") + public static let filterListURLTextFieldPlaceholder = NSLocalizedString("FilterListURLTextFieldPlaceholder", tableName: "BraveShared", bundle: .module, value: "Enter filter list URL here ", comment: "This is a placeholder for the custom filter list url text field where a user may enter a custom filter list URL") + public static let filterListsDownloadFailed = NSLocalizedString("FilterListsDownloadFailed", tableName: "BraveShared", bundle: .module, value: "Download failed", comment: "This is a generic error message when downloading a filter list fails.") + public static let filterListAddInvalidURLError = NSLocalizedString("FilterListAddInvalidURLError", tableName: "BraveShared", bundle: .module, value: "The URL entered is invalid", comment: "This is an error message when a user tries to enter an invalid URL into the custom filter list URL text field.") + public static let filterListAddOnlyHTTPSAllowedError = NSLocalizedString("FilterListAddOnlyHTTPSAllowedError", tableName: "BraveShared", bundle: .module, value: "Only secure (https) URLs are allowed for custom filter lists", comment: "This is an error message when a user tries to enter a non-https scheme URL into the 'add custom filter list URL' input field") +} diff --git a/Sources/Data/models/CustomFilterListSetting.swift b/Sources/Data/models/CustomFilterListSetting.swift new file mode 100644 index 00000000000..a10a5e76f5b --- /dev/null +++ b/Sources/Data/models/CustomFilterListSetting.swift @@ -0,0 +1,75 @@ +// Copyright 2023 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import Foundation +import CoreData +import Shared +import os.log + +@MainActor public final class CustomFilterListSetting: NSManagedObject, CRUD, Identifiable { + @NSManaged public var uuid: String + @NSManaged public var isEnabled: Bool + @NSManaged public var externalURL: URL + + /// Load all the flter list settings + public class func loadAllSettings(fromMemory: Bool) -> [CustomFilterListSetting] { + return all(context: fromMemory ? DataController.viewContextInMemory : DataController.viewContext) ?? [] + } + + /// Create a filter list setting for the given UUID and enabled status + public class func create(externalURL: URL, isEnabled: Bool, inMemory: Bool) -> CustomFilterListSetting { + var newSetting: CustomFilterListSetting! + + // Settings are usually accesed on view context, but when the setting doesn't exist, + // we have to switch to a background context to avoid writing on view context(bad practice). + let writeContext = inMemory ? DataController.newBackgroundContextInMemory() : DataController.newBackgroundContext() + + save(on: writeContext) { + newSetting = CustomFilterListSetting(entity: CustomFilterListSetting.entity(writeContext), insertInto: writeContext) + newSetting.uuid = UUID().uuidString + newSetting.isEnabled = isEnabled + newSetting.externalURL = externalURL + } + + let viewContext = inMemory ? DataController.viewContextInMemory : DataController.viewContext + let settingOnCorrectContext = viewContext.object(with: newSetting.objectID) as? CustomFilterListSetting + return settingOnCorrectContext ?? newSetting + } + + public class func save(inMemory: Bool) { + self.save(on: inMemory ? DataController.viewContextInMemory : DataController.viewContext) + } + + public func delete(inMemory: Bool) { + let viewContext = inMemory ? DataController.viewContextInMemory : DataController.viewContext + + Self.save(on: viewContext) { + self.delete(context: .existing(viewContext)) + } + } + + /// Save this entry + private class func save( + on writeContext: NSManagedObjectContext, + changes: (() -> Void)? = nil + ) { + writeContext.performAndWait { + changes?() + + if writeContext.hasChanges { + do { + try writeContext.save() + } catch { + Logger.module.error("CustomFilterListSetting save error: \(error.localizedDescription)") + } + } + } + } + + // Currently required, because not `syncable` + private static func entity(_ context: NSManagedObjectContext) -> NSEntityDescription { + return NSEntityDescription.entity(forEntityName: "CustomFilterListSetting", in: context)! + } +} diff --git a/Sources/Data/models/FilterListSetting.swift b/Sources/Data/models/FilterListSetting.swift index 756ce0213c1..a2fb732e5c8 100644 --- a/Sources/Data/models/FilterListSetting.swift +++ b/Sources/Data/models/FilterListSetting.swift @@ -18,6 +18,7 @@ public final class FilterListSetting: NSManagedObject, CRUD { @MainActor @NSManaged public var uuid: String @MainActor @NSManaged public var componentId: String? @MainActor @NSManaged public var isEnabled: Bool + @MainActor @NSManaged public var order: NSNumber? @MainActor @NSManaged private var folderPath: String? @MainActor public var folderURL: URL? { @@ -36,7 +37,9 @@ public final class FilterListSetting: NSManagedObject, CRUD { } /// Create a filter list setting for the given UUID and enabled status - @MainActor public class func create(uuid: String, componentId: String?, isEnabled: Bool, inMemory: Bool) -> FilterListSetting { + @MainActor public class func create( + uuid: String, componentId: String?, isEnabled: Bool, order: Int, inMemory: Bool + ) -> FilterListSetting { var newSetting: FilterListSetting! // Settings are usually accesed on view context, but when the setting doesn't exist, @@ -48,6 +51,7 @@ public final class FilterListSetting: NSManagedObject, CRUD { newSetting.uuid = uuid newSetting.componentId = componentId newSetting.isEnabled = isEnabled + newSetting.order = NSNumber(value: order) } let viewContext = inMemory ? DataController.viewContextInMemory : DataController.viewContext @@ -77,6 +81,14 @@ public final class FilterListSetting: NSManagedObject, CRUD { } } + @MainActor public func delete(inMemory: Bool) { + let viewContext = inMemory ? DataController.viewContextInMemory : DataController.viewContext + + Self.save(on: viewContext) { + self.delete(context: .existing(viewContext)) + } + } + // Currently required, because not `syncable` @MainActor private static func entity(_ context: NSManagedObjectContext) -> NSEntityDescription { return NSEntityDescription.entity(forEntityName: "FilterListSetting", in: context)! diff --git a/Sources/Data/models/Model.xcdatamodeld/.xccurrentversion b/Sources/Data/models/Model.xcdatamodeld/.xccurrentversion index ce3dd0c014b..cb0d593a3d0 100644 --- a/Sources/Data/models/Model.xcdatamodeld/.xccurrentversion +++ b/Sources/Data/models/Model.xcdatamodeld/.xccurrentversion @@ -3,6 +3,6 @@ _XCCurrentVersionName - Model17.xcdatamodel + Model18.xcdatamodel diff --git a/Sources/Data/models/Model.xcdatamodeld/Model18.xcdatamodel/contents b/Sources/Data/models/Model.xcdatamodeld/Model18.xcdatamodel/contents new file mode 100644 index 00000000000..727eb569f4d --- /dev/null +++ b/Sources/Data/models/Model.xcdatamodeld/Model18.xcdatamodel/contents @@ -0,0 +1,201 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +