Fix: SFSafariApplication.dispatchMessage Not Delivering Messages to Safari Web Extension Background Script

Fix: SFSafariApplication.dispatchMessage Not Delivering Messages to Safari Web Extension Background Script

Written by

in

Quick answer: SFSafariApplication.dispatchMessage returns no error but delivers nothing because the extension’s SafariWebExtensionHandler must implement SFSafariExtensionHandling and the messageReceivedFromContainingApp method. Add this protocol conformance to your handler class, then forward messages to the background script via the extension’s native messaging port.

Why this happens

When you call SFSafariApplication.dispatchMessage from your macOS app, the system validates the call and returns nil for the error — even when the message never reaches the Safari Web Extension’s background script. The root cause is a missing piece on the extension side: the SafariWebExtensionHandler class (created by Xcode’s Safari Web Extension template) must conform to SFSafariExtensionHandling and implement messageReceivedFromContainingApp. Without this, the message is accepted by the framework but has no route into the extension’s JavaScript context.

Apple’s documentation and sample code show this requirement, but it’s easy to overlook because the Swift call appears to succeed. The handler acts as the bridge: it receives the message from the containing app, then forwards it through the native messaging port that the background script has already opened with browser.runtime.connectNative.

How to fix it

1. Conform SafariWebExtensionHandler to SFSafariExtensionHandling

Open the SafariWebExtensionHandler.swift file in your extension target. Add the protocol conformance and implement the required method. This method is the entry point for every message sent via dispatchMessage.

import SafariServices
import os.log

class SafariWebExtensionHandler: NSObject, SFSafariExtensionHandling {
    // Required by the protocol — receives messages from the containing app
    func messageReceivedFromContainingApp(
        withName messageName: String,
        userInfo: [String: Any]?
    ) {
        // Forward to the background script via the native messaging port
        // (see step 2 for the forwarding implementation)
        ExtensionMessageRouter.shared.route(messageName: messageName, userInfo: userInfo)
    }

    // Other SFSafariExtensionHandling methods...
    func toolbarItemClicked(in window: SFSafariWindow) { }
    func validateToolbarItem(in window: SFSafariWindow, validationHandler: @escaping ((Bool, String) -> Void)) {
        validationHandler(true, "")
    }
    func popoverViewController() -> SFSafariExtensionViewController { SafariExtensionViewController() }
}

2. Create a message router to forward to the background script

The handler runs in the extension’s app extension process. The background script runs in a separate JavaScript context and communicates via a native messaging port. Use a singleton router that holds a reference to the NSXPCConnection (or the SFSafariExtensionManager channel) to post messages into the extension’s message pipeline.

final class ExtensionMessageRouter {
    static let shared = ExtensionMessageRouter()
    private var messagePort: SFSafariExtensionMessagePort?

    private init() {}

    func setMessagePort(_ port: SFSafariExtensionMessagePort) {
        self.messagePort = port
    }

    func route(messageName: String, userInfo: [String: Any]?) {
        let payload: [String: Any] = [
            "name": messageName,
            "userInfo": userInfo ?? [:]
        ]
        messagePort?.dispatchMessage(withName: "messageFromApp", userInfo: payload) { error in
            if let error = error {
                os_log(.error, "Failed to forward message to background script: %@", error.localizedDescription)
            }
        }
    }
}

3. Connect the router when the extension starts

In your SafariExtensionViewController (or wherever you initialize the extension UI), grab the message port from SFSafariExtensionManager and hand it to the router.

class SafariExtensionViewController: SFSafariExtensionViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        SFSafariExtensionManager.getMessagePort { port in
            ExtensionMessageRouter.shared.setMessagePort(port)
        }
    }
}

4. Update the background script to listen on the correct port

The background script must open a native messaging port to the extension’s own bundle identifier, not the containing app’s. The port name matches what you use in dispatchMessage from the handler.

// background.js
const port = browser.runtime.connectNative(
    "com.yourcompany.your-safari-web-extension" // extension bundle ID
);

port.onMessage.addListener((message) => {
    if (message.name === "messageFromApp") {
        const { name, userInfo } = message.userInfo;
        console.log("Received from app:", name, userInfo);
        // Handle the message (e.g., trigger content script actions)
    }
});

port.onDisconnect.addListener(() => {
    console.log("Native messaging port disconnected");
});

5. Verify the containing app’s dispatch call

Your app code is already correct, but ensure the extension bundle identifier matches exactly (case-sensitive) and that both targets share an App Group if you need shared storage.

Button {
    let messageName = "Hello from App"
    let messageInfo = ["AdditionalInformation": "Goes Here"]
    let extensionBundleIdentifier = "com.yourcompany.your-safari-web-extension"
    SFSafariApplication.dispatchMessage(
        withName: messageName,
        toExtensionWithIdentifier: extensionBundleIdentifier,
        userInfo: messageInfo
    ) { error in
        if let error = error {
            print("Dispatch error: \(error)")
        } else {
            print("Message dispatched successfully")
        }
    }
} label: {
    Text("Test Button")
}

How to prevent it

  • Always implement SFSafariExtensionHandling on your SafariWebExtensionHandler when creating a new Safari Web Extension target.
  • Wire the SFSafariExtensionMessagePort to a router before the app attempts to send messages (do it in viewDidLoad of the extension view controller).
  • Use the extension’s own bundle identifier in browser.runtime.connectNative, not the containing app’s.
  • Test with a simple log in messageReceivedFromContainingApp first to confirm the bridge works before adding complex forwarding logic.

FAQ

Why does dispatchMessage return nil error but nothing appears in the extension console?

The dispatchMessage completion handler only reports whether the system accepted the call for delivery. It does not confirm the message reached the JavaScript context. The missing SFSafariExtensionHandling conformance means the message is dropped silently after acceptance.

Can I send messages from the extension back to the containing app?

Yes. In the background script, call port.postMessage({ ... }) on the native messaging port. On the Swift side, implement messageReceivedFromExtension in your SFSafariExtensionHandling conformance (or observe the message port) to receive them.

Does this work on iOS / iPadOS?

No. Safari Web Extensions with native messaging are macOS-only. On iOS, the containing app cannot communicate with the extension via SFSafariApplication.dispatchMessage; use App Groups and shared containers instead.

Sources & credits

Parts of this guide are adapted from content licensed under CC BY-SA 4.0; this article is shared under the same license.