Have you ever been asked to put together the list of licenses of all frameworks that are used within your iOS, iPad OS, or macOS app? Manually completing this task quickly becomes tedious but may be required due to legal- or customer requests.
To mitigate this issue, I developed Licenses, a native macOS app that automates this procedure by collecting and exporting your licenses into a single spreadsheet (CSV) file.
In this article, I want to share my experience as well as the challenges that I faced when developing the app using SwiftUI 2.0 and Combine. This way, I hope to provide additional documentation on how declarative macOS apps can be built and to encourage others to also bring their ideas to the Mac.
Licenses, uses a redux-inspired architecture, as illustrated in figure 1, consisting of Data-, Bloc- (Business Logic Component), ViewStore- and UI-related components. This way, state changes only occur within the bloc’s reducer function, transforming incoming actions as well as the current state to an updated state that is ultimately consumed by the UI.
Also, side effects are performed by returning publishers from the reducer resulting in additional actions that are sent to the bloc. Hence, asynchronous work is treated similarly to synchronous work in the way that it only affects the state from within the reducer. Thus, the correctness of the reducer and as such the correctness of all state changes becomes testable through unit tests.
Note that blocs are not directly connected to the UI, but rather via view stores that act as the main communication gateway of the view. As a result, domain-specific knowledge is not exposed, but rather gets translated into view-specific models that only include the formatted data that is ready to be shown in the UI. As an example, instead of passing repositories, i.e.,
[GitHubRepository], to the view directly, we can rather pass a list of items, i.e.,
[ListItem], where each item only consists of UI-related data (e.g.,
subtitle) and omits any internal data that is repository-specific. Similarly, view actions are translated into domain-specific actions that are forwarded by the view store to the bloc. Excluding business- and domain-specific knowledge out of the view keeps them lean and facilitates simplified previews using mock data in Xcode.
Having established an architectural overview of the app, let’s focus on the business logic in terms of the processing pipeline and CSV export.
Users can select manifests in Licenses by either dragging them on top of the application’s window or choosing them manually from disk. In this regard, it does not matter whether a single or multiple files are selected or whether they are kept in an enclosing folder. Either way, Licenses searches for manifests at the specified location and forwards their
filePaths: [URL] to the processing pipeline. As soon as licenses could be derived, the user can export them into a single spreadsheet file (CSV).
The Manifest Processing Pipeline:
As illustrated in figure 2, decoding and extracting licenses involves three consecutive steps:
Step 1: Manifest Publisher:
First, Licenses searches for files named Package.resolved (SwiftPm), Cartfile.resolved (Carthage) or Podfile.lock (CocoaPods) and instantiates a
Manifest for each occurence respectively.
The initializer matches the last component of the given file path against a predefined set of identifiers. As soon as a match is made, the package manager associated with the file name is assigned to the manifest. Note that the latter is required to determine the decoding strategy that is used to derive packages from the manifest.
Step 2: ManifestDecodingStrategy
Second, Licenses tries to retrieve the minimum set of data, like the name, author, and version of the package by applying the decoding strategy that is associated with its type. Since the decoding may fail due to syntax errors or missing information, Licenses tries to handle these cases gracefully by continuing decoding the remaining set of manifests.
Note that the algorithm makes use of the strategy pattern to be easily extensible in the future if new package managers come along. This way, we can define additional strategies by conforming to the
As an example, please consider the implementation of the
SwiftPmManifestDecodingStrategy as stated below. If the strategy could decode the given content as
ResolvedPackagesEntity it publishes an instance of
GithubRepository for each package respectively.
It is important to note that packages derived from CocoaPods manifests do not include their author and hence require additional processing before licenses are fetched using the GitHub API. The
CocoaPodsRepositoryProcessor takes care of this requirement and uses the package manager's centralized registry named "CocoaPodsTrunk" to retrieve the missing information of the package.
Step 3: LicenseProcessor — Retrieving Licenses from GitHub:
Finally, given the name and author of a package we have collected sufficient information to retrieve its licenses using the Github API:
Note that network requests in Licenses are made using Aphrodite (Link), a lightweight, generic, and reactive network layer that is built on top of Combine and
NSURLSession. This way, the
LicenseRepositoryProcessor does not need to deal with the raw data that is returned from the Github API but rather uses a simplified model that results from the clear entity- and domain model separation offered by Aphrodite.
Consequently, all the above-mentioned steps are executed by the reducer directly or returned as side effects resulting in additional actions that are fed back into the bloc. Thus, state changes can only happen at a predefined location. Subsequently, please find the handling of the
.fetchLicenses action that corresponds to the third step of the processing pipeline:
As a result, we obtain the enriched list of repositories
[GithubRepository], ready to be exported or shown in the UI.
To export licenses into a machine-readable format, Licenses uses the
CSVRowFactoryto generate the header as well as the body including the
license of the given repositories. Note that the content of each row gets normalized to avoid malformed data:
Finally, each generated row is written to the specified destination:
Having referred to the processing pipeline and CSV export as the main driver of the app, let’s focus on the UI as well as the challenges that I faced when bringing Licenses to the Mac.
With the introduction of the SwiftUI lifecycle at WWDC 2020, Apple removed the need for an
App-/SceneDelegate and offered a declarative API to specify the entry point of the app. Licenses uses a
When adopting the SwiftUI lifecycle, it was difficult at first to come up with a solution to specify that the application should terminate as soon as the last window was closed. Luckily, Apple provides the
@NSApplicationDelegateAdaptor property wrapper that supports functionality that is not covered by the declarative approach yet. This way, the intended behaviour is specified in an additional
Bloc- and ViewStoreProvider
Within the window group, container views are used to provide the bloc as well as the view store for the window’s content view. Note that the
BlocProvider uses the
@StateObject property wrapper internally to ensure that the lifetime of the bloc is bound to the lifespan of the window. To request a bloc via the
BlocProvider we only need to provide the bloc's initial state as well as its reducer function that is used for handling state changes of the window. Finally, given the bloc, the
ViewStoreProvider establishes the mapping between the view-specific state/actions as well as the domain-specific state/actions using the
WindowContentViewActionMapper respectively. Injecting providers via container views keeps our content views lean and facilitates UI development using SwiftUI previews.
In Licenses the
WindowContentView is made of smaller views that together compose the UI:
Hence, the content view uses the
ViewStore of its parent to derive smaller stores that are dedicated for each child. As an example, the file drop area's store is derived from the parent by limiting its scope to the
fileDropAreaState property. In addition, the
actionMapper establishes the mapping between
File Drop Area
Note that SwiftUI features the
onDrop(of:isTargeted:content) modifier which is well-suited for our needs. In addition to the supported file type, i.e., "public.file-url", we also specify a binding to
isTargeted property of the store. Note that the binding is derived from the store such that it sends a
.didUpdateIsTargeted(Bool) action as soon as a change is made. Similarly, the view store is notified by the view when files of the specified type are detected (
The file drop area’s content consists of the
NavigationView that establishes the master-detail relationship between the repository list (master) and the repository's detail view. Note that the latter is only shown when repositories exist. Otherwise, a placeholder is shown asking the user to either import manifests manually from disk or to use one of the example-manifests that are bundled with the app.
Repository List (Master)
As soon as manifests are selected, detected repositories are shown in NavigationView’s sidebar. Unfortunately, I could not find a solution to specify different styles for the background of a list item, similar to what is offered by the
.emphasized style of
NSTableViewCell. The behaviour is desired since we can improve the readability of the selected item by adapting the font color of the title and subtitle label in case that the item is selected:
Although you can pass a binding to access the selected item, it does not account for the emphasized state of the cell and is only changed when the selection got made. To provide feedback even before the cursor is lifted, I decided to bridge to a standard
NSTableView using the
Note that in the future we can drop the
NSTableView and rather use SwiftUI's default
List component as soon as the emphasized state is supported. If you are interested in the detailed implementation of the
NSTableView please have a look at the source code on Github (Link).
Repository Metadata (Detail)
The detail view of the
NavigationView provides additional metadata about the selected repository, like the license's type and content that was fetched using the Github API:
Note that the detail view uses the
ViewStoreWithNonOptionalStateProvider instead of the default
ViewStoreProvider since the parent's
placeholderState may be
DetailViewStateMapper decides whether the placeholder or a repository's metadata is shown. Hence, the
ViewStoreWithNonOptionalStateProvider only renders the
DetailListView in case that the parent's
listState is not
nil. Otherwise the provider fallbacks to the failure case and renders the provided component in case it exists. Similarly, the
DetailPlaceholderView is only shown if the parent's
placeholderState is not
nil. This way, the
ViewStoreWithNonOptionalStateProvider provides a non-optional state to the child if the parent's state is not
nil. Otherwise, the
failure view is rendered.
The toolbar provides quick access to the main features of the app. As an example, users can import manifests using the toolbar’s primary action. Besides, users can toggle the sidebar’s visibility using the navigation button of the bar. Unfortunately,
NavigationView does not offer a modifier to specify the sidebar's visibility. Instead, we can search for the
SplitViewController that is the first responder in the key window and toggle its sidebar:
To prevent unintended behaviour while licenses are fetched, we disable the toolbar buttons using the
.disabled() modifier. Also, the
.help() modifier attaches a tooltip to each button, providing additional guidance for the user.
.sheet() modifier is used to present the
OnboardingView in case that Licenses is opened for the very first time.
Note that we do not specify an action mapper, since the
SupportedManifestsView is static and does not include any interaction. Instead, we use the
.withoutActions property to derive an actionless store from the parent.
This article walked you through the steps that I took when building a native Mac app using SwiftUI 2.0 and Combine from scratch. This way, I wanted to explore the capabilities of Swift UI and tried to examine whether it can be used in production. Even though a lot of the things that are offered by UIKit, like the
.emphasized background style of a cell, are still missing, I appreciate the declarative nature of SwiftUI on the Mac. This way, we can avoid spending time on standard components like the master-detail view and rather focus on features that make up the app.
Happy Coding 🚀