Code: Megapixel99/kestrel
Kestrel is a macOS browser I have been building on WKWebView to test whether a browser can hold a memory budget the way a game engine holds a frame budget. It used to ship six built-in imitations of common extensions, including an ad blocker. When macOS 15.4 shipped WKWebExtension I replaced all six with the real thing: Kestrel unpacks an .xpi and hands it to WebKit, and all 25 add-ons from my Firefox profiles load.
Then I measured whether the real ad blockers actually block anything. They do not. Neither manifest version works, the failure is silent in both, and the mechanism I deleted is the only one I can demonstrate working.
The measurement
blocktest loads an add-on, opens five known tracker URLs, and reports which of them the network layer actually fetched.
cd kestrel && ./.build/debug/kestrel blocktest ubolite 45
| what | how | result |
|---|---|---|
MV2 blocking webRequest
|
test add-on returns {cancel:true}
|
request went through |
MV3 declarativeNetRequest, uBO Lite, 6 enabled rulesets including EasyList |
5 tracker probes | 0 of 5 blocked |
| the same, after 180 s for rule compilation | same | 0 of 5 blocked |
| the same, in a web view built from the controller's own configuration | same | loaded |
WKContentRuleList compiled by Kestrel itself |
same probe | blocked |
The last row is the reason to believe the other four. A negative result from a probe is worthless unless you can show the probe detects the positive case; without that row "loaded" could just as easily mean the test never looked. The self-check runs every time, so "loaded" means the request really was not blocked.
What WebKit does here is worse than refusing. It grants webRequestBlocking and then ignores what the handler returns. It accepts declarativeNetRequest rulesets, and hasContentModificationRules reports true, without applying them to any web view I can reach through the public API. That includes one built from WKWebExtensionController.Configuration.webViewConfiguration, which is the configuration the extension controller itself hands you. An add-on has no way to detect any of this. It reports itself as working, its dashboard shows six enabled rulesets, and every request goes through.
I stated the opposite twice
Before running the test properly I wrote in this project's own notes that MV2 blocking webRequest was the problem and that an MV3 blocker would be the fix. I wrote it twice, with more confidence than the evidence supported, and it was wrong in a specific way worth naming: the manifest version is not the discriminator. Both fail; MV3 fails after accepting the rulesets and reporting them compiled, which is why it looked like the answer from the outside.
What made the claim survive is that I checked whether uBO Lite loaded, which it does, rather than whether it blocked, which it does not. Those are different questions and only one of them needed a probe.
What I have not ruled out
Two possibilities are still open, and neither is settled by anything above.
WebKit may honour only dynamic rules, added through declarativeNetRequest.updateDynamicRules, and not manifest-declared static rulesets. A test add-on that adds a single dynamic rule for a known URL would settle it in an afternoon, and it is the obvious next experiment. Note that this would not rescue uBO Lite, whose rulesets are static, but it would change the finding from "declarativeNetRequest does not work" to something narrower and more useful.
Alternatively, Safari may implement extension content blocking through its own content-blocker plumbing, which a host application embedding WebKit does not inherit. If that is the case, this is not fixable from outside the engine and should be recorded that way rather than left looking like a bug someone could fix.
I am stating both because the measurement supports "no add-on blocked anything through the public API" and does not support "WebKit cannot block." Those get conflated easily, and the second is a much larger claim than I have evidence for.
The thing that works is the thing I deleted
WKContentRuleList compiled by the browser blocks reliably. The self-check proves it on every run of blocktest, against the same probe the add-ons fail.
That is precisely the mechanism the deleted built-in ad blocker used: it converted Adblock Plus filter lists into a WKContentRuleList and handed it to the web view. I removed it, along with five other imitations, on the reasoning that real add-ons had made the imitations redundant. For five of the six that was true. For the ad blocker it was exactly backwards, and I did not find out until after the code was gone, because I checked that the replacements loaded rather than that they worked.
Restoring a native content blocker is the only demonstrated way to block ads in this browser. Which is an odd conclusion for a project whose whole point was to stop reimplementing things the platform provides.
A related failure, for contrast
Adblock Plus's options page is a separate problem with a clean answer, and it is worth putting beside the first one because it looks similar and is not.
The page came up blank. ABP's options page is a shell whose only content is an iframe of desktop-options.html, and WebKit refuses that subframe unless the add-on declared web_accessible_resources, which is stricter than Firefox or Chrome and which ABP does not declare. Kestrel now navigates the tab to the inner page directly when a lone iframe fails, so the content loads with nothing loosened: no manifest rewriting, and nothing of the add-on made readable by arbitrary web pages. The check has to be repeated rather than made once, because at didFinish the iframe carries only data-src; ABP's options.js is deferred and sets the real src a beat later.
The page now renders, and then refuses:
options page in a tab: …/desktop-options.html nodes=8
getBrowserInfo=undefined
ua=Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:141.0) Gecko/20100101 Firefox/141.0
text=Your browser version is no longer supported. Please upgrade
It is not the user agent, which is a clean Firefox string with no AppleWebKit prefix; it is browser.runtime.getBrowserInfo, a Firefox-specific API that WebKit's runtime does not implement, so ABP has no version to compare against and defaults to unsupported. That one is shimmable by injecting a script into add-on pages, but shimming it means telling an add-on it is Firefox 141 and inviting every other Gecko-only path it might then take, which fail less visibly than this one does. uBO Lite is unaffected, since its dashboard is a page rather than a frame shell.
The contrast is the useful part. The ABP failure is a missing API that announces itself: undefined is checkable, the add-on notices, and it tells the user. The blocking failure announces nothing at all. An API that is present, accepts your input, reports success and does nothing is harder to find than one that is absent, and it is the reason this took a purpose-built probe with a control rather than an afternoon of clicking around.
What generalises
When you test whether a subsystem works, the cheap version of the test asks the subsystem. The expensive version asks something downstream that has no reason to cooperate. Here the cheap version was uBO Lite's dashboard reporting six enabled rulesets, and it was wrong for three minutes and then still wrong. The expensive version was five URLs and a check of what the network actually fetched.
The control row is what turns that from an anecdote into a result, and it costs one extra case: block the same URL by a mechanism you know works, and confirm the probe notices. Without it a negative finding is indistinguishable from a broken test, which is the position I was in when I twice wrote down the wrong cause.
The full measurement, including what has been ruled out and what has not, is in BROKEN.md. Kestrel is 10,746 lines of Swift across 45 files; measurements are on an M1 Max running macOS 15.5, Swift 6.1.2 and system WebKit.
Top comments (0)