Fix iOS/macOS build chain + restore adblock-rust generic procedural coverage#363
Merged
Conversation
Rust release profile sets lto=true, which makes rustc emit LLVM bitcode .o files for the linker to finish codegen. When the Rust toolchain's LLVM is newer than Xcode's (e.g. rust 1.95 / LLVM 22 vs Xcode 17 / LLVM 17), ld/nm reject the archive with "Unknown attribute kind (NNN)", zero ws_* symbols make it into Runner, and ContentBlocker silently falls back to the Dart parser. Pass CARGO_PROFILE_RELEASE_LTO=false for ios/macos targets only — native Mach-O .o emission avoids the LLVM version skew. Linux/Android keep full LTO.
The Podfile verify phase runs inside xcodebuild and can be skipped by build-system caching or swallowed by output filtering. Add an explicit post-build nm check in CI for both iOS Runner and the macOS binary, so a missing rust link surfaces as a red job instead of a green build that falls back to the Dart parser at runtime.
Podfile post_install in 54391eb writes -force_load, -Wl,-u,_ws_engine_new, and the verify build phase into Runner.xcodeproj at install time. Those edits aren't committed. Flutter's build step only runs pod install when Manifest.lock disagrees with Podfile.lock — under our pod cache they always agree, so post_install never fires and the pbxproj keeps its committed (unmodified) form, dropping all ws_* symbols at link. Run pod install explicitly for both ios and macos before flutter build to force the hook against each run's fresh checkout.
The previous attempt to call `pod install` standalone trips flutter_additional_ios_build_settings (podhelper.rb:61), likely because Generated.xcconfig isn't fully set up outside the flutter build flow. Deleting Pods/Manifest.lock makes Flutter's own pod-install-needed check return true, so the standard flutter build invocation runs pod install with the proper FLUTTER_ROOT and Generated.xcconfig env, and post_install fires inside that.
…d hooks Replaces the post_install gymnastics in ios/Podfile and macos/Podfile — shell build phase for the rust lib, -force_load + -Wl,-u dance, verify phase, manual pbxproj mutation — with a real CocoaPod we own at rust/webspace_adblock/cocoapods/. Its source files are a single keep_alive.c (auto-generated by scripts/build_rust.sh apple from the crate's #[no_mangle] FFI surface) plus the vendored binaries: * iOS: WebspaceAdblock.xcframework (device arm64 + sim arm64/x86_64) * macOS: libwebspace_adblock-macos.a (fat arm64 + x86_64) Both built by `scripts/build_rust.sh apple` into the podspec dir. CocoaPods links the .a into the framework, keep_alive.c's __used array of FFI addresses defeats -dead_strip, and Runner gets the symbols at runtime via dlsym(RTLD_DEFAULT, ...) from webspace_adblock.framework embedded in the .app bundle. Eliminates the whole class of CI bugs we'd been chasing: * Pod cache + uncommitted pbxproj mods (post_install never re-ran) * Per-symbol -Wl,-u list drift (now auto-derived from src/lib.rs) * -force_load + LLVM bitcode version skew (no force_load at all) * xcframework-vs-force_load incompatibility (no force_load) Net: -214 +172 lines, ios/Podfile drops from 232 to 68.
…uild Adds a script_phases entry to the webspace_adblock podspec so xcodebuild runs scripts/build_rust.sh apple before linking the pod's framework, plus a post_install hook in both Podfiles that flips alwaysOutOfDate=1 on that phase so Xcode doesn't skip it once it's run successfully. Net effect: every `fvm flutter build` (ios/macos) invokes the rust build. cargo's incremental compile keeps the no-op steady-state cost near zero; the win is that a src/lib.rs edit can never silently miss the framework — the FFI surface in the linked binary always matches the current source at build time. Also stops swallowing `rustup target add` failures in the script — previously they surfaced as the much more confusing cargo error "can't find crate for `core`" several seconds later.
Symptom: rustup target list/add says aarch64-apple-ios is installed, but cargo build emits E0463 "can't find crate for \`core\`" several seconds in. Cause is a second rust on PATH (typically /opt/homebrew/ bin/cargo from `brew install rust`) shadowing ~/.cargo/bin/cargo — rustup populated rustup's sysroot, but cargo is reading brew's. Fix: prepend rustup's bin to PATH inside the apple build, and drive cargo through `rustup run <toolchain> cargo …` so the toolchain hosting the targets is the one doing the build, regardless of PATH order. Also fail fast with a useful message if rustup isn't on PATH at all.
…pec era CocoaPods doesn't auto-remove what a previous post_install added. Users upgrading from the force_load architecture have leftover `[webspace] Build adblock-rust static lib` + `Verify ...` phases on Runner pointing at deleted shell-script paths, plus -force_load / -Wl,-u flags in OTHER_LDFLAGS. Both Podfiles now strip them on every pod install — idempotent no-op once the project is clean.
Runner had `-framework webspace_adblock` in its OTHER_LDFLAGS, the pod's framework binary linked correctly with 15 ws_* exports, and the framework was copied into the .app bundle — but `otool -L Runner` did not list it, and dlsym(RTLD_DEFAULT, "ws_engine_new") returned null at runtime. Cause: Apple ld defaults to `dead_strip_dylibs`, which removes the LC_LOAD_DYLIB load command for any dylib that has zero compile-time symbol references. Runner's Swift/ObjC code never references ws_* directly (Dart FFI does dlsym at runtime), so ld dropped the dependency. Result: framework on disk, dyld never loads it. `-Wl,-needed_framework,webspace_adblock` tells ld to keep the LC_LOAD_DYLIB regardless of references. Wired via the podspec's user_target_xcconfig so it propagates to Runner without us touching its xcconfig directly.
Two stacked issues found from CI/user run after the podspec refactor: 1) xcodebuild -create-xcframework rejected the simulator slice when its file basename (iphonesimulator-fat-libwebspace_adblock.a) didn't match the device slice (libwebspace_adblock.a). CocoaPods then refused to install the xcframework: "static libraries with differing binary names". Stage the simulator fat .a in a dedicated target/iphonesimulator-fat/ directory under the same basename. 2) Pod user_target_xcconfig OTHER_LDFLAGS doesn't make it into Pods-Runner.xcconfig — CocoaPods drops overrides for keys it already manages — so Apple ld's dead_strip_dylibs kept removing webspace_adblock from Runner's LC_LOAD_DYLIB despite the flag being declared in the podspec. Inject -Wl,-needed_framework, webspace_adblock directly into Runner's OTHER_LDFLAGS via the existing post_install hook in both Podfiles instead. Idempotent.
…ata: adblock-rust's Engine::url_cosmetic_resources constructs a Request from the URL and returns UrlSpecificResources::empty() when that fails — which it does for any URL without a real hostname. The generic-rule merge (misc_generic_selectors: attribute selectors, :has, :style, procedural shapes) lives downstream of that early return, so all complex generic cosmetic shapes silently miss on file:// / blob: / data: / about: pages. Pre-65be41b the Dart parser's _cosmeticSelectors[''] bucket caught these. 65be41b deleted the parser without restoring the path, so the probe page (file:///) and HTML imports lost everything except class/id-keyed rules. Pass `https://webspace.invalid/` to the engine when the page URL is hostless. `.invalid` is reserved by RFC 6761; no real filter list will target that host, so domain-specific rules can't accidentally fire. The misc-generic-selector merge runs and returns the rules we want.
Without this flag, adblock-rust 0.12 silently fails to parse procedural cosmetic rules (`:has-text()`, `:upward()`, `:remove()`, `:remove-attr()`, `:remove-class()`, `:style()`) on class/id-anchored generic selectors — even though the crate stores them internally, they never appear in cosmeticResources(url).procedural_actions. Upstream issue brave/adblock-rust#537 documents the symptom; PR brave/adblock-rust#609 proposes making the flag default-on for exactly this reason. Enabling it brings the cosmetic-rule surface the FFI exposes up to the level the existing procedural shim runner already expects, without us having to either fork the crate or re-implement an in-Dart procedural parser. The flag pulls in `cssparser` + `selectors`; ~70 kB added to the static library, no API change. Cargo.lock will be regenerated on the next build.
Enabling the css-validation feature in d1f74fa added cssparser + selectors (plus their transitives) to the resolved dependency graph. build_rust.sh runs `cargo build --locked`, which refuses to update Cargo.lock and fails with "cannot update the lock file ... because --locked was passed to prevent this" until the lockfile is regenerated.
Adds a focused Dart parser for the two cosmetic rule shapes adblock-rust 0.12 parses correctly (with css-validation) but has no public API to retrieve by class/id: * `##sel:remove() / :remove-attr() / :remove-class()` procedurals * `##sel:style(decls)` style overrides Per-anchor buckets keyed on the first class/id token in the selector, mirroring `engine.hiddenClassIdSelectors`. Output shape matches `buildProceduralCosmeticShim`'s input so backfilled rules and engine rules flow through the same runner without origin branching. Not yet wired into ContentBlockerService — leaving it as a standalone module for review first. Wiring is a separate commit that touches the engine rebuild path and the page-side `genericCosmeticScan` bridge handler.
… host
adblock-rust 0.12 rejects every generic procedural rule at parse
time (cosmetic.rs:444 — `if sharp_index == 0 && action.is_some()
{ return Err(GenericAction) }`). No FFI path surfaces them and
no feature flag including css-validation changes that — the
rejection runs before validation. Verified with isolated tests
in the new procedural_backfill integration suite.
Prepend a synthetic hostname (`localhost`) to any generic
cosmetic rule that carries a `:remove()` / `:remove-attr()` /
`:remove-class()` / `:style()` action before handing the filter
list to the engine. The parser then treats it as domain-scoped,
stores it in the cosmetic cache, and `cosmeticResources(
"https://localhost/")` returns the rule via the standard
`procedural_actions` field in the same JSON shape as native
domain-scoped procedurals — so the existing procedural shim
runner consumes both without branching.
Replaces the in-Dart procedural-rule parser drafted earlier
(now 319 lines lighter): pure prefix rewrite plus a regex to
detect the action pseudo. All parsing and selector handling
stays inside the crate.
Adds a 4-test integration suite in `rust/webspace_adblock/tests/`
that pins:
- generic procedural rules are dropped at parse time
- synthetic-host prefix recovers them via procedural_actions
- all four action shapes round-trip
- domain-scoped procedurals are unaffected
CI runs `cargo test` on the Linux job so a future adblock-rust
upgrade can't silently undo this behaviour.
The Dart-side caller (ContentBlockerService) still needs to call
`rewriteGenericProceduralsForBackfill` before passing rulesText
to the engine, and query `cosmeticResources("https://localhost/")`
in addition to the page URL — those wiring changes follow in a
separate commit.
Two integration points for procedural_action_backfill.dart:
1. `_rebuildEngine` now feeds the filter-list text through
`rewriteGenericProceduralsForBackfill` before passing to
`AdblockEngine.load`. Generic procedural rules (`##.foo:remove()`
etc.) get a `localhost##` prefix that bypasses the crate's
parse-time `GenericAction` rejection.
2. `_engineCosmeticFor(pageUrl)` now also queries
`cosmeticResources('https://localhost/')` and unions the returned
`procedural_actions` into the result. Skipped when the page URL
already maps to the synthetic host (file:// → localhost path).
Hides + exceptions from the synthetic query are discarded — the
real-URL query already surfaces them via the misc-generic merge.
Cache invalidation: bumps `_kEngineCacheVersion` to 2 and prefixes
the rules hash with it, so blobs written by builds before the
rewrite landed get rejected on first launch and the engine re-parses
the rewritten text. Without the bump, cached engines would be stuck
without procedural rules until the user manually nuked the cache.
The probe page's sections 1B / 1C / 1E should now flip from
"missed" to "action fired" / "height: 1px" without any cache wipe.
…r pseudos Two follow-ups to the procedural backfill rewriter: 1. Filter-pseudo rules with no action pseudo (`##.foo:has-text(X)`, `##.foo:-abp-has(.x)`, `##.foo:contains(X)`) are default-hide in uBO syntax. Adblock-rust stores them as hide selectors with the procedural pseudo embedded in the selector string — fine for storage but the early-CSS injection then can't match anything, because the pseudo isn't real CSS. The rewriter now appends `:style(display: none !important)` to convert these into procedural-style rules that the procedural shim runner handles correctly. 2. With css-validation on, the crate's parser only accepts canonical uBO pseudo names. ABP-syntax aliases (`:-abp-contains`, `:contains`, `:-abp-has`) are silently rejected and the whole rule is dropped. The rewriter now normalises them BEFORE handing to the parser: `:contains(` / `:-abp-contains(` → `:has-text(`, `:-abp-has(` → `:has(`. After normalisation the procedural shim sees a uniform has-text/has structure regardless of original syntax. Drive-by: the inline `rules_to_engine` test helper in src/lib.rs hadn't been updated when `ws_engine_new` gained the `enable_ubo_resources` param. Fixing — both as a syntax error and as a behaviour mismatch (the redirect test needs resources loaded). Probe page sections 1B / 1C / 1E now green, section 1 rows 7-9 (the filter-pseudo-only rules) also flip to BLOCKED.
…dation objective_c (transitive Flutter dep) ships a precompiled .framework without a dSYM. Xcode archive validation then refuses to distribute: "The archive did not include a dSYM for the objective_c.framework with the UUIDs ...". Set DEBUG_INFORMATION_FORMAT = dwarf for ALL pod targets so debug info is inlined into each pod's binary and no separate dSYM is expected. Apple's crash symbolication against pod code becomes approximate, but Flutter app stacks land mostly in Dart anyway, where symbolication uses our own symbol map regardless. Trade-off worth it for unblocking archive distribution.
theoden8
pushed a commit
that referenced
this pull request
May 20, 2026
Setting DEBUG_INFORMATION_FORMAT=dwarf on pod targets (PR #363) was not enough — archive validation runs against Runner's own setting, and Runner still defaults to dwarf-with-dsym. Xcode therefore still expects a dSYM for every linked framework, including objective_c.framework (transitive Flutter dep, ships precompiled without one). Result: "The archive did not include a dSYM for the objective_c.framework with the UUIDs [...]" and the archive can't be distributed. Apply the same setting to Runner's build configs via the post_install hook. Runner's debug info is then inlined in its binary rather than emitted as Runner.app.dSYM. Crash reports symbolicate against Runner approximately; Dart stacks resolve via the embedded VM symbol map regardless of dSYM presence.
theoden8
added a commit
that referenced
this pull request
May 26, 2026
…#364) Setting DEBUG_INFORMATION_FORMAT=dwarf on pod targets (PR #363) was not enough — archive validation runs against Runner's own setting, and Runner still defaults to dwarf-with-dsym. Xcode therefore still expects a dSYM for every linked framework, including objective_c.framework (transitive Flutter dep, ships precompiled without one). Result: "The archive did not include a dSYM for the objective_c.framework with the UUIDs [...]" and the archive can't be distributed. Apply the same setting to Runner's build configs via the post_install hook. Runner's debug info is then inlined in its binary rather than emitted as Runner.app.dSYM. Crash reports symbolicate against Runner approximately; Dart stacks resolve via the embedded VM symbol map regardless of dSYM presence. Co-authored-by: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two-headed branch that started as
flutter build ipa --releasefailing on iOS and grew into a content-blocker rule-shape recovery once the build chain was healthy enough to actually test the engine.Build chain — iOS/macOS rust integration
Symptom:
dlsym(RTLD_DEFAULT, "ws_engine_new")returned null at runtime; cosmetic rules fell back to nothing because the rust adblock engine wasn't reachable. Rust 1.95's LLVM 22 produced bitcode the Xcode 17 linker couldn't read; even after that, the old-force_load+-Wl,-u,_ws_engine_newpattern only pinned a single symbol against dead-strip.Refactor:
scripts/build_rust.sh: newapplemode that builds the five Apple slices once, assembles an.xcframeworkfor iOS + a fat.afor macOS, writeskeep_alive.cderived from the rust#[no_mangle]surface, drops everything intorust/webspace_adblock/cocoapods/.CARGO_PROFILE_RELEASE_LTO=falseworks around the LLVM-version skew.rustup rundrives cargo so a Homebrew-installed cargo can't shadow rustup's toolchain.rust/webspace_adblock/cocoapods/webspace_adblock.podspec: real CocoaPod we own.keep_alive.cis its only source file;WebspaceAdblock.xcframework+libwebspace_adblock-macos.aare vendored binaries.script_phasesruns the rust build on everyxcodebuild(alwaysOutOfDate = '1'set via post_install).ios/Podfile+macos/Podfile: 232 → ~85 lines each. Drop the force_load +-Wl,-uLDFLAG dance, drop the in-pbxproj[webspace] Build adblock-rust static lib+Verifybuild phases, drop the verify shell script. The post_install hook now only injects-Wl,-needed_framework,webspace_adblockinto Runner'sOTHER_LDFLAGS(Apple ld'sdead_strip_dylibswould otherwise drop the framework dependency because no Swift/ObjC code references ws_* symbols at compile time) and migration-cleans any stale phases / flags from the pre-podspec era. Also setsDEBUG_INFORMATION_FORMAT = dwarffor all pod targets so the missingobjective_c.frameworkdSYM doesn't fail archive validation.lib/services/adblock_engine.dart: comment updated to reflect the framework-loaded-at-startup path instead of static-baked.Content-blocker — procedural rule coverage
Once the engine actually loaded, the ABP rule probe (
test/fixtures/abp_rule_probe.html) revealed that most generic procedural rules silently failed. Tracked down through direct rust tests:Engine::url_cosmetic_resources(file:///...)returns empty because the URL has no hostname (Request::newfails). Substitute hostless schemes (file://,blob:,data:,about:) tohttps://localhost/before querying — the generic-CSS-rule merge runs and returns ~1000 selectors.adblock-rust0.12 rejects every generic procedural rule at parse time (cosmetic_filter_cache_builder.rs→cosmetic.rs:444:if sharp_index == 0 && action.is_some() { Err(GenericAction) }). The crate doesn't store them in any retrievable bucket — fork would need to add storage, not just an API. Workaround: rewrite filter-list text at ingestion to prepend a syntheticlocalhost##to any generic rule carrying a procedural action pseudo (:remove(),:remove-attr(...),:remove-class(...),:style(...)). The rule is then stored as domain-scoped forlocalhost;cosmeticResources('https://localhost/').procedural_actionsreturns it in the same JSON shape as native procedurals, so the existing procedural shim consumes it without branching.##.foo:has-text(X),##.foo:-abp-has(.x),##.foo:contains(X)) are default-hide in uBO syntax. The crate stores them as hide selectors but with the procedural pseudo embedded in the selector string, which can't match anything as plain CSS. The rewriter appends:style(display: none !important)to convert these into procedural-style rules.css-validationenabled (PR Enable css-validation feature by default to fix :has-text() selector brave/adblock-rust#609, applied locally as the feature flag was off by default), the crate's parser only accepts canonical uBO pseudo names. ABP-syntax aliases (:-abp-contains,:contains,:-abp-has) are silently rejected. The rewriter normalises them —:contains(/:-abp-contains(→:has-text(,:-abp-has(→:has(.ContentBlockerServicecallscosmeticResources('https://localhost/')alongside the page URL and unions the procedural lists. Cache key gets a_kEngineCacheVersionprefix bumped to 2 so blobs serialised by old builds get rejected and the engine re-parses with the rewriter active.Tests
rust/webspace_adblock/tests/procedural_backfill.rs(new, 5 tests, runs on the Linux CI job viacargo test --locked) pins the upstream-crate behaviour the rewriter depends on:procedural_actionsIf a future adblock-rust upgrade stops rejecting
GenericAction, the first test fails loud and we know to remove the backfill.CI also now
nm-checks that_ws_engine_newis exported from the embeddedwebspace_adblock.frameworkin both iOS Runner and macOS app bundles, so a missing-symbol regression breaks the build rather than the runtime.Probe state on this branch
Sections 1 (all 9), 1B (the global rule), 1C (all 5), 1D (all 7), 1E (all 3) now BLOCKED / "action fired" / "height: 1px". The remaining "visible (off-host)" / "visible" rows are intentional — host-scoped off-host rules and unsupported rule shapes that the parser correctly skips.