Build a MacOS Application with Spotify Connectivity using Swift

Build a MacOS Application with Spotify Connectivity using Swift

Integrate a desktop MacOS app with the Spotify API and get authenticated to the service using OAuthSwift and OAuth2

Go to the profile of  Jonathan Banks
Jonathan Banks
26 min read
Time
3hours
Platforms
Mac
Difficulty
Medium>Hard
Technologies
Swift, OAuth2, Spotify API

Learn step-by-step how to create a basic MacOS app that acquires application authorization from Spotify using the OAuth2 protocol and makes a simple Spotify API call. Use this tutorial and code as the basis for a more complex desktop application that can interact with the robust set of Spotify API offerings.

Overview

On a personal level, I'm a Spotify "Power User" given the number of hours I spend each day using their applications across my devices. I even use their desktop app quite a bit. It's fast-loading, responsive, and the cross-device playback is seamless. Still, I felt like a few features were missing for my use, and I was curious about what their API had to offer. Turns out, they offer quite a bit!

I decided to make a MacOS application to toy around with their APIs and see if I can add some functionality to help streamline my use of their product. Here are some possible improvements that would help me:

  • One-click add the currently playing song to a pre-determined "Best" playlist?
  • Create a tiny version of the player (the desktop app does not have this)
  • Taskbar add-on that displays the current song name and artist?
  • Exporting a list of artists you follow or a favorite playlist?
  • Integrate commands with Siri?

As I figure out exactly what I want to do, and if it turns out to be universally useful, I'll release the various stages of completed code out in the wild for others to use. In the meantime, I'll show you how to get a basic MacOS application authorized to work with the Spotify API and how to make a single, simple API call.

End Product

Below is an animated sample of the end product in operation.   When the app opens, it uses the OAuthSwift framework to make an authorize request to Spotify. If the user isn't logged in, the app receives a redirect to the login page and displays a dropdown sheet to the user:

Once the user is logged in, the app automatically makes a second API call.  It uses the OAuthSwift framework to request authorization from the user to view and modify that user's resources.  Spotify calls these "Scopes".  If granted, the app makes a single Spotify API call to acquire account information about the logged in user and displays it on the main screen.

The code provided for this tutorial creates an extensible skeleton of a working app. The authorization component is complete and it's ready to make meaningful API calls into the Spotify stack.  It will be a solid foundation for a more complex app.

Project Code

Download the source code from Bitbucket, in the Spotify Integration repo. This tutorial will walk through the code detailing the complex parts. It will expand into a series of posts as additional functionality is added in the months to come.

There's an equally important task for the reader to complete individually. It's located at developer.spotify.com. Sign-up and setup a new application in the account dashboard. Once complete, the required ClientID and Client Secret keys are provided by Spotify.

Project Setup

Start by creating a new Cocoa App project in XCode.  The OAuthSwift and Alamofire  frameworks support all authorization API calls.  Add the following Podfile to the XCode project directory:

# Uncomment the next line to define a global platform for your project
# platform :osx, '10.12'

target 'SpotifyIntegration' do
  # Comment the next line if you're not using Swift and don't want to use dynamic frameworks
  use_frameworks!

  # Pods for SpotifyIntegration
  pod 'OAuthSwift', '~> 2.0.0'
  pod 'Alamofire', '~> 5.0.0-rc.2'
end


Run the following command from the project folder to install the frameworks.  Finally, open the .xcworkspace file in XCode:

pod install

Storyboard

This application only needs two main ViewControllers:

  • MainScreenViewController: handles the authorization and serves as the main screen for the larger app.
  • SpotifyWebLoginViewController: handles the user login flow using a WebView to display the Spotify login and App permission pages.

MainScreenViewController is instantiated first and calls to the Spotify backend for authorization. Based on the return URL, it will invoke the SpotifyWebLoginViewController if the user needs to login.

Create the Class Files

Creating a new project provides a ViewController for free but one more is needed. Drag one additional ViewController onto the Storyboard from the Library.

Refactor the file named ViewController, renaming it to MainScreenViewController. Do this by highlighting the name of the class in the class file, right-click on it, select the Refactor option, and apply the new name.

Go back to the Storyboard, select the ViewController created with the initial project, select the blue button at the top and then the Identity Inspector.

Screen-Shot-2019-11-05-at-11.34.51-PM

Change the custom class to MainScreenViewController.

Next, do the same for the other new ViewController dragged into Storyboard, creating a new file in the project that extends NSViewController, and name it SpotifyWebLoginViewController

Create Segue

Ctrl-drag from the MainScreenViewController over to the SpotifyWebLoginViewController to create a new segue. Make it a Sheet from the modal dropdown.

The reason it's a sheet is that the login flow should happen directly in the main window and not in a separate pop-up window. Click on the segue that's drawn between the two windows, select the Attributes Inspector, and name it "segueWebLogin"

WKWebView

The next task is to display the Spotify login and authorization pages inside a browser view. Go back to the Library and drag a WKWebView (WebKit View) onto SpotifyWebLoginViewController, set the constraints to 0 in all directions. WKWebView will take up the entire parent ViewController.

Open the Assistant Editor, make sure it's displaying the SpotifyWebLoginViewController class, then ctrl-drag from that WKWebView over to the class to create a new IBOutlet named webview.

Resize Windows

