diff --git a/ora/App/OraRoot.swift b/ora/App/OraRoot.swift index 9a0e9f5c..8f8f7840 100644 --- a/ora/App/OraRoot.swift +++ b/ora/App/OraRoot.swift @@ -109,6 +109,13 @@ struct OraRoot: View { .enableInjection() .onAppear { downloadManager.toastManager = toastManager + Task { + let containerIDs = await MainActor.run { + (try? tabContext.fetch(FetchDescriptor()))?.map(\.id) ?? [] + } + await AdBlockService.shared.start(containerIDs: containerIDs) + } + // Dialog keyboard shortcuts (highest priority — checked first) keyModifierListener.registerKeyDownHandler { event in // Escape: dismiss top dialog @@ -271,6 +278,14 @@ struct OraRoot: View { } } + NotificationCenter.default + .addObserver(forName: .spacePrivacySettingsChanged, object: nil, queue: .main) { note in + Task { @MainActor in + guard let containerId = note.userInfo?["containerId"] as? UUID else { return } + tabManager.refreshPrivacySettings(for: containerId) + } + } + // Clear cache and reload NotificationCenter.default .addObserver(forName: .clearCacheAndReload, object: nil, queue: .main) { note in diff --git a/ora/Core/BrowserEngine/BrowserEngine.swift b/ora/Core/BrowserEngine/BrowserEngine.swift index fa7800cb..37c61ff6 100644 --- a/ora/Core/BrowserEngine/BrowserEngine.swift +++ b/ora/Core/BrowserEngine/BrowserEngine.swift @@ -11,8 +11,12 @@ struct BrowserPageConfiguration { let mediaPlaybackRequiresUserAction: Bool let scriptMessageNames: [String] let userScripts: [BrowserUserScript] + let privacySettings: SpacePrivacySettings - static func oraDefault(userScripts: [BrowserUserScript]) -> BrowserPageConfiguration { + static func oraDefault( + userScripts: [BrowserUserScript], + privacySettings: SpacePrivacySettings + ) -> BrowserPageConfiguration { BrowserPageConfiguration( userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0.1 Safari/605.1.15", allowsPictureInPicture: true, @@ -23,7 +27,8 @@ struct BrowserPageConfiguration { allowsBackForwardNavigationGestures: true, mediaPlaybackRequiresUserAction: false, scriptMessageNames: ["listener", "linkHover", "mediaEvent", "passwordManager"], - userScripts: userScripts + userScripts: userScripts, + privacySettings: privacySettings ) } } diff --git a/ora/Core/BrowserEngine/BrowserPage.swift b/ora/Core/BrowserEngine/BrowserPage.swift index 8e90201e..af2fe1d4 100644 --- a/ora/Core/BrowserEngine/BrowserPage.swift +++ b/ora/Core/BrowserEngine/BrowserPage.swift @@ -11,6 +11,9 @@ final class BrowserPage: NSObject, WKNavigationDelegate, WKUIDelegate, WKScriptM private(set) var lastCommittedURL: URL? private(set) var isDownloadNavigation = false private(set) var sslBypassedHosts: Set = [] + private var isReadyForNavigation = false + private var pendingLoadRequest: URLRequest? + private var pendingReload = false init( profile: BrowserEngineProfile, @@ -74,6 +77,14 @@ final class BrowserPage: NSObject, WKNavigationDelegate, WKUIDelegate, WKScriptM layer.isOpaque = true layer.drawsAsynchronously = true } + + BrowserPrivacyService.shared.prepareConfiguration( + webConfiguration, + spaceID: profile.identifier + ) { [weak self] in + self?.isReadyForNavigation = true + self?.flushPendingNavigationIfNeeded() + } } var contentView: NSView { @@ -109,10 +120,22 @@ final class BrowserPage: NSObject, WKNavigationDelegate, WKUIDelegate, WKScriptM } func load(_ request: URLRequest) { + guard isReadyForNavigation else { + pendingLoadRequest = request + pendingReload = false + return + } + webView.load(request) } func reload() { + guard isReadyForNavigation else { + pendingReload = true + pendingLoadRequest = nil + return + } + webView.reload() } @@ -164,6 +187,19 @@ final class BrowserPage: NSObject, WKNavigationDelegate, WKUIDelegate, WKScriptM sslBypassedHosts.insert(host) } + private func flushPendingNavigationIfNeeded() { + if let pendingLoadRequest { + self.pendingLoadRequest = nil + webView.load(pendingLoadRequest) + return + } + + if pendingReload { + pendingReload = false + webView.reload() + } + } + private func emitNavigationEvent( phase: BrowserNavigationPhase, url: URL?, diff --git a/ora/Core/Constants/AppEvents.swift b/ora/Core/Constants/AppEvents.swift index 67a51c9e..49077f2f 100644 --- a/ora/Core/Constants/AppEvents.swift +++ b/ora/Core/Constants/AppEvents.swift @@ -29,6 +29,7 @@ extension Notification.Name { // Cache and cookies static let clearCacheAndReload = Notification.Name("ClearCacheAndReload") static let clearCookiesAndReload = Notification.Name("ClearCookiesAndReload") + static let spacePrivacySettingsChanged = Notification.Name("SpacePrivacySettingsChanged") /// App lifecycle static let quitRequested = Notification.Name("QuitRequested") diff --git a/ora/Core/Utilities/SettingsStore.swift b/ora/Core/Utilities/SettingsStore.swift index 3831ac86..992f013b 100644 --- a/ora/Core/Utilities/SettingsStore.swift +++ b/ora/Core/Utilities/SettingsStore.swift @@ -147,6 +147,7 @@ class SettingsStore: ObservableObject { private let fingerprintingKey = "settings.tracking.blockFingerprinting" private let adBlockingKey = "settings.tracking.adBlocking" private let cookiesPolicyKey = "settings.cookies.policy" + private let adBlockFilterListsKey = "settings.adBlock.filterLists" private let sitePermissionsKey = "settings.permissions.sitePermissions" private let customSearchEnginesKey = "settings.customSearchEngines" private let globalDefaultSearchEngineKey = "settings.globalDefaultSearchEngine" @@ -176,6 +177,10 @@ class SettingsStore: ObservableObject { "settings.container.\(containerId.uuidString).autoClearTabsAfter" } + private func keyForPrivacySettings(for containerId: UUID) -> String { + "settings.container.\(containerId.uuidString).privacy" + } + @Published var autoUpdateEnabled: Bool { didSet { defaults.set(autoUpdateEnabled, forKey: autoUpdateKey) } } @@ -200,6 +205,10 @@ class SettingsStore: ObservableObject { didSet { saveCodable(sitePermissions, forKey: sitePermissionsKey) } } + @Published private(set) var adBlockFilterLists: [FilterListRecord] { + didSet { saveCodable(adBlockFilterLists, forKey: adBlockFilterListsKey) } + } + @Published var customSearchEngines: [CustomSearchEngine] { didSet { saveCodable(customSearchEngines, forKey: customSearchEnginesKey) } } @@ -258,7 +267,7 @@ class SettingsStore: ObservableObject { init() { autoUpdateEnabled = defaults.bool(forKey: autoUpdateKey) blockThirdPartyTrackers = defaults.bool(forKey: trackingThirdPartyKey) - blockFingerprinting = defaults.bool(forKey: fingerprintingKey) + blockFingerprinting = defaults.object(forKey: fingerprintingKey) as? Bool ?? true adBlocking = defaults.bool(forKey: adBlockingKey) if let raw = defaults.string(forKey: cookiesPolicyKey), let policy = CookiesPolicy(rawValue: raw) @@ -271,6 +280,10 @@ class SettingsStore: ObservableObject { sitePermissions = Self.loadCodable([String: SitePermissionSettings].self, key: sitePermissionsKey) ?? [:] + adBlockFilterLists = FilterListCatalogService.shared.normalizedRecords( + from: Self.loadCodable([FilterListRecord].self, key: adBlockFilterListsKey) ?? [] + ) + customSearchEngines = Self.loadCodable([CustomSearchEngine].self, key: customSearchEnginesKey) ?? [] @@ -358,10 +371,29 @@ class SettingsStore: ObservableObject { objectWillChange.send() } + func privacySettings(for containerId: UUID) -> SpacePrivacySettings { + Self.loadCodable(SpacePrivacySettings.self, key: keyForPrivacySettings(for: containerId)) + ?? legacyPrivacySettings + } + + func setPrivacySettings(_ value: SpacePrivacySettings, for containerId: UUID) { + saveCodable(value, forKey: keyForPrivacySettings(for: containerId)) + objectWillChange.send() + } + + func notifySpacePrivacySettingsChanged(for containerId: UUID) { + NotificationCenter.default.post( + name: .spacePrivacySettingsChanged, + object: nil, + userInfo: ["containerId": containerId] + ) + } + func removeContainerSettings(for containerId: UUID) { defaults.removeObject(forKey: keyForDefaultSearch(for: containerId)) defaults.removeObject(forKey: keyForDefaultAI(for: containerId)) defaults.removeObject(forKey: keyForAutoClear(for: containerId)) + defaults.removeObject(forKey: keyForPrivacySettings(for: containerId)) objectWillChange.send() } @@ -387,6 +419,32 @@ class SettingsStore: ObservableObject { customSearchEngines = engines } + // MARK: - Ad block filter catalog + + func adBlockFilterList(id: String) -> FilterListRecord? { + adBlockFilterLists.first { $0.id == id } + } + + func setAdBlockFilterLists(_ records: [FilterListRecord]) { + adBlockFilterLists = FilterListCatalogService.shared.normalizedRecords(from: records) + } + + func upsertAdBlockFilterList(_ record: FilterListRecord) { + var records = adBlockFilterLists + if let index = records.firstIndex(where: { $0.id == record.id }) { + records[index] = record + } else { + records.append(record) + } + adBlockFilterLists = FilterListCatalogService.shared.normalizedRecords(from: records) + } + + func removeAdBlockFilterList(id: String) { + adBlockFilterLists = FilterListCatalogService.shared.normalizedRecords( + from: adBlockFilterLists.filter { $0.id != id } + ) + } + func removeCustomSearchEngine(withId id: String) { customSearchEngines = customSearchEngines.filter { $0.id != id } } @@ -459,4 +517,13 @@ class SettingsStore: ObservableObject { abs(lhs - value) < abs(rhs - value) } ?? defaultSeconds } + + private var legacyPrivacySettings: SpacePrivacySettings { + SpacePrivacySettings( + blockThirdPartyTrackers: blockThirdPartyTrackers, + blockFingerprinting: blockFingerprinting, + adBlocking: adBlocking, + cookiesPolicy: cookiesPolicy + ) + } } diff --git a/ora/Features/Privacy/Models/AdBlockModels.swift b/ora/Features/Privacy/Models/AdBlockModels.swift new file mode 100644 index 00000000..5b7ccd72 --- /dev/null +++ b/ora/Features/Privacy/Models/AdBlockModels.swift @@ -0,0 +1,190 @@ +import Foundation + +enum AdBlockUpdateMode: String, CaseIterable, Codable, Identifiable { + case onLaunchDaily + case manualOnly + case aggressiveAuto + + var id: String { + rawValue + } + + var title: String { + switch self { + case .onLaunchDaily: + "On Launch + Daily" + case .manualOnly: + "Manual Refresh Only" + case .aggressiveAuto: + "Aggressive Auto-Refresh" + } + } + + var refreshInterval: TimeInterval? { + switch self { + case .onLaunchDaily: + 24 * 60 * 60 + case .manualOnly: + nil + case .aggressiveAuto: + 6 * 60 * 60 + } + } +} + +enum FilterListSourceKind: String, Codable { + case builtin + case custom +} + +enum FilterListStatus: String, Codable { + case idle + case updating + case ready + case failed +} + +struct FilterListCoverage: Codable, Equatable, Hashable { + var totalRuleCount: Int + var convertedRuleCount: Int + var skippedRuleCount: Int + var safariRuleCount: Int + var shardCount: Int +} + +struct FilterListRecord: Codable, Equatable, Hashable, Identifiable { + let id: String + var name: String + var summary: String + let sourceKind: FilterListSourceKind + let sourceURL: String + var isRecommended: Bool + var enabledByDefault: Bool + var status: FilterListStatus + var lastErrorMessage: String? + var lastFetchAt: Date? + var lastSuccessfulRefreshAt: Date? + var etag: String? + var lastModified: String? + var activeRevision: String? + var coverage: FilterListCoverage? + + init( + id: String, + name: String, + summary: String, + sourceKind: FilterListSourceKind, + sourceURL: String, + isRecommended: Bool, + enabledByDefault: Bool, + status: FilterListStatus = .idle, + lastErrorMessage: String? = nil, + lastFetchAt: Date? = nil, + lastSuccessfulRefreshAt: Date? = nil, + etag: String? = nil, + lastModified: String? = nil, + activeRevision: String? = nil, + coverage: FilterListCoverage? = nil + ) { + self.id = id + self.name = name + self.summary = summary + self.sourceKind = sourceKind + self.sourceURL = sourceURL + self.isRecommended = isRecommended + self.enabledByDefault = enabledByDefault + self.status = status + self.lastErrorMessage = lastErrorMessage + self.lastFetchAt = lastFetchAt + self.lastSuccessfulRefreshAt = lastSuccessfulRefreshAt + self.etag = etag + self.lastModified = lastModified + self.activeRevision = activeRevision + self.coverage = coverage + } + + var isBuiltin: Bool { + sourceKind == .builtin + } +} + +struct SpaceAdBlockSettings: Codable, Equatable, Hashable { + var enabled: Bool + var enabledBuiltinListIDs: [String] + var enabledCustomListIDs: [String] + var updateMode: AdBlockUpdateMode + + init( + enabled: Bool = false, + enabledBuiltinListIDs: [String] = FilterListCatalogService.defaultBuiltinSelectionIDs, + enabledCustomListIDs: [String] = [], + updateMode: AdBlockUpdateMode = .onLaunchDaily + ) { + self.enabled = enabled + self.enabledBuiltinListIDs = Self.normalized(enabledBuiltinListIDs) + self.enabledCustomListIDs = Self.normalized(enabledCustomListIDs) + self.updateMode = updateMode + } + + var enabledListIDs: [String] { + enabledBuiltinListIDs + enabledCustomListIDs + } + + func isEnabled(_ record: FilterListRecord) -> Bool { + switch record.sourceKind { + case .builtin: + enabledBuiltinListIDs.contains(record.id) + case .custom: + enabledCustomListIDs.contains(record.id) + } + } + + mutating func setEnabled(_ isEnabled: Bool, for record: FilterListRecord) { + switch record.sourceKind { + case .builtin: + if isEnabled { + enabledBuiltinListIDs = Self.normalized(enabledBuiltinListIDs + [record.id]) + } else { + enabledBuiltinListIDs.removeAll { $0 == record.id } + } + case .custom: + if isEnabled { + enabledCustomListIDs = Self.normalized(enabledCustomListIDs + [record.id]) + } else { + enabledCustomListIDs.removeAll { $0 == record.id } + } + } + } + + mutating func removeCustomList(id: String) { + enabledCustomListIDs.removeAll { $0 == id } + } + + private static func normalized(_ values: [String]) -> [String] { + var seen = Set() + return values.filter { seen.insert($0).inserted } + } +} + +enum AdBlockServiceError: LocalizedError { + case invalidCustomListURL + case invalidFilterResponse + case downloadFailed(statusCode: Int) + case missingCachedList(String) + case emptyFilterList(String) + + var errorDescription: String? { + switch self { + case .invalidCustomListURL: + "Only http and https filter list URLs are supported." + case .invalidFilterResponse: + "The filter list response could not be read." + case let .downloadFailed(statusCode): + "The filter list download failed with status \(statusCode)." + case let .missingCachedList(name): + "No cached filter rules are available for \(name)." + case let .emptyFilterList(name): + "No WebKit-compatible rules were produced for \(name)." + } + } +} diff --git a/ora/Features/Privacy/Models/SpacePrivacySettings.swift b/ora/Features/Privacy/Models/SpacePrivacySettings.swift new file mode 100644 index 00000000..fd5a522b --- /dev/null +++ b/ora/Features/Privacy/Models/SpacePrivacySettings.swift @@ -0,0 +1,56 @@ +import Foundation + +struct SpacePrivacySettings: Codable, Equatable, Hashable { + var blockThirdPartyTrackers: Bool + var blockFingerprinting: Bool + var adBlock: SpaceAdBlockSettings + var cookiesPolicy: CookiesPolicy + + init( + blockThirdPartyTrackers: Bool = false, + blockFingerprinting: Bool = true, + adBlocking: Bool = false, + adBlock: SpaceAdBlockSettings? = nil, + cookiesPolicy: CookiesPolicy = .allowAll + ) { + self.blockThirdPartyTrackers = blockThirdPartyTrackers + self.blockFingerprinting = blockFingerprinting + self.adBlock = adBlock ?? SpaceAdBlockSettings(enabled: adBlocking) + self.cookiesPolicy = cookiesPolicy + } + + var adBlocking: Bool { + get { adBlock.enabled } + set { adBlock.enabled = newValue } + } + + enum CodingKeys: String, CodingKey { + case blockThirdPartyTrackers + case blockFingerprinting + case adBlocking + case adBlock + case cookiesPolicy + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + blockThirdPartyTrackers = try container.decodeIfPresent(Bool.self, forKey: .blockThirdPartyTrackers) ?? false + blockFingerprinting = try container.decodeIfPresent(Bool.self, forKey: .blockFingerprinting) ?? true + cookiesPolicy = try container.decodeIfPresent(CookiesPolicy.self, forKey: .cookiesPolicy) ?? .allowAll + + if let nestedAdBlock = try container.decodeIfPresent(SpaceAdBlockSettings.self, forKey: .adBlock) { + adBlock = nestedAdBlock + } else { + let legacyAdBlocking = try container.decodeIfPresent(Bool.self, forKey: .adBlocking) ?? false + adBlock = SpaceAdBlockSettings(enabled: legacyAdBlocking) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(blockThirdPartyTrackers, forKey: .blockThirdPartyTrackers) + try container.encode(blockFingerprinting, forKey: .blockFingerprinting) + try container.encode(adBlock, forKey: .adBlock) + try container.encode(cookiesPolicy, forKey: .cookiesPolicy) + } +} diff --git a/ora/Features/Privacy/Services/AdBlockService.swift b/ora/Features/Privacy/Services/AdBlockService.swift new file mode 100644 index 00000000..29435e06 --- /dev/null +++ b/ora/Features/Privacy/Services/AdBlockService.swift @@ -0,0 +1,395 @@ +import Foundation + +actor AdBlockService { + enum RefreshReason { + case startup + case scheduled + case manual + case settingsChanged + } + + static let shared = AdBlockService() + + private let catalogService: FilterListCatalogService + private let updateService: FilterListUpdateService + private let compileService: ContentBlockerCompileService + private let artifactStore: ContentBlockerArtifactStore + private var knownContainerIDs: Set = [] + private var schedulerTask: Task? + + init( + catalogService: FilterListCatalogService = .shared, + updateService: FilterListUpdateService = FilterListUpdateService(), + compileService: ContentBlockerCompileService = ContentBlockerCompileService(), + artifactStore: ContentBlockerArtifactStore = .shared + ) { + self.catalogService = catalogService + self.updateService = updateService + self.compileService = compileService + self.artifactStore = artifactStore + } + + func start(containerIDs: [UUID]) async { + knownContainerIDs.formUnion(containerIDs) + await MainActor.run { + SettingsStore.shared.setAdBlockFilterLists(SettingsStore.shared.adBlockFilterLists) + } + + for containerID in knownContainerIDs where await shouldRefreshOnLaunch(containerId: containerID) { + _ = await refreshSpace(containerId: containerID, reason: .startup) + } + + scheduleBackgroundRefresh() + } + + func registerSpace(containerId: UUID) { + knownContainerIDs.insert(containerId) + scheduleBackgroundRefresh() + } + + func spaceSettingsDidChange(containerId: UUID) { + knownContainerIDs.insert(containerId) + scheduleBackgroundRefresh() + } + + func addCustomList(sourceURL: String, suggestedName: String? = nil) async throws -> FilterListRecord { + guard let normalizedURL = updateService.normalizedURL(from: sourceURL) else { + throw AdBlockServiceError.invalidCustomListURL + } + + let existingRecord = await MainActor.run { () -> FilterListRecord? in + SettingsStore.shared.adBlockFilterLists.first { + $0.sourceKind == .custom && + $0.sourceURL.caseInsensitiveCompare(normalizedURL.absoluteString) == .orderedSame + } + } + + if let existingRecord { + return existingRecord + } + + let defaultName = suggestedName?.trimmingCharacters(in: .whitespacesAndNewlines) + let record = FilterListRecord( + id: "custom-\(UUID().uuidString.lowercased())", + name: (defaultName?.isEmpty == false ? defaultName : normalizedURL.host) ?? "Custom Filter List", + summary: "Custom remotely hosted filter list.", + sourceKind: .custom, + sourceURL: normalizedURL.absoluteString, + isRecommended: false, + enabledByDefault: false, + status: .idle + ) + + await MainActor.run { + SettingsStore.shared.upsertAdBlockFilterList(record) + } + + _ = await refreshRecord(withID: record.id, allowNetworkFetch: true) + + return await MainActor.run { + SettingsStore.shared.adBlockFilterList(id: record.id) ?? record + } + } + + func renameCustomList(id: String, name: String) async { + let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedName.isEmpty else { return } + + await MainActor.run { + guard var record = SettingsStore.shared.adBlockFilterList(id: id), + record.sourceKind == .custom + else { + return + } + + record.name = trimmedName + SettingsStore.shared.upsertAdBlockFilterList(record) + } + } + + func removeCustomList(id: String) async { + let containerIDs = Array(knownContainerIDs) + + let affectedContainers = await MainActor.run { () -> [UUID] in + let store = SettingsStore.shared + store.removeAdBlockFilterList(id: id) + var affected: [UUID] = [] + + for containerID in containerIDs { + var settings = store.privacySettings(for: containerID) + guard settings.adBlock.enabledCustomListIDs.contains(id) else { continue } + settings.adBlock.removeCustomList(id: id) + store.setPrivacySettings(settings, for: containerID) + affected.append(containerID) + } + return affected + } + + artifactStore.removeArtifacts(for: id) + scheduleBackgroundRefresh() + + for containerID in affectedContainers { + await postPrivacyRefresh(for: containerID) + } + } + + @discardableResult + func refreshSpace(containerId: UUID, reason: RefreshReason) async -> Bool { + knownContainerIDs.insert(containerId) + + let privacySettings = await MainActor.run { + SettingsStore.shared.privacySettings(for: containerId) + } + + guard privacySettings.adBlock.enabled else { + scheduleBackgroundRefresh() + if reason == .settingsChanged { + await postPrivacyRefresh(for: containerId) + } + return false + } + + let enabledListIDs = Set(privacySettings.adBlock.enabledListIDs) + guard !enabledListIDs.isEmpty else { + scheduleBackgroundRefresh() + if reason == .settingsChanged { + await postPrivacyRefresh(for: containerId) + } + return false + } + + let records = await MainActor.run { + SettingsStore.shared.adBlockFilterLists + } + + let selectedRecords = records.filter { enabledListIDs.contains($0.id) } + var changedListIDs: Set = [] + + for record in selectedRecords { + if await refreshRecord( + withID: record.id, + allowNetworkFetch: reason != .settingsChanged || record.activeRevision == nil + ) { + changedListIDs.insert(record.id) + } + } + + scheduleBackgroundRefresh() + + var containerIDsToRefresh = await affectedContainerIDs(forChangedListIDs: changedListIDs) + if reason == .settingsChanged { + containerIDsToRefresh.insert(containerId) + } + + for affectedContainerID in containerIDsToRefresh { + await postPrivacyRefresh(for: affectedContainerID) + } + + return !changedListIDs.isEmpty + } + + static func failedRecord(_ record: FilterListRecord, error: Error) -> FilterListRecord { + var failed = record + failed.status = .failed + failed.lastErrorMessage = error.localizedDescription + return failed + } + + private func refreshRecord(withID recordID: String, allowNetworkFetch: Bool) async -> Bool { + guard let record = await MainActor.run(body: { + SettingsStore.shared.adBlockFilterList(id: recordID) + }) else { + return false + } + + await updateRecord(record) { draft in + draft.status = .updating + draft.lastErrorMessage = nil + } + + do { + let fetchResult = try await resolveFilterSource(for: record, allowNetworkFetch: allowNetworkFetch) + let rawText = try rawText(for: fetchResult.record, fetchedRawText: fetchResult.rawText) + let revision = artifactStore.revisionHash(for: rawText) + let hadActiveRevision = record.activeRevision + + let coverage: FilterListCoverage + if artifactStore.hasCompiledArtifacts(for: record.id, revision: revision), + let cachedCoverage = artifactStore.coverage(for: record.id, revision: revision) + { + coverage = cachedCoverage + } else { + let compiled = try compileService.compile(record: fetchResult.record, rawText: rawText) + try artifactStore.storeRawListText(rawText, for: record.id) + try artifactStore.storeCompiledArtifacts( + jsonShards: compiled.jsonShards, + coverage: compiled.coverage, + for: record.id, + revision: compiled.revision + ) + coverage = compiled.coverage + } + + await MainActor.run { + var updatedRecord = fetchResult.record + updatedRecord.status = .ready + updatedRecord.lastErrorMessage = nil + updatedRecord.coverage = coverage + updatedRecord.activeRevision = revision + updatedRecord.lastSuccessfulRefreshAt = Date() + SettingsStore.shared.upsertAdBlockFilterList(updatedRecord) + } + + return hadActiveRevision != revision + } catch { + await MainActor.run { + if let current = SettingsStore.shared.adBlockFilterList(id: record.id) { + SettingsStore.shared.upsertAdBlockFilterList(Self.failedRecord(current, error: error)) + } + } + return false + } + } + + private func resolveFilterSource( + for record: FilterListRecord, + allowNetworkFetch: Bool + ) async throws -> FilterListFetchResult { + guard allowNetworkFetch || artifactStore.rawListText(for: record.id) != nil else { + throw AdBlockServiceError.missingCachedList(record.name) + } + + if allowNetworkFetch { + do { + return try await updateService.fetchLatest(for: record) + } catch { + guard artifactStore.rawListText(for: record.id) != nil else { throw error } + var fallbackRecord = record + fallbackRecord.lastErrorMessage = error.localizedDescription + return FilterListFetchResult(record: fallbackRecord, rawText: nil) + } + } + + return FilterListFetchResult(record: record, rawText: nil) + } + + private func rawText(for record: FilterListRecord, fetchedRawText: String?) throws -> String { + if let fetchedRawText { + return fetchedRawText + } + if let cached = artifactStore.rawListText(for: record.id) { + return cached + } + throw AdBlockServiceError.missingCachedList(record.name) + } + + private func affectedContainerIDs(forChangedListIDs changedListIDs: Set) async -> Set { + guard !changedListIDs.isEmpty else { return [] } + let containerIDs = Array(knownContainerIDs) + + return await MainActor.run { + let store = SettingsStore.shared + + return Set(containerIDs.filter { containerID in + let settings = store.privacySettings(for: containerID) + guard settings.adBlock.enabled else { return false } + return !changedListIDs.isDisjoint(with: settings.adBlock.enabledListIDs) + }) + } + } + + private func shouldRefreshOnLaunch(containerId: UUID) async -> Bool { + await MainActor.run { + let store = SettingsStore.shared + let settings = store.privacySettings(for: containerId) + guard settings.adBlock.enabled, + settings.adBlock.updateMode != .manualOnly + else { + return false + } + + let enabledRecords = store.adBlockFilterLists.filter { + settings.adBlock.enabledListIDs.contains($0.id) + } + + guard !enabledRecords.isEmpty else { return false } + + let cutoff = Date().addingTimeInterval(-(settings.adBlock.updateMode.refreshInterval ?? 0)) + return enabledRecords.contains { + $0.activeRevision == nil || ($0.lastSuccessfulRefreshAt ?? .distantPast) < cutoff + } + } + } + + private func scheduleBackgroundRefresh() { + schedulerTask?.cancel() + + schedulerTask = Task { [weak self] in + guard let self else { return } + guard let nextWakeInterval = await self.minimumAutoRefreshInterval() else { return } + + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(nextWakeInterval)) + guard !Task.isCancelled else { break } + await self.refreshScheduledSpaces() + } + } + } + + private func minimumAutoRefreshInterval() async -> TimeInterval? { + let containerIDs = Array(knownContainerIDs) + return await MainActor.run { + let store = SettingsStore.shared + + return containerIDs.compactMap { containerID -> TimeInterval? in + let settings = store.privacySettings(for: containerID) + guard settings.adBlock.enabled else { return nil } + return settings.adBlock.updateMode.refreshInterval + }.min() + } + } + + private func refreshScheduledSpaces() async { + let knownIDs = Array(knownContainerIDs) + let containerIDs = await MainActor.run { () -> [UUID] in + let store = SettingsStore.shared + return knownIDs.filter { containerID in + let settings = store.privacySettings(for: containerID) + guard settings.adBlock.enabled, + let refreshInterval = settings.adBlock.updateMode.refreshInterval + else { + return false + } + + let enabledRecords = store.adBlockFilterLists.filter { + settings.adBlock.enabledListIDs.contains($0.id) + } + + guard !enabledRecords.isEmpty else { return false } + + let cutoff = Date().addingTimeInterval(-refreshInterval) + return enabledRecords.contains { + $0.activeRevision == nil || ($0.lastSuccessfulRefreshAt ?? .distantPast) < cutoff + } + } + } + + for containerID in containerIDs { + _ = await refreshSpace(containerId: containerID, reason: .scheduled) + } + } + + private func updateRecord(_ record: FilterListRecord, mutate: @escaping (inout FilterListRecord) -> Void) async { + await MainActor.run { + guard var storedRecord = SettingsStore.shared.adBlockFilterList(id: record.id) else { return } + mutate(&storedRecord) + SettingsStore.shared.upsertAdBlockFilterList(storedRecord) + } + } + + private func postPrivacyRefresh(for containerId: UUID) async { + await MainActor.run { + SettingsStore.shared.notifySpacePrivacySettingsChanged(for: containerId) + } + } +} diff --git a/ora/Features/Privacy/Services/BrowserPrivacyService.swift b/ora/Features/Privacy/Services/BrowserPrivacyService.swift new file mode 100644 index 00000000..ddaf67cd --- /dev/null +++ b/ora/Features/Privacy/Services/BrowserPrivacyService.swift @@ -0,0 +1,625 @@ +import Foundation +@preconcurrency import WebKit + +// swiftlint:disable type_body_length function_body_length +struct FingerprintingProtectionProfile: Equatable { + let hardwareConcurrency: Int + let deviceMemory: Int + let maxTouchPoints: Int + let webdriver: Bool + let platform: String + let vendor: String + let language: String + let languages: [String] + let screenWidth: Int + let screenHeight: Int + let availWidth: Int + let availHeight: Int + let colorDepth: Int + let pixelDepth: Int + let devicePixelRatio: Double + let webGLVendor: String + let webGLRenderer: String + let webGLRendererName: String + let webGLVersion: String + let webGLShadingLanguageVersion: String + let canvasShift: Int + let audioNoise: Double + let audioBucketSize: Int + let mediaDeviceKinds: [String] + + static let balanced = FingerprintingProtectionProfile( + hardwareConcurrency: 8, + deviceMemory: 8, + maxTouchPoints: 0, + webdriver: false, + platform: "MacIntel", + vendor: "Apple Computer, Inc.", + language: "en-US", + languages: ["en-US", "en"], + screenWidth: 1440, + screenHeight: 900, + availWidth: 1440, + availHeight: 877, + colorDepth: 24, + pixelDepth: 24, + devicePixelRatio: 2, + webGLVendor: "Apple Inc.", + webGLRenderer: "Apple GPU", + webGLRendererName: "WebKit WebGL", + webGLVersion: "WebGL 1.0", + webGLShadingLanguageVersion: "WebGL GLSL ES 1.0", + canvasShift: 1, + audioNoise: 0.000_015_258_789_062_5, + audioBucketSize: 64, + mediaDeviceKinds: ["audioinput", "audiooutput", "videoinput"] + ) + + func scriptSource() -> String { + let profileObject: [String: Any] = [ + "audioBucketSize": audioBucketSize, + "audioNoise": audioNoise, + "availHeight": availHeight, + "availWidth": availWidth, + "canvasShift": canvasShift, + "colorDepth": colorDepth, + "deviceMemory": deviceMemory, + "devicePixelRatio": devicePixelRatio, + "hardwareConcurrency": hardwareConcurrency, + "language": language, + "languages": languages, + "maxTouchPoints": maxTouchPoints, + "mediaDeviceKinds": mediaDeviceKinds, + "pixelDepth": pixelDepth, + "platform": platform, + "screenHeight": screenHeight, + "screenWidth": screenWidth, + "vendor": vendor, + "webdriver": webdriver, + "webGLRenderer": webGLRenderer, + "webGLRendererName": webGLRendererName, + "webGLShadingLanguageVersion": webGLShadingLanguageVersion, + "webGLVendor": webGLVendor, + "webGLVersion": webGLVersion + ] + + guard JSONSerialization.isValidJSONObject(profileObject), + let data = try? JSONSerialization.data(withJSONObject: profileObject, options: [.sortedKeys]), + let profileJSON = String(data: data, encoding: .utf8) + else { + return "" + } + + return """ + (function () { + if (window.__oraFingerprintingProtectionInstalled) { + return; + } + window.__oraFingerprintingProtectionInstalled = true; + + const profile = \(profileJSON); + + function defineGetter(target, property, getter) { + if (!target) return; + try { + Object.defineProperty(target, property, { + configurable: true, + enumerable: false, + get: getter + }); + } catch (error) {} + } + + function defineValue(target, property, value) { + defineGetter(target, property, function () { return value; }); + } + + function wrapMethod(target, property, wrapper) { + if (!target || typeof target[property] !== 'function') return; + const original = target[property]; + try { + Object.defineProperty(target, property, { + configurable: true, + value: wrapper(original) + }); + } catch (error) {} + } + + function cloneLanguages() { + return profile.languages.slice(); + } + + function makeDevice(kind, index) { + const suffix = String(index + 1); + const device = { + deviceId: 'ora-' + kind + '-' + suffix, + groupId: 'ora-group-' + kind, + kind: kind, + label: '', + toJSON: function () { + return { + deviceId: this.deviceId, + groupId: this.groupId, + kind: this.kind, + label: this.label + }; + } + }; + + return Object.freeze(device); + } + + function normalizeMediaDevices(devices) { + const hasLabels = Array.isArray(devices) && devices.some(function (device) { + return !!(device && device.label); + }); + if (hasLabels) { + return devices; + } + + return profile.mediaDeviceKinds.map(function (kind, index) { + return makeDevice(kind, index); + }); + } + + function shouldSanitizeCanvas(canvas) { + if (!canvas) return false; + const width = canvas.width || 0; + const height = canvas.height || 0; + const area = width * height; + return width > 0 && height > 0 && area <= 262144; + } + + function mutatePixelData(data, step) { + if (!data || !data.length) return; + const stride = Math.max(8, step * 16); + for (let index = 0; index < data.length; index += stride) { + data[index] = (data[index] + profile.canvasShift) & 255; + if (index + 1 < data.length) { + data[index + 1] = (data[index + 1] + profile.canvasShift) & 255; + } + } + } + + function cloneAndSanitizeCanvas(canvas) { + if (!shouldSanitizeCanvas(canvas)) return canvas; + + try { + const clone = document.createElement('canvas'); + clone.width = canvas.width; + clone.height = canvas.height; + const context = clone.getContext('2d'); + if (!context) return canvas; + + context.drawImage(canvas, 0, 0); + const imageData = context.getImageData(0, 0, clone.width, clone.height); + mutatePixelData(imageData.data, Math.max(1, clone.width % 13)); + context.putImageData(imageData, 0, 0); + return clone; + } catch (error) { + return canvas; + } + } + + function sanitizeImageData(imageData, widthHint) { + if (!imageData || !imageData.data) return imageData; + mutatePixelData(imageData.data, Math.max(1, widthHint % 11)); + return imageData; + } + + function sanitizeAudioBuffer(buffer) { + if (!buffer || typeof buffer.numberOfChannels !== 'number') return buffer; + + for (let channel = 0; channel < buffer.numberOfChannels; channel += 1) { + let samples; + try { + samples = buffer.getChannelData(channel); + } catch (error) { + continue; + } + + const stride = Math.max(profile.audioBucketSize, 32); + for (let index = 0; index < samples.length; index += stride) { + const sample = samples[index] || 0; + samples[index] = Math.fround(sample + profile.audioNoise); + } + } + + return buffer; + } + + function sanitizeArrayValues(values, mode) { + if (!values || typeof values.length !== 'number') return values; + const stride = Math.max(profile.audioBucketSize, 16); + for (let index = 0; index < values.length; index += stride) { + if (mode === 'float') { + values[index] = Math.fround((values[index] || 0) + profile.audioNoise); + } else { + values[index] = Math.max(0, Math.min(255, (values[index] || 0) + 1)); + } + } + return values; + } + + defineValue(Navigator.prototype, 'hardwareConcurrency', profile.hardwareConcurrency); + defineValue(Navigator.prototype, 'deviceMemory', profile.deviceMemory); + defineValue(Navigator.prototype, 'maxTouchPoints', profile.maxTouchPoints); + defineValue(Navigator.prototype, 'webdriver', profile.webdriver); + defineValue(Navigator.prototype, 'platform', profile.platform); + defineValue(Navigator.prototype, 'vendor', profile.vendor); + defineValue(Navigator.prototype, 'language', profile.language); + defineGetter(Navigator.prototype, 'languages', cloneLanguages); + + defineValue(Screen.prototype, 'width', profile.screenWidth); + defineValue(Screen.prototype, 'height', profile.screenHeight); + defineValue(Screen.prototype, 'availWidth', profile.availWidth); + defineValue(Screen.prototype, 'availHeight', profile.availHeight); + defineValue(Screen.prototype, 'colorDepth', profile.colorDepth); + defineValue(Screen.prototype, 'pixelDepth', profile.pixelDepth); + defineValue(window, 'devicePixelRatio', profile.devicePixelRatio); + + if (navigator.permissions && typeof navigator.permissions.query === 'function') { + const originalQuery = navigator.permissions.query.bind(navigator.permissions); + navigator.permissions.query = function (parameters) { + const permissionName = parameters && parameters.name; + if (permissionName === 'camera' || + permissionName === 'microphone' || + permissionName === 'geolocation' || + permissionName === 'notifications') { + return Promise.resolve({ state: 'prompt', onchange: null }); + } + return originalQuery(parameters); + }; + } + + if (navigator.mediaDevices && typeof navigator.mediaDevices.enumerateDevices === 'function') { + const originalEnumerateDevices = navigator.mediaDevices.enumerateDevices.bind(navigator.mediaDevices); + navigator.mediaDevices.enumerateDevices = function () { + return originalEnumerateDevices().then(normalizeMediaDevices); + }; + } + + wrapMethod(HTMLCanvasElement.prototype, 'toDataURL', function (original) { + return function () { + const sanitizedCanvas = cloneAndSanitizeCanvas(this); + return original.apply(sanitizedCanvas, arguments); + }; + }); + + wrapMethod(HTMLCanvasElement.prototype, 'toBlob', function (original) { + return function () { + const sanitizedCanvas = cloneAndSanitizeCanvas(this); + return original.apply(sanitizedCanvas, arguments); + }; + }); + + wrapMethod(CanvasRenderingContext2D && CanvasRenderingContext2D.prototype, 'getImageData', function (original) { + return function () { + const result = original.apply(this, arguments); + if (!this || !this.canvas || !shouldSanitizeCanvas(this.canvas)) { + return result; + } + return sanitizeImageData(result, this.canvas.width || 1); + }; + }); + + function wrapWebGLContext(contextType) { + if (!contextType || !contextType.prototype) return; + + wrapMethod(contextType.prototype, 'getParameter', function (original) { + return function (parameter) { + if (parameter === 37445) return profile.webGLVendor; + if (parameter === 37446) return profile.webGLRenderer; + if (parameter === 7936) return profile.webGLVendor; + if (parameter === 7937) return profile.webGLRendererName; + if (parameter === 7938) return profile.webGLVersion; + if (parameter === 35724) return profile.webGLShadingLanguageVersion; + return original.apply(this, arguments); + }; + }); + } + + wrapWebGLContext(window.WebGLRenderingContext); + wrapWebGLContext(window.WebGL2RenderingContext); + + if (window.OfflineAudioContext && window.OfflineAudioContext.prototype) { + wrapMethod(window.OfflineAudioContext.prototype, 'startRendering', function (original) { + return function () { + const rendering = original.apply(this, arguments); + return Promise.resolve(rendering).then(function (buffer) { + return sanitizeAudioBuffer(buffer); + }); + }; + }); + } + + wrapMethod(window.AnalyserNode && window.AnalyserNode.prototype, 'getFloatFrequencyData', function (original) { + return function (array) { + const response = original.apply(this, arguments); + sanitizeArrayValues(array, 'float'); + return response; + }; + }); + + wrapMethod(window.AnalyserNode && window.AnalyserNode.prototype, 'getFloatTimeDomainData', function (original) { + return function (array) { + const response = original.apply(this, arguments); + sanitizeArrayValues(array, 'float'); + return response; + }; + }); + + wrapMethod(window.AnalyserNode && window.AnalyserNode.prototype, 'getByteFrequencyData', function (original) { + return function (array) { + const response = original.apply(this, arguments); + sanitizeArrayValues(array, 'byte'); + return response; + }; + }); + + wrapMethod(window.AnalyserNode && window.AnalyserNode.prototype, 'getByteTimeDomainData', function (original) { + return function (array) { + const response = original.apply(this, arguments); + sanitizeArrayValues(array, 'byte'); + return response; + }; + }); + })(); + """ + } +} + +// swiftlint:enable type_body_length function_body_length + +final class BrowserPrivacyService { + private enum StaticRuleListIdentifier: String { + case trackers = "com.orabrowser.privacy.trackers.v1" + case thirdPartyCookies = "com.orabrowser.privacy.cookies.third-party.v1" + case allCookies = "com.orabrowser.privacy.cookies.all.v1" + } + + static let shared = BrowserPrivacyService() + + private let ruleListStore = WKContentRuleListStore.default()! + private let cacheLock = NSLock() + private let artifactStore = ContentBlockerArtifactStore.shared + private var cachedRuleLists: [String: WKContentRuleList] = [:] + private var pendingRuleListCallbacks: [String: [(WKContentRuleList?) -> Void]] = [:] + + func activeRuleListIdentifiers(for spaceID: UUID) -> [String] { + let privacySettings = SettingsStore.shared.privacySettings(for: spaceID) + guard privacySettings.adBlock.enabled else { return [] } + + return SettingsStore.shared.adBlockFilterLists + .filter { privacySettings.adBlock.enabledListIDs.contains($0.id) } + .flatMap { record -> [String] in + guard let revision = record.activeRevision else { return [] } + return artifactStore.ruleListIdentifiers(for: record.id, revision: revision) + } + } + + func prepareConfiguration( + _ configuration: WKWebViewConfiguration, + spaceID: UUID, + completion: @escaping () -> Void + ) { + let privacySettings = SettingsStore.shared.privacySettings(for: spaceID) + let enabledRuleLists = enabledRuleLists(for: spaceID, privacySettings: privacySettings) + let group = DispatchGroup() + + for identifier in enabledRuleLists { + group.enter() + contentRuleList(for: identifier) { ruleList in + DispatchQueue.main.async { + if let ruleList { + configuration.userContentController.add(ruleList) + } + group.leave() + } + } + } + + group.enter() + applyCookiePolicy(privacySettings.cookiesPolicy, to: configuration.websiteDataStore) { + group.leave() + } + + group.notify(queue: .main, execute: completion) + } + + static func privacyScripts(for privacySettings: SpacePrivacySettings) -> [BrowserUserScript] { + guard privacySettings.blockFingerprinting else { return [] } + + return [ + BrowserUserScript( + name: "ora-fingerprinting-protection", + source: fingerprintingProtectionScriptSource(), + injectionTime: .atDocumentStart, + forMainFrameOnly: false + ) + ] + } + + static func fingerprintingProtectionScriptSource() -> String { + FingerprintingProtectionProfile.balanced.scriptSource() + } + + private func enabledRuleLists(for spaceID: UUID, privacySettings: SpacePrivacySettings) -> [String] { + var identifiers: [String] = [] + + if privacySettings.blockThirdPartyTrackers { + identifiers.append(StaticRuleListIdentifier.trackers.rawValue) + } + + switch privacySettings.cookiesPolicy { + case .allowAll: + break + case .blockThirdParty: + identifiers.append(StaticRuleListIdentifier.thirdPartyCookies.rawValue) + case .blockAll: + identifiers.append(StaticRuleListIdentifier.allCookies.rawValue) + } + + return identifiers + activeRuleListIdentifiers(for: spaceID) + } + + private func applyCookiePolicy( + _ policy: CookiesPolicy, + to dataStore: WKWebsiteDataStore, + completion: @escaping () -> Void + ) { + guard #available(macOS 14.0, *) else { + completion() + return + } + + let cookiePolicy: WKHTTPCookieStore.CookiePolicy = switch policy { + case .blockAll: + .disallow + case .allowAll, .blockThirdParty: + .allow + } + + dataStore.httpCookieStore.setCookiePolicy(cookiePolicy, completionHandler: completion) + } + + private func contentRuleList( + for identifier: String, + completion: @escaping (WKContentRuleList?) -> Void + ) { + cacheLock.lock() + if let cachedRuleList = cachedRuleLists[identifier] { + cacheLock.unlock() + completion(cachedRuleList) + return + } + + if pendingRuleListCallbacks[identifier] != nil { + pendingRuleListCallbacks[identifier, default: []].append(completion) + cacheLock.unlock() + return + } + + pendingRuleListCallbacks[identifier] = [completion] + cacheLock.unlock() + + ruleListStore.lookUpContentRuleList(forIdentifier: identifier) { [weak self] ruleList, _ in + guard let self else { return } + + if let ruleList { + self.finishLoadingRuleList(identifier, ruleList: ruleList) + return + } + + guard let encodedRuleList = Self.encodedRuleList(for: identifier, artifactStore: self.artifactStore) else { + self.finishLoadingRuleList(identifier, ruleList: nil) + return + } + + self.ruleListStore.compileContentRuleList( + forIdentifier: identifier, + encodedContentRuleList: encodedRuleList + ) { [weak self] compiledRuleList, error in + if let error { + print("Failed to compile privacy rule list \(identifier): \(error.localizedDescription)") + } + self?.finishLoadingRuleList(identifier, ruleList: compiledRuleList) + } + } + } + + private func finishLoadingRuleList(_ identifier: String, ruleList: WKContentRuleList?) { + cacheLock.lock() + if let ruleList { + cachedRuleLists[identifier] = ruleList + } + let callbacks = pendingRuleListCallbacks.removeValue(forKey: identifier) ?? [] + cacheLock.unlock() + + callbacks.forEach { $0(ruleList) } + } + + private static func encodedRuleList( + for identifier: String, + artifactStore: ContentBlockerArtifactStore + ) -> String? { + switch identifier { + case StaticRuleListIdentifier.trackers.rawValue: + return encodeRules(networkBlockingRules(for: trackerDomains)) + case StaticRuleListIdentifier.thirdPartyCookies.rawValue: + return encodeRules([ + [ + "trigger": [ + "url-filter": ".*", + "load-type": ["third-party"] + ], + "action": ["type": "block-cookies"] + ] + ]) + case StaticRuleListIdentifier.allCookies.rawValue: + return encodeRules([ + [ + "trigger": ["url-filter": ".*"], + "action": ["type": "block-cookies"] + ] + ]) + default: + return artifactStore.encodedRuleList(for: identifier) + } + } + + private static func networkBlockingRules(for domains: [String]) -> [[String: Any]] { + domains.map { domain in + [ + "trigger": [ + "url-filter": regexForDomain(domain), + "load-type": ["third-party"] + ], + "action": ["type": "block"] + ] + } + } + + static func regexForDomain(_ domain: String) -> String { + let escapedDomain = NSRegularExpression.escapedPattern(for: domain) + return "^https?://([^/]+\\.)?\(escapedDomain)(?:[/:]|$)" + } + + private static func encodeRules(_ rules: [[String: Any]]) -> String { + guard let data = try? JSONSerialization.data(withJSONObject: rules, options: []), + let encoded = String(data: data, encoding: .utf8) + else { + return "[]" + } + + return encoded + } + + private static let trackerDomains = [ + "google-analytics.com", + "googletagmanager.com", + "doubleclick.net", + "googleadservices.com", + "facebook.net", + "connect.facebook.net", + "analytics.twitter.com", + "ads-twitter.com", + "snap.licdn.com", + "px.ads.linkedin.com", + "bat.bing.com", + "clarity.ms", + "cdn.segment.com", + "api.segment.io", + "api.amplitude.com", + "cdn.amplitude.com", + "mixpanel.com", + "api.mixpanel.com", + "fullstory.com", + "edge.fullstory.com", + "static.hotjar.com", + "script.hotjar.com", + "intercom.io", + "widget.intercom.io", + "static.intercomassets.com" + ] +} diff --git a/ora/Features/Privacy/Services/ContentBlockerArtifactStore.swift b/ora/Features/Privacy/Services/ContentBlockerArtifactStore.swift new file mode 100644 index 00000000..4811141c --- /dev/null +++ b/ora/Features/Privacy/Services/ContentBlockerArtifactStore.swift @@ -0,0 +1,162 @@ +import CryptoKit +import Foundation + +final class ContentBlockerArtifactStore { + struct RevisionManifest: Codable { + let coverage: FilterListCoverage + } + + static let shared = ContentBlockerArtifactStore() + + private let fileManager: FileManager + private let baseURL: URL + private let identifierPrefix = "com.orabrowser.adblock" + + init(fileManager: FileManager = .default, baseURL: URL? = nil) { + self.fileManager = fileManager + self.baseURL = baseURL ?? fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask) + .first! + .appendingPathComponent("Ora", isDirectory: true) + .appendingPathComponent("ContentBlockers", isDirectory: true) + + try? fileManager.createDirectory(at: self.baseURL, withIntermediateDirectories: true, attributes: nil) + } + + func revisionHash(for rawText: String) -> String { + let digest = SHA256.hash(data: Data(rawText.utf8)) + return digest.compactMap { String(format: "%02x", $0) }.joined().prefix(16).description + } + + func rawListText(for listID: String) -> String? { + try? String(contentsOf: rawListURL(for: listID), encoding: .utf8) + } + + func storeRawListText(_ rawText: String, for listID: String) throws { + let url = rawListURL(for: listID) + try fileManager.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true, + attributes: nil + ) + try rawText.write(to: url, atomically: true, encoding: .utf8) + } + + func storeCompiledArtifacts( + jsonShards: [String], + coverage: FilterListCoverage, + for listID: String, + revision: String + ) throws { + let revisionURL = compiledRevisionURL(for: listID, revision: revision) + try? fileManager.removeItem(at: revisionURL) + try fileManager.createDirectory(at: revisionURL, withIntermediateDirectories: true, attributes: nil) + + for (index, json) in jsonShards.enumerated() { + try json.write( + to: revisionURL.appendingPathComponent("shard-\(index).json"), + atomically: true, + encoding: .utf8 + ) + } + + let manifestURL = revisionURL.appendingPathComponent("manifest.json") + let manifestData = try JSONEncoder().encode(RevisionManifest(coverage: coverage)) + try manifestData.write(to: manifestURL, options: .atomic) + + let listCompiledRoot = compiledListURL(for: listID) + let revisionDirectories = (try? fileManager.contentsOfDirectory( + at: listCompiledRoot, + includingPropertiesForKeys: nil + )) ?? [] + + for oldRevisionURL in revisionDirectories where oldRevisionURL.lastPathComponent != revision { + try? fileManager.removeItem(at: oldRevisionURL) + } + } + + func hasCompiledArtifacts(for listID: String, revision: String) -> Bool { + !ruleListIdentifiers(for: listID, revision: revision).isEmpty + } + + func coverage(for listID: String, revision: String) -> FilterListCoverage? { + let manifestURL = compiledRevisionURL(for: listID, revision: revision).appendingPathComponent("manifest.json") + guard let data = try? Data(contentsOf: manifestURL) else { return nil } + return try? JSONDecoder().decode(RevisionManifest.self, from: data).coverage + } + + func ruleListIdentifiers(for listID: String, revision: String) -> [String] { + let revisionURL = compiledRevisionURL(for: listID, revision: revision) + let urls = (try? fileManager.contentsOfDirectory( + at: revisionURL, + includingPropertiesForKeys: nil + )) ?? [] + + return urls + .filter { $0.pathExtension == "json" && $0.lastPathComponent.hasPrefix("shard-") } + .compactMap { url -> (Int, String)? in + guard let shardIndex = Int(url.deletingPathExtension().lastPathComponent.replacingOccurrences( + of: "shard-", + with: "" + )) else { + return nil + } + + return (shardIndex, ruleListIdentifier(for: listID, revision: revision, shardIndex: shardIndex)) + } + .sorted { $0.0 < $1.0 } + .map(\.1) + } + + func encodedRuleList(for identifier: String) -> String? { + guard let descriptor = parse(identifier: identifier) else { return nil } + let url = compiledRevisionURL(for: descriptor.listID, revision: descriptor.revision) + .appendingPathComponent("shard-\(descriptor.shardIndex).json") + return try? String(contentsOf: url, encoding: .utf8) + } + + func removeArtifacts(for listID: String) { + try? fileManager.removeItem(at: compiledListURL(for: listID)) + try? fileManager.removeItem(at: rawListURL(for: listID)) + } + + private func ruleListIdentifier(for listID: String, revision: String, shardIndex: Int) -> String { + "\(identifierPrefix).\(listID).\(revision).\(shardIndex)" + } + + private func parse(identifier: String) -> (listID: String, revision: String, shardIndex: Int)? { + let prefix = "\(identifierPrefix)." + guard identifier.hasPrefix(prefix) else { return nil } + + let components = identifier.replacingOccurrences(of: prefix, with: "").split(separator: ".") + guard components.count >= 3, + let revision = components.dropLast().last, + let shardComponent = components.last, + let shardIndex = Int(shardComponent) + else { + return nil + } + + return ( + listID: components.dropLast(2).joined(separator: "."), + revision: String(revision), + shardIndex: shardIndex + ) + } + + private func rawListURL(for listID: String) -> URL { + baseURL + .appendingPathComponent("raw", isDirectory: true) + .appendingPathComponent("\(listID).txt") + } + + private func compiledListURL(for listID: String) -> URL { + baseURL + .appendingPathComponent("compiled", isDirectory: true) + .appendingPathComponent(listID, isDirectory: true) + } + + private func compiledRevisionURL(for listID: String, revision: String) -> URL { + compiledListURL(for: listID) + .appendingPathComponent(revision, isDirectory: true) + } +} diff --git a/ora/Features/Privacy/Services/ContentBlockerCompileService.swift b/ora/Features/Privacy/Services/ContentBlockerCompileService.swift new file mode 100644 index 00000000..48f885c5 --- /dev/null +++ b/ora/Features/Privacy/Services/ContentBlockerCompileService.swift @@ -0,0 +1,76 @@ +import ContentBlockerConverter +import Foundation + +struct CompiledFilterArtifacts { + let revision: String + let coverage: FilterListCoverage + let jsonShards: [String] +} + +final class ContentBlockerCompileService { + private let artifactStore: ContentBlockerArtifactStore + + init(artifactStore: ContentBlockerArtifactStore = .shared) { + self.artifactStore = artifactStore + } + + func compile(record: FilterListRecord, rawText: String) throws -> CompiledFilterArtifacts { + var contiguousRawText = rawText + contiguousRawText.makeContiguousUTF8() + let rules = contiguousRawText.components(separatedBy: .newlines) + + guard rules.contains(where: { !$0.trimmingCharacters(in: .whitespaces).isEmpty }) else { + throw AdBlockServiceError.emptyFilterList(record.name) + } + + let revision = artifactStore.revisionHash(for: contiguousRawText) + let shardResults = compileShards(from: rules) + let jsonShards = shardResults + .map(\.safariRulesJSON) + .filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + + let totalRuleCount = shardResults.reduce(0) { $0 + $1.sourceRulesCount } + let convertedRuleCount = max( + shardResults.reduce(0) { $0 + max($1.sourceSafariCompatibleRulesCount - $1.errorsCount, 0) }, + 0 + ) + let skippedRuleCount = max(totalRuleCount - convertedRuleCount, 0) + let safariRuleCount = shardResults.reduce(0) { $0 + $1.safariRulesCount } + let coverage = FilterListCoverage( + totalRuleCount: totalRuleCount, + convertedRuleCount: convertedRuleCount, + skippedRuleCount: skippedRuleCount, + safariRuleCount: safariRuleCount, + shardCount: jsonShards.count + ) + + guard coverage.shardCount > 0, coverage.safariRuleCount > 0 else { + throw AdBlockServiceError.emptyFilterList(record.name) + } + + return CompiledFilterArtifacts( + revision: revision, + coverage: coverage, + jsonShards: jsonShards + ) + } + + private func compileShards(from rules: [String]) -> [ConversionResult] { + guard !rules.isEmpty else { return [] } + + let result = ContentBlockerConverter().convertArray( + rules: rules, + safariVersion: .autodetect(), + advancedBlocking: false + ) + + if result.discardedSafariRules > 0, rules.count > 1 { + let midpoint = rules.count / 2 + let firstHalf = Array(rules[.. 0 ? [result] : [] + } +} diff --git a/ora/Features/Privacy/Services/FilterListCatalogService.swift b/ora/Features/Privacy/Services/FilterListCatalogService.swift new file mode 100644 index 00000000..38c630e2 --- /dev/null +++ b/ora/Features/Privacy/Services/FilterListCatalogService.swift @@ -0,0 +1,95 @@ +import Foundation + +struct FilterListCatalogService { + static let shared = FilterListCatalogService() + + static let adGuardBaseID = "adguard-base" + static let adGuardMobileAdsID = "adguard-mobile-ads" + static let adGuardTrackingProtectionID = "adguard-tracking-protection" + static let adGuardURLTrackingID = "adguard-url-tracking" + static let adGuardAnnoyancesID = "adguard-annoyances" + + static let defaultBuiltinSelectionIDs = [ + adGuardBaseID, + adGuardMobileAdsID + ] + + let builtinRecords: [FilterListRecord] = [ + FilterListRecord( + id: FilterListCatalogService.adGuardBaseID, + name: "AdGuard Base", + summary: "Core AdGuard ads list for general web ad blocking.", + sourceKind: .builtin, + sourceURL: "https://filters.adtidy.org/extension/chromium/filters/2.txt", + isRecommended: true, + enabledByDefault: true, + status: .idle + ), + FilterListRecord( + id: FilterListCatalogService.adGuardMobileAdsID, + name: "AdGuard Mobile Ads", + summary: "Additional mobile ad-network coverage for embedded and responsive mobile ads.", + sourceKind: .builtin, + sourceURL: "https://filters.adtidy.org/extension/chromium/filters/11.txt", + isRecommended: true, + enabledByDefault: true, + status: .idle + ), + FilterListRecord( + id: FilterListCatalogService.adGuardTrackingProtectionID, + name: "AdGuard Tracking Protection", + summary: "Broader tracker and analytics coverage from AdGuard’s privacy list.", + sourceKind: .builtin, + sourceURL: "https://filters.adtidy.org/extension/chromium/filters/3.txt", + isRecommended: false, + enabledByDefault: false, + status: .idle + ), + FilterListRecord( + id: FilterListCatalogService.adGuardURLTrackingID, + name: "AdGuard URL Tracking", + summary: "Removes common tracking parameters from requested URLs when WebKit rules can express them.", + sourceKind: .builtin, + sourceURL: "https://filters.adtidy.org/windows/filters/17.txt", + isRecommended: false, + enabledByDefault: false, + status: .idle + ), + FilterListRecord( + id: FilterListCatalogService.adGuardAnnoyancesID, + name: "AdGuard Annoyances", + summary: "Targets cookie notices, popups, widgets, and other page annoyances.", + sourceKind: .builtin, + sourceURL: "https://filters.adtidy.org/extension/chromium/filters/14.txt", + isRecommended: false, + enabledByDefault: false, + status: .idle + ) + ] + + func normalizedRecords(from stored: [FilterListRecord]) -> [FilterListRecord] { + let builtinByID = Dictionary(uniqueKeysWithValues: builtinRecords.map { ($0.id, $0) }) + let storedByID = Dictionary(uniqueKeysWithValues: stored.map { ($0.id, $0) }) + + let mergedBuiltins = builtinRecords.map { builtin -> FilterListRecord in + guard let storedBuiltin = storedByID[builtin.id] else { return builtin } + + var merged = builtin + merged.status = storedBuiltin.status + merged.lastErrorMessage = storedBuiltin.lastErrorMessage + merged.lastFetchAt = storedBuiltin.lastFetchAt + merged.lastSuccessfulRefreshAt = storedBuiltin.lastSuccessfulRefreshAt + merged.etag = storedBuiltin.etag + merged.lastModified = storedBuiltin.lastModified + merged.activeRevision = storedBuiltin.activeRevision + merged.coverage = storedBuiltin.coverage + return merged + } + + let customRecords = stored + .filter { $0.sourceKind == .custom && builtinByID[$0.id] == nil } + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + + return mergedBuiltins + customRecords + } +} diff --git a/ora/Features/Privacy/Services/FilterListUpdateService.swift b/ora/Features/Privacy/Services/FilterListUpdateService.swift new file mode 100644 index 00000000..5a92dfa8 --- /dev/null +++ b/ora/Features/Privacy/Services/FilterListUpdateService.swift @@ -0,0 +1,70 @@ +import Foundation + +struct FilterListFetchResult { + let record: FilterListRecord + let rawText: String? +} + +final class FilterListUpdateService { + private let session: URLSession + + init(session: URLSession = .shared) { + self.session = session + } + + func isValidCustomListURL(_ rawValue: String) -> Bool { + guard let url = normalizedURL(from: rawValue) else { return false } + guard let scheme = url.scheme?.lowercased() else { return false } + return scheme == "http" || scheme == "https" + } + + func normalizedURL(from rawValue: String) -> URL? { + guard let url = URL(string: rawValue.trimmingCharacters(in: .whitespacesAndNewlines)) else { + return nil + } + guard let scheme = url.scheme?.lowercased(), ["http", "https"].contains(scheme) else { + return nil + } + return url + } + + func fetchLatest(for record: FilterListRecord) async throws -> FilterListFetchResult { + guard let url = normalizedURL(from: record.sourceURL) else { + throw AdBlockServiceError.invalidCustomListURL + } + + var request = URLRequest(url: url) + if let etag = record.etag { + request.setValue(etag, forHTTPHeaderField: "If-None-Match") + } + if let lastModified = record.lastModified { + request.setValue(lastModified, forHTTPHeaderField: "If-Modified-Since") + } + + let (data, response) = try await session.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw AdBlockServiceError.invalidFilterResponse + } + + switch httpResponse.statusCode { + case 200 ..< 300: + guard let rawText = String(data: data, encoding: .utf8) else { + throw AdBlockServiceError.invalidFilterResponse + } + + var updatedRecord = record + updatedRecord.lastFetchAt = Date() + updatedRecord.etag = httpResponse.value(forHTTPHeaderField: "ETag") + updatedRecord.lastModified = httpResponse.value(forHTTPHeaderField: "Last-Modified") + updatedRecord.lastErrorMessage = nil + return FilterListFetchResult(record: updatedRecord, rawText: rawText) + case 304: + var updatedRecord = record + updatedRecord.lastFetchAt = Date() + updatedRecord.lastErrorMessage = nil + return FilterListFetchResult(record: updatedRecord, rawText: nil) + default: + throw AdBlockServiceError.downloadFailed(statusCode: httpResponse.statusCode) + } + } +} diff --git a/ora/Features/Settings/Sections/PrivacySecuritySettingsView.swift b/ora/Features/Settings/Sections/PrivacySecuritySettingsView.swift deleted file mode 100644 index 07b91d83..00000000 --- a/ora/Features/Settings/Sections/PrivacySecuritySettingsView.swift +++ /dev/null @@ -1,44 +0,0 @@ -import SwiftUI - -struct PrivacySecuritySettingsView: View { - @StateObject private var settings = SettingsStore.shared - - var body: some View { - SettingsSection { - SettingsCard(header: "Tracking Prevention") { - HStack { - Text("Soon") - .font(.caption) - .foregroundStyle(.secondary) - .padding(.horizontal, 8) - .padding(.vertical, 3) - .background(Color(.controlColor), in: Capsule()) - } - - Toggle("Block third-party trackers", isOn: .constant(false)) - Toggle("Block fingerprinting", isOn: .constant(false)) - Toggle("Ad Blocking", isOn: .constant(false)) - } - .disabled(true) - - SettingsCard(header: "Cookies") { - HStack { - Text("Soon") - .font(.caption) - .foregroundStyle(.secondary) - .padding(.horizontal, 8) - .padding(.vertical, 3) - .background(Color(.controlColor), in: Capsule()) - } - - Picker("", selection: .constant(CookiesPolicy.allowAll)) { - ForEach(CookiesPolicy.allCases) { policy in - Text(policy.rawValue).tag(policy) - } - } - .pickerStyle(.radioGroup) - } - .disabled(true) - } - } -} diff --git a/ora/Features/Settings/Sections/SpacesSettingsView.swift b/ora/Features/Settings/Sections/SpacesSettingsView.swift index 74090fcb..1df8f7b8 100644 --- a/ora/Features/Settings/Sections/SpacesSettingsView.swift +++ b/ora/Features/Settings/Sections/SpacesSettingsView.swift @@ -1,6 +1,7 @@ import SwiftData import SwiftUI +// swiftlint:disable type_body_length large_tuple struct SpacesSettingsView: View { private enum ClearDataAction: Hashable { case cache(UUID) @@ -14,6 +15,7 @@ struct SpacesSettingsView: View { @State private var searchService = SearchEngineService() @State private var selectedContainerId: UUID? @State private var completedClearActions: Set = [] + @State private var newCustomFilterListURL = "" @Environment(\.modelContext) private var modelContext @EnvironmentObject private var toastManager: ToastManager @@ -116,6 +118,9 @@ struct SpacesSettingsView: View { } } + privacySettingsCard(for: container) + adBlockingSettingsCard(for: container) + SettingsCard(header: "Clear Data") { VStack(spacing: 8) { Button( @@ -183,7 +188,14 @@ struct SpacesSettingsView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .clipped() } - .onAppear { if selectedContainerId == nil { selectedContainerId = containers.first?.id } } + .onAppear { + if selectedContainerId == nil { selectedContainerId = containers.first?.id } + Task { + for container in containers { + await AdBlockService.shared.registerSpace(containerId: container.id) + } + } + } } private func clearHistory(for container: TabContainer) -> Bool { @@ -212,4 +224,356 @@ struct SpacesSettingsView: View { ) -> String { completedClearActions.contains(action) ? completedTitle : defaultTitle } + + private func privacySettingsCard(for container: TabContainer) -> some View { + SettingsCard(header: "Privacy") { + Text( + "These protections apply only to \(container.name). Open tabs in this space are refreshed automatically." + ) + .font(.subheadline) + .foregroundStyle(.secondary) + + Toggle( + "Block third-party trackers", + isOn: privacyBinding(for: container, keyPath: \.blockThirdPartyTrackers) + ) + Toggle( + "Block fingerprinting", + isOn: privacyBinding(for: container, keyPath: \.blockFingerprinting) + ) + + Text( + "Reduces browser and device fingerprint surface for this space. This does not block cookies or other storage by itself." + ) + .font(.caption) + .foregroundStyle(.secondary) + + Divider() + + Picker( + "Cookies", + selection: privacyBinding(for: container, keyPath: \.cookiesPolicy) + ) { + ForEach(CookiesPolicy.allCases) { policy in + Text(policy.rawValue).tag(policy) + } + } + .pickerStyle(.radioGroup) + } + } + + @ViewBuilder + private func adBlockingSettingsCard(for container: TabContainer) -> some View { + let privacySettings = settings.privacySettings(for: container.id) + let enabledRecords = enabledAdBlockRecords(for: container) + let builtinRecords = settings.adBlockFilterLists.filter(\.isBuiltin) + let customRecords = settings.adBlockFilterLists.filter { $0.sourceKind == .custom } + let summary = adBlockSummary(for: container) + + SettingsCard( + header: "Ad Blocking", + description: "Powered by embedded AdGuard filter lists compiled for WebKit in this space." + ) { + Toggle("Enable Ad Blocking", isOn: adBlockEnabledBinding(for: container)) + + Divider() + + Picker("Update Policy", selection: adBlockUpdateModeBinding(for: container)) { + ForEach(AdBlockUpdateMode.allCases) { mode in + Text(mode.title).tag(mode) + } + } + .pickerStyle(.menu) + + HStack(spacing: 12) { + Button("Refresh Now") { + Task { + let changed = await AdBlockService.shared.refreshSpace( + containerId: container.id, + reason: .manual + ) + _ = await MainActor.run { + toastManager.show( + changed ? "Filter lists updated for \(container.name)" : + "Filter lists are already current", + type: .info, + icon: .system("arrow.clockwise.circle") + ) + } + } + } + .disabled(!privacySettings.adBlock.enabled || enabledRecords.isEmpty || summary.isUpdating) + + if summary.isUpdating { + ProgressView() + .controlSize(.small) + } + } + + Divider() + + VStack(alignment: .leading, spacing: 4) { + Text("Status") + .font(.subheadline.weight(.semibold)) + Text(summary.statusText) + .foregroundStyle(summary.hasFailures ? .red : .secondary) + if let lastUpdatedText = summary.lastUpdatedText { + Text("Last updated \(lastUpdatedText)") + .font(.caption) + .foregroundStyle(.secondary) + } + if let coverageText = summary.coverageText { + Text(coverageText) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Divider() + + VStack(alignment: .leading, spacing: 10) { + Text("Built-In Lists") + .font(.subheadline.weight(.semibold)) + + ForEach(builtinRecords) { record in + filterListRow(for: container, record: record, showRemoveButton: false) + } + } + + Divider() + + VStack(alignment: .leading, spacing: 10) { + Text("Custom Lists") + .font(.subheadline.weight(.semibold)) + + HStack(alignment: .center, spacing: 10) { + TextField("https://example.com/filter.txt", text: $newCustomFilterListURL) + .textFieldStyle(.roundedBorder) + Button("Add") { + addCustomFilterList(for: container) + } + .disabled(newCustomFilterListURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + + if customRecords.isEmpty { + Text("Add a remote AdGuard-style list URL to make it available in this space.") + .font(.caption) + .foregroundStyle(.secondary) + } else { + ForEach(customRecords) { record in + filterListRow(for: container, record: record, showRemoveButton: true) + } + } + } + } + } + + private func filterListRow( + for container: TabContainer, + record: FilterListRecord, + showRemoveButton: Bool + ) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .top, spacing: 12) { + Toggle(isOn: adBlockFilterBinding(for: container, record: record)) { + VStack(alignment: .leading, spacing: 4) { + if record.sourceKind == .custom { + TextField( + "Custom list name", + text: customFilterNameBinding(for: record) + ) + .textFieldStyle(.roundedBorder) + } else { + Text(record.name) + .font(.body.weight(.medium)) + } + Text(record.summary) + .font(.caption) + .foregroundStyle(.secondary) + Text(filterListStatusText(for: record)) + .font(.caption) + .foregroundStyle(record.status == .failed ? .red : .secondary) + } + } + .toggleStyle(.checkbox) + + if showRemoveButton { + Button("Remove", role: .destructive) { + Task { + await AdBlockService.shared.removeCustomList(id: record.id) + _ = await MainActor.run { + toastManager.show("Removed \(record.name)", type: .info, icon: .system("trash")) + } + } + } + .buttonStyle(.borderless) + } + } + } + } + + private func privacyBinding( + for container: TabContainer, + keyPath: WritableKeyPath + ) -> Binding { + Binding( + get: { + settings.privacySettings(for: container.id)[keyPath: keyPath] + }, + set: { newValue in + var updatedSettings = settings.privacySettings(for: container.id) + updatedSettings[keyPath: keyPath] = newValue + settings.setPrivacySettings(updatedSettings, for: container.id) + settings.notifySpacePrivacySettingsChanged(for: container.id) + } + ) + } + + private func adBlockEnabledBinding(for container: TabContainer) -> Binding { + Binding( + get: { + settings.privacySettings(for: container.id).adBlock.enabled + }, + set: { newValue in + applyAdBlockSettingsChange(for: container) { updatedSettings in + updatedSettings.adBlock.enabled = newValue + } + } + ) + } + + private func adBlockUpdateModeBinding(for container: TabContainer) -> Binding { + Binding( + get: { + settings.privacySettings(for: container.id).adBlock.updateMode + }, + set: { newValue in + var updatedSettings = settings.privacySettings(for: container.id) + updatedSettings.adBlock.updateMode = newValue + settings.setPrivacySettings(updatedSettings, for: container.id) + Task { + await AdBlockService.shared.spaceSettingsDidChange(containerId: container.id) + } + } + ) + } + + private func adBlockFilterBinding(for container: TabContainer, record: FilterListRecord) -> Binding { + Binding( + get: { + settings.privacySettings(for: container.id).adBlock.isEnabled(record) + }, + set: { isEnabled in + applyAdBlockSettingsChange(for: container) { updatedSettings in + updatedSettings.adBlock.setEnabled(isEnabled, for: record) + } + } + ) + } + + private func customFilterNameBinding(for record: FilterListRecord) -> Binding { + Binding( + get: { + settings.adBlockFilterList(id: record.id)?.name ?? record.name + }, + set: { newValue in + guard var updatedRecord = settings.adBlockFilterList(id: record.id) else { return } + updatedRecord.name = newValue + settings.upsertAdBlockFilterList(updatedRecord) + } + ) + } + + private func addCustomFilterList(for container: TabContainer) { + let submittedURL = newCustomFilterListURL + Task { + do { + let record = try await AdBlockService.shared.addCustomList(sourceURL: submittedURL) + await AdBlockService.shared.spaceSettingsDidChange(containerId: container.id) + _ = await MainActor.run { + newCustomFilterListURL = "" + toastManager.show("Added \(record.name)", icon: .system("checkmark.circle")) + } + } catch { + await MainActor.run { + toastManager.show(error.localizedDescription, type: .error) + } + } + } + } + + private func applyAdBlockSettingsChange( + for container: TabContainer, + mutate: (inout SpacePrivacySettings) -> Void + ) { + var updatedSettings = settings.privacySettings(for: container.id) + mutate(&updatedSettings) + settings.setPrivacySettings(updatedSettings, for: container.id) + Task { + await AdBlockService.shared.refreshSpace(containerId: container.id, reason: .settingsChanged) + } + } + + private func enabledAdBlockRecords(for container: TabContainer) -> [FilterListRecord] { + let enabledListIDs = Set(settings.privacySettings(for: container.id).adBlock.enabledListIDs) + return settings.adBlockFilterLists.filter { enabledListIDs.contains($0.id) } + } + + private func filterListStatusText(for record: FilterListRecord) -> String { + switch record.status { + case .idle: + return "Not downloaded yet" + case .updating: + return "Updating list and compiling WebKit rules" + case .ready: + return if let coverage = record.coverage { + "\(coverage.convertedRuleCount) converted, \(coverage.skippedRuleCount) skipped" + } else { + "Compiled and ready" + } + case .failed: + return record.lastErrorMessage ?? "The list could not be refreshed" + } + } + + private func adBlockSummary(for container: TabContainer) -> ( + statusText: String, + lastUpdatedText: String?, + coverageText: String?, + isUpdating: Bool, + hasFailures: Bool + ) { + let privacySettings = settings.privacySettings(for: container.id) + guard privacySettings.adBlock.enabled else { + return ("Ad blocking is off for this space.", nil, nil, false, false) + } + + let enabledRecords = enabledAdBlockRecords(for: container) + guard !enabledRecords.isEmpty else { + return ("Select at least one filter list to block ads in this space.", nil, nil, false, false) + } + + let isUpdating = enabledRecords.contains { $0.status == .updating } + let failedRecords = enabledRecords.filter { $0.status == .failed } + let lastUpdated = enabledRecords.compactMap(\.lastSuccessfulRefreshAt).max() + let totalConverted = enabledRecords.compactMap(\.coverage).reduce(0) { $0 + $1.convertedRuleCount } + let totalSkipped = enabledRecords.compactMap(\.coverage).reduce(0) { $0 + $1.skippedRuleCount } + + let statusText = if isUpdating { + "Updating filter lists and recompiling WebKit rule sets." + } else if let failedRecord = failedRecords.first { + "\(failedRecord.name) needs attention." + } else { + "Ready in \(enabledRecords.count) enabled list\(enabledRecords.count == 1 ? "" : "s")." + } + + let lastUpdatedText = lastUpdated?.formatted(date: .abbreviated, time: .shortened) + let coverageText = totalConverted == 0 && totalSkipped == 0 + ? nil + : "\(totalConverted) converted rules, \(totalSkipped) skipped" + + return (statusText, lastUpdatedText, coverageText, isUpdating, !failedRecords.isEmpty) + } } + +// swiftlint:enable type_body_length large_tuple diff --git a/ora/Features/Settings/SettingsContentView.swift b/ora/Features/Settings/SettingsContentView.swift index 9e99cf09..d90cf263 100644 --- a/ora/Features/Settings/SettingsContentView.swift +++ b/ora/Features/Settings/SettingsContentView.swift @@ -4,7 +4,6 @@ import SwiftUI enum SettingsTab: String, Hashable, CaseIterable { case general case spaces - case privacySecurity case passwords case shortcuts case searchEngines @@ -13,7 +12,6 @@ enum SettingsTab: String, Hashable, CaseIterable { switch self { case .general: return "General" case .spaces: return "Spaces" - case .privacySecurity: return "Privacy" case .passwords: return "Passwords" case .shortcuts: return "Shortcuts" case .searchEngines: return "Search" @@ -24,7 +22,6 @@ enum SettingsTab: String, Hashable, CaseIterable { switch self { case .general: return "gearshape" case .spaces: return "rectangle.3.group" - case .privacySecurity: return "lock.shield" case .passwords: return "key.horizontal" case .shortcuts: return "command" case .searchEngines: return "magnifyingglass" @@ -37,8 +34,6 @@ enum SettingsTab: String, Hashable, CaseIterable { return "Browser defaults, app behavior, and software updates." case .spaces: return "Space-specific defaults and per-space data controls." - case .privacySecurity: - return "Tracking prevention, cookies, and privacy protections." case .passwords: return "Password manager integration, vault access, and autofill behavior." case .shortcuts: @@ -76,6 +71,7 @@ struct SettingsContentView: View { NavigationSplitView { List(SettingsTab.allCases, id: \.self, selection: selection) { tab in Label(tab.title, systemImage: tab.symbol) + .tag(tab) } .navigationSplitViewColumnWidth(200) .padding(.top, 8) @@ -94,8 +90,6 @@ struct SettingsContentView: View { GeneralSettingsView() case .spaces: SpacesSettingsView() - case .privacySecurity: - PrivacySecuritySettingsView() case .passwords: PasswordsSettingsView() case .shortcuts: diff --git a/ora/Features/Tabs/Models/Tab.swift b/ora/Features/Tabs/Models/Tab.swift index a200bdb9..db0c7bbf 100644 --- a/ora/Features/Tabs/Models/Tab.swift +++ b/ora/Features/Tabs/Models/Tab.swift @@ -212,9 +212,14 @@ class Tab: ObservableObject, Identifiable { let engine = BrowserEngine.shared let profile = engine.makeProfile(identifier: container.id, isPrivate: isPrivate) + let privacySettings = SettingsStore.shared.privacySettings(for: container.id) + let userScripts = OraBrowserScripts.userScripts() + BrowserPrivacyService.privacyScripts(for: privacySettings) let page = engine.makePage( profile: profile, - configuration: BrowserPageConfiguration.oraDefault(userScripts: OraBrowserScripts.userScripts()), + configuration: BrowserPageConfiguration.oraDefault( + userScripts: userScripts, + privacySettings: privacySettings + ), delegate: nil ) browserPage = page @@ -347,6 +352,24 @@ class Tab: ObservableObject, Identifiable { browserPage?.reload() } + func refreshBrowserPageForPrivacySettings() { + guard isWebViewReady, + let historyManager, + let downloadManager, + let tabManager + else { + return + } + + destroyWebView() + restoreTransientState( + historyManager: historyManager, + downloadManager: downloadManager, + tabManager: tabManager, + isPrivate: isPrivate + ) + } + func evaluateJavaScript(_ script: String, completion: ((Any?, Error?) -> Void)? = nil) { browserPage?.evaluateJavaScript(script, completion: completion) } diff --git a/ora/Features/Tabs/State/TabManager.swift b/ora/Features/Tabs/State/TabManager.swift index 7bb9c4f2..ff32741d 100644 --- a/ora/Features/Tabs/State/TabManager.swift +++ b/ora/Features/Tabs/State/TabManager.swift @@ -620,6 +620,17 @@ class TabManager: ObservableObject { self.reorderTabs(from: tab, toTab: newTab) } + func refreshPrivacySettings(for containerId: UUID) { + guard let container = fetchContainer(id: containerId) else { return } + + let loadedTabs = container.tabs.filter(\.isWebViewReady) + guard !loadedTabs.isEmpty else { return } + + for tab in loadedTabs { + tab.refreshBrowserPageForPrivacySettings() + } + } + private func trackRecentlyClosedTab(_ tab: Tab) { recentlyClosedTabs.append(ClosedTabSnapshot(tab: tab)) if recentlyClosedTabs.count > maxRecentlyClosedTabs { diff --git a/oraTests/oraTests.swift b/oraTests/oraTests.swift index 68d5dbb1..3a522313 100644 --- a/oraTests/oraTests.swift +++ b/oraTests/oraTests.swift @@ -9,6 +9,51 @@ import Foundation @testable import Ora import Testing +private final class RequestCountingURLProtocol: URLProtocol, @unchecked Sendable { + private static let lock = NSLock() + private static var handledRequestCount = 0 + + static func reset() { + lock.lock() + handledRequestCount = 0 + lock.unlock() + } + + static var requestCount: Int { + lock.lock() + let count = handledRequestCount + lock.unlock() + return count + } + + override class func canInit(with request: URLRequest) -> Bool { + true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + Self.lock.lock() + Self.handledRequestCount += 1 + Self.lock.unlock() + + let response = HTTPURLResponse( + url: request.url ?? URL(string: "https://example.com/filter.txt")!, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data("||ads.example^".utf8)) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} +} + struct OraTests { @Test func normalizesHostsForPasswordMatching() { #expect(PasswordManagerService.normalizeHost("WWW.Example.COM.") == "www.example.com") @@ -137,4 +182,295 @@ struct OraTests { #expect(emailSuggestions.savedPasswordEntries.isEmpty) #expect(emailSuggestions.emailSuggestions == [emailSuggestion]) } + + @Test func storesPrivacySettingsPerSpaceIndependently() { + let store = SettingsStore.shared + let firstContainerID = UUID() + let secondContainerID = UUID() + let baselineSecondSettings = store.privacySettings(for: secondContainerID) + + defer { + store.removeContainerSettings(for: firstContainerID) + store.removeContainerSettings(for: secondContainerID) + } + + let updatedSettings = SpacePrivacySettings( + blockThirdPartyTrackers: true, + blockFingerprinting: true, + adBlocking: true, + adBlock: SpaceAdBlockSettings( + enabled: true, + enabledBuiltinListIDs: [ + FilterListCatalogService.adGuardBaseID, + FilterListCatalogService.adGuardAnnoyancesID + ], + enabledCustomListIDs: ["custom-test-list"], + updateMode: .aggressiveAuto + ), + cookiesPolicy: .blockThirdParty + ) + + store.setPrivacySettings(updatedSettings, for: firstContainerID) + + #expect(store.privacySettings(for: firstContainerID) == updatedSettings) + #expect(store.privacySettings(for: secondContainerID) == baselineSecondSettings) + } + + @Test func removingContainerSettingsResetsSpacePrivacyOverrides() { + let store = SettingsStore.shared + let containerID = UUID() + let baselineSettings = store.privacySettings(for: containerID) + + defer { + store.removeContainerSettings(for: containerID) + } + + var updatedSettings = baselineSettings + updatedSettings.cookiesPolicy = baselineSettings.cookiesPolicy == .blockAll ? .allowAll : .blockAll + updatedSettings.adBlocking.toggle() + + store.setPrivacySettings(updatedSettings, for: containerID) + #expect(store.privacySettings(for: containerID) == updatedSettings) + + store.removeContainerSettings(for: containerID) + #expect(store.privacySettings(for: containerID) == baselineSettings) + } + + @Test func seedsBuiltInAdBlockLists() { + let builtinIDs = Set(SettingsStore.shared.adBlockFilterLists.filter(\.isBuiltin).map(\.id)) + + #expect(builtinIDs.contains(FilterListCatalogService.adGuardBaseID)) + #expect(builtinIDs.contains(FilterListCatalogService.adGuardMobileAdsID)) + #expect(builtinIDs.contains(FilterListCatalogService.adGuardTrackingProtectionID)) + #expect(builtinIDs.contains(FilterListCatalogService.adGuardURLTrackingID)) + #expect(builtinIDs.contains(FilterListCatalogService.adGuardAnnoyancesID)) + } + + @Test func validatesCustomAdBlockURLs() { + let service = FilterListUpdateService() + + #expect(service.isValidCustomListURL("https://example.com/filter.txt")) + #expect(service.isValidCustomListURL("http://example.com/filter.txt")) + #expect(service.isValidCustomListURL("ftp://example.com/filter.txt") == false) + #expect(service.isValidCustomListURL("file:///tmp/filter.txt") == false) + } + + @Test func persistsAdBlockUpdateModePerSpace() { + let store = SettingsStore.shared + let containerID = UUID() + + defer { + store.removeContainerSettings(for: containerID) + } + + var updatedSettings = store.privacySettings(for: containerID) + updatedSettings.adBlock.updateMode = .aggressiveAuto + store.setPrivacySettings(updatedSettings, for: containerID) + + #expect(store.privacySettings(for: containerID).adBlock.updateMode == .aggressiveAuto) + } + + @Test func spacePrivacySettingsDefaultToFingerprintingOnAndCookiesAllowed() { + let defaults = SpacePrivacySettings() + + #expect(defaults.blockFingerprinting) + #expect(defaults.cookiesPolicy == .allowAll) + } + + @Test func fingerprintingEnabledSpacesGenerateProtectionScripts() { + let disabledScripts = BrowserPrivacyService.privacyScripts( + for: SpacePrivacySettings(blockFingerprinting: false) + ) + let enabledScripts = BrowserPrivacyService.privacyScripts( + for: SpacePrivacySettings(blockFingerprinting: true) + ) + + #expect(disabledScripts.isEmpty) + #expect(enabledScripts.count == 1) + #expect(enabledScripts.first?.source.isEmpty == false) + } + + @Test func fingerprintingScriptDoesNotDependOnCookiePolicy() { + let allowAllScript = BrowserPrivacyService.privacyScripts( + for: SpacePrivacySettings(blockFingerprinting: true, cookiesPolicy: .allowAll) + ).first?.source + let blockAllScript = BrowserPrivacyService.privacyScripts( + for: SpacePrivacySettings(blockFingerprinting: true, cookiesPolicy: .blockAll) + ).first?.source + + #expect(allowAllScript == blockAllScript) + } + + @Test func balancedFingerprintingProfileIsInternallyCoherent() { + let profile = FingerprintingProtectionProfile.balanced + + #expect(profile.language == profile.languages.first) + #expect(profile.availWidth <= profile.screenWidth) + #expect(profile.availHeight <= profile.screenHeight) + #expect(profile.devicePixelRatio > 0) + #expect(profile.platform == "MacIntel") + #expect(profile.vendor == "Apple Computer, Inc.") + #expect(profile.mediaDeviceKinds == ["audioinput", "audiooutput", "videoinput"]) + } + + @Test func fingerprintingScriptIncludesBalancedSurfaceNormalization() { + let script = BrowserPrivacyService.fingerprintingProtectionScriptSource() + + #expect(script.contains("hardwareConcurrency")) + #expect(script.contains("devicePixelRatio")) + #expect(script.contains("enumerateDevices")) + #expect(script.contains("toDataURL")) + #expect(script.contains("OfflineAudioContext")) + #expect(script.contains("WebGLRenderingContext")) + } + + @Test func trackerRegexMatchesRootAndSubdomains() throws { + let pattern = BrowserPrivacyService.regexForDomain("hotjar.com") + let regex = try NSRegularExpression(pattern: pattern) + let rootURL = "https://hotjar.com/script.js" + let subdomainURL = "https://static.hotjar.com/c/hotjar.js" + let otherURL = "https://not-hotjar-example.com/script.js" + + let rootRange = NSRange(rootURL.startIndex ..< rootURL.endIndex, in: rootURL) + let subdomainRange = NSRange(subdomainURL.startIndex ..< subdomainURL.endIndex, in: subdomainURL) + let otherRange = NSRange(otherURL.startIndex ..< otherURL.endIndex, in: otherURL) + + #expect(regex.firstMatch(in: rootURL, range: rootRange) != nil) + #expect(regex.firstMatch(in: subdomainURL, range: subdomainRange) != nil) + #expect(regex.firstMatch(in: otherURL, range: otherRange) == nil) + } + + @Test func tracksUnsupportedRulesInCoverageSummary() throws { + let compiler = ContentBlockerCompileService() + let record = FilterListRecord( + id: "test-filter", + name: "Test Filter", + summary: "Fixture list", + sourceKind: .custom, + sourceURL: "https://example.com/filter.txt", + isRecommended: false, + enabledByDefault: false + ) + + let rawText = """ + ||ads.example^ + example.com#%#console.log('advanced') + """ + + let artifacts = try compiler.compile(record: record, rawText: rawText) + + #expect(artifacts.coverage.totalRuleCount >= 2) + #expect(artifacts.coverage.skippedRuleCount >= 1) + #expect(artifacts.coverage.shardCount >= 1) + #expect(artifacts.jsonShards.isEmpty == false) + } + + @Test func artifactIdentifiersSupportDottedListIDs() throws { + let temporaryArtifactsURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let store = ContentBlockerArtifactStore(baseURL: temporaryArtifactsURL) + let coverage = FilterListCoverage( + totalRuleCount: 1, + convertedRuleCount: 1, + skippedRuleCount: 0, + safariRuleCount: 1, + shardCount: 1 + ) + + defer { + try? FileManager.default.removeItem(at: temporaryArtifactsURL) + } + + try store.storeCompiledArtifacts( + jsonShards: ["[{\"trigger\":{\"url-filter\":\".*\"},\"action\":{\"type\":\"block\"}}]"], + coverage: coverage, + for: "custom.list", + revision: "revision123" + ) + + let identifiers = store.ruleListIdentifiers(for: "custom.list", revision: "revision123") + + #expect(identifiers.count == 1) + #expect(store.encodedRuleList(for: identifiers[0])?.contains("\"type\":\"block\"") == true) + } + + @Test func failedAdBlockRefreshPreservesLastKnownGoodRevision() { + let record = FilterListRecord( + id: "test-filter", + name: "Test Filter", + summary: "Fixture list", + sourceKind: .custom, + sourceURL: "https://example.com/filter.txt", + isRecommended: false, + enabledByDefault: false, + status: .ready, + activeRevision: "last-good-revision", + coverage: FilterListCoverage( + totalRuleCount: 10, + convertedRuleCount: 8, + skippedRuleCount: 2, + safariRuleCount: 8, + shardCount: 1 + ) + ) + + let failed = AdBlockService.failedRecord(record, error: AdBlockServiceError.emptyFilterList("Test Filter")) + + #expect(failed.activeRevision == "last-good-revision") + #expect(failed.status == .failed) + } + + @Test func settingsRefreshStaysOfflineWithoutCachedRawList() async { + let store = SettingsStore.shared + let baselineLists = store.adBlockFilterLists + let containerID = UUID() + let temporaryArtifactsURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + + let record = FilterListRecord( + id: "offline-only-filter", + name: "Offline Only Filter", + summary: "Offline refresh regression fixture", + sourceKind: .custom, + sourceURL: "https://example.com/filter.txt", + isRecommended: false, + enabledByDefault: false, + status: .ready, + activeRevision: "existing-revision" + ) + + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [RequestCountingURLProtocol.self] + let session = URLSession(configuration: configuration) + let service = AdBlockService( + updateService: FilterListUpdateService(session: session), + artifactStore: ContentBlockerArtifactStore(baseURL: temporaryArtifactsURL) + ) + + var settings = store.privacySettings(for: containerID) + settings.adBlock.enabled = true + settings.adBlock.updateMode = .manualOnly + settings.adBlock.enabledBuiltinListIDs = [] + settings.adBlock.enabledCustomListIDs = [record.id] + + RequestCountingURLProtocol.reset() + store.upsertAdBlockFilterList(record) + store.setPrivacySettings(settings, for: containerID) + + defer { + store.setAdBlockFilterLists(baselineLists) + store.removeContainerSettings(for: containerID) + try? FileManager.default.removeItem(at: temporaryArtifactsURL) + } + + let didChange = await service.refreshSpace(containerId: containerID, reason: .settingsChanged) + let refreshedRecord = store.adBlockFilterList(id: record.id) + + #expect(didChange == false) + #expect(RequestCountingURLProtocol.requestCount == 0) + #expect(refreshedRecord?.status == .failed) + #expect(refreshedRecord?.lastErrorMessage == AdBlockServiceError.missingCachedList(record.name) + .errorDescription) + #expect(refreshedRecord?.activeRevision == record.activeRevision) + } } diff --git a/project.yml b/project.yml index 3fc70686..808464de 100644 --- a/project.yml +++ b/project.yml @@ -12,6 +12,9 @@ packages: FaviconFinder: url: https://github.com/will-lumley/FavIconFinder exactVersion: 5.1.5 + SafariConverterLib: + url: https://github.com/AdguardTeam/SafariConverterLib + exactVersion: 4.2.2 targets: ora: @@ -30,6 +33,8 @@ targets: - package: Sparkle - package: Inject - package: FaviconFinder + - package: SafariConverterLib + product: ContentBlockerConverter preBuildScripts: - name: "Copy Icon Bundle"