How do you get messages to Swift directly?
This article is part of a series.
Relevant Repo
- Lines https://github.com/carlynorama/lines
- relevant tags: hello-native-messaging, native-gets-the-clip
Intro
Last time left off with a web extension that was mostly boilerplate still but had a copy paste feature added to the content.js
This time we’ll get it talking to the the “Native App
”, or the Safari App Extension
piece of the trifecta.
Changes to the content script
Mozilla again comes to the rescue with example code to notify the background based on a click from a content script. I adapted it to be called from the copying function added last time. The call to notifyBackgroundPage
goes before or after the call to updateClipboard
. They’re independent features at this point.
function handleResponse(message) {
console.log(`Message from the background script: ${message}`);
}
function handleError(error) {
console.log(`Error: ${error}`);
}
function notifyBackgroundPage(message) {
const sending = browser.runtime.sendMessage({
greeting: "newClip",
message: message
});
sending.then(handleResponse, handleError);
}
Changes to manifest file and background script
Native Messaging Hello World
When a web extension calls the part of the extension that lives outside the browser, like the password storage of a password manager the API calls it Native messaging. For us that’s the part in Swift and found in the LineGrabber targets.
Non-Safari web extensions have an additional manifest file to explain to the browser how to relate to this “native app.” That’s taken care of by Safari Services
for Safari Web Extensions.
First, update the permissions in our extension’s manifest.json to add the “nativeMessaging” permission.
"permissions": [ "activeTab", "scripting", "clipboardWrite", "nativeMessaging"]
The below is demo code from Apple’s that was shown in WWDC20’s 10665: Meet Safari Web Extensions. It works perfectly with the the boilerplate SafariWebExtensionHandler
from the basic Safari App Extension from the File > New > Target
dialog.
//Use runtime.onMessageExternal to talk to other extensions or websites
browser.runtime.sendNativeMessage("application.id", {message: "Hello from background page"}, function(response) {
console.log("Received sendNativeMessage response:");
console.log(response);
});
// Set up a connection to receive messages from the native app.
let port = browser.runtime.connectNative("application.id");
port.postMessage("Hello from JavaScript Port");
port.onMessage.addListener(function(message) {
console.log("Received native port message:");
console.log(message);
});
port.onDisconnect.addListener(function(disconnectedPort) {
console.log("Received native port disconnect:");
console.log(disconnectedPort);
});
Confirm this works by:
- rebuilding the extension app (CMD+B)
- opening a webpage that has permission to run the extension
- having both:
- the console of the page (OPT+CMD+C or the Develop menu for the Simulator) open
- the console for background script open
Develop > Web Extension Background Content > YOUR EXTENSION NAME
The messages should be being passed around. Make sure to open the objects in the console to look at them.
The Javascript Demo Code Line by Line
sendNativeMessage
browser.runtime.sendNativeMessage("application.id", {message: "Hello from background page"}, function(response) {
console.log("Received sendNativeMessage response:");
console.log(response);
});
browser.runtime.sendNativeMessage
is asynchronous code. The demo code uses an older callback style.
- application: A string, “application.id” used to determine who the native app is, Safari replaces this for us with the right info behind the scenes. It’s supposed to be the same name as in the Application’s manifest.
- message: An object, provided as the JSON {message: “Hello from background page”}
- a function to handle the response.
Mozilla provides newer Promise-style example calls on the website linked to above.
//ping_pong is the app name and "ping" is the message.
let sending = browser.runtime.sendNativeMessage("ping_pong", "ping");
sending.then(onResponse, onError);
connectNativeMessage
browser.runtime.connectNative
opens the two way street which can send or receive messages called a Port
let port = browser.runtime.connectNative("application.id");
Like with sendNative message, Safari handles replacing “application.id” for us.
Send a message
port.postMessage("Hello from JavaScript Port");
Add an event listener to handle messages
port.onMessage.addListener(function(message) {
console.log("Received native port message:");
console.log(message);
});
could be rewritten as
var messageHandler = (message) => {
console.log("Received native port message:");
console.log(message);
}
port.onMessage.addListener(messageHandler);
Note: if a function is written as an expression like it is here, the function does not get hoisted like it does as a statement. The expression must be defined BEFORE the call site.
Disconnect
It would be a good idea to read up on the port lifecycle to see how ports handle themselves in general. When done communicating, closing the port has no side effects in Chrome/Chromium browsers, but in Firefox that closes the port even if other listeners still need it. TODO: What happens in WebKit?
ThisonDisconnect
code takes a function expression inside the closure, which is yet another pattern.
port.onDisconnect.addListener((p) => {
console.log("Received native port disconnect:");
console.log(p);
if (p.error) { //runtime.lastError in chrome
console.log(`Disconnected due to an error: ${p.error.message}`);
}
});
Changes to the background script
The content script dutifully sends the clip along. Our background script sends a messages. But the background script doesn’t send THAT message. After understanding the demo code it becomes clearer how to add a new function to send a message containing the clipboard. It can be trigger it from the background.js runtime.onMessage
listener when it gets that new clip.
function sendNewClip(message) {
console.log("something else")
const nSend = browser.runtime.sendNativeMessage("application.id", {
message: message
},
function(response) {
console.log("Received newClip response:");
console.log(response);
}
);
nSend
}
browser.runtime.onMessage.addListener((request, sender, sendResponse) => {
console.log("Received request: ", request);
if (request.greeting === "hello")
sendResponse({ farewell: "goodbye" });
if (request.greeting === "newClip") {
console.log(request.message);
sendNewClip(request.message);
//sendResponse({ farewell: "goodbye" });
}
});
console.log("hello from background.js")
The Swift Demo Code Line by Line
All of our Safari Web Extension code lives in SafariWebExtensionHandler
Take the time to look into both of the Info.plist
files for the extension targets. The line item for NSExtensionPrincipalClass has the value $(PRODUCT_MODULE_NAME).SafariWebExtensionHandler
. Moving the conformance to NSExtensionRequestHandling to a different class or renaming the class listed here means coming back the plist to make updates as well.
Whatever the principal class ends up being, it will have the beginRequest
function which handles communication with Safari. The request
is incoming from Safari not outgoing from our code, but our code can provide responses.
func beginRequest(with context: NSExtensionContext) {
let request = context.inputItems.first as? NSExtensionItem
The NSExtensionContext gets created by SafariServices using the extension’s id and the native app’s id. This context has a list of input items and we’re just going to skim the first off the top and keep our fingers crossed it can be cast as a NSExtensionItem.
Among a couple other things the request
might have userInfo([AnyHashable : Any]) or attachments(NSProvider). A lot of hopeful casting goes on in this neck of the code. The Lines app example relies primarily on Strings
, but this (older?) example from Hacking With Swift uses images.
let profile: UUID?
if #available(iOS 17.0, macOS 14.0, *) {
profile = request?.userInfo?[SFExtensionProfileKey] as? UUID
} else {
profile = request?.userInfo?["profile"] as? UUID
}
Is the profile information in the userInfo
dictionary? Safari puts some in there and SFExtensionProfileKey
contains a default key to get it out.
let message: Any?
if #available(iOS 17.0, macOS 14.0, *) {
message = request?.userInfo?[SFExtensionMessageKey]
} else {
message = request?.userInfo?["message"]
}
Is there a message? Safari puts that in there too and SFExtensionMessageKey contains a default key.
Safari uses these keys assuming you will too. Any messages packaged up by the native messaging code end up under this key. The value of this key does NOT appear in the extension’s javascript from what I can tell.
Changing background.js to…
...runtime.sendNativeMessage("application.id", {kumquat: "Hello from background page"}, functi...)
…doesn’t change how it works. The SFExtensionMessageKey is the name given by Safari & the Web Extension API to that position of the arguments when it packages them up, not the key inside the object. That’s up to your code.
os_log(.default, "Received message from browser.runtime.sendNativeMessage: %@ (profile: %@)", String(describing: message), profile?.uuidString ?? "none")
App Extension have a separate context from the Shell app. Print statements and debug logs do not appear in Xcode’s console when they run, as far as I’ve been able to tell. Additionally I have never been able to find this log message in the Console App. Which is not to say it isn’t there, I’m just a Console App rube. Logging will be switched to Logger soon.
let response = NSExtensionItem()
response.userInfo = [ SFExtensionMessageKey: [ "echo": message ] ]
context.completeRequest(returningItems: [ response ], completionHandler: nil)
}
The example response dictionary just echos back the received message. I’ve found it useful for debugging so I’ve left it in.
Summary
Native messaging comes together pretty easily with the demo code, but it took a minute to understand how exactly. The Safari Web Extension uses an XPC service under the hood, but its commingled with JavaScript on the other end. It will be interesting to keep them talking, but not tangled. What we still haven’t is taken that one last step of adding