Next, tweak the sizes of the windows. The settings in the downloaded project for MainScreenViewController is 600x400 while the SpotifyWebLoginViewController is 500x300.

The size is chosen for the inner web login Sheet should be smaller in both width and height than the parent so it can drop down into the main window confines.

Profile IBOutlets

Drag an NSImageView and five NSTextFields onto the MainScreenViewController. These will be populated with user data after the final test call made to the Spotify API service. Freely position them but use the downloadable project code as reference. The profile image should definitely go above the labels. The project names for these are:

  • profileImageView
  • profileNameTextView
  • spotifyAccountTypeTextView
  • spotifyPublicProfileTextView
  • spotifyFollowersTextView

Drag IBOutlets for each of them into the MainScreenViewController using the Assistant Editor.

AppDelegate

There are modifications required in the AppDelegate that ensure the window re-opens in the same spot the user positioned it. Ideally, if the window is dragged to a new location on the screen it'll always re-open there.

UserDefaults

Add the following member variable to AppDelegate:

 let defaults = UserDefaults.init(suiteName: "com.fusionblender.spotify")

This is the same UserDefaults found on the iOS side of things. It is a database that stores key-value pairs accessible from the calling application.

ApplicationWillFinishLaunching

Next, modify applicationWillFinishLaunching(_ notification: Notification) by adding the following code:

func applicationWillFinishLaunching(_ notification: Notification) {

    //1
    let window = NSApplication.shared.mainWindow!
    window.makeKeyAndOrderFront(self)

    //2
    if let appFrame = defaults?.string(forKey: Settings.Keys.windowPosition){

        let frame:NSRect = NSRectFromString(appFrame)
        window.setFrame(frame, display: false)

    }
    else{
        window.center()
    }

    //3
    if let fullScreenEnabled = defaults?.bool(forKey: Settings.Keys.appFullScreen){
        if fullScreenEnabled{
            window.toggleFullScreen(self)
        }
    }

}

In more detail:

  1. The first section places the main window in front of all other windows on the user's screen by getting a reference to the shared mainWindow
  2. The second part checks the defaults for the window position key, and if it exists, the saved NSRect String is used to set the window position.
  3. If the key doesn't exist, the window is centered on the screen. A new file named Settings will handle this.
  4. The final section checks if the app was made fullscreen when saved last and will toggle it on if so.

Two key points to elaborate:

  1. The code is not added in applicationDidFinishLaunching. Doing that will make the window open in the same spot then visibly flash and redrawn at the stored position.
  2. By the time applicationDidFinishLaunching is called, the app has already been drawn on the screen and the code inside is run afterwards.

ApplicationWillTerminate

The last step in AppDelegate is to save the window's position when the app is shut down. Add the following code:

func applicationWillTerminate(_ notification: Notification) {
       
    let window = NSApplication.shared.mainWindow!
    defaults?.set(NSStringFromRect(window.frame), forKey: Settings.Keys.windowPosition)
    defaults?.synchronize()
}

The window position is saved by creating an NSString from the main window's frame using the NSStringFromRect(:NSRect) method, and saving it with the same key from before.

Settings

The Settings file will be a central repo for any app-specific settings information shared across all application classes. For now, it will only be used as a repo for keys. Create a new file in XCode named Settings and add the following code:


import Foundation

struct Settings{
    
    struct Keys{
        static var windowPosition:String = "AppScreenSizeAndPosition"
        static var appFullScreen:String = "appFullScreen"
    }
}

MainScreenViewController Redux

Code must be added to the MainScreenViewController to cover window placement manipulation. Add the following extension of NSWindowDelegate at the bottom of the class:

extension MainScreenViewController: NSWindowDelegate{
    
    func windowShouldClose(_ sender: NSWindow) -> Bool {
        
        let window = NSApplication.shared.mainWindow!
        if(sender == window) {
            defaults?.set(window.isZoomed ? true : false, forKey:Settings.Keys.appFullScreen)
        }
        return true;
    }
}

This code covers setting the appFullScreen key in UserDefaults to true or false if the user makes the app full-screen/leaves full screen.

In order to enable the delegation, add the following to the viewDidAppear method:

let window = NSApplication.shared.mainWindow!
window.delegate = self

There are many other functions of NSWindowDelegate that are worthwhile exploring. There are a few important ones commented out in the source code download from the project Bitbucket repo.

OAuth2 and Spotify Integration

Spotify supports three authorization models in their API. Of those, the Authorization Code Flow is the model used for this application. Refer to the flow below:

Screen-Shot-2019-11-06-at-12.29.52-AM

Using OAuthSwift will simplify the slew of OAuth2 requests to the Spotify Accounts Service.

Authorization Flow

OAuth2 can be complex but think of the flow from a high-level perspective. The application needs an access token to pass to Spotify when making API calls. In order to get this access token, do the following:

  1. Send the API credentials and list of requested scopes to Spotify to authenticate to the service and its permissions. Required for access to the Spotify APIs.
  2. If the credentials pass inspection but the user is not logged in, the result of step 1 is a redirect to a login page for the user.
  3. If the user logs in but hasn't yet authorized our application to have its requested access, another redirect is received to a Spotify page for the user to authorize that access.
  4. If the user allows access, the app will get a result that includes a temporary authorization code .
  5. The app should send this authorization code back to Spotify for an access and refresh token.
  6. Finally, the app is ready to make API calls. It passes the access token with every API call, until it becomes expired (1 hour) and then requests a refreshed token on expiration.

