Okay, but how about all the way up to the View?

This article is part of a series.


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.


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

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.


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"
    static let id = "group.com.carlynorama.safariextensionland"


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? {
    private func dumpUserDefaults() {
    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> {
    private func removeKey(_ key: some StringProtocol) {
        userDefaults.removeObject(forKey: key as! String)

    //for sharedContainerIdentifier if needed.
    private var storageLocation:URL? {

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? {

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 {

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"
                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:");

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? {


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
    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)
            Button("Load Latest") {
    func loadLatest() {
        statusText = "checking for lines..."
        Task {
            statusText = await myLines.updateOrWarn(input: extensionInfo.getExtensionMessage())


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.

combined screenshot of both the macOS and iOS Lines app with a focus on the new “load latest” link

This article is part of a series.