Safari Web Extension Native Messaging with Swift

This article provides a quick overview and step-by-step guide on establishing communication between a Safari Web Extension and its containing native macOS or iOS application using Swift. By leveraging Apple's Native Messaging APIs, developers can pass data from the browser's JavaScript environment to the native app extension handler, send responses back, and bridge that data to the main host application using App Groups.

1. Configure the Extension Manifest

To enable communication between your JavaScript files and the native Swift code, you must first declare the nativeMessaging permission in your extension's manifest.json file.

{
  "manifest_version": 3,
  "name": "Example Extension",
  "version": "1.0",
  "permissions": [
    "nativeMessaging"
  ],
  "background": {
    "scripts": ["background.js"],
    "type": "module"
  }
}

2. Send a Message from JavaScript

Only the extension's background script or popup script can communicate directly with the native Swift layer. If you need to send data from a content script (which runs in the context of a web page), you must first pass the data to your background script using browser.runtime.sendMessage.

From your background.js or popup script, use browser.runtime.sendNativeMessage to send a JSON-serializable payload to the Swift handler.

// Inside background.js or popup.js
function sendToNativeApp() {
    const payload = { 
        action: "processData", 
        data: "Hello from JavaScript" 
    };

    // Safari ignores the application ID string, so a placeholder is used.
    browser.runtime.sendNativeMessage("application.id", payload)
        .then((response) => {
            console.log("Received response from Swift:", response);
        })
        .catch((error) => {
            console.error("Error communicating with native app:", error);
        });
}

3. Receive and Respond in Swift

When you create a Safari Web Extension project in Xcode, it automatically generates a SafariWebExtensionHandler.swift file inside the extension target. This class conforms to NSExtensionRequestHandling and intercepts all native messages sent from JavaScript.

Messages arrive in the beginRequest(with:) method. You extract the data using SFExtensionMessageKey and send a response back by completing the request with a new NSExtensionItem.

import SafariServices
import os.log

class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling {

    func beginRequest(with context: NSExtensionContext) {
        // 1. Extract the incoming message
        guard let item = context.inputItems.first as? NSExtensionItem,
              let userInfo = item.userInfo as? [String: Any],
              let message = userInfo[SFExtensionMessageKey] as? [String: Any] else {
            context.completeRequest(returningItems: nil, completionHandler: nil)
            return
        }

        // 2. Process the message payload
        let action = message["action"] as? String ?? "Unknown"
        let data = message["data"] as? String ?? "No Data"
        os_log(.default, "Received action: %{public}@ with data: %{public}@", action, data)

        // 3. Create a response
        let response = NSExtensionItem()
        let responsePayload: [String: Any] = [
            "status": "success",
            "reply": "Swift received your message: \(data)"
        ]
        response.userInfo = [SFExtensionMessageKey: responsePayload]

        // 4. Send the response back to JavaScript
        context.completeRequest(returningItems: [response], completionHandler: nil)
    }
}

4. Share Data with the Containing Native App

A crucial architectural detail in iOS and macOS is that SafariWebExtensionHandler runs in the Extension Target, which is a separate process and sandbox from your Containing App Target (the main Swift app).

To share data between the extension handler and the containing native app, you must configure an App Group:

  1. Open your project in Xcode.
  2. Select your Main App Target, go to the Signing & Capabilities tab, and click + Capability.
  3. Add App Groups and create a new group identifier (e.g., group.com.yourcompany.appname).
  4. Select your Extension Target, go to Signing & Capabilities, add the App Groups capability, and check the exact same group identifier.

Once configured, both targets can read and write to a shared UserDefaults suite, or access a shared file container (for CoreData or SQLite databases).

// Writing data in SafariWebExtensionHandler.swift
if let sharedDefaults = UserDefaults(suiteName: "group.com.yourcompany.appname") {
    sharedDefaults.set(data, forKey: "lastReceivedExtensionData")
}

// Reading data in the Containing App (e.g., ViewController.swift or ContentView.swift)
if let sharedDefaults = UserDefaults(suiteName: "group.com.yourcompany.appname") {
    let savedData = sharedDefaults.string(forKey: "lastReceivedExtensionData")
    print("Data from extension: \(savedData ?? "None")")
}

5. Establish Persistent Connections (Optional)

If you need to send multiple messages back and forth without closing the communication channel, use browser.runtime.connectNative in JavaScript instead of sendNativeMessage.

// Establish a port connection in background.js
let port = browser.runtime.connectNative("application.id");

// Listen for messages from Swift
port.onMessage.addListener((message) => {
    console.log("Received from Swift via port:", message);
});

// Send a message through the port
port.postMessage({ status: "Connected" });

On the Swift side, connectNative triggers the exact same beginRequest(with:) method in SafariWebExtensionHandler, allowing you to handle single requests and port connections using the same Swift logic.