In summary, the following is happening:

  1. Sending the Spotify developer credentials for access
  2. Receiving a temporary code that's used to pass back to Spotify
  3. Sending that code back for an access and refresh token to in return. That access token is used in each subsequent API call.

Not so bad when it's put that way, right? This application is attempting to request limited access to the Spotify API service through a third-party application. The application credentials are passed to the backend to get the limited-use access token that allows those API calls.

Security Implications

There is a security caveat to discuss:

This tutorial and sample code sends the Spotify API keys (public and private) from the application to Spotify during the OAuth2 authorization flow. This is fine for a personal, toy project but would NEVER be done in a commercial product.

If the application is meant to be a commercial product the private key should only be sent to Spotify from a server owned by the developer. The application would request access from that server which would pass the private key to Spotify, receive the access code, then forward it back to the user.

The sample code that's provided is stripped of the required API keys. The reader is required to get their own keys from Spotify and place them in code. If that hasn't been done yet, do so now in the account profile at developer.spotify.com.

SpotifyAPIManager

SpotifyAPIManager is a centralized class that handles all the Spotify API calls. There will only be one sharable SpotifyAPIManager object in existence through the duration of the application's running state. That singleton instance maintains the Spotify API token state.

There is room for expanding the SpotifyAPIManager in future tutorials. For example, introducing DispatchQueue and DispatchGroup asynchronous protections for multithreading would be one critical next step. Currently, the SpotifyAPIManager invokes methods in a linear fashion so there's no need to add this yet.

Initialization

Create a file in the project named SpotifyAPIManager and paste the following code into it:

var baseURL:String = "https://api.spotify.com"
var authURLString =  "https://accounts.spotify.com/authorize"
var oauth2: OAuth2Swift!

var accessToken:String!
var refreshToken:String!
var scopes: [String]!
var expires:Int!

static let shared = SpotifyAPIManager()

private init(){

    oauth2 = OAuth2Swift(
        consumerKey:    "<YOUR SPOTIFY PUBLIC KEY>",
        consumerSecret: "<YOUR SPOTIFY PRIVATE KEY>",
        authorizeUrl:   "https://accounts.spotify.com/en/authorize",
        accessTokenUrl: "https://accounts.spotify.com/api/token",
        responseType:   "code"
    )
}

This code sets up the basics required for making Spotify API calls:

  • baseURL: the base URL for Spotify API calls
  • authURLString: the URL to request authorization to Spotify using OAuth2
  • oauth2: instance of OAuth2Swift that will be universally used to make authorization calls

Once the authorization flow is complete the following variables will be initialized with values:

  • accessToken: unique string sent with each API call to validate authorization
  • refreshToken: used to request a new token if the current access token is expired
  • scopes: an array containing the authorized scopes that were requested
  • expires: an Integer value in seconds that allows the caller to know when the accessToken is expired.

The static variable named shared instantiates the first (and only) instance of SpotifyAPIManager on first access, using the private initializer that prevents more instances from being created.

The init() method, consumerKey and consumerSecret are where private credentials should be pasted into the code. These are independently obtained from Spotify at developer.spotify.com.

Authorize Scope Method

Paste the following method code into SpotifyAPIManager

// First API Call to send Spotify app credentials and scope request
    func authorizeScope(){
        
        let handle = oauth2.authorize(
            withCallbackURL: URL(string: "http://localhost:8080")!,
            scope: "user-library-modify playlist-read-collaborative playlist-read-private playlist-modify-private playlist-modify-public user-read-currently-playing user-modify-playback-state user-read-playback-state user-library-modify user-library-read user-follow-modify user-follow-read user-read-recently-played user-top-read user-read-private",
            state: "test12345") { result in
                switch result {
                case .success(let (credential, _, _)):
                    print("Authorization success")
                case .failure(let error):
                    print(error.description)
                }
        }
    }

This is the initial OAuth2 GET request to Spotify for authorization. The OAuth2 instance was initialized with authorizeURL/accessTokenURL values and knows where to make requests.

The authorize method call sets the following query parameters of the OAuth2Swift instance:

  • responseType: set to code. Required, as Spotify will read this and know it needs to send back an authorization "code" that will be exchanged for the access token during a subsequent call.
  • scope: a listing of each scope the client requires. For example, playlist-modify-private scope requests access to a user's private playlists and the ability to make changes to them. A full list of the scopes and what they do can be found here.
  • withCallbackURL: this is the required redirect_uri. It's the URI that Spotify will redirect to when the user decides to provide access.
  • state: not required for this application, but is useful when running code on a backend server. The server would generate a random hash of client state and when the state is sent back on the response from Spotify, the server can validate it came from the same client. Here it's set to test12345 since it's not actually being used.

The authorizeScope method is called from the MainScreenViewController. If the user is not logged in or needs to authorize the application, the redirect from Spotify will be interpreted by the webview (shown later). Subsequently, it is handled by a segue to the SpotifyWebLoginViewController.

The user will log in directly to Spotify from an embedded WKWebView that displays the content of the Spotify login/authorization pages.

Authorize the Redirect URL in the Spotify Portal

