1 Nov 2023
How to authenticate 3-legged token with your desktop applications (MacOS)
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:
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.
We need to give a unique name for our app group. For this sample, I am using DAS.AppShellDemoPort.
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.
Please check out the sample on Github.