A few years ago, I published a post that described how to build a Mac application in Swift that would run a shell command. Surprisingly, this post still gets a lot of traffic. Xcode and Swift have progressed over the last three years and that old example is mostly useless now. It is time for an update!
In this post, I will describe how to build a simple Mac app which runs a shell command using SwiftUI.
SwiftUI is the new framework from Apple to build user interfaces across platforms. Applications built with Swift UI will require 10.15 Catalina and higher. As you will see, SwiftUI does simplify a lot of the work of creating the user interface. I believe that learning SwiftUI now will be a good investment into the future.
I have written this post with Xcode 11.2.1 on macOS Catalina 10.15.1. That means we are using Swift 5.1. You can download Xcode from the Mac App Store or Apple’s developer page.
In this post, we will be using the say
command to make the Mac speak. You can test the command by opening the Terminal and entering
> say "Hello World"
The say
command is a simple and fun stand in for other command line tools. It also provides instant feedback, so you know whether it is working or not. That said, when you want to provide text-to-speech functionality in a Swift app, you should probably use the text-to-speech frameworks, rather than sending out a shell command.
Nevertheless, for Mac Admin tasks, there are some that are only possible, or at least much easier, with shell tools.
First: Swift UI hello world
Before we get to more complicated things, let’s honor the classics and build a “Hello, World” application using Swift UI.
Open Xcode and from the project picker select “New Project.”
In the template chooser select ‘macOS’ and ‘Application’. Then click ‘Next.’
In the next pane, enter a name for the project: “SayThis.” Verify the other data and make sure the choice for ‘User Interface’ is ‘SwiftUI’. Then click ‘Next.’
The window with the new project will have four vertical panes. You can use the controller element in the top right of the toolbar to hide the right most “Inspector” pane as we will not need it.
Click it the “Play” button in the top left to build and run the template code, a window should open which show the place holder text “Hello World!”
When you return to Xcode the preview pane on the right should now be active and also display the “Hello World!” text. If it does not there should be a “Resume” button at the top of the preview pane (also called “Canvas”) which you can click to make Xcode resume live updating the preview. There are many situations that will stop Xcode from continuously updating the preview and you can click this button to resume.
The left column shows a list of files in this project. Select the ContentView.swift
file. This file contains the Swift UI code that sets up the interface. You will see the code in the center pane. The relevant part of the code is:
struct ContentView: View {
var body: some View {
Text("Hello, World!")
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
The ContentView
contains a body
which contains a Text
element with the text Hello World!
. at the end of the Text
object, you a ‘modifier’ that sets the frame of the Text object to use all available space.
Hello, me!
In the code pane, change World
to something else:
Text("Hello, Armin!")
You will see that the text in the preview pane updates as you change the code.
Note: there are several reasons Xcode might stop updating and when it does, you will have to hit the ‘Resume’ button above the preview pane.
The preview pane or canvas allows you to edit as well. When you (command) ⌘-click on the “Hello, …” text in the canvas, you will get an “Action menu.”
The first item in the action menu: “Show SwiftUI Inspector” will an inspector with the attributes or “modifiers” of the text object. Note that, even though there is no visual indication, the inspector view can be scrolled to reveal more options.
Change the font of the text to Body
. The preview in the canvas will update, as well as the code. The code will now look like:
Text("Hello, Armin!")
.font(.body)
.frame(maxWidth: .infinity, maxHeight: .infinity)
I have broken the modifiers into their own lines for clarity.
Stacking it up… or down… or sideways…
The SwiftUI body can only contain one object. But there are SwiftUI objects that you can use to group multiple objects together. Command-Click on the text and choose “Embed in VStack” from the action menu.
The code will change to:
VStack {
Text("Hello, Armin!")
.font(.body)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
The VStack
stands for ‘vertical stack.’ The VStack
and its sibling the HStack
(horizontal) can contain multiple objects. So, you can add another Text
over our “Hello” text:
VStack {
Text("SayThis")
.font(.largeTitle)
Text("Hello, Armin!")
.font(.body)
}.frame(maxWidth: .infinity, maxHeight: .infinity)
This adds the "SayThis"
text above the "Hello, ..."
text in a larger font. I have also moved the .frame(...)
modifier to the VStack
because that leads to a nicer output. Feel free to apply the .frame
modifer to different objects to see how that affects the layout.
The text is a little bit close together, so we can add some .padding()
modifiers:
VStack {
Text("SayThis")
.font(.largeTitle)
.padding()
Text("Hello, Armin!")
.font(.body)
.padding()
}.frame(maxWidth: .infinity, maxHeight: .infinity)
You can choose to change the properties using the inspector UI or in the code. Either way the changes should be reflected in the canvas and the code.
Adding some interaction…
Now we want to add some interaction. Eventually, we want the user to be able to enter some text, which will be sent to the say
command. Before we get, we will add a field, to enter some text.
To add a TextField
where the user can enter text to the layout, click on the +
icon in the top right of the tool bar. A window will appear with SwiftUI objects. Enter TextField
in the search area to find the TextField
object and drag it between the two text items.
You will want to add a .padding()
modifier to the text field as well.
We also need a local variable in our ContentView
to store the value of the TextField
. Add this variable declaration under the struct ContentView
definition and before the body
declaration:
@State var message = "Hello, World!"
This adds and initializes a variable named message
. The @State
marker tells SwiftUI this ‘State’ variable will be used with the view. The variable is part of the ‘state’ of the view. Changes to the variable will immediately update the UI and vice versa.
Use the message
variable for the TextField
and Text
objects:
@State var message = "Hello, World!"
var body: some View {
VStack {
Text("SayThis")
.font(.largeTitle)
.padding()
TextField("Message", text: $message)
.padding()
Text(message)
.font(.body)
.padding()
}.frame(maxWidth: .infinity, maxHeight: .infinity)
}
When you look at this interface in the preview pane, you will see that the contents of the message
variable are reflected in the TextField
and Text
. To test the interactivity, you need to either ‘build and run’ the application or hit the ‘Play’ button in the preview. Then you can change the text in the text field and immediately see the changes displayed in the Text below.
Declaring the message
variable as a @State
will automatically set up all the notifications for these seamless updates.
Buttons and Actions
While updating another object is quite cool, it is not what we set out to do. Let’s get back on track.
- Remove the last
Text(message)
and all its modifiers. - Command-click on the TextField and use the action menu to embed it in an
HStack
(horizontal stack) - Drag a
Button
from the object Library (you can open this with the ‘+’ button in the top right of the window) next to the text field
The code you generated should look like this:
HStack {
TextField("Message", text: $message)
.padding()
Button(action: {}) {
Text("Button")
}
}
You can change the label of the button by changing the Text
object inside of it. Change "Button"
to "Say"
. You also want to add a .padding()
modifier to the button.
The preview should now look like this:
The action
of the Button
is a code block that will be run when the button is clicked. Add the code between the action closure brackets:
Button(action: {
let executableURL = URL(fileURLWithPath: "/usr/bin/say")
try! Process.run(executableURL,
arguments: [self.message],
terminationHandler: nil)
}) {
Text("Say")
}.padding(.trailing)
This uses the run()
convenience method of the Process
class. You need to provide the full path to the command line tool. You can determine the full path in Terminal with the which
command:
> which say
/usr/bin/say
The arguments to the say
command need to be provided as an Array
of String
s. The process is launched asynchronously, so the main thread continues immediately. We can also provide a code block that will be executed when the Process
terminates. For now, we have no code that needs to be run on termination, so we set the terminationHandler
to nil
.
Note: using the
try!
statement to crash when this method throws an exception is lazy. I use it here to simplify the code drastically.Process.run()
willthrow
an error when the file atexectableURL
does not exist or is not executable. We can be fairly certain the/usr/bin/say
executable exists, so thetry!
statement is justifiable. You should probably add proper error prevention and handling in production code.
Updating the Interface
The say
command is executed asynchronously. This means that it will be running in the background and our user interface remains responsive. However, you may want to provide some feedback to the user while the process is running. One way of doing that, is to disable the ‘Say’ button while the process is running. Then you can re-enable it when the process is done.
Add a second @State
variable to track whether our process is running:
@State var isRunning = false
Add a .disabled(isRunning)
modifier to the Button
. This will disable the button when the value of the isRunning
variable changes to true
.
Then we add a line in the Button
action code to set that variable to true
. We also add a code block to the terminationHandler
which sets its value back to false
:
Button(action: {
let executableURL = URL(fileURLWithPath: "/usr/bin/say")
self.isRunning = true
try! Process.run(executableURL,
arguments: [self.message],
terminationHandler: { _ in self.isRunning = false })
}) {
Text("Say")
}.disabled(isRunning)
.padding(.trailing)
Now, when you press the button, it will disable until the say
process is done speaking.
Sample Code
For your convenience, here is the finished code for the ContentView.swift
class:
//
// ContentView.swift
// SayThis
//
import SwiftUI
struct ContentView: View {
@State var message = "Hello, World!"
@State var isRunning = false
var body: some View {
VStack {
Text("SayThis")
.font(.largeTitle)
.padding()
HStack {
TextField("Message", text: $message)
.padding(.leading)
Button(action: {
let executableURL = URL(fileURLWithPath: "/usr/bin/say")
self.isRunning = true
try! Process.run(executableURL,
arguments: [self.message],
terminationHandler: { _ in self.isRunning = false })
}) {
Text("Say")
}.disabled(isRunning)
.padding(.trailing)
}
}.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
More Tutorials
If you want to learn more about SwiftUI and how it works, Apple has some excellent Tutorials on their developer page:
- Apple Developer: SwiftUI Tutorials
Update 2023-08-16: nearly five years later, I wrote a second part to this tutorial.
Why don’t you use just the NSSpeechSynthesizer?
It’s right there in the sixth paragraph of the post:
Greetings Armin,
Regarding “Note that, even though there is no visual indication, the inspector view can be scrolled to reveal more options.”, I get a scroll bar in that view on the right side. This may be due to my settings.
The default settings in macOS will only show the scrollbars when the view actually being scrolled. It’s one of those “pretty but actually really annoying” things.
I’m not sure what I am doing wrong. I am using Xcode 11.4 on macOS 10.15.4 and keep getting the following error whether following along or doing a copy/paste of your finished code;
“Failed to build ContentView.swift. Compiling failed: use of unresolved identifier ‘$message’.”
All I can say is “it works for me…” I know that is not terribly helpful, but remote diagnosis is very hard, if not impossible here. Have you tried ‘Clean Build Folder…’ from the ‘Project’ menu?
I am trying to use the same approach with a command line tool store in usr/local/bin/toolname
when I try to run the process, it returns the error
Fatal error: ‘try!’ expression unexpectedly raised an error: Error Domain=NSCocoaErrorDomain Code=4 “The file “ toolname” doesn’t exist.”
It exists and I can run the tool in terminal, so maybe I lack some insights into what I need to do to access a tool in usr/local/bin
any help would be appreciated. Thank you
remote diagnoses are hard and error prone, but I would guess that the path you are giving for the `executableURL` is not correct. Check that the path is entered completely and exactly so `/usr/local/bin/toolname` with no extra (or missing spaces or slashes.
it works with various tools in /usr/bin/ but non of the tools in /usr/local/bin/ … all result in the same error that the file doesn’t exist. I am wondering if this is something related to new features in Catalina where I can only access system tools and additionally installed tools in usr/local/bin/ require some additional step in Xcode?
Are you giving the full path to the tool? `/usr/local/bin/toolname` (including the leading slash?)
yes. I made some progress. If I remove the sandbox entitlement completely from the project it works, so it is related to that. But I cannot figure out how to create an exception to the sandbox entitlements for this tool in /usr/local/bin
Peter, have you made any progress? I am trying to run a script that issues some docker commands, but docker is outside of the sandbox 🙁
Very useful article! Is there a way to capture the output of the run? I’d like to be able to get the stdout message.
when I run the app, I can see the output in my Xcode terminal, but I’d like to be able to capture it in the swiftui and show it on my UI screen.
Yes, the Swift `Process` class gives you the tools to capture the output and return code of the command. But that was beyond the scope of this tutorial.
Hi! This was a great tutorial, but I’d like to know if you can run a python script using this method.
Yes, you can. As long as the python environment is set up properly
How would you properly set up the python environment?
For most python scripts the default environment (python 2) should ‘just work.’ For Python 3 and special needs you will have to set it up according to your requirements.
That depends entirely on what you need or want to do. Most python (version 2) scripts should work fine with the pre-installed python environment.
Thanks for this training, I have a simple question, I guess…
(and I stay on purpose on the previous exemple)
if you want to add another argument to the command, let say I want to add “-v Amelie” to use french as a language…
Yes you can. The arguments go in an Array of Strings, so you would add „-v“, „Amelie“ to the array. The entire command would look like this:
try! Process.run(executableURL,
arguments: ["-v", "Amelie", self.message],
terminationHandler: nil)
Hi, i managed to make this work running a command, but in my case i want to run a command x that will call the command y.
if i just do: let executableURL = URL(fileURLWithPath: “/usr/bin/x”)
i get an error, because swift could not find the path of y (called by x), kinda confusing, but i hope this is understandable.
Do you know how can i tell my program to just look everything that is in mt $PATH, so the fileURLWithPath is “x” and all the dependencies are also found?
Thanks
Yes, PATH configuration can be setup. When you run scripts from any app, you will have to assume that the PATH is _not_ set, or only set to very basic system folders, You can set the environment variables for the task using its `environment` attribute. You can find detail about PATH here: https://scriptingosx.com/2018/02/setting-the-path-in-scripts/