The withCallbackURL was specified in the previous step but also needs to be authorized in the developer portal. Go back to developer.spotify.com, login, get to the dashboard, select the application created here, and click on edit settings. If all steps went well, the screen looks like this:

Screen-Shot-2019-11-06-at-3.27.42-PM

In the Redirect URIs section, click the Add button and input http://localhost:8080 for the URI. This is a master list of authorized redirect URIs.

For security reasons, the redirect URI would normally be to a self-owned, remote webserver that mediates the authorization steps. That architecture prevents a man-in-the-middle attack where an attacker might redirect the Spotify response back to a server they own.

For this application, the localhost URI forces the redirect back to the application. The application needs to process the response and acquire the unique code that's used to exchange for the access token, not a remote server.

AuthorizeWithRequestToken Method

Paste the next authorization method into SpotifyAPIManager:


 func authorizeWithRequestToken(code:String, completion: @escaping (Result<OAuthSwift.TokenSuccess, OAuthSwiftError>) -> ()) {
     
     oauth2.postOAuthAccessTokenWithRequestToken(byCode: code, callbackURL: URL.init(string: "http://localhost:8080")!) { result in
            
    switch result{

    case .failure(let error):
        print("postOAuthAccessTokenWithRequestToken Error: \(error)")
        completion(result)
    case .success(let response):

        print("Received Authorization Token: ")
        print(response)

        if let access_token = response.parameters["access_token"], let refresh_token = response.parameters["refresh_token"], let expires = response.parameters["expires_in"], let scope = response.parameters["scope"]{



            self.refreshToken = refresh_token as? String
            self.accessToken = access_token as? String

            if let t_scope = scope as? String{
                let t_vals = t_scope.split(separator:" ")
                self.scopes = [String]()
                t_vals.forEach({ scopeParameter in
                    self.scopes.append(String(scopeParameter))
                })
            }

            self.expires = expires as? Int

            print("ACCESS TOKEN \(String(describing: self.accessToken))")
            print("REFRESH TOKEN \(String(describing: self.refreshToken))")
            print("EXPIRES \(String(describing: self.expires))")
            print("SCOPE: \(String(describing: self.scopes))")

            completion(result)
        }
    }

}
}

If the response from the authorizeScope method call is successful, it will result in a redirect URI from the Spotify service containing the code. It is parsed by a WKWebView navigation mediation method, (refer to the next section).

The response from the Spotify service contains parameters that are parsed and stored as class variables in SpotifyAPIManager They are:

  • accessToken: provided in subsequent calls back to the Spotify platform
  • refreshToken: token sent to the Spotify Accounts service in place of an authorization code if the access token expires. This code is not implemented for this example but for reference it works like this: POST request to the /api/token endpoint with the refresh token, Spotify returns a new access token.
  • scopes: the granted scopes provided
  • expires: seconds until the current access token expires.

Make note of the completion closure parameter. It permits passing the result of the authorization call back to the caller of the method to take action on a success or failure result.

GetSpotifyAccountInfo Method

The SpotifyAPIManager is ready to make standard (non-authorization) API calls to the Spotify backend if the previous two method calls were successful. After those calls complete the manager has the required access token and will use it for each subsequent API call until expired.

Add a method in SpotifyAPIManager that makes a simple call with that access token to get basic user information from Spotify. This serves as a key test to see if everything's setup and working. Paste the following into the API manager:


func getSpotifyAccountInfo(completed: @escaping (AFDataResponse<Any>)->()){
        
    let aboutURL = baseURL + "/v1/me"
    let headers: HTTPHeaders = [
        "Authorization": "Bearer " + self.accessToken,
    ]


    AF.request(aboutURL,
               headers: headers).responseJSON { response in
                completed(response)
    }

}

Notice that Alamofire simplifies and streamlines the HTTP GET calls to the Spotify API. The only header added to the call is Authorization: Bearer. That header is specific to OAuth2 and is a requirement of the protocol. The accessToken, acquired in the previous method call, will be the Bearer token and is passed to Spotify.

The completion closure of the getSpotifyAccountInfo method passes the response back to the caller for processing the JSON return values. One possible refactoring might be to keep that parsing logic in the SpotifyAPIManager and return a simple data structure to the caller.

Final Methods for SpotifyAPIManager

External classes use the remaining methods in SpotifyAPIManager to set the OAuth2Swift authorizeURLHandler, response tokens, scopes, and the expiration of the current token.

Paste the following methods into the API manager:

func setAuthorizeHandler(vc:OAuthSwiftURLHandlerType){
    oauth2.authorizeURLHandler = vc
}

func setTokens(refresh:String, access:String){
    self.refreshToken = refresh
    self.accessToken = access
}

func setScopes(scopes:[String]){

    self.scopes = scopes
}

func setExpires(expires:Int){

    self.expires = expires
}

MainScreenViewController

Examine the project code for the MainScreenViewController class. There is a WKWebWebView instance in the class but no WebView on display in the ViewController.

The Spotify Web APIs make authorization and API calls, but there needs to be a way to handle them and the redirection when Spotify responds. WKWebWebView has delegate methods that will take care of this problem. It can handle the redirection, intercept the responses, and parse them.

The documentation of OAuthSwift specifies an alternative; register a redirect URL handler on NSAppleEventManager for event type kAEGetURL. Code for this exists in the project but it's commented out because it does not work. Give it a shot if you prefer not using the WKWebView.

