Okay, but how about all the way up to the View?
This article is part of a series.
Related Repo
- Lines https://github.com/carlynorama/lines
- relevant tag: F_helloAppGroup
Intro
Sharing information between processes has been solved many ways. It’s already being solved in part of the app by an NSExtension which probably gets backed by XPC and gives us SafariServices. To do the same we’d need to have Lines have its own hooks into the launchd system.
An easier path, one of the common ways Apple developers get told to solve the communication problem between their current app and their future restarted app is UserDefaults. With an App Group
those UserDefaults can be shared across multiple processes on the same device, e.g. different apps, their Widgets, a web extension, etc.
I’ve used app groups once before in an iPad based project, so some things I knew and somethings I did not. I’d already learned about registering an iOS app group. I did not realize the configuration was so different between macOS and iOS. I hadn’t made a project that need multiple entitlement files before either. So I wasted some time.
At the end of this post the project will have a very simplistic 2 channel communication protocol set up between the Lines target and the LineGrabber target, both on iOS and macOS, reusing the same code. UserDefaults are not wires. It’s not expensive to make specific channels for specific purposes. There’s no BOM increase for another SPI bus, etc. But we’re not doing anything too fancy yet so I went with the inbox for A is the outbox for B model RXTX style.
Lines
will update it’s GUI based on what it finds in the UserDefaults.
References
- https://developer.apple.com/documentation/xcode/configuring-app-groups
- https://developer.apple.com/help/account/manage-identifiers/register-an-app-group
- https://developer.apple.com/documentation/bundleresources/entitlements/com_apple_security_application-groups
- https://developer.apple.com/documentation/foundation/userdefaults/
- https://jeffreyfulton.ca/blog/2018/02/userdefaults-limitations-and-alternatives
Setting up the AppGroups
In provisionedOS land (iOS & the Tablettes) where Apple has made the call that consumer data safety out ranks everything else, one has to notify Apple that there will be an App Group by linking it to a developer account. This prevents non authorized apps from just wandering up to the app group and asking for what’s in there.
On macOS CMD+SHFT+G in the Finder, point it towards /Users/$YOUR_USERNAME/Library/Group Containers/
and take a poke around. It’s really fun and educational. It also proves the point that no data that your app doesn’t encrypt itself is safe in there from any app that hasn’t agreed to stay in its sandbox. (Annual reminder, don’t do your banking on your dev machine, folks.)
cd /Users/$YOUR_USERNAME/Library/Group\ Containers
ls -la
At the top of the list will be a bunch of directories with a 10 character id code specific to the developer who created the app group. That’s what Lines will get on the Mac, too.
On provisionedOS our App Group name will start with group
It looks like specially signed macOS Apps can use group
names (Apple’s do) but when I tried to use them I kept getting CFPrefsPlistSource
/kCFPreferencesAnyUser
related errors. The only thing that resolved those for me was
- separate entitlement files for all the targets (which we’ve done)
- separate App Group names for iOS and macOS
- macOS names that didn’t mimic ones registered to my developer account
App Group names get made most easily in Xcode in the Signing & Capabilities area. Hit the " + Capability " button and a dialog with all the options will pop up. Select App Groups. If the active target has both provisionedOS and macOS SDKs enabled in “Supported Destinations”, AppGroup will be listed twice as a capability to add.
Stop.
Go back to the “General” tab. Make sure that one Lines
/LineGrabber
target pair has ONLY macOS selected as an SDK under “Supported Destinations” and the other has the rest you’d like to support. Do not skip this step.
Once the capability has been added to the target the App Groups
section appears. A +
button again allows for the creation of a new App Group. In the provisionedOS target, a check-box-bulleted list of existing app groups registered with your developer account comes along too.
If making a new app group, in the macOS area the $(TeamIdentifierPrefix)
will be in the text field waiting for you. Xcode will also add the .
, so just put the first character next to the )
. In the provisionedOS target Xcode will prepend group.
and inform you that creating this new group will add it to the developer account.
A target can be in more than one App Group. It just can’t be in both group.
prefixed and team identifier prefixed app groups at the same time.
To help the project use these app groups I made an AppGroupSettings
enum that conditionally compiles like with the views. This code doesn’t work on Linux AT ALL anyway so the macOS or no distinction works well enough.
enum AppGroupSettings {
#if os(macOS)
static let id = "KH3G9PXA68.com.carlynorama.lines"
#else
static let id = "group.com.carlynorama.safariextensionland"
#endif
}
SEMessageService
So what code even uses these IDs?
I made the scaffold for a full duplex protocol between the web extension’s “Native” part (LineGrabber
) and the parent or “Shell” app (Lines
). There’s a UserDefault bucket for Lines
to drop info into and a bucket for LineGrabber
to drop info into. While technically nothing in this code prevents them from dropping values into the other’s bucket, hopefully the function names will help prevent mistakes.
NOTE: Keep an eye on what targets’s each Swift file belong to. The SEMessageService containing files need to belong to ALL FOUR.
//
// SEMessageService.swift
// Lines
//
// Created by Carlyn Maw on 2/10/24.
//
import Foundation
protocol SEMessageService {
var fromExtensionKey: String { get }
var fromShellKey: String { get }
func setFromExtensionMessage(to:some StringProtocol)
func getFromExtensionMessage() -> String?
func setFromShellMessage(to:some StringProtocol)
func getFromShellMessage() -> String?
}
struct AppGroupService {
private let appGroupID: String
private let userDefaults: UserDefaults
var containerURL:URL? {
FileManager.default.containerURL(forSecurityApplicationGroupIdentifier:appGroupID)
}
private func dumpUserDefaults() {
print(userDefaults.dictionaryRepresentation())
}
private func stringForKey(_ key:some StringProtocol) -> String? {
userDefaults.string(forKey: key as! String) //as? String
}
private func setString(_ value: some StringProtocol, forKey key: some StringProtocol) {
userDefaults.set(value as! String, forKey: key as! String)
}
private func allDefaults() -> Dictionary<String, Any> {
userDefaults.dictionaryRepresentation()
}
private func removeKey(_ key: some StringProtocol) {
userDefaults.removeObject(forKey: key as! String)
}
//for sharedContainerIdentifier if needed.
private var storageLocation:URL? {
FileManager.default.containerURL(forSecurityApplicationGroupIdentifier:appGroupID)
}
}
extension AppGroupService {
init?(appGroupID: String) {
self.appGroupID = appGroupID
guard let userDefs = UserDefaults(suiteName: appGroupID) else {
return nil
}
self.userDefaults = userDefs
}
}
extension AppGroupService:SEMessageService {
var fromExtensionKey:String { "toShell" }
var fromShellKey:String { "toExtension" }
func setFromExtensionMessage(to message: some StringProtocol) {
setString(message, forKey:fromExtensionKey)
}
func getFromExtensionMessage() -> String? {
setString("test", forKey: fromShellKey)
return stringForKey(fromExtensionKey)
}
func setFromShellMessage(to message: some StringProtocol) {
setString(message, forKey:fromShellKey)
}
func getFromShellMessage() -> String? {
stringForKey(fromShellKey)
}
}
I’ve put a SEMessageService Protocol around the AppGroupService in case at some point I replace the AppGroup / UserDefaults style of communication with something else.
Code in LineGrabber
In SafariWebExtensionHandler
let shellMessageService = AppGroupService(appGroupID: AppGroupSettings.id)!
func setMessageForApp(_ message:String) {
shellMessageService.setFromExtensionMessage(to: message)
}
func confirmMessageForApp() -> String {
shellMessageService.getFromExtensionMessage() ?? "No Message"
}
var udKey:String {
shellMessageService.fromExtensionKey
}
Inside beginRequest function
beginRequest
has been restructured to use a defer
to send a response dictionary that’s been collecting reply messages along the way. It also uses a new key that wasn’t in the javascript before to detect if its’ time to update the UserDefaults.
func beginRequest(with context: NSExtensionContext) {
var responseContent: [ String : Any ] = [ : ]
defer {
let response = NSExtensionItem()
response.userInfo = [ SFExtensionMessageKey: responseContent ]
context.completeRequest(returningItems: [ response ], completionHandler: nil)
}
//... NO CHANGE
if let messageDictionary = message as? Dictionary<String,String> {!
if let _ = messageDictionary["isClip"] { // <===== NEW KEY
responseContent["receivedClip"] = "true"
setMessageForApp(messageDictionary["message"]!)
responseContent["updatedForKey"] = udKey
responseContent["updatedWith"] = confirmMessageForApp()
}
}
responseContent["echo"] = message
}
The very first version of this code used
if let myMessage = message as? String, myMessage.contains("|") { }
catch a message with a potential line in it. And it did work. However, I want to transition this code away from string parsing to object decoding so I added one line to background.sendNewClip
over in the extension javascript from what was there last post.
function sendNewClip(message) {
console.log("something else")
const nSend = browser.runtime.sendNativeMessage("application.id", {
message: message,
isClip: "true" //<========= NEW LINE
},
function(response) {
console.log("Received newClip response:");
console.log(response);
}
);
nSend
}
It doesn’t actually even matter what value the isClip
key holds at this stage. That it’s in there at all creates the right flag for now.
Code in Lines
Getting the new line out the UserDefaults was easy.
class ExtensionManager:ObservableObject {
let extensionMessageService:SEMessageService = AppGroupService(appGroupID: AppGroupSettings.id)!
func getExtensionMessage() -> String? {
extensionMessageService.getFromExtensionMessage()
}
...
}
Getting the new line into the Lines array and then updating the UI took a few more changes.
@State var Lines:[Line]
got upgraded to a
@ObservedObject var myLines = LinesVM(lines: Lines())
Switching to the new macro will come later.
import Foundation
import SwiftUI
class LinesVM:ObservableObject {
@Published private(set) var lines:Lines
init(lines:Lines = Lines()) {
self.lines = lines
}
@discardableResult
func append(possibleLine:String) async -> Bool {
await MainActor.run {
let before = lines.count
lines.append(possibleLine: possibleLine)
return before + 1 == lines.count
}
}
func updateOrWarn(input:String?) async -> String {
if input != nil {
let appendTask = await self.append(possibleLine:input!)
return appendTask ? "Success" : "Failed to add line text:\(input!)"
} else {
return "No message waiting."
}
}
}
and ExtesnionInfoView
needed a new EnvironmentObject to handle the button that drives Lines
to go looking for changes.
import SwiftUI
struct ExtensionInfoView: View {
let extensionInfo = ExtensionManager()
@EnvironmentObject var myLines:LinesVM
@State var statusText = ""
var body: some View {
VStack {
#if os(macOS)
macExtInfoView().environmentObject(extensionInfo)
#else
iOSExtInfoView().environmentObject(extensionInfo)
#endif
Text(statusText)
Button("Load Latest") {
loadLatest()
}
}
}
func loadLatest() {
statusText = "checking for lines..."
Task {
statusText = await myLines.updateOrWarn(input: extensionInfo.getExtensionMessage())
}
}
}
Summary
both macOS and iOS have a new button in the ExtensionInfoView that acts like the paste button in that, when the user clicks, the line will appear on the screen. Parity achieved.