Dactyloidae iOS initial commit

This commit is contained in:
wuggy 2026-06-26 21:04:09 -07:00
commit 7154a0497e
2123 changed files with 197052 additions and 0 deletions

View file

@ -0,0 +1,310 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import WebKit
import Shared
import Deferred
fileprivate let NotificationContentBlockerReloadNeeded = "NotificationContentBlockerReloadNeeded"
@available(iOS 11.0, *)
class ContentBlockerHelper {
static let PrefKeyEnabledState = "prefkey.trackingprotection.enabled"
static let PrefKeyStrength = "prefkey.trackingprotection.strength"
fileprivate let blocklistBasic = ["disconnect-advertising", "disconnect-analytics", "disconnect-social"]
fileprivate let blocklistStrict = ["disconnect-content"]
fileprivate let ruleStore: WKContentRuleListStore
fileprivate weak var tab: Tab?
fileprivate weak var profile: Profile?
static var blockImagesRule: WKContentRuleList?
// Raw values are stored to prefs, be careful changing them.
enum EnabledState: String {
case on
case onInPrivateBrowsing
case off
var settingTitle: String {
switch self {
case .on:
return Strings.TrackingProtectionOptionAlwaysOn
case .onInPrivateBrowsing:
return Strings.TrackingProtectionOptionOnInPrivateBrowsing
case .off:
return Strings.TrackingProtectionOptionAlwaysOff
}
}
static let allOptions: [EnabledState] = [.on, .onInPrivateBrowsing, .off]
}
// Raw values are stored to prefs, be careful changing them.
enum BlockingStrength: String {
case basic
case strict
var settingTitle: String {
switch self {
case .basic:
return Strings.TrackingProtectionOptionBlockListTypeBasic
case .strict:
return Strings.TrackingProtectionOptionBlockListTypeStrict
}
}
var subtitle: String {
switch self {
case .basic:
return Strings.TrackingProtectionOptionBlockListTypeBasicDescription
case .strict:
return Strings.TrackingProtectionOptionBlockListTypeStrictDescription
}
}
static let allOptions: [BlockingStrength] = [.basic, .strict]
}
static func prefsChanged() {
NotificationCenter.default.post(name: Notification.Name(rawValue: NotificationContentBlockerReloadNeeded), object: nil)
}
class func name() -> String {
return "ContentBlockerHelper"
}
private static var heavyInitHasRunOnce = false
init(tab: Tab, profile: Profile) {
self.ruleStore = WKContentRuleListStore.default()
self.tab = tab
self.profile = profile
if ContentBlockerHelper.heavyInitHasRunOnce {
return
}
ContentBlockerHelper.heavyInitHasRunOnce = true
removeOldListsByDateFromStore() {
self.removeOldListsByNameFromStore() {
self.compileListsNotInStore(completion: {})
}
}
let blockImages = "[{'trigger':{'url-filter':'.*','resource-type':['image']},'action':{'type':'block'}}]".replacingOccurrences(of: "'", with: "\"")
ruleStore.compileContentRuleList(forIdentifier: "images", encodedContentRuleList: blockImages) {
rule, error in
assert(rule != nil && error == nil)
ContentBlockerHelper.blockImagesRule = rule
}
}
func setupForWebView() {
NotificationCenter.default.addObserver(self, selector: #selector(ContentBlockerHelper.reloadTab), name: NSNotification.Name(rawValue: NotificationContentBlockerReloadNeeded), object: nil)
addActiveRulesToTab()
}
deinit {
NotificationCenter.default.removeObserver(self)
}
@objc func reloadTab() {
addActiveRulesToTab()
}
fileprivate var blockingStrengthPref: BlockingStrength {
let pref = profile?.prefs.stringForKey(ContentBlockerHelper.PrefKeyStrength) ?? ""
return BlockingStrength(rawValue: pref) ?? .basic
}
fileprivate var enabledStatePref: EnabledState {
let pref = profile?.prefs.stringForKey(ContentBlockerHelper.PrefKeyEnabledState) ?? ""
return EnabledState(rawValue: pref) ?? .onInPrivateBrowsing
}
fileprivate func addActiveRulesToTab() {
guard let tab = tab else { return }
removeTrackingProtectionFromTab()
switch enabledStatePref {
case .off:
return
case .on:
break
case .onInPrivateBrowsing:
if !tab.isPrivate {
return
}
}
let rules = blocklistBasic + (blockingStrengthPref == .strict ? blocklistStrict : [])
for name in rules {
ruleStore.lookUpContentRuleList(forIdentifier: name) { rule, error in
guard let rule = rule else {
let msg = "lookUpContentRuleList for \(name): \(error?.localizedDescription ?? "empty rules")"
Sentry.shared.send(message: "Content blocker error", tag: .general, description: msg)
return
}
self.addToTab(contentRuleList: rule)
}
}
}
func removeTrackingProtectionFromTab() {
guard let tab = tab else { return }
tab.webView?.configuration.userContentController.removeAllContentRuleLists()
if let rule = ContentBlockerHelper.blockImagesRule, tab.noImageMode {
addToTab(contentRuleList: rule)
}
}
fileprivate func addToTab(contentRuleList: WKContentRuleList) {
tab?.webView?.configuration.userContentController.add(contentRuleList)
}
func noImageMode(enabled: Bool) {
guard let rule = ContentBlockerHelper.blockImagesRule else { return }
if enabled {
addToTab(contentRuleList: rule)
} else {
tab?.webView?.configuration.userContentController.remove(rule)
}
// Async required here to ensure remove() call is processed.
DispatchQueue.main.async() {
self.tab?.webView?.evaluateJavaScript("window.__firefox__.NoImageMode.setEnabled(\(enabled))", completionHandler: nil)
}
}
}
// MARK: Private initialization code
// The rule store can compile JSON rule files into a private format which is cached on disk.
// On app boot, we need to check if the ruleStore's data is out-of-date, or if the names of the rule files
// no longer match. Finally, any JSON rule files that aren't in the ruleStore need to be compiled and stored in the
// ruleStore.
@available(iOS 11, *)
extension ContentBlockerHelper {
fileprivate func loadJsonFromBundle(forResource file: String, completion: @escaping (_ jsonString: String) -> Void) {
DispatchQueue.global().async {
guard let path = Bundle.main.path(forResource: file, ofType: "json"),
let source = try? NSString(contentsOfFile: path, encoding: String.Encoding.utf8.rawValue) as String else {
return
}
DispatchQueue.main.async {
completion(source)
}
}
}
fileprivate func lastModifiedSince1970(forFileAtPath path: String) -> Timestamp? {
do {
let url = URL(fileURLWithPath: path)
let attr = try FileManager.default.attributesOfItem(atPath: url.path)
guard let date = attr[FileAttributeKey.modificationDate] as? Date else { return nil }
return UInt64(1000.0 * date.timeIntervalSince1970)
} catch {
return nil
}
}
fileprivate func dateOfMostRecentBlockerFile() -> Timestamp {
let blocklists = blocklistBasic + blocklistStrict
return blocklists.reduce(Timestamp(0)) { result, filename in
guard let path = Bundle.main.path(forResource: filename, ofType: "json") else { return result }
let date = lastModifiedSince1970(forFileAtPath: path) ?? 0
return date > result ? date : result
}
}
fileprivate func removeAllRulesInStore(completion: @escaping () -> Void) {
ruleStore.getAvailableContentRuleListIdentifiers { available in
guard let available = available else {
completion()
return
}
let deferreds: [Deferred<Void>] = available.map { filename in
let result = Deferred<Void>()
self.ruleStore.removeContentRuleList(forIdentifier: filename) { _ in
result.fill()
}
return result
}
all(deferreds).uponQueue(.main) { _ in
completion()
}
}
}
// If any blocker files are newer than the date saved in prefs,
// remove all the content blockers and reload them.
fileprivate func removeOldListsByDateFromStore(completion: @escaping () -> Void) {
let fileDate = self.dateOfMostRecentBlockerFile()
let prefsNewestDate = profile?.prefs.longForKey("blocker-file-date") ?? 0
if prefsNewestDate < 1 || fileDate <= prefsNewestDate {
completion()
return
}
profile?.prefs.setTimestamp(fileDate, forKey: "blocker-file-date")
self.removeAllRulesInStore() {
completion()
}
}
fileprivate func removeOldListsByNameFromStore(completion: @escaping () -> Void) {
var noMatchingIdentifierFoundForRule = false
ruleStore.getAvailableContentRuleListIdentifiers { available in
guard let available = available else {
completion()
return
}
let blocklists = self.blocklistBasic + self.blocklistStrict
for contentRuleIdentifier in available {
if !blocklists.contains(where: { $0 == contentRuleIdentifier }) {
noMatchingIdentifierFoundForRule = true
break
}
}
let fileDate = self.dateOfMostRecentBlockerFile()
let prefsNewestDate = self.profile?.prefs.timestampForKey("blocker-file-date") ?? 0
if prefsNewestDate > 0 && fileDate <= prefsNewestDate && !noMatchingIdentifierFoundForRule {
completion()
return
}
self.profile?.prefs.setTimestamp(fileDate, forKey: "blocker-file-date")
self.removeAllRulesInStore {
completion()
}
}
}
fileprivate func compileListsNotInStore(completion: @escaping () -> Void) {
let blocklists = blocklistBasic + blocklistStrict
let deferreds: [Deferred<Void>] = blocklists.map { filename in
let result = Deferred<Void>()
ruleStore.lookUpContentRuleList(forIdentifier: filename) { contentRuleList, error in
if contentRuleList != nil {
result.fill()
return
}
self.loadJsonFromBundle(forResource: filename) { jsonString in
self.ruleStore.compileContentRuleList(forIdentifier: filename, encodedContentRuleList: jsonString) { _, _ in
result.fill()
}
}
}
return result
}
all(deferreds).uponQueue(.main) { _ in
completion()
}
}
}

View file

@ -0,0 +1,99 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import Foundation
import Shared
@available(iOS 11.0, *)
class ContentBlockerSettingsTableView: SettingsTableViewController {
// The first section header gets a More Info link
override func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? {
if section == 0 {
return super.tableView(tableView, viewForFooterInSection: section)
}
// TODO: Get a dedicated string for this.
let title = NSLocalizedString("More Info…", tableName: "SendAnonymousUsageData", comment: "Re-using more info label from 'anonymous usage data' item for showing a 'More Info' link on the Tracking Protection settings screen.")
var attributes = [String: AnyObject]()
attributes[NSFontAttributeName] = UIFont.systemFont(ofSize: 12, weight: UIFontWeightRegular)
attributes[NSForegroundColorAttributeName] = UIConstants.HighlightBlue
let button = UIButton()
button.setAttributedTitle(NSAttributedString(string: title, attributes: attributes), for: .normal)
button.contentHorizontalAlignment = .left
// Top and left insets are needed to match the table row style.
button.contentEdgeInsets = UIEdgeInsets(top: 8, left: 16, bottom: 0, right: 0)
button.addTarget(self, action: #selector(ContentBlockerSettingsTableView.moreInfoTapped), for: .touchUpInside)
return button
}
func moreInfoTapped() {
let viewController = SettingsContentViewController()
viewController.url = SupportUtils.URLForTopic("tracking-protection-ios")
navigationController?.pushViewController(viewController, animated: true)
}
}
@available(iOS 11.0, *)
class ContentBlockerSettingViewController: ContentBlockerSettingsTableView {
let prefs: Prefs
let EnabledStates = ContentBlockerHelper.EnabledState.allOptions
let BlockingStrengths = ContentBlockerHelper.BlockingStrength.allOptions
var currentEnabledState: ContentBlockerHelper.EnabledState
var currentBlockingStrength: ContentBlockerHelper.BlockingStrength
init(prefs: Prefs) {
self.prefs = prefs
currentEnabledState = ContentBlockerHelper.EnabledState(rawValue: prefs.stringForKey(ContentBlockerHelper.PrefKeyEnabledState) ?? "") ?? .onInPrivateBrowsing
currentBlockingStrength = ContentBlockerHelper.BlockingStrength(rawValue: prefs.stringForKey(ContentBlockerHelper.PrefKeyStrength) ?? "") ?? .basic
super.init(style: .grouped)
self.title = Strings.SettingsTrackingProtectionSectionName
hasSectionSeparatorLine = false
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func generateSettings() -> [SettingSection] {
let enabledSetting: [CheckmarkSetting] = EnabledStates.map { option in
return CheckmarkSetting(title: NSAttributedString(string: option.settingTitle), subtitle: nil, isEnabled: {
return option == self.currentEnabledState
}, onChanged: {
self.currentEnabledState = option
self.prefs.setString(self.currentEnabledState.rawValue, forKey: ContentBlockerHelper.PrefKeyEnabledState)
self.tableView.reloadData()
ContentBlockerHelper.prefsChanged()
LeanPlumClient.shared.track(event: .trackingProtectionSettings, withParameters: ["Enabled option": option.rawValue as AnyObject])
UnifiedTelemetry.recordEvent(category: .action, method: .change, object: .setting, value: ContentBlockerHelper.PrefKeyEnabledState, extras: ["to": option.rawValue])
})
}
let strengthSetting: [CheckmarkSetting] = BlockingStrengths.map { option in
return CheckmarkSetting(title: NSAttributedString(string: option.settingTitle), subtitle: NSAttributedString(string: option.subtitle), isEnabled: {
return option == self.currentBlockingStrength
}, onChanged: {
self.currentBlockingStrength = option
self.prefs.setString(self.currentBlockingStrength.rawValue, forKey: ContentBlockerHelper.PrefKeyStrength)
self.tableView.reloadData()
ContentBlockerHelper.prefsChanged()
LeanPlumClient.shared.track(event: .trackingProtectionSettings, withParameters: ["Strength option": option.rawValue as AnyObject])
UnifiedTelemetry.recordEvent(category: .action, method: .change, object: .setting, value: ContentBlockerHelper.PrefKeyStrength, extras: ["to": option.rawValue])
})
}
let firstSection = SettingSection(title: NSAttributedString(string: Strings.TrackingProtectionOptionOnOffHeader), footerTitle: NSAttributedString(string: Strings.TrackingProtectionOptionOnOffFooter), children: enabledSetting)
// The bottom of the block lists section has a More Info button, implemented as a custom footer view,
// SettingSection needs footerTitle set to create a footer, which we then override the view for.
let blockListsTitle = Strings.TrackingProtectionOptionBlockListsTitle
let secondSection = SettingSection(title: NSAttributedString(string: blockListsTitle), footerTitle: NSAttributedString(string: "placeholder replaced with UIButton"), children: strengthSetting)
return [firstSection, secondSection]
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long