Skip to content

Codex/content blockers#44

Open
tifroz wants to merge 7 commits intoskiptools:mainfrom
tifroz:codex/content-blockers
Open

Codex/content blockers#44
tifroz wants to merge 7 commits intoskiptools:mainfrom
tifroz:codex/content-blockers

Conversation

@tifroz
Copy link
Copy Markdown
Contributor

@tifroz tifroz commented Mar 28, 2026

Thank you for contributing to the Skip project! Please use this space to describe your change and add any labels (bug, enhancement, documentation, etc.) to help categorize your contribution.

Please review the contribution guide at https://skip.dev/docs/contributing/ for advice and guidance on making high-quality PRs.

Skip Pull Request Checklist:

  • REQUIRED: I have signed the Contributor Agreement
  • REQUIRED: I have tested my change locally with swift test
  • OPTIONAL: I have tested my change on an iOS simulator or device
  • OPTIONAL: I have tested my change on an Android emulator or device

  • AI was used to generate or assist with generating this PR. Please specify below how you used AI to help you, and what steps you have taken to manually verify the changes.

Codex generated the code. Unit test all green when running locally + ran Smoke Tests from Android/iOS devices:

Content Blocking Smoke Suite

Smoke Tests Summary

These smoke tests focus on four higher-level areas:

  • Configuration and propagation: default blocker state and inheritance into popup child engines.
  • Android navigation client ownership: preserving caller-supplied clients while keeping blocker enforcement active.
  • Android cosmetic planning and script generation: rule defaults, URL/origin/frame filtering, history/redirect handling, and generated CSS injection/removal scripts.
  • iOS rule-list lifecycle: compilation, cache reuse, installation into WKWebView, cache invalidation, and pruning.
  • Error handling and safety rails: invalid rule files, unsupported Android features, and fallback behavior when document-start injection is unavailable.

Smoke Tests Details

  • testContentBlockerConfigurationDefaults: verifies a fresh blocker config starts empty on both iOS and Android surfaces.
  • testPopupChildMirroredConfigurationPreservesContentBlockers: verifies popup child engines inherit the parent’s content-blocker configuration.
  • testAndroidLegacyEngineDelegateDoesNotReplaceInternalWebViewClient: verifies the deprecated Android navigation hook no longer displaces the engine-owned blocker client.
  • testAndroidSuppliedWebViewClientIsPreservedUnderInternalClient: verifies a caller-supplied Android WebViewClient is embedded under the internal blocker client instead of being dropped.
  • testAndroidPageContextDefaultsHostFromURL: verifies Android page context derives host from the supplied page URL.
  • testAndroidCosmeticRuleDefaults: verifies Android cosmetic rules default to wildcard origins, main-frame scope, and document-start timing.
  • testAndroidCosmeticRuleHiddenSelectorsConvenienceInitializer: verifies selector-based hide rules normalize into CSS plus the expected scope/filter metadata.
  • testAndroidRedirectDetectionSkipsLookupWhenFeatureUnsupported: verifies redirect probing is skipped when the Android WebView feature is unavailable.
  • testAndroidHistoryNavigationIndexUsesOffsetFromCurrentEntry: verifies back/forward navigation computes the correct target history index.
  • testAndroidHistoryNavigationIndexRejectsOutOfBoundsTargets: verifies invalid history offsets are rejected instead of pre-registering cosmetics for a bad entry.
  • testAndroidCosmeticPlanPreservesDocumentStartRuleOrder: verifies document-start rules keep caller order when native document-start support exists.
  • testAndroidCosmeticPlanFallsBackOnlyForMainFrameRulesWhenUnsupported: verifies unsupported document-start injection degrades only main-frame rules to lifecycle CSS.
  • testAndroidCosmeticPlanKeepsPageLifecycleRulesForMainFrameOnly: verifies lifecycle injection keeps only rules that are valid for main-frame fallback.
  • testAndroidCosmeticPlanFiltersLifecycleRulesByAllowedOriginRules: verifies lifecycle CSS is filtered by allowed-origin matching before injection.
  • testAndroidCosmeticPlanFiltersFallbackRulesByAllowedOriginRules: verifies document-start fallback still respects allowed-origin filters.
  • testAndroidCosmeticPlanFiltersFallbackRulesByURLFilterPattern: verifies document-start fallback still respects urlFilterPattern matching on the main document URL.
  • testAndroidRedirectFallbackPlanDegradesDocumentStartRulesEvenWhenSupported: verifies redirected final pages intentionally degrade document-start rules to late main-frame injection.
  • testAndroidContentBlockerStyleInjectionScriptAddsMainFrameGuard: verifies generated CSS injection scripts bail out in subframes for main-frame-only rules.
  • testAndroidContentBlockerStyleInjectionScriptAddsSubframeGuard: verifies generated CSS injection scripts bail out in the top frame for subframe-only rules.
  • testAndroidContentBlockerStyleInjectionScriptAddsURLGuard: verifies generated CSS injection scripts include a URL-match guard for URL-scoped rules.
  • testAndroidContentBlockerStyleInjectionScriptLeavesAllFramesUngarded: verifies all-frame scripts omit frame guards so they can run in any frame context.
  • testAndroidContentBlockerStyleRemovalScriptTargetsInjectedStyle: verifies stale injected blocker CSS can be removed by targeting the expected style element.
  • testIOSContentBlockerRuleListsCompileAndReusePersistentCache: verifies iOS rule lists compile once and are reused from persistent cache on subsequent loads.
  • testIOSContentBlockersInstallIntoSuppliedWKWebView: verifies configured iOS blocker rule lists install into a caller-supplied WKWebView.
  • testIOSContentBlockerRuleListChangesInvalidateCachedIdentifier: verifies changing a rule file recompiles it and prunes the old cached identifier.
  • testIOSContentBlockerRemovingRuleFilePrunesStaleIdentifier: verifies removing a configured rule file prunes its stale compiled rule-list entry.
  • testIOSContentBlockerSetupErrorsAreRecordedWithoutFailingConfiguration: verifies invalid iOS rule files surface setup errors without aborting async config or engine setup.

