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:
- Open your project in Xcode.
- Select your Main App Target, go to the Signing & Capabilities tab, and click + Capability.
- Add App Groups and create a new group identifier
(e.g.,
group.com.yourcompany.appname). - 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.