Setup and Lifecycle Methods

Add the following imports at the top of the MainScreenViewController class file:

import Cocoa
import OAuthSwift
import WebKit

Then add the following class variables:

let defaults = UserDefaults.init(suiteName: "com.fusionblender.spotify")
var webView: WKWebView!
var spotifyManager:SpotifyAPIManager = SpotifyAPIManager.shared

The WKWebWebView is instantiated programmatically, so paste the following into viewDidLoad()

override func viewDidLoad() {
    super.viewDidLoad()
    
    let webConfiguration = WKWebViewConfiguration()
    webView = WKWebView(frame: .zero, configuration: webConfiguration)
    webView.uiDelegate = self
    webView.navigationDelegate = self
    
    webView.cleanAllCookies()
}
    
    

Instantiate the webView with a zero sized frame because it remains invisible during the duration of the application's life. It's used for the underlying functionality, not for display.

The webView uiDelegate and navigationDelegate are set to self since the MainScreenViewController will adopt the WKUIDelegate and WKNavigationDelegate protocols. The WKNavigationDelegate is most important because it handles the navigation and redirection.

Next, add two calls to viewDidAppear() to set the SpotifyAPIManager authorizeHandler to this class and make the first call to the Spotify API authorizeScope() method. viewDidAppear() should look like this now:

override func viewDidAppear() {
    let window = NSApplication.shared.mainWindow!
    window.delegate = self

    spotifyManager.setAuthorizeHandler(vc: self)
    spotifyManager.authorizeScope()
}

Prepare Method

Finally, override the prepare method to setup the SpotifyWebLoginViewController. It must be configured with the proper URL to load and way to callback to the MainScreenViewController if the user successfully logs into Spotify.

Paste in the following:

override func prepare(for segue: NSStoryboardSegue, sender: Any?) {

    if let swlvc = segue.destinationController as? SpotifyWebLoginViewController{
        print("Prepare: Set Login URL of SpotifyWebLoginVC to \(webView.url)" )
        swlvc.loginURL = webView.url
        swlvc.loginDelegate = self
    }
}

Breaking down that prepare method:

  • The SpotifyWebLoginViewController code below details the loginDelegate and the corresponding Protocol implementation.
  • Setting the loginURL is a requirement to receive the login redirect from Spotify. The WKNavigationDelegate provides the redirect if the user isn't logged in. The SpotifyWebLoginViewController loads the URL in viewDidAppear. It must be set before invoking the segue.

WKUIDelegate and WKNavigationDelegate

Adopt the WKUIDelegate and WKNavigationDelegate protocols that will work with the WkWebView to control navigation and parse responses.

WKUIDelegate

At the bottom of the MainScreenViewController class file paste the following:


extension MainScreenViewController:WKUIDelegate{
    override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
        if keyPath == "estimatedProgress" {
            print(Float(webView.estimatedProgress))
        }
    }
}

The WKUIDelegate isn't required, in fact it's not used yet in the current project. At some point in the future, if there's a long-running request using WKWebView, then the delegate can assist with the display of a progress bar or keeping an animation running while loading happens and remove it when complete. In other words, it's for future use.

WKNavigationDelegate Implementation

Next up, is a critical protocol, WKNavigationDelegate. According to Apple's documentation:

The methods of the WKNavigationDelegate protocol help you implement custom behaviors that are triggered during a web view's process of accepting, loading, and completing a navigation request.

That's sounds promising. It will provide methods to assess the URLs that the WKWebView is loading. They'll be parsed for content, if required, and then the logic will decide next steps based on which URL is being processed. Specifically, the URLs of most concern are the server redirects returned from Spotify.

Insert the following code at the bottom of the class:

extension MainScreenViewController: WKNavigationDelegate{
    
  public func webView(_ webView: WKWebView, didReceiveServerRedirectForProvisionalNavigation navigation: WKNavigation!) {

   var code:String? = nil
    var state:String? = nil

    if let t_url = webView.url{

        // If redirect is to Login then user isn't logged in according to initial OAuth2 request.
        // Segue to SpotifyWebLoginViewController for login flow
        if t_url.lastPathComponent == "login"{
            self.performSegue(withIdentifier: "segueWebLogin", sender: self)

        }else{

            //Redirect after initial OAuth2 request for authorization.
            //Contains code to pass back to Spotify for Access and Refresh tokens
            if let queryItems = NSURLComponents(string: t_url.description)?.queryItems {

                for item in queryItems {
                    if item.name == "code" {
                        if let itemValue = item.value {
                            code = itemValue
                        }
                    }else if item.name == "state"{
                        if let itemValue = item.value{
                            state = itemValue
                        }
                    }


                }

                if let code_found = code{

                    // Get Access and Refresh tokens from Spotify
                    self.spotifyManager.authorizeWithRequestToken(code: code_found, completion:{ response in

                        switch response{
                            case .success(let (credential, _, _)):
                                print("Authorization Success")
                                
                                //Get the account information
                                self.spotifyManager.getSpotifyAccountInfo(completed: { response in

                                    switch response.result {
                                    case .success:


                                        let JSON = response.value as! NSDictionary
                                        print(JSON)
                                        self.updateUserProfileScreen(json: JSON)

                                    case let .failure(error):
                                        print(error)
                                    }

                                })
                            case .failure(let error):
                                print(error.description)
                        }

                    })

                }

            }

        }
    }
        
}

All redirects coming as response from Spotify are handled by this delegate method.

Breaking down that method:

