1 Nov 2023

How to authenticate 3-legged token with your desktop applications (MacOS)

Mac user

In previous blog, we implemented our custom protocol for Windows. In this blog, we'll implement it for MacOS with Swift and SwiftUI.

Surprisingly, Mac is easier than Windows. You only need to add the protocol to the plist and MacOS will register it for you. However, it could be tricky for you if you are not familiar with plist or Mac’s security settings.

I am using Xcode 14 and MacOS Ventura at this point to demonstrate the concept. The interface of Xcode might change in the future, but the idea should be similar.

Like windows, we’ll store our APS client id with environment variables. We can do that with launchctl to create it for our login session. e.g.,

launchctl setenv APS_CLIENT_ID YOURCLIENTID

We are going to create a MacOS application in Xcode using Swift. Our sample will be based on SwiftUI. Using XIB or storyboard would be similar. My APP is called APSShellDemoAPP and its Bundle Identifier is DAS.APSShellDemo.

To make our demo simple, I made it a single window application and does not have tab support. Make following changes in APSShellDemoAPP.swift before we begin.

import SwiftUI
import Foundation

final class APPDelegate: NSObject, NSApplicationDelegate {
     func applicationDidFinishLaunching(_ notification: Notification) {
        NSWindow.allowsAutomaticWindowTabbing = false;
    }
}

@main
struct APSShellDemoApp: App {

    @NSApplicationDelegateAdaptor(APPDelegate.self) var appDelegate

    var body: some Scene {
        Window("APSShellDemo", id: "main") {
            ContentView()
        }
    }
}

Let’s make our APP singleton first. We can check if there is an app with the same bundle identifier in applicationDidFinishLaunching.

func applicationDidFinishLaunching(_ notification: Notification) {

        NSWindow.allowsAutomaticWindowTabbing = false;

        let runningApp =
            NSWorkspace.shared.runningApplications
                .filter { item in item.bundleIdentifier == Bundle.main.bundleIdentifier }
                .first { item in item.processIdentifier != getpid() }

        if runningApp != nil {
           // Exit
           exit(0)
        }

        else{
           // Do nothing
        }
    }

Now we have a single instance app. Let’s add protocol support now.

Mac uses plist to register URL protocol. It could be added with Xcode easily. Click on the project and choose the main target. You’ll find URL Types under Info tab. Add your protocol like below:

Adding protocol

Now we can get the url in APPDelegate, add following method in APPDelegate class.

func application(_ application: NSApplication, open urls: [URL]) {
     if urls.count > 0 {
         // url[0] should be what we need
     }
}

To test if it works properly so far, we can add two text labels for display. SwiftUI could bind view model, let’s create one. Make these changes to the APSShellDemoApp.swift.

...
import Foundation
...

class AppState: ObservableObject {
    @Published var strText: String
    @Published var clientId: String

    init(){
        self.strText = "Waiting"
        self.clientId = (ProcessInfo.processInfo.environment["APS_CLIENT_ID"] ?? "Missing Client ID in environment variables.\n Use 'launchctl setenv' to add to your environment") as String
    }
}


final class APPDelegate: NSObject, NSApplicationDelegate {

    var appState = AppState()

    func application(_ application: NSApplication, open urls: [URL]) {
        if urls.count > 0 {
           // Set url string to the data object.
           appState.strText = urls[0].absoluteString
        }
    }
    ...
}


@main
struct APSShellDemoApp: App {

    @NSApplicationDelegateAdaptor(APPDelegate.self) var appDelegate

    var body: some Scene {
        Window("APSShellDemo", id: "main") {
            // Make appState available in ContentView
            ContentView()
                .environmentObject(appDelegate.appState)
        }
    }
}
...

The view should be updated too. Let’s add the authenticate button also.

...
struct ContentView: View {
    @EnvironmentObject private var appState: AppState
    var body: some View {
        VStack {
            Text($appState.strText.wrappedValue)
            Text($appState.clientId.wrappedValue)
            Button("Authenticate!", action: {
                let str = "https://developer.api.autodesk.com/authentication/v1/authorize?response_type=code&client_id=\(appState.clientId)&redirect_uri=apsshelldemo://oauth&scope=data:read%20data:create%20data:write"
                // Open the url with NSWorkspace.shared.open
                if let url = URL(string:str){
                    NSWorkspace.shared.open(url)
                }
            })
        }
        .padding()
    }
}
...

Now we are going to work on the IPC between the callback process and main window process. We’ll use CFMessage here. According to Apple’s security setting, you can only use it between processes within the same App Group or with a testing entitlement. We want to make our app in the same App Group because the other option cannot pass AppStore checking.

Click on your project and select our main target. Click +Capability button under Signing & Capabilities tab. Choose App Groups.

 App group capability

We need to give a unique name for our app group. For this sample, I am using DAS.AppShellDemoPort.
AppGroupSettings

The rest of this part is easy, let’s add CFMessage to our AppDelegate.

final class APPDelegate: NSObject, NSApplicationDelegate {

    var appState = AppState()

    // Callback function
    lazy var callback: CFMessagePortCallBack = { messagePort, messageID, cfData, info in
        guard let pointer = info,
              let dataReceived = cfData as Data?,
              let string = String(data: dataReceived, encoding: .utf8  ) else {
            return nil
            }

        // Cast info back to AppDelegate
        let appDelegate = Unmanaged<APPDelegate>.fromOpaque(pointer).takeUnretainedValue()
        appDelegate.appState.strText = string
        return nil
    }

    func applicationDidFinishLaunching(_ notification: Notification) {
        NSWindow.allowsAutomaticWindowTabbing = false;
        // We must add a port after our app group like below
        let port = "DAS.APSShellDemoPort.oAuth" as CFString

        let runningApp =
            NSWorkspace.shared.runningApplications
                .filter { item in item.bundleIdentifier == Bundle.main.bundleIdentifier }
                .first { item in item.processIdentifier != getpid() }

        if runningApp != nil {
            // Send the url to existing process via CFMessage
            guard let messagePort = CFMessagePortCreateRemote(nil, port)
            else{
                let alert = NSAlert()
                alert.messageText = "error"
                alert.runModal()
                exit(1)
            }

            // Send the message
            var unmanagedData: Unmanaged<CFData>? = nil;
            CFMessagePortSendRequest(messagePort, 0, Data(self.appState.strText.utf8) as CFData, 3.0, 3.0, CFRunLoopMode.defaultMode.rawValue, &unmanagedData)
            exit(0)
        }
        else{
            // Create a CFMessagePort for our main process
            let info = Unmanaged.passUnretained(self).toOpaque()
            var context = CFMessagePortContext(version: 0, info: info, retain: nil, release: nil, copyDescription: nil)

            if let messagePort = CFMessagePortCreateLocal(nil, port, callback, &context, nil) ,
                let source = CFMessagePortCreateRunLoopSource(nil, messagePort, 0 ){
                    CFRunLoopAddSource(CFRunLoopGetMain(), source, .defaultMode)
                }
        }
    }
}

Here is the result.

MacResult

Please check out the sample on Github.

Related Article