Quick Usage Example

struct ContentBlockingProvider: AndroidContentBlockingProvider {
    func requestDecision(for request: AndroidBlockableRequest) -> AndroidRequestBlockDecision {
        if request.url.host?.contains("ads") == true {
            return .block
        }
        return .allow
    }

    func cosmeticRules(for page: AndroidPageContext) -> [AndroidCosmeticRule] {
        [
            AndroidCosmeticRule(hiddenSelectors: [".ad-banner"]),
            AndroidCosmeticRule(
                hiddenSelectors: [".ad-slot", ".tracking-frame"],
                urlFilterPattern: ".*\\/ad-frame\\.html",
                allowedOriginRules: ["https://*.doubleclick.net"],
                frameScope: .subframesOnly
            ),
            AndroidCosmeticRule(
                css: ["body.modal-open { overflow: auto !important; }"],
                preferredTiming: .pageLifecycle
            )
        ]
    }
}

let configuration = WebEngineConfiguration(
    contentBlockers: WebContentBlockerConfiguration(
        iOSRuleListPaths: ["/path/to/content-blockers.json"],
        androidMode: .custom(ContentBlockingProvider())
    )
)

let webViewConfiguration = await configuration.makeWebViewConfiguration()
let webView = WKWebView(frame: .zero, configuration: webViewConfiguration)
let engine = WebEngine(configuration: configuration, webView: webView)
_ = await engine.awaitContentBlockerSetup()

New API Shape In This Branch

Configuration Surface

public struct WebContentBlockerConfiguration {
    public var iOSRuleListPaths: [String]
    public var androidMode: AndroidContentBlockingMode
    @available(*, deprecated, message: "Use androidMode with AndroidContentBlockingMode.custom(...) instead.")
    public var androidRequestBlocker: (any AndroidRequestBlocker)?
    @available(*, deprecated, message: "Use androidMode with AndroidContentBlockingMode.custom(...) instead.")
    public var androidCosmeticBlocker: (any AndroidCosmeticBlocker)?

    public init(
        iOSRuleListPaths: [String] = [],
        androidMode: AndroidContentBlockingMode = .disabled,
        androidRequestBlocker: (any AndroidRequestBlocker)? = nil,
        androidCosmeticBlocker: (any AndroidCosmeticBlocker)? = nil
    )
}
public struct WebEngineConfiguration {
    public var contentBlockers: WebContentBlockerConfiguration?
    public private(set) var contentBlockerSetupErrors: [WebContentBlockerError]