  • Check the webView.url lastPathComponent to see if it's the login url. If so, call the performSegue method to invoke the SpotifyWebLoginViewController handling Spotify login.
  • If not the login URL, the only other redirect Spotify would send to the MainScreenViewController is to http://localhost:8080. That's the same URL previously authorized in the Spotify dashboard. It contains the code to pass back to Spotify for the API access token.
  • Query the URLComponents to extract the code and state, and then pass it back to the SpotifyAPIManager with a call to authorizeWithRequestToken(code:).
  • Obtain the response in the closure completion method, check it for success or failure. If successful, call the final getSpotifyAccountInfo() method. That's the final method call and will print out to console the results of the successful Spotify API call
  • Transform the response JSON into an NSDictionary and pass to updateUserProfileScreen(json:). Apply the response values to the main screen UI.

Next, implement the decidePolicyFor method of WKNavigationDelegate. Paste the following code into the delegate section:

public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Swift.Void){
        
    print("MainScreenVC: Deciding Policy")

    if(navigationAction.navigationType == .other)
    {
        if navigationAction.request.url != nil
        {

            // Initial or access token request
            if navigationAction.request.url?.lastPathComponent == "authorize" || navigationAction.request.url?.host == "localhost"{
                decisionHandler(.allow)
                return

            }else{

                self.performSegue(withIdentifier: "segueWebLogin", sender: self)

            }

        }
        decisionHandler(.cancel)
        return
    }


    decisionHandler(.cancel)
}

This delegate method arbitrates the URLs loaded by the WKWebView.

The first URL that'll come through this method will be accounts.spotify.com/en/authorize with parameters and headers setup by the OAuthSwift instance and a response_type of "code". That's the URL created by OAuthSwift on invocation of spotifyManager.authorizeScope() in viewDidAppear.

How did that URL, coming from OAuthSwift, get here though? Doesn't it seem like the OAuthSwift instance should handle that?

That's all the work of the authorizeHandler of OAuthSwift The SpotifyAPIManager sets the authorizeHandler of the OAuthSwift instance to MainScreenViewController and another method named handle.

Insert the following code at the bottom of the MainScreenViewController class:

extension MainScreenViewController: OAuthSwiftURLHandlerType {
    
    func handle(_ url: URL) {
        
        print("Initial Request URL:\n\n\n \(url.description)")
        let request = URLRequest(url: url)
        self.webView.load(request)
    }

}

With this method it's starting to come together. Breaking it down:

  • The SpotifyAPIManager sets up the OAuthSwift instance with the credentials and URLs
  • When the SpotifyAPIManager makes authorization calls, it tells its OAuthSwiftURLHandlerType delegate to "handle" that URL.
  • In the Delegate implementation, handle(:URL), the WKWebView loads the URL and calls the WKWebView arbitration delegate method to decide how to handle the incoming URL.

Going back to the webView(_ webView: WKWebView, decidePolicyFor..) method, this is the piece of code that decides how to handle the URL:

// Initial request
if navigationAction.request.url?.lastPathComponent == "authorize" || 
    navigationAction.request.url?.host == "localhost"{

    decisionHandler(.allow)
    return

}else{

     self.performSegue(withIdentifier: "segueWebLogin", sender: self)

}

The logic of that code is:

  • If the last path component is authorize or the host is localhost, it is handled locally to this class.
  • If the Spotify service redirects with a URL containing login then segue to the SpotifyWebLoginViewController

SpotifyLoginProtocol

Create a file named SpotifyLoginProtocol in the project and paste the following code into it:


import Foundation
import OAuthSwift

protocol SpotifyLoginProtocol{
    
    func loginSuccess(code:String, state:String)
    func loginFailure(msg:String)
}

MainScreenViewController implements the application user interface. External classes, like SpotifyWebLoginViewController, run the login code and need a way to alert the main screen on successful login.

The SpotifyLoginProtocol resolves the challenge of communication between UIViewControllers.

Implement the two protocol methods in MainScreenViewController by pasting in the following code to MainScreenViewController:

extension MainScreenViewController:SpotifyLoginProtocol{
    
    func loginFailure(msg:String) {
        print("Login Failure:" + msg)
    }
    
    func loginSuccess(code:String, state:String) {
        print("Login Success: Code \(code)")
        
        // Complete the authorization, get the access and refresh tokens, call the spotify API
        self.spotifyManager.authorizeWithRequestToken(code: code) { (String) in
            
            self.spotifyManager.getSpotifyAccountInfo(completed: { response in
                
                switch response.result {
                case .success:
                    
                    let JSON = response.value as! NSDictionary
                    print(JSON)
                    self.updateUserProfileScreen(json: JSON)
                    
                case let .failure(error):
                    print(error)
                }
                
            })
        }
    }

}

On login success, SpotifyWebLoginViewController passes the code and state and then completes the final test API call to getSpotifyAccountInfo. The method transforms the JSON into a NSDictionary and then passes it to updateUserProfileScreen(json:). That method updates the user interface (implemented in the next section).

If there's a failure, the method simply prints the error message to console. Real applications would trigger a different flow on successful login, such as setting up application components for display, querying Spotify for playlist data, etc.

UpdateUserProfileScreen

MainScreenViewController requires one additional method to update the user profile information on the screen after the final method call to Spotify. Paste in the following code:

private func updateUserProfileScreen(json:NSDictionary){
        
    profileNameTextField.stringValue = json["display_name"] as! String
    spotifyAccountTypeTextField.stringValue = json["product"] as! String + "account"

    let external_urls = json["external_urls"] as! NSDictionary
    spotifyPublicProfileTextField.stringValue = external_urls["spotify"] as! String

    let followersDict = json["followers"] as! NSDictionary
    let followers = followersDict["total"] as! Int64
    let followersText = followers == 1 ? "follower" : "followers"
    spotifyFollowersTextField.stringValue = String(followers) + " " + followersText

    let profilePathArr = json["images"] as! NSArray
    let profilePathDict = profilePathArr[0] as! NSDictionary

    DispatchQueue.main.async{

        do{

            let data = try Data(contentsOf: URL(string: profilePathDict["url"] as! String)!)
            var image: NSImage?
            image = NSImage.init(data: data)

            guard let t_img = image else{
                self.profileImageView.image = NSImage.init(named: "profile")
                return
            }

            if t_img.isValid{
                self.profileImageView.image = t_img
                self.profileImageView.makeRounded()
            }

        } catch {
            print("Error downloading profile image")
        }
    }

}
    

This method uses the incoming NSDictionary, parsed from the JSON returned by Spotify, to populate the user content on the main screen. Run the sample code and view the JSON that's printed to console to see the return values.

The most interesting component of the method is parsing the profile picture. DispatchQueue pulls the image from Spotify asynchronously. Spotify provides the path used as input to the Data object. If it's a valid path, then create the NSImage and round it using an extention detailed in the next section.

It was a lot of work but we're finally done with MainScreenViewController. That means we're nearly done with everything and the end is in sight.

Extensions

There is a method call in MainScreenViewController to cleanAllCookies() and makeRounded(). That hasn't been implemented yet so create a new file in the project named Extensions and paste the following code:

extension WKWebView {
    
    private var httpCookieStore: WKHTTPCookieStore  { return WKWebsiteDataStore.default().httpCookieStore }
    
    func cleanAllCookies() {
        HTTPCookieStorage.shared.removeCookies(since: Date.distantPast)
        WKWebsiteDataStore.default().fetchDataRecords(ofTypes: WKWebsiteDataStore.allWebsiteDataTypes()) { records in
            records.forEach { record in
                WKWebsiteDataStore.default().removeData(ofTypes: record.dataTypes, for: [record], completionHandler: {})
               
            }
        }
    }
    
    func refreshCookies() {
        self.configuration.processPool = WKProcessPool()
    }
    
    
    func getCookies(for domain: String? = nil, completion: @escaping ([String : Any])->())  {
        var cookieDict = [String : AnyObject]()
        httpCookieStore.getAllCookies { cookies in
            for cookie in cookies {
                if let domain = domain {
                    if cookie.domain.contains(domain) {
                        cookieDict[cookie.name] = cookie.properties as AnyObject?
                    }
                } else {
                    cookieDict[cookie.name] = cookie.properties as AnyObject?
                }
            }
            completion(cookieDict)
        }
    }
}

extension NSImageView {
    
    func makeRounded() {
        
        self.layer?.borderWidth = 1
        self.layer?.masksToBounds = false
        self.layer?.borderColor = NSColor.lightGray.cgColor
        self.layer?.borderWidth = 1.0
        self.layer?.cornerRadius = self.frame.height / 2
        self.layer?.masksToBounds = true
    }
}


On MacOS and iOS, each application has its own container that manages its own cookies. The purpose of this extension to WKWebView is to provide the option to delete login cookies stored by to the app. It's used purely for testing purposes.

If there's not a way to delete the cookies the developer will never be able to test that flow again unless the cookie expires. Once done with testing those lines are commented out or deleted. The end-user would expect to stay logged in unless they unchecked "remember me" in the login form. The project code can handle an already logged-in user.

Lastly, the extension to NSImageView adjusts the CALayer of the ImageView to make the Spotify profile picture rounded.

SpotifyWebLoginViewController

The SpotifyWebLoginViewController is the last class to implement. It deals only with login to Spotify and communicating results back to MainScreenViewController. Many of the methods are similar between the two UIViewControllers

Open the SpotifyWebLoginViewController and paste the following imports:

import Cocoa
import WebKit
import OAuthSwift
import Alamofire

Lifecycle Methods

Take care of the lifecycle methods first by pasting the following code at the top of the file:


@IBOutlet weak var webView: WKWebView!
var loginURL:URL?
var loginDelegate:SpotifyLoginProtocol!

var spotifyManager:SpotifyAPIManager = SpotifyAPIManager.shared

//MARK:- Lifecycle
override func viewDidLoad() {
    super.viewDidLoad()
    webView.navigationDelegate = self
}

override func viewDidAppear() {

    super.viewDidAppear()

    guard let t_login_url = self.loginURL else{
        self.loginDelegate.loginFailure(msg:"Malformed URL")
        dismiss(self)
        return
    }



    let request = URLRequest(url: t_login_url)
    self.webView.load(request)

    // Fade-in WebView
    NSAnimationContext.runAnimationGroup({ _ in
        NSAnimationContext.current.duration = 2.0
        webView.animator().alphaValue = 1.0
    }) {
        // Complete Code if needed later on
    }
}


Breaking the key class variables down:

  • loginURL: set from the prepare method in MainScreenViewController, the login URL redirect from Spotify.
  • loginDelegate: instance of the protocol that's set to MainScreenViewController in the prepare method
  • navigationDelegate: set to self for implementation of WKNavigationDelegate

The class WkWebView loads the loginURL in viewDidAppear. The animation code is what displays the nifty fade-in of the WkWebView on load to give the app a bit of UI slickness.

WKNavigationDelegate

The code for the delegate is nearly the same as what was done in MainScreenViewController with a couple key differences.

Add the following delegate implementation:

extension SpotifyWebLoginViewController: WKNavigationDelegate{
    
public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Swift.Void){

    if(navigationAction.navigationType == .other){

        if navigationAction.request.url != nil{

            // Called when Spotify redirects to authorize the app Scopes
            if  navigationAction.request.url?.lastPathComponent == "authorize" {
                decisionHandler(.allow)
                return
            }

            // If user is logged in already, and App authorized, the first redirect from Spotify
            // returns the code required for the Access and Refresh tokens
            if navigationAction.request.url?.host == "localhost"{
                let codeAndState = parseCodeAndStateFromURL(navigationAction: navigationAction)

                if let code_found = codeAndState.0, let state_found = codeAndState.1{
                    loginDelegate.loginSuccess(code:code_found, state:state_found)
                }

                decisionHandler(.cancel)
                self.dismiss(nil)
                return
            }
            //allows all others including Oauth2 "Login" and Captcha from Spotify
            decisionHandler(.allow)
            return


        }

        decisionHandler(.cancel)
        return

    }else if navigationAction.navigationType == .formSubmitted{ // User submits login and authorize forms

        // Invoked when user accepts request for App Scopes
        if  navigationAction.request.url?.lastPathComponent == "accept" {
            decisionHandler(.allow)
            return

        // Invoked when user cancels request for App Scopes, handle appropriately
        }else if navigationAction.request.url?.lastPathComponent == "cancel"{

            decisionHandler(.allow)
            self.loginDelegate.loginFailure(msg:"User cancelled login flow")
            self.dismiss(nil)
            return

        // After the user hits Agree the Spotify service redirects formsubmitted to localhost w/the temp code.
        // Intercept, process, and pass the code back to MainScreenViewController to complete OAuth2 authorization
        // and get access and refresh tokens.
        }else if navigationAction.request.url?.host == "localhost"{

            let codeAndState = parseCodeAndStateFromURL(navigationAction: navigationAction)

            if let code_found = codeAndState.0, let state_found = codeAndState.1{
                loginDelegate.loginSuccess(code:code_found, state:state_found)
            }


            decisionHandler(.cancel)
            self.dismiss(nil)
            return

        }
    }

    decisionHandler(.cancel)

}

The WKNavigationAction is split into two sections depending on the incoming navigationAction.navigationType:

  • other: handles authorization and parsing of the localhost redirect from Spotify. It makes the callback to MainScreenViewController when it has acquired the access token (code) for Spotify. It's called when a user is not logged in but has previously authorized the app. When it happens, dismiss() returns the user to the main screen.
  • formSubmitted: picks up whether the user accepted or canceled the authorization flow. On cancel, the loginDelegate communicates the login failure back to MainScreenViewController.

If the user isn't logged in and hasn't authorized the app, every call goes through the formSubmitted section of the code.

Look at the call to to the method named parseCodeAndStateFromURL(navigationAction:). It performs one activity, it parses the code from the Spotify redirect URL. Add that at the bottom of the class:

private func parseCodeAndStateFromURL(navigationAction: WKNavigationAction) -> (String?, String?){

    var code:String? = nil
    var state:String? = nil

    if let queryItems = NSURLComponents(string: navigationAction.request.url!.description)?.queryItems {
        
        for item in queryItems {
            if item.name == "code" {
                if let itemValue = item.value {
                    code = itemValue
                }
            }else if item.name == "state"{
                if let itemValue = item.value{
                    state = itemValue
                }
            }

        }
        
    }
    
    return (code,state)

}

The parsing occurs exactly the same as in MainScreenViewController but it's extracted out to a function because it's called in two different places in the code for this class.

Summary

At this point, all the components are in-place for a working application. Go ahead and run it. The main screen user interface is updated with the personal account information and the following JSON prints in the console.

{
    "display_name" = "fake.display.name-us";
    "external_urls" =     {
        spotify = "https://open.spotify.com/user/fake.display.name-us";
    };
    followers =     {
        href = "<null>";
        total = 1;
    };
    href = "https://api.spotify.com/v1/users/fake.display.name-us";
    id = "fake.display.name-us";
    images =     (
                {
            height = "<null>";
            url = "https://profile-images.scdn.co/images/userprofile/default/<fake_hash>";
            width = "<null>";
        }
    );
    type = user;
    uri = "spotify:user:fake.display.name-us";
}

Study the rest of the Spotify APIs, determine what features to add, then go and make some radical new additions to the Spotify application! Wrapping up, here are some links to helpful pages on developer.spotify.com: