Skip to content

Content blocking on Apple platforms

A Safari content blocker never runs while you browse. It supplies a list of rules ahead of time, WebKit compiles them, and WebKit does the matching. This is what makes it possible for a blocker to be effective and to learn nothing about the pages you open. Blue Guard uses this API for its Safari blocking.

The rule format

Rules are JSON. Each has a trigger describing what to match and an action describing what to do.

{
  "trigger": {
    "url-filter": "^https?://([^/]+\\.)?example-ads\\.com",
    "resource-type": ["script", "image"],
    "load-type": ["third-party"]
  },
  "action": { "type": "block" }
}

url-filter takes a restricted regular expression, matched against the full URL. The restriction matters: WebKit compiles these into a finite state machine, so constructs that would require backtracking are rejected at compile time rather than slowing down page loads.

The useful trigger keys are url-filter, if-domain and unless-domain for scoping to sites, resource-type for narrowing to scripts, images, stylesheets and so on, and load-type for distinguishing first-party from third-party requests.

Four actions matter in practice.

ActionEffect
blockThe request is never made
block-cookiesThe request proceeds without cookies
css-display-noneElements matching a selector are hidden
ignore-previous-rulesEarlier rules are discarded for this URL

ignore-previous-rules is how an allowlist works. Order is significant: a later rule overrides an earlier one, so exceptions are appended after the rules they exempt.

Modelling rules in Swift

The JSON is a stable contract, so Codable types carry it more safely than assembled strings.

struct ContentRule: Encodable {
    struct Trigger: Encodable {
        var urlFilter: String
        var ifDomain: [String]?
        var unlessDomain: [String]?
        var resourceType: [String]?

        enum CodingKeys: String, CodingKey {
            case urlFilter = "url-filter"
            case ifDomain = "if-domain"
            case unlessDomain = "unless-domain"
            case resourceType = "resource-type"
        }
    }

    struct Action: Encodable {
        var type: String
        var selector: String?
    }

    var trigger: Trigger
    var action: Action
}

The hyphenated keys are the reason for the explicit CodingKeys. Encoding a list is then an ordinary encode:

let data = try JSONEncoder().encode(rules)
let json = String(decoding: data, as: UTF8.self)

Compiling

WebKit compiles the list and stores the result. Compilation is expensive and the result is cached, so it belongs at install or update time, not at launch.

import WebKit

func compile(_ json: String, identifier: String) async throws -> WKContentRuleList {
    let store = WKContentRuleListStore.default()
    guard let list = try await store.compileContentRuleList(
        forIdentifier: identifier,
        encodedContentRuleList: json
    ) else {
        throw CompilationFailure.rejected
    }
    return list
}

A malformed rule fails the whole list. Compilation is all or nothing, which is why a blocker validates its own rules before handing them over: partial application would leave the user with a list they believe is active and is not.

Limits

There is a ceiling on rule count. WebKit imposes a limit per list, and exceeding it fails compilation. A blocker with more rules than the ceiling must either split them across multiple lists, which an extension can supply, or refuse the change.

css-display-none works only in Safari. Hiding an element requires a selector applied to a rendered page. Nothing outside a browser has a page to apply it to, so this action has no equivalent for device-wide filtering.

Rules cannot depend on page content. The trigger sees a URL and a resource type. It cannot read the document, so a rule cannot match on what a page says.

The blocker cannot observe. There is no callback, no report, and no way to be told a rule fired. This is a deliberate design of the API, and it is why a content blocker cannot tell you what it blocked.

Where this leaves the reader

The declarative model trades expressiveness for two things: page loads are not slowed by an extension process, and the extension is structurally incapable of watching you. Anything a blocker claims to know about your browsing has to have come from somewhere else.

For what this covers in Blue Guard, see Block ads and trackers in Safari.