9.
Adding Your Own Tasks
Written by Sarah Reichelt
In the previous two chapters, you created a menu bar app, used a custom view to display menu items and added a timer to control the app.
Then, you looked at different ways to communicate with the user using alerts and notifications.
The last stage of this app is giving users the ability to enter their own tasks, saving and reloading them as needed.
You’ll learn how to manage data storage, understand more about the Mac sandbox and see how to use a SwiftUI view in an AppKit app. Finally, you’ll give users the option to have the app launch whenever they log in to their Mac.
Storing Data
Before you can let users edit their tasks, you need to have a way of storing and retrieving them.
Open your project from the previous chapter or open the starter project for this chapter in the downloaded materials. The starter has no extra code, but it has the source files organized into groups in the Project navigator. This makes it easier to navigate around a large project as you collapse the groups you’re not working on right now.
Open the assets folder in the downloaded materials and drag DataStore.swift into the Models group. Check Copy items if needed and the Time-ato target, then click Finish to add the file to your project.
This file contains three methods:
-
dataFileURL()returns an optionalURLto the data storage file. Right now, this returnsnil, but you’re going to fix that shortly. -
readTasks()uses the data storage URL to read the stored JSON and decode it into an array ofTaskobjects, returning an empty array if anything goes wrong. -
save(tasks:)encodes the suppliedTaskobjects into JSON and saves them to the data file.
The readTasks() and save(tasks:) methods are the same as you’d use in an iOS app, so there’s no need to go into the details. But dataFileURL() is going to be interesting.
Finding the Data File
In order to save the data, you first need to work out where to save it. Replace return nil in dataFileURL() with:
// 1
let fileManager = FileManager.default
// 2
do {
// 3
let docsFolder = try fileManager.url(
for: .documentDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true)
// 4
let dataURL = docsFolder
.appendingPathComponent("Timeato_Tasks.json")
return dataURL
} catch {
// 5
print("URL error: \(error.localizedDescription)")
return nil
}
If you worked through Chapter 1, “Designing the Data Model”, you’ll remember some of this, but stepping through it you:
- Use the default
FileManagerto access files and folders. - Put the file management code inside a
doblock since it can throw. - Ask
fileManagerfor the URL to the Documents folder in the current user’s folder. - Append a file name to the URL and return it.
- Print the error and return
nilif there was a problem.
Now that dataFileURL() returns a file path URL, you can test it by saving the sample tasks.
Open TaskManager.swift and add this property declaration:
let dataStore = DataStore()
As a test, insert this as the first line in init():
dataStore.save(tasks: tasks)
Build and run the app. There won’t be anything new to see, but TaskManager creates a DataStore and saves the sample tasks to your data file.
The code specifically asked FileManager for a path to the Documents folder. You probably thought this was a bad idea. Why clutter up your Documents folder with files like this? Shouldn’t the app hide them away somewhere?
Go and search your Documents folder for a file called Timeato_Tasks.json. It’s not there! Was there a save error?
Check the Xcode console. Do you see any entries labelled URL error or Save error? No, so it looks like the file saved, but where is it?
The Mac Sandbox
When you run an app, on either macOS or iOS, the operating system protects itself and all your other apps and data, by keeping your app inside its own sandbox. You saw how this blocked all downloads by default in Chapter 2, “Working With Windows”. Now, you’re running into the way that it protects your files.
Open a Finder window, then open Finder’s Go menu. Hold down Option to see Library appear in the menu and select it to open your Library folder.
Scroll down until you see Containers and open that. The Containers folder holds a very strange set of folders. Some of them have the names of apps, like Calendar, while some of them use bundle identifiers like com.apple.photolibraryd. And most oddly, there are what appear to be multiple sets of folders with the same name!
Whats happening here? Finder is lying to you, but Terminal never lies. Open your Terminal app so that you can see what’s really in this folder.
In Terminal, type this command and press Return:
cd ~/Library/Containers
You use cd to move into a different directory. In file paths, tilde (~) is a shorthand way of getting the current user’s directory, so in my case ~ is the same as typing /Users/sarah. Then, you’re changing into the Library directory and finally into Containers.
Next enter this and press Return:
ls -l
The ls command lists the current directory, and the -l argument tells it to list in long format, with one file or folder per line:
You can see that all the folders are really using either a bundle identifier or a unique identifier. Finder is translating these into the associated app names.
Now that you know what’s going on, return to your Finder window and scroll down through the containers until you find the Time-ato folder. (Terminal lists this folder as com.raywenderlich.Time-ato). The only thing inside is a folder called Data. Open Data and you’ll see a strange mirror of parts of your user folder:
Some of the folders have a little arrow on the icon that tells you they’re aliases to other folders. If you open the Desktop alias, you’ll see all the files on your actual desktop.
Documents is not an alias and, if you open it, you’ll only see one file: Timeato_Tasks.json. So even though you asked FileManager to save the data file in your Documents folder, it saved it in a sandboxed Documents folder, leaving your actual Documents folder untouched.
This feels wrong, but it’s actually a great system. It means that you don’t have to worry about any other app using the same file or folder names. You can’t over-write them and they can’t over-write you. And as a developer, you’ll often want to do a complete reset on your app to test it, which you can do by deleting its container.
Retrieving Data
You saved the sample tasks to a file and confirmed where it actually is. Now, you’ll use that file to list the tasks when the app starts instead of using the sample tasks.
Open TaskManager.swift and replace var tasks: [Task] = Task.sampleTasks with:
var tasks: [Task]
Next, to get rid of the errors, replace dataStore.save(tasks: tasks) in init() with:
tasks = dataStore.readTasks()
Finally, so that you can really be sure that the tasks are coming from the file, open the JSON data file and make a change. The JSON isn’t formatted, but you can see the task titles. Change at least one title.
Build and run the app to see your edited task in the menu:
Opening the Sandbox
This app works perfectly inside the sandbox, without any extra permissions, but not all apps are the same. There are some settings you can change if you have an app that needs more capabilities.
Back in Xcode, select the project at the top of the Project navigator, and then select the Time-ato target. Click Signing & Capabilities across the top and look at the App Sandbox settings:
The most common exceptions to the sandbox are accessible here.
You’ve already used Outgoing Connections (Client) in Section 1 of this book to allow downloads. Incoming Connections (Server) is only required if your app is going to receive connections that it didn’t initiate.
The Hardware and App Data settings are similar to their iOS equivalents, but for iOS apps, you add privacy descriptions in the Info.plist instead of checking buttons.
There are some differences to be aware of in the File Access settings. First, these are not on or off settings — you select None, Read Only or Read/Write access using the popup beside each one.
The User Selected File option means that as long as you show a file dialog and let the user select the file or folder directly, your app can access any folder on the Mac. If you want your app to remember that access, you’ll need to create security-scoped bookmarks. Apple’s documentation on Enabling App Sandbox has all the details.
If there are folders or features that your app needs to access but that are not covered in these settings, you can edit the app’s entitlements file to request temporary access. Despite the name, such access does not expire, but it may not always get past the App Store review process. Document why you need the exception in the app review notes to increase your chances of approval.
And finally, if your app really can’t operate within the sandbox and you don’t plan to distribute it through the Mac App Store, you can remove the sandbox limitations from your app completely by clicking the Trashcan at the top right of the App Sandbox settings.
Editing the Tasks
You’ve got the file handling working and tested. Now, it’s time to allow your users to edit their own tasks. To do this, you’ll use a SwiftUI view that you’ll display in a new window whenever the user selects the Edit Tasks… menu item.
First, add a new SwiftUI View file to the Views group in your project. Call it EditTasksView.swift.
Add these two properties to EditTasksView:
@State private var dataStore = DataStore()
@State private var tasks: [Task] = []
An editor like this must have the option to cancel without changing anything. As a result, it gets its own DataStore and reads its own list of Task objects. If the user saves the changes, then DataStore can save the edited tasks to the data file.
Next, to set up the UI for this view, replace the standard Text with:
// 1
VStack {
// 2
ForEach($tasks) { $task in
HStack(spacing: 20) {
// 3
TextField(
"",
text: $task.title,
prompt: Text("Task title"))
.textFieldStyle(.squareBorder)
// 4
Image(systemName: task.status == .complete
? "checkmark.square"
: "square")
.font(.title2)
// 5
Button {
// delete task here
} label: {
Image(systemName: "trash")
}
}
}
// 6
.padding(.top, 12)
.padding(.horizontal)
// buttons go here
}
// 7
.frame(minWidth: 400, minHeight: 430)
What is this SwiftUI code doing?
-
Wrap the entire view in a
VStackfor display down the window. -
Use a binding property as the data source for the
ForEach. This allows changes to the data inside each row to flow back to the@Stateproperty. -
Use a
TextFieldas the editor for each task’s title, styling it with a square border. TheTextFieldhas no label, but it has some placeholder text. -
Add an
Imageto each row indicating whether the task is complete or not. -
Set up a
Buttonfor deleting eachTask. -
Apply some padding to make it look better.
-
Choose the minimum size for this view.
Showing the Data
Right now, this view has nothing to show, so add these methods to EditTasksView:
func getTaskList() {
// 1
tasks = dataStore.readTasks()
// 2
addEmptyTasks()
}
func addEmptyTasks() {
// 3
while tasks.count < 10 {
tasks.append(Task(id: UUID(), title: ""))
}
}
What do they do?
- Use
dataStoreto read in the stored list of tasks. - Call
addEmptyTasks(). - Add a new task with a blank title until there are 10 tasks in the list.
The Pomodoro technique suggests setting up 10 tasks per day, so the editor will show 10 edit fields. If tasks has fewer than 10 elements, addEmptyTasks() adds extras to fix it. This ensures that the ForEach loop always has 10 entries to show, even if some of them are blank.
You’re nearly ready to see what this looks like, but first you need to add this modifier to the VStack, right under where you set the frame:
.onAppear {
getTaskList()
}
This makes it load the tasks from the data file when the view appears.
Click Resume in the canvas preview or press Command-Option-P to refresh the preview:
Deleting Tasks
So far, so good. The new view looks great. Now, you must make the delete buttons work. Add this new method beneath the others:
func deleteTask(id: UUID) {
// 1
let taskIndex = tasks.firstIndex {
$0.id == id
}
// 2
if let taskIndex = taskIndex {
// 3
tasks.remove(at: taskIndex)
// 4
addEmptyTasks()
}
}
What’s this method doing?
- Look in
tasksfor the index of aTaskwith anidmatching the parameter. - Check if this returns an index.
- Delete the task at this index in the array.
- Make sure there are still 10 tasks in the view.
Scroll back up to the layout part of the file and replace // delete task here with:
deleteTask(id: task.id)
Don’t build and run yet, since you have no way of showing this view. Instead, turn on the Live Preview. Click Bring Forward to see the Xcode Preview window and test the view.
Edit some titles and delete some tasks:
Deleting and editing are all working as expected.
Adding the Buttons
Next, you need to add three control buttons:
- Cancel to close the window without saving.
-
Mark All Incomplete to reset all the tasks. This is convenient if you use some of the same tasks every day and don’t want to delete them but need to set their status back to
notStarted. - Save to store all your changes and close the window.
Still in EditTasksView.swift, replace // buttons go here with:
Spacer()
HStack {
}
The Spacer is to push the buttons to the bottom of the window and the HStack is to hold them. But this chunk of SwiftUI code is quite long enough already, so you’re going to separate the buttons out into their own view.
Command-click the HStack you just added and select Extract Subview:
This replaces the HStack with ExtractedSubview() and adds a new view at the end of the file with this name.
Rename this to EditButtonsView in two places: in the original view and in the new view definition.
Note: Xcode used to automatically select both these for one-step editing after extracting a subview. You can achieve this same effect by right-clicking
ExtractedSubview()and choosing Refactor ▸ Rename….
Now that you’ve got EditButtonsView, replace the contents of its body with:
// 1
HStack {
// 2
Button("Cancel", role: .cancel) {
// close window
}
// 3
.keyboardShortcut(.cancelAction)
// 4
Spacer()
// 5
Button("Mark All Incomplete") {
// mark tasks as incomplete
}
Spacer()
Button("Save") {
// save tasks & close window
}
}
// 6
.padding(12)
Stepping through these lines, you:
- Replace the empty
HStackwith this one. - Add a Cancel button, setting its role.
- Give it the
cancelActionkeyboard shortcut so that pressing Escape triggers it. - Put in a
Spacerto spread the three buttons across the bottom of the view. - Create two more buttons, separated by another
Spacer. - Apply some
paddingso they’re not too near the edges of the view.
Resume the preview to check out how this looks:
Your preview is now too wide to see easily, so add this frame modifier to the preview to set it to the minimum size for the view:
.frame(width: 400, height: 430)
Coding the Buttons
The last task for this view is to make these new buttons do their jobs.
To handle the Cancel button, first add this method to EditButtonsView:
func closeWindow() {
NSApp.keyWindow?.close()
}
This uses the shared instance of NSApplication to close the key, or frontmost, window, which is the one that the user last interacted with.
To call this method, replace // close window in the Cancel button’s action with:
closeWindow()
The other two methods need access to tasks and dataStore, so you’ll pass these in to EditButtonsView.
Insert these declarations at the top of EditButtonsView before the body:
@Binding var tasks: [Task]
let dataStore: DataStore
By using @Binding on tasks, you’ve ensured that any changes flow back to the parent view.
Scroll back up to EditTasksView to see the error this has caused. Replace the line showing the error with:
EditButtonsView(tasks: $tasks, dataStore: dataStore)
Now, EditTasksView is sending the task data and the data store to EditButtonsView.
With that in place, give EditButtonsView the remaining two methods it needs:
func saveTasks() {
// 1
tasks = tasks.filter {
!$0.title.isEmpty
}
// 2
dataStore.save(tasks: tasks)
// 3
closeWindow()
}
func markAllTasksIncomplete() {
// 4
for index in 0 ..< tasks.count {
tasks[index].reset()
}
}
These methods:
- Get rid of any tasks with empty titles.
- Use
dataStoreto save the edited data. - Close the window.
- Loop through all the tasks and reset them to
notStarted.
Finally, set your buttons to call these methods.
Replace // mark tasks as incomplete with:
markAllTasksIncomplete()
And replace // save tasks & close window with:
saveTasks()
Your editing interface is all in place, you’ve hooked it up to the code, so it’s time to make it appear in your app.
Showing the Edit Window
Open AppDelegate.swift and find the @IBAction called showEditTasksWindow(_:). You’ve already connected this to the Edit Tasks… menu item.
Put this code inside that method:
// 1
let hostingController = NSHostingController(
rootView: EditTasksView())
// 2
let window = NSWindow(contentViewController: hostingController)
window.title = "Edit Tasks"
// 3
let controller = NSWindowController(window: window)
// 4
NSApp.activate(ignoringOtherApps: true)
// 5
controller.showWindow(nil)
Then, add this at the top of the file to remove the error:
import SwiftUI
This provides the bridge between AppKit and SwiftUI, so there are a few things to notice:
- To show a SwiftUI view in AppKit, set up an
NSHostingControllerand assign the SwiftUI view as itsrootView. - Once you’ve got the hosting controller, which is a subclass of
NSViewController, create anNSWindowand set the hosting controller as itscontentViewController. You can also configure the window here, so change its title. - In an AppKit app, every window needs an
NSWindowController, so the next step configures a controller for the window. This gives you the full chain: NSWindowController — NSWindow — NSHostingController — EditTasksView. - Like you did when showing alerts in the previous chapter, make sure the app is the active app.
- Tell the window controller to show its window.
Now you’re ready to try it out. Build and run the app, choose Edit Tasks… from the menu and there is your window, showing a SwiftUI view inside an AppKit app:
Saving and Reloading
Your interface is looking good, so now it’s time to run some tests and check that everything is working as expected. Open the Edit Tasks window and then press Escape. The window closes as expected.
Use the menu controls to start the first task, then mark it as completed. Open the Edit Tasks window again. That’s strange. Why is the first task not marked with a checkmark?
Remember how EditTasksView loads its tasks from storage? If the main part of the app hasn’t saved any changes, this isn’t going to show them. The app needs to save whenever anything changes so that the tasks and their properties persist across app launches. The best time to do this is after any task starts and after any task ends.
Open TaskManager.swift and add this line to the end of startNextTask():
dataStore.save(tasks: tasks)
And add the same line to the end of stopRunningTask(at:).
Run your test again: Build and run the app, start and complete the first task, then open the Edit Tasks window:
Success! Your tasks are now saved whenever anything changes. But this means you have some housekeeping to do when the app starts and loads the data. What if there’s a task in progress?
Still in TaskManager.swift, insert these lines into init(), after reading the tasks, but before starting the timer:
// 1
let activeTaskIndex = tasks.firstIndex {
$0.status == .inProgress
}
if let activeTaskIndex = activeTaskIndex {
// 2
timerState = .runningTask(taskIndex: activeTaskIndex)
}
What is this doing?
- Look for the index of the first task that has a status of
inProgress. - If there is a task in progress, set
timerStatetorunningTask, associating the task’s index.
With this in place, you’ll be able to start and stop the app while a task is running and it’ll pick up the timing and continue it.
Time for another test. Build and run again. Your first task is still marked as completed in the menu. Open the Edit Tasks window and make two changes:
- Click Mark All Incomplete to remove the checkmark beside the completed task.
- Edit the title of any task.
Click Save to save your changes and when the window closes, open the menu. Your edits are not showing up!
Quit and restart the app and now your edits appear in the menu:
What’s going on? The Edit Tasks window is saving the edited tasks, but when the window closes, TaskManager doesn’t know to reload them and so it shows the old version of tasks until the app restarts.
Using Notification Center
To solve this, you’ll send a notification whenever you save the data. This isn’t a visible notification like you used in the last chapter. Instead, you’ll use the NotificationCenter to post an NSNotification. Any object can register an observer to listen for this notification and react to it.
Every NSNotification has to have a name. This name is not merely a string, it’s a Notification.Name. There are standard names for notifications sent by the system — for iOS apps, you may have used some of them to detect keyboard changes. But in this case, you’re going to define your own name to send a custom notification.
You can define this name anywhere in your project, but since it relates to the stored data, add this extension to DataStore.swift, outside the DataStore structure:
extension Notification.Name {
static let dataRefreshNeeded =
Notification.Name("dataRefreshNeeded")
}
This creates a name that you can use to refer to your notification, without using strings, which are subject to error and can’t be auto-completed.
There are two parts to using notifications. The first one is post.
Open EditTasksView.swift and add this to the end of saveTasks():
NotificationCenter.default.post(
name: .dataRefreshNeeded,
object: nil)
This uses the default NotificationCenter and posts a notification using the Notification.Name you just created. It sends the notification, but it doesn’t know or care if anyone receives the message.
The second part is to observe this notification, and that’s a job for TaskManager.
Open TaskManager.swift and define this new property:
var refreshNeededSub: AnyCancellable?
Like with the Timer, you’re going to use Combine to subscribe to a NotificationCenter publisher for this notification name. This property holds a reference to the subscription so that it stays active while TaskManager exists and cancels itself when TaskManager is de-initialized.
Next, add this to the end of init():
// 1
refreshNeededSub = NotificationCenter.default
// 2
.publisher(for: .dataRefreshNeeded)
// 3
.sink { _ in
// 4
self.tasks = self.dataStore.readTasks()
}
What’s happening here?
- Use the default
NotificationCenter. - Create a publisher for the
Notification.Nameyou set up earlier. - Subscribe to the publisher using
sink. - Whenever a notification arrives, refresh the data from the storage file.
Build and run the app. Edit a task, save your edits and check the menu. Your changes are there right away:
So that’s it. Your app is now fully functional. You can start and finish tasks. The timer works out how long you have to go. The menu shows the timers and the progress bars. And finally, you can edit your tasks and you have persistent data storage.
There’s only one more thing that would be nice to have…
Launching on Login
A utility app like this is the kind of app people want to have running all the time, and that means you need to add a way for the app to start when the user logs in.
Your app must not apply this setting automatically. You have to provide this as an option the user can choose to enable. Apple’s App Store Review Guidelines contain this relevant section:
They (apps) may not auto-launch or have other code run automatically at startup or login without consent.
The process for setting a sandboxed app to launch on login is a convoluted one that requires creating a helper app and configuring it and the parent app in particular ways. The helper app’s only role is to launch the main app.
Conveniently, there is a Swift package called LaunchAtLogin that does all the hard work.
Adding a Swift Package
You’ll use the Swift Package Manager to include this package in your app. If you’ve used SwiftPM in an iOS app, then this is a familiar process.
Start by selecting the project at the top of the Project navigator and then select the Time-ato project, not the target. Click Package Dependencies at the top, and then click the + button to add a new dependency.
Enter this URL into the search field:
https://github.com/sindresorhus/LaunchAtLogin
This finds the package you want to install:
When the LaunchAtLogin package appears, click Add Package. Xcode downloads the package and then asks for confirmation. Check the LaunchAtLogin Library and click Add Package again:
The Project navigator now includes a Package Dependencies section with the LaunchAtLogin library listed:
There is one more configuration step before you can start using the package.
Select the Time-ato target and click Build Phases.
Next, click the + at the top left and select New Run Script Phase:
Expand the newly added Run Script and replace the comment in the script field with:
"${BUILT_PRODUCTS_DIR}/LaunchAtLogin_LaunchAtLogin.bundle/Contents/Resources/copy-helper-swiftpm.sh"
This is a direct copy from the Usage instructions for the package. Your new build phase now looks like this:
That’s all the setup you need to do before you can start using the library. And if you’re wondering how much work that saved you, check out the Before and after description from the library’s GitHub.
Using the New Library
To implement this feature, you need to be able to tell whether the user has enabled launch on login so that you can show a checkmark in its menu item. And, the menu item has to be able to toggle the setting.
Start by importing the library. Open AppDelegate.swift and add this line to the other import statements at the top:
import LaunchAtLogin
Now that’s in place, you can use it. Scroll down to updateMenuItemTitles(taskIsRunning:) and add this code to the end of that method:
launchOnLoginMenuItem.state = LaunchAtLogin.isEnabled ? .on : .off
This shows or hides the checkmark depending on whether the user has enabled the launch feature. You already made the @IBOutlet connection between the storyboard and AppDelegate for this menu item, so you can refer to it without any further setup.
Finally, you can add the code to toggle this setting. You’ve set up an @IBAction for this menu item earlier, so find toggleLaunchOnLogin(_:) and insert this line:
LaunchAtLogin.isEnabled.toggle()
Time to test this. Build and run the app. Open the menu, select Launch on Login, then open the menu again to see that it’s now checked:
For the real test, you need to log out of your user account and log back in again (or you can restart the whole computer). Wait for everything to restart, and there’s the app in your menu bar:
Troubleshooting
If the app didn’t launch on login, it may be due to having too many old builds of the app on your hard drive. Deleting Xcode’s derived data will most likely fix it.
Open Terminal and enter this command:
rm -rf ~/Library/Developer/Xcode/DerivedData
This also deletes the downloaded version of the LaunchAtLogin package. In Xcode, select File ▸ Packages ▸ Resolve Package Versions to fetch it again:
Press Shift-Command-K to clean your build folder and run the app again.
Confirm that Launch on Login is still checked before logging out and in again. If you’re still having problems, there are more suggestions in the FAQ section of the package’s ReadMe.
Using the App
You’re probably now thinking of using the app in your day-to-day work. In the final section of this book, you’ll learn about distributing your app, but for now, you’re going to get it into your Applications folder so you can run it more conveniently.
For testing purposes, the app uses shortened times for tasks and breaks. You’re still going to be running a debug build of the app, so you need to change these times manually.
Open TaskTimes.swift. You don’t want to delete all the debug times, because you might want to come back and work on improvements to the app later, so replace the enumeration with this:
enum TaskTimes {
// #if DEBUG
// // in debug mode, shorten all the times to make testing faster
// static let taskTime: TimeInterval = 2 * 60
// static let shortBreakTime: TimeInterval = 1 * 60
// static let longBreakTime: TimeInterval = 3 * 60
// #else
static let taskTime: TimeInterval = 25 * 60
static let shortBreakTime: TimeInterval = 5 * 60
static let longBreakTime: TimeInterval = 30 * 60
// #endif
}
This allows you to swap back into debug mode any time you’re working on the app.
Next, you’ll install the app in your Applications folder, but how can you do that? Where is the app? When an app is running in the Dock, you can right-click to show it in the Finder, but you can’t do that for this app.
The solution is to ask the app itself.
Open AppDelegate.swift and add this line to the end of applicationDidFinishLaunching(_:):
print(Bundle.main.bundlePath)
Run, then quit the app and check in Xcode’s console:
Copy this weird looking path, except for Time-ato.app at the end. In Finder, select Go ▸ Go to Folder… and paste in your copied path. Press Return to open the folder that contains the app. Now you can drag it into your Applications folder.
Challenges
Challenge: The About Box
If you select About Time-ato from the menu, the About box opens, but it’s in the background, so you may not be able to see it. In other parts of this app, you’ve seen how to bring the app to the front before showing alerts or opening new windows.
The method to open the About box is:
NSApp.orderFrontStandardAboutPanel(nil)
The About Time-ato menu item is calling this method directly. Can you make it call a new @IBAction that brings the app to the front, and then uses this method?
Try this yourself, but check out the challenge project for this chapter, if you need any hints.
Key Points
- macOS apps operate inside a sandbox. This keeps their data and settings inside a container folder.
- Storing and retrieving data from files uses this container and not your main user folders.
- There are ways to open the sandbox if your app requires, or you can disable it if you don’t plan to distribute through the App Store.
- AppKit apps can contain SwiftUI views.
- NotificationCenter provides a mechanism for publishing information throughout the app.
- Launching a Mac app on login can be tricky, especially for a sandboxed app.
- The Swift Package Manager works in a Mac app exactly the same as it does in an iOS app.
Where to Go From Here?
You’ve reached the end of this section and of this app. You can probably think of lots of improvements to make to it, so go for it. Make it into the app that you want to use.
In this section, you covered two main concepts: Building an AppKit app and building a menu bar app. Along the way, you learned more than you probably ever wanted to know about the Mac sandbox, you found out how to integrate SwiftUI into an AppKit app and you got to use the Swift Package Manager in a Mac app.
In the next section, you’ll start a new app. You’re going back to using SwiftUI but in a completely different type of app.