    @MainActor public func makeWebViewConfiguration() async -> WebViewConfiguration
    @MainActor public func installContentBlockers(
        into userContentController: WKUserContentController
    ) async -> [WebContentBlockerError]
}
public final class WebEngine {
    public private(set) var contentBlockerSetupErrors: [WebContentBlockerError]

    public func awaitContentBlockerSetup() async -> [WebContentBlockerError]
}

Android Content Blocking Mode

public protocol AndroidContentBlockingProvider {
    func requestDecision(for request: AndroidBlockableRequest) -> AndroidRequestBlockDecision
    func cosmeticRules(for page: AndroidPageContext) -> [AndroidCosmeticRule]
}
public enum AndroidContentBlockingMode {
    case disabled
    case custom(any AndroidContentBlockingProvider)
}

Android Request Blocking

public enum AndroidRequestBlockDecision: Sendable {
    case allow
    case block
}
public enum AndroidResourceTypeHint: String, CaseIterable, Hashable, Sendable {
    case document
    case subdocument
    case stylesheet
    case script
    case image
    case font
    case media
    case xhr
    case fetch
    case websocket
    case other
}
public struct AndroidBlockableRequest: Equatable, Sendable {
    public var url: URL
    public var mainDocumentURL: URL?
    public var method: String
    public var headers: [String: String]
    public var isForMainFrame: Bool
    public var hasGesture: Bool
    public var isRedirect: Bool?
    public var resourceTypeHint: AndroidResourceTypeHint?

    public init(
        url: URL,
        mainDocumentURL: URL? = nil,
        method: String,
        headers: [String: String] = [:],
        isForMainFrame: Bool,
        hasGesture: Bool,
        isRedirect: Bool? = nil,
        resourceTypeHint: AndroidResourceTypeHint? = nil
    )
}
public protocol AndroidRequestBlocker {
    func decision(for request: AndroidBlockableRequest) -> AndroidRequestBlockDecision
}

These legacy Android request/cosmetic protocols remain available as deprecated compatibility shims for one release, but the primary branch API is androidMode: .custom(...).

Android Cosmetic Blocking

public struct AndroidPageContext: Equatable, Sendable {
    public var url: URL
    public var host: String?

    public init(url: URL, host: String? = nil)
}
public enum AndroidCosmeticFrameScope: String, CaseIterable, Hashable, Sendable {
    case mainFrameOnly
    case subframesOnly
    case allFrames
}
public enum AndroidCosmeticInjectionTiming: String, CaseIterable, Hashable, Sendable {
    case documentStart
    case pageLifecycle
}
public struct AndroidCosmeticRule: Equatable, Sendable {
    public var css: [String]
    public var urlFilterPattern: String?
    public var allowedOriginRules: [String]
    public var frameScope: AndroidCosmeticFrameScope
    public var preferredTiming: AndroidCosmeticInjectionTiming

    public init(
        css: [String] = [],
        urlFilterPattern: String? = nil,
        allowedOriginRules: [String] = ["*"],
        frameScope: AndroidCosmeticFrameScope = .mainFrameOnly,
        preferredTiming: AndroidCosmeticInjectionTiming = .documentStart
    )

    public init(
        hiddenSelectors: [String],
        urlFilterPattern: String? = nil,
        allowedOriginRules: [String] = ["*"],
        frameScope: AndroidCosmeticFrameScope = .mainFrameOnly,
        preferredTiming: AndroidCosmeticInjectionTiming = .documentStart
    )
}
public protocol AndroidCosmeticBlocker {
    func cosmetics(for page: AndroidPageContext) -> [AndroidCosmeticRule]
}

Error Reporting

public enum WebContentBlockerError: Error, Equatable, LocalizedError {
    case storeUnavailable(String)
    case fileReadFailed(path: String, description: String)
    case fileEncodingFailed(path: String)
    case cacheLookupFailed(identifier: String, description: String)
    case compilationFailed(path: String, description: String)
    case metadataReadFailed(String)
    case metadataWriteFailed(String)
    case staleRuleRemovalFailed(identifier: String, description: String)
    case operationTimedOut(String)
}

@cla-bot cla-bot bot added the cla-signed label Mar 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant