deathau.weblog.lol/weblog/B4. Friendica & Beyond/B4.0015 Going on Safari (Chapter Two).md

180 lines
9.1 KiB
Markdown
Raw Permalink Normal View History

2024-09-10 02:33:42 +00:00
---
title: B4.0015 Going on Safari (Chapter Two)
date: 2024-09-06
location: /B4.0015
tags: Blog, Markdownload, Development, Swift, JavaScript
2024-09-10 02:39:42 +00:00
external_url: https://monrepos.casa/objects/0e03068e-2466-dfb1-4167-b95934026045
2024-09-10 02:33:42 +00:00
---
# 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](https://death.id.au/b4.0013) or my [previous efforts](https://death.id.au/b4.0014)*
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](https://www.atomicbird.com/blog/sharing-with-app-extensions/) by [Tom Harrington](https://mastodon.social/@atomicbird), 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:
```swift
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:
```swift
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:
```swift
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:
```swift
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:
```javascript
webkit.messageHandlers.controller.postMessage(jsonString)
```
```swift
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
```swift
let jsonObject = try JSONSerialization.jsonObject(with: jsonString!.data(using: .utf8)) as? [String:Any]
SFSafariApplication.dispatchMessage(withName: "message", toExtensionWithIdentifier: extensionBundleIdentifier, userInfo: jsonObject)
```
```javascript
const port = browser.runtime.connectNative("au.death.MarkDownload")
port.onMessage.addListener(message => {
if(message.name == "message") const jsonObject = message.userInfo
})
```
- Web Extension → App Extension
```javascript
browser.runtime.sendNativeMessage("au.death.MarkDownload", jsonObject)
// or:
const port = browser.runtime.connectNative("au.death.MarkDownload")
port.postMessage(jsonObject)
```
```swift
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)
```swift
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 {...}
```
```swift
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
```swift
self.webView.evaluateJavaScript("recieveMessage(\(jsonString!))")
```
```javascript
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.