deathau.weblog.lol/weblog/B4. Friendica & Beyond/B4.0015 Going on Safari (Chapter Two).md
Gordon Pedersen 24e795c7b3
All checks were successful
/ weblog.lol (push) Successful in 16s
Added post b4.0015
2024-09-10 12:33:55 +10:00

9 KiB

title date location tags
B4.0015 Going on Safari (Chapter Two) 2024-09-06 /B4.0015 Blog, Markdownload, Development, Swift, JavaScript

Going on Safari (Chapter Two)

This is part of a series I'm writing documenting my efforts to establish native messaging communication between my MarkDownload web extension and a native app. Go check out the motivation or my previous efforts

Okay, so, where I left off last time was being able to communicate from the swift app → web extension (via a web view in the app) with no issues. However, communicating back to the app was a problem, in no small part because the web extension only ever sends messages back to an "app extension", which is sandboxed separately from the app itself. I set both targets to be part of the same app group, but still had troubles getting any sort of data through the App Defaults back to the app.

Sending message from app extension → app (for real this time)

I was attempting to google my way through the issues I was having, when I discovered this post by Tom Harrington, which among other things, suggested using NSFileCoordinator and NSFilePresenter to write to, read from, and get notified of changes to a file which lives in a folder shared within the app group. So what does this look like in the actual code? Something like this:

func beginRequest(with context: NSExtensionContext) {
	let request = context.inputItems.first as? NSExtensionItem
	let message: Any? = request?.userInfo?[SFExtensionMessageKey]
	
	var dict = message as? [String:Any] ?? [:] // assuming I'm sending an object from javascript and not just a plain string
	
	let groupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.au.death.MarkDownload")
    let fileURL = groupURL?.appendingPathComponent("message.json")

	do {
		let jsonData = try JSONSerialization.data(withJSONObject: dict, options: .prettyPrinted)
		try jsonData.write(to: fileURL!)
	}
	catch {
		// do something with the error
		os_log(.error, "Error serializing json (or saving file): %@\n\n%@", error.localizedDescription, String(describing: message))
	}
}

... not really much different from putting the data in the User Defaults, but I somehow feel better writing to a plain text file than some arbitrary data store (which turns out to be a .plist file in the shared folder, but I digress).

The differences show up mainly in the View Controller code, where I have to implement the NSFilePresenter protocol. This requires two new parameters: the url of the file I'm watching, and an operation queue. I'm still just getting my feet wet with swift, so I don't actually know anything about operation queues. I just copied some code I found online to simply use the main operation queue:

class ViewController: PlatformViewController, WKNavigationDelegate, WKScriptMessageHandler, NSFilePresenter {
    @IBOutlet var webView: WKWebView!
    /// Shared URL for the message json
    var presentedItemURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.au.death.MarkDownload")?.appendingPathComponent("message.json")
    lazy var presentedItemOperationQueue = OperationQueue.main
    ...
}

To actually start getting notified that something changed, I also have to implement the presentedItemDidChange function. In my case, as I've written json to a file, I can just read that and pass it directly to the javascript code in my app's web view like so:

func presentedItemDidChange() {
	do {
		let jsonString = try String.init(contentsOf: self.presentedItemURL!, encoding: .utf8)
		DispatchQueue.main.async {
           self.webView.evaluateJavaScript("recieveMessage(\(jsonString!))")
        }
	}
	catch {
		debugPrint("Error reading file: \(error.localizedDescription)\nfile name: \(self.presentedItemURL?.absoluteString ?? "nil")")
	}
}

I'm not sure how thread-safe the notifications are, so I decided to hedge my bets and specifically send the code to the web view on the main thread. I don't actually know if this is necessary, but it makes me feel a little better.

So, this seems simple enough. Time to run it and... it still doesn't work. It turns out I've missed one very important part: I have to register the View Controller object as a file presenter via the File Coordinator. After googling for this, I also found that it's required to remove the file presenter when the app goes into the background or closes, so I have this code:

override func viewWillAppear() {
	super.viewWillAppear()
	NSFileCoordinator.addFilePresenter(self)
}

override func viewWillDisappear() {
	super.viewWillDisappear()
	NSFileCoordinator.removeFilePresenter(self)
}

And guess what? It actually works this time!

Putting it together

Now, I can achieve my main aim. I have a web view inside a native app that can send a message to a web extension, and a web extension can send a message back all the way to the web view.

Here's a basic recap:

  • Web View → App:
webkit.messageHandlers.controller.postMessage(jsonString)
class ViewController: ..., WKScriptMessageHandler {
	override func viewDidLoad() {
		...
		self.webView.configuration.userContentController.add(self, name: "controller")
		...
	}
	func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
		let body = message.body as? String
		...
	}
}
  • App → Web Extension
let jsonObject = try JSONSerialization.jsonObject(with: jsonString!.data(using: .utf8)) as? [String:Any]
SFSafariApplication.dispatchMessage(withName: "message", toExtensionWithIdentifier: extensionBundleIdentifier, userInfo: jsonObject)
const port = browser.runtime.connectNative("au.death.MarkDownload")
port.onMessage.addListener(message => {
	if(message.name == "message") const jsonObject = message.userInfo
})
  • Web Extension → App Extension
browser.runtime.sendNativeMessage("au.death.MarkDownload", jsonObject)
// or:
const port = browser.runtime.connectNative("au.death.MarkDownload")
port.postMessage(jsonObject)
class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling {
    func beginRequest(with context: NSExtensionContext) {
        let request = context.inputItems.first as? NSExtensionItem
        let message: Any?
        if #available(iOS 15.0, macOS 11.0, *) {
            message = request?.userInfo?[SFExtensionMessageKey]
        } else {
            message = request?.userInfo?["message"]
        }
        let jsonObject = message as? [String:Any] ?? [:]
        ...
	}
}
  • App Extension → App (via shared file)
do {
	let jsonData = try JSONSerialization.data(withJSONObject: jsonObject, options: .prettyPrinted)
	let groupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.au.death.MarkDownload")
	let fileURL = groupURL?.appendingPathComponent("message.json")
	try jsonData.write(to: fileURL!)
}
catch {...}
class ViewController: ..., NSFilePresenter {
    var presentedItemURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.au.death.MarkDownload")?.appendingPathComponent("message.json")
    lazy var presentedItemOperationQueue = OperationQueue.main
    ...
    override func viewWillAppear() {
		super.viewWillAppear()
		NSFileCoordinator.addFilePresenter(self)
	}
	override func viewWillDisappear() {
		super.viewWillDisappear()
		NSFileCoordinator.removeFilePresenter(self)
	}
	func presentedItemDidChange() {
		do {
			let jsonString = try String.init(contentsOf: self.presentedItemURL!, encoding: .utf8)
			...
		}
		catch {...}
	}
}
  • App → Web View
self.webView.evaluateJavaScript("recieveMessage(\(jsonString!))")
function recieveMessage(jsonObject) {
	...
}

Checkpoint!

Does this mean my safari is over? Of course not. I still have to implement / bring in code from my existing MarkDownload extension. And because I'm still aiming for cross-platform, cross-browser, I have to figure out ways of detecting whether the native app approach is necessary / viable. If the extension is running in Chrome on a Mac, with the app installed, should it communicate with the app, or stick to using the side panel? If so, how does the communication work? It's not sending message to the app extension, but the app itself, via the app's standard input stream. So how does that work?

While I have hit a major milestone, it's still time to set up camp. To see what the next leg of my journey will look like. Native app messaging between other browsers and the mac os app? How does the extension work on iOS Safari? Is there a potential need for Windows and Linux versions of the native app? What about Android?

As hinted in the previous blog post, I do have a way of listening to the standard input stream and write to the standard output stream in the swift mac app. This should mean I can communicate between the app and other browsers like Chrome and Firefox. This could be the next step.