How do you get messages to Swift directly?

This article is part of a series.

Relevant Repo

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:

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.

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

This article is part of a series.