Front-End Web & Mobile

Building macOS apps using Amplify Library for Swift

June 27, 2024: This blog post covers Amplify Gen 1. For new Amplify apps, we recommend using Amplify Gen 2. You can learn more about Gen 2 in our launch blog post.

The Amplify Library for Swift is now generally available for macOS.

Amplify is an open-source client-side library making it easier to access a cloud backend from your front-end application code. It provides language-specific constructs to abstract low-level details of the cloud API. It helps you to integrate services such as analytics, object storage, REST or GraphQL APIs, user authentication, geolocation and mapping, and push notifications.

We released Amplify for Swift v2.0 in October 2022, with preview support for macOS. That new version also brought new capabilities, such as native support for Swift concurrency model (also known as async/await).

In parallel, Apple released two technologies making it easier to write applications that run the same code base on iOS, iPadOS, and macOS: SwiftUI and Catalyst. SwiftUI helps developer to build cross-platform applications using a declarative language based on Swift. Catalyst allows you to run mobile and tablet applications on macOS.

These technologies allows you to write code that run unmodified on a large variety of systems and screen sizes. So it was natural to expect Amplify Library to be available for iOS, iPadOS, and macOS. Actually, it was the most requested feature since we launched the preview and we plan to add support for watchOS and tvOS in the future.

Using Amplify Library for Swift, you can now write beautiful macOS applications that connect to the same cloud backend as their iOS counterparts. During the preview, we collected your feedbacks and suggestions. You may now use Amplify Library for Swift for your production workloads.

How it Works

To show you how easy it is to access a cloud backend from your macOS applications, I developed a sample podcast-like application. It lists categories, podcasts, and episodes. The app consumes data from a GraphQL API backed by Amazon DynamoDB. The following diagram illustrates the backend architecture.

architecture

The app uses Catalyst and runs —unmodified— on iOS and macOS. It has one single code base for both systems. Let’s highlight some of the Amplify-specific steps I used to build this app. The full source code is available on my personal GitHub repo.

Here is how the final application looks. The macOS version is on the left side, and the iOS version on the right side.

Swift and macOS Amplify Apps

To get started, I open Xcode and create a new multi-platform project. I give my project a name and choose its directory. The General tab of the project target shows the supported platforms.

Apple device targets

Add Amplify Library

The next step is to add the project dependency on the Amplify Library. I select the Package Dependencies tab in the project configuration pane.

I search for https://github.com/aws-amplify/amplify-swift/ and I make sure Up to next major version is selected

Add Amplify package to SPM

Xcode downloads the library and its dependencies. Then, I choose what modules I want to add to my project. Amplify proposes one module per category (authentication, storage, geolocation, etc).

I want to use the DataStore, so I add AWSAPIPlugin, AWSDataStorePlugin, and Amplify and select Add Package. I can always add or remove modules afterwards by going back to the project settings: target > Build Phases > Link Binary with Libraries.

Add Amplify Modules

Create and Deploy an API Cloud Backend

Now that my local project is ready, let’s create a cloud backend. I install the amplify command line interface if not done yet.

I open a Terminal and navigate to my project directory. I enter amplify init and I answer the prompts.

Amplify Init

Next, I add an API and a data type.

I enter amplify add api and I answer the prompts, as shown on the following screenshot.

Amplify Add API

I edit the GraphQL schema at the proposed path (<project directory>/amplify/backend/api/<project name>/schema.graphql) and I add the data types for this project.

Here is the data schema my app uses. It defines a podcast, an episode, and a one-to-many relationship between them: one podcast has multiple episodes.

type PodcastData @model {
    id: ID!
    name: String!
    category: PodcastCategoryData!
    author: String!
    rating: Int
    episodes: [EpisodeData] @hasMany
    image: String
}

type EpisodeData @model {
    id: ID!
    date: String!
    title: String!
    duration: String!
    description: String
}

enum PodcastCategoryData {
    Technology
    Comedy
    Cloud
}

Tip: I usually add the amplify directory to my Xcode project (but not to the build). It allows me to easily browse and edit Amplify configuration files when needed.

Finally, I push this configuration to the cloud with the command amplify push. I answer Y to the question Are you sure you want to continue? and N to the question Do you want to generate code for your newly created GraphQL API? (we will generate code during next step)

amplify push

It may take a couple of minutes to create the backend infrastructure on my AWS account.

When the command runs successfully, it generates two JSON configuration files that I will add to my project in the next step.

Use the Amplify DataStore API

The last phase happens in Xcode again. I write code to add episodes and to query them.

But first, I generate the Swift code to represent my data objects. The amplify command line generates Swift code based on the GraphQL data model.

Back to a Terminal, I enter amplify codegen model.

amplify codegen

Then, I import the two amplify configuration files I mentioned earlier and the generated code to my Xcode project. The configuration files are located next to my Xcode project file. The generated Swift model files are under
amplify/generated/model.

Add models

Finally, it is time to write some code. I’d like to show you four blocks of codes: one to initialize amplify, one to save and one to query data, and one to subscribe to backend changes.

First, I initialize the Amplify library. I typically add that code in a Service struct’s constructor but it can be located anywhere. I just make sure this code is executed only once when the application starts.

init() {

  do {
    // AmplifyModels is generated by the command line
    try Amplify.add(plugin: AWSDataStorePlugin(
                                   modelRegistration: AmplifyModels()))
    try Amplify.add(plugin: AWSAPIPlugin())
    try Amplify.configure()

  } catch {
    print("Failed to initialize Amplify with \(error)")
  }
}

Second, I write code to create episode data in the backend. When invoked, the Amplify library calls the backend GraphQL API that, in turn, persists the data to the database.

func addEpisode(_ episode: EpisodeData) async {
    
    do {
       try await Amplify.DataStore.save(episode)
    } catch let error as DataStoreError {
        print("Error creating podcast - \(error)")
    } catch {
        print("Unexpected error \(error)")
    }
}

Third, I write code to query the database for a given podcast. Again, the Amplify Library sends GraphQL queries to the backend.

func loadEpisodes(for podcast: Podcast) async throws -> [EpisodeData] {
    
   let e = EpisodeData.keys
        
   // load episodes
   return try await
          Amplify.DataStore
                 .query(EpisodeData.self,
                        where: e.podcastDataEpisodesId == podcast.id)

}

Optionally, I can subscribe to changes happening on the backend. This is by far my favorite API call. It creates a long running subscription that returns an asynchronous stream of events, such as entities being added, modified, or deleted.

func loadEpisodes(for podcast: Podcast) -> AmplifyAsyncThrowingSequence<DataStoreQuerySnapshot<EpisodeData>> {
    
    if !episodeSubscriptions.keys.contains(podcast) {
               
        // load episodes
        let e = EpisodeData.keys
        
        episodeSubscriptions[podcast] = Amplify.DataStore
            .observeQuery(for: EpisodeData.self,
                          where: e.podcastDataEpisodesId == podcast.id)
    }
    return episodeSubscriptions[podcast]!
}

Note that the return value of observeQuery(for:where:) must be persisted at struct-level, otherwise the subscription is cancelled when the variable goes out of scope and the runtime reclaims the memory.

I wrote the following code in my ViewModel to consume such a stream of events:

func loadEpisodes(for podcast: Podcast) async  {
   // change a @State variable triggers a UI refresh
   episodeState[podcast.id] = . loading 
    
   // consuming the AsyncStream
   do {
      // this loop exist when subscription gets cancelled
      for try await snapshot in 
          self.backend.loadEpisodes(for: podcast) {

        let data = snapshot.items
        let result = convertDataToModel(episodeData: data)

        // change a @State variable triggers a UI refresh
        self.episodeState[podcast.id] = .dataAvailable(result)
     }
   } catch {
         // change a @State variable triggers a UI refresh
         self.episodeState[podcast.id] = .error(error)
    }
}

Amplify Library for Swift offers many additional possibilities. Have a look at the documentation page for more details.

Additional Thoughts

The Amplify Library for Swift relies on the keychain to store and retrieve values that are specific to the application, such as authentication and session tokens. Therefore, it requires an app bundle to work correctly.

It means that when you add Amplify Library for Swift to a macOS apps, you also must include a provisioning profile and sign the code with the application-identifier entitlement. This is usual for iOS apps, they always include a provisioning profile and the required entitlements. However, this is not always the case for macOS.

Amplify Library for Swift is available both for native macOS applications and for Catalyst applications. When you add the Amplify Library to a macOS native app, you must add the Keychain Sharing capability. This automatically creates a provisioning profile, which in turn adds the application-identifier and team-identifier entitlements to your app.

Note that this only applies for native macOS apps. Apps running under Catalyst will work with no extra steps required.

By default, command-line applications have no entitlements, no bundle, and are not code-signed. They will not work with Amplify.

Available today

Amplify Library for Swift is open-source, feel free to check the source code under an Apache 2.0 license.

The library is free to use, but you will be charged for the services your applications consumes, such as Amazon S3 for file storage, AWS AppSync and Amazon DynamoDB for database storage. These services have a free tier, allowing you to learn or evaluate them without being charged. The free tier page has the details.

Join the Amplify community on discord (be sure to fuse the #swift tag to filter content relative specific to Swift) and start developing your first macOS Amplify app today!