Safari
On this page 7
Safari Web Extensions ship inside a macOS app, so the Safari port has two halves:
dist-safari/— the web extension itself, built from the same codebase bybun run build:safari(buddy extension:build --target safari, provided by@stacksjs/browser-extension).safari/— a checked-in Xcode project with the container app (VeryGoodAdBlock) and the Safari Web Extension target (VeryGoodAdBlock Extension) that embeds the synced bundle. New projects generate the equivalent tree withbuddy extension:safari:init.
Requirements
-
Safari 18.4+ — pinned via
browser_specific_settings.safari.strict_min_version. That is the first Safari with every API this extension uses:Feature Safari Used for declarativeNetRequest+ dynamic rules +getMatchedRules15.4 network blocking, per-tab stats MV3 background service workers 15.4 background.jsactionbadge APIs15.4 per-tab blocked counter content_scriptsworld: "MAIN"18.0 YouTube/X/Twitch in-page pruners, pop-up guard match_about_blank18.4 pop-up guard inside about:blankframes -
Xcode (full install, not just Command Line Tools) to build the app.
buddy extension:safari:appchecks and tells you what to do; pointDEVELOPER_DIRat a beta toolchain (e.g./Applications/Xcode-beta.app/Contents/Developer) to avoid changing the system default.
The build pipeline
buddy extension:build --target safari (from @stacksjs/browser-extension)
builds the Chrome-shaped output into dist-safari and post-processes it — the
safari target is a first-class framework target, so there is no repo-local
build script:
Manifest transform
- Drops
minimum_chrome_version(Chrome-only). - Keeps
background.service_workerbut dropstype: "module"— the bundle is a classic IIFE, and the module hint buys nothing. - Replaces
browser_specific_settingswith{ safari: { strict_min_version: "18.4" } }(the gecko block is Firefox-only). - Everything else — permissions (including
declarativeNetRequestFeedback, which Safari honors forgetMatchedRules), host permissions, content scripts, the static ruleset, web-accessible stubs — carries over verbatim.
Namespace rewrite (chrome.* → browser.*)
The codebase calls promise-style chrome.* everywhere (49 call sites). That
works in Chrome and in Firefox 140+, but Safari's chrome.* namespace is
callback-flavored while its browser.* namespace is promise-native. The build
therefore rewrites chrome.<ns>. → browser.<ns>. in the four shipped bundles
(background.js, content.js, popup.js, options.js), anchored to the
audited API list (runtime, tabs, declarativeNetRequest, storage,
action, alarms) so string literals can never match. The MAIN-world in-page
scripts (x-inpage, yt-inpage, popup-guard) use page globals only and are
untouched. validate:extension:safari fails the build if any chrome.* API
access survives.
The container app
safari/VeryGoodAdBlock.xcodeproj is a hand-maintained Xcode project (two
targets, macOS only, SwiftUI) — no safari-web-extension-converter step needed
day-to-day:
VeryGoodAdBlock— a single-window SwiftUI app that shows the extension state and opens Safari's extension settings. This is what users install and launch once; the extension registers with Safari when the app runs.VeryGoodAdBlock Extension— the appex. ItsResources/folder (a folder reference, so no per-file project edits) receives the exact contents ofdist-safariwhenbuddy extension:safari:appsyncs, minus the marketing-site pages listed assafariExcludeinconfig/extension.ts.SafariWebExtensionHandler.swiftimplements the native-messaging protocol; the extension keeps all state in the web layer, so it only echoes.
Bundle IDs: org.verygoodadblock.VeryGoodAdBlock and ….Extension.
Build commands
bun run build:safari # buddy extension:build --target safari → dist-safari/
bun run package:safari # + validate + very-good-adblock-<v>-safari.zip
bun run safari:app # buddy extension:safari:app — build + sync + xcodebuild
bun run safari:app --skip-xcodebuild # build + sync only (CI-friendly)
bun run safari:init # buddy extension:safari:init — re-scaffold project + icons
See safari/README.md for signing, unsigned local
builds, and distribution (App Store vs Developer ID + notarization).
Known Safari differences
Deliberate degradations, all feature-detected in the shared code:
storage.syncdoes not sync in Safari — it behaves likelocal(settings/stats stay on the device; everything still works).action.setBadgeTextColoris absent andsetBadgeBackgroundColoris a no-op — the badge falls back to Safari's default colors (calls are optional-chained).onRuleMatchedDebugis absent — packed Chrome installs already count viagetMatchedRules, which Safari supports; behavior is unchanged.- Report screenshots:
tabs.captureVisibleTabworks, but writing PNG to the clipboard from an extension page is best-effort — the pre-filled GitHub issue still opens either way, and the report labels the browser as "Safari x.y on macOS" (UA client hints don't exist in Safari). - Content scripts need per-site approval — Safari asks the user to grant website access for the extension (toolbar icon → "Always Allow on Every Website"), standard for all Safari content blockers.