Chapters

Hide chapters

macOS by Tutorials

First Edition · macOS 12 · Swift 5.5 · Xcode 13

Section I: Your First App: On This Day

Section 1: 6 chapters
Show chapters Hide chapters

12. Diving Deeper Into Your Mac
Written by Sarah Reichelt

So far, you’ve created three different Mac apps: a windowed app, a menu bar app and a document-based app. In this section, you’re going to create another windowed app, but with a different purpose.

You’ll dive deep into your Mac’s system and learn about the command line utilities that are part of macOS. You’ll learn more about using Terminal and how to invoke Terminal commands using Swift.

In the app, you’ll use a Terminal command called sips, which stands for scriptable image processing system. This is a powerful tool, but it’s difficult to remember the syntax. Creating a graphical user interface will make it much easier to use.

Terminal Commands

macOS, and its predecessor OS X, are built on top of Unix. Unix has an extensive set of commands you can run from the command line. These commands are mostly small, single-purpose utilities you can chain together, if needed. You’ve already used a sequence of commands like this in Chapter 1, “Designing the Data Model”, where you formatted the downloaded JSON to make it easier to read.

These commands are executable files stored in secret folders, hidden deep in your Mac’s file system, but now you’re going to find them.

Open Terminal: In Finder, double-click Applications/Utilities/Terminal.app, or press Command-Space to activate Spotlight, and start typing Terminal until the app becomes selectable.

In the Terminal window, type these two commands, pressing Return after each one:

cd /usr/bin
ls

The cd command changes directory to one of the hidden folders. And ls lists the contents. This gives you a huge list of commands that you have access to. Scroll back to see where you typed ls, then right-click it and select Open man Page:

Opening a manual page
Opening a manual page

Nearly every command has its own man page, or manual page, that you can read to find what the command does and what arguments it takes. Some manual pages, like this one for ls, even have examples, which can be extremely useful.

Terminal displays these manual pages using the man command. Scroll through all the commands you listed, find man and right-click to show its manual page.

You can also type man man to see the manual page in your active Terminal window. Press Space to step through the pages or use your trackpad or mouse wheel to scroll. Press q to exit, which makes the manual page disappear.

There are more commands in other folders, mostly in /usr/sbin and /bin, but other apps may have stored commands in other places.

Note: You can make your system unusable with Terminal commands. Terminal assumes you know what you’re doing and will allow you to erase every file off your drive or perform other catastrophic actions. Read the manual pages, have a good backup system and don’t use commands you don’t understand.

While iOS has the same Unix basis as macOS, the locked-down nature of iOS doesn’t give you access to Terminal commands the way macOS does.

Testing Some Commands

Now that you know where macOS keeps these command files, it’s time for you to test some. Here are some interesting — and safe — ones you can run by typing them one at a time into Terminal:

whoami
uptime
cal
man splain

Testing some commands.
Testing some commands.

Press q to quit that last man command.

A lot of these commands are old, but there are some new ones. macOS 12 added a new command to test your internet connection. Run this command and wait for it to complete, which takes about 30 seconds on my network:

networkQuality -sv

The command name is networkQuality, but what’s this -sv? Right-click the command to open its manual page:

networkQuality manual page
networkQuality manual page

The SYNOPSIS lists several optional arguments. You can tell they’re optional because they appear inside square brackets. The -I argument allows you to specify the interface so, if your computer has more than one network connection, you can use it to select the one to test. The other options are four flags: -c, -h, -s and -v. Reading down the page, you can see what each of these does. Experiment with different combinations to see what you get.

Command line arguments are always case-sensitive. The networkQuality command has -C and -c options that do completely different things. You can type arguments as a single word if the manual page shows them like that. For this example, you used networkQuality -sv, but networkQuality -s -v works exactly the same.

Here’s another command that has even more arguments:

ping -c 5 apple.com

Run the command, then open the manual page for ping to see what these arguments are. In this case, you’re not merely turning on a setting, you’re providing information. The -c argument, and the number following it, specify how many times to ping the server. The host, apple.com in this example, is not optional. So this command pings the Apple servers five times and reports how long it takes to get a response.

Terminal Shortcuts

Here is a collection of tips to make working in Terminal easier, and more efficient.

  • To clear the Terminal window at any time, press Command-K.

  • Press the up or down arrow keys to cycle through recently used commands. Use Return when you see the one you want to use.

  • When changing directory, type cd, followed by a space, and then drag a folder from Finder into the Terminal window to insert its path.

  • When entering a command or file path, start typing and then press Tab. Terminal tries to auto-complete the command or path for you, or it lists possible candidates.

Running Commands in a Playground

Now that you know something about Terminal commands, it’s time to get back into Xcode and see how to run them from there.

Open Xcode and select File ▸ New ▸ Playground…. Choose the macOS Blank template and name it Commands.playground:

New macOS playground
New macOS playground

Apple provides a class called Process for running other programs like these Terminal commands.

Note: Process used to be called NSTask, and you’ll still see that name used a lot, including in some of Apple’s own documentation.

You set up a Process with a file path URL for the command it’s to run. When you run commands in Terminal, you type in the name of the command, e.g. whoami. That doesn’t work in a Process, so you need to discover exactly where the command file is.

Terminal gives us a way to get this information. Swap back to Terminal and use the which command to locate the executable command file:

which whoami

This command returns /usr/bin/whoami and that’s what you need to use in your Process:

Finding a command file.
Finding a command file.

Replace the contents of the playground with:

// 1
import Cocoa

// 2
let process = Process()

// 3
process.executableURL = URL(fileURLWithPath: "/usr/bin/whoami")

// arguments go here

// standard output goes here

// 4
try? process.run()

Stepping through these lines, you:

  1. Import Cocoa so you can access Process.
  2. Create a new Process.
  3. Set the executableURL for the process to a file URL based on the path you discovered.
  4. Try to run your Process.

Run the playground now, and you’ll see your macOS user name appear in the console at the bottom:

Running your first process.
Running your first process.

A command called whoami sounds like it should answer some deep, philosophical questions about life, but all it does is give you the name of the currently logged in user. :[

Your Process works and your playground can call a Terminal command, but where is the result? And how can you get that result into a string so you can use it?

Adding Some Pipes

When you run any Terminal command, it opens three channels:

  • stdin: standard input for receiving data
  • stdout: standard output for sending data
  • stderr: standard error for sending errors

In Terminal, these all default to the Terminal itself. You provide input on the command line, and results or errors appear there too.

In the playground, Process gets its input from the URL and from an arguments array you’ll see in a few minutes. It uses its console for output and errors. But if you want to do anything with the data, you have to set up your own standardOutput.

In your playground, replace // standard output goes here with:

// 1
let outPipe = Pipe()
// 2
let outFile = outPipe.fileHandleForReading
// 3
process.standardOutput = outPipe

With this code, you:

  1. Create a Pipe to provide communication between processes. One end of the Pipe connects to your process, and the other end connects to the Terminal command.
  2. Set up a FileHandle so you can read the data coming through the Pipe.
  3. Assign this pipe as the standardOutput for your process.

This gives you the mechanism you need to access the output of the command. Next you need to read from it.

Replace the try? process.run() line with this:

// 1
do {
  // 2
  try process.run()
  process.waitUntilExit()

  // 3
  if
    let data = try outFile.readToEnd(),
    let returnValue = String(data: data, encoding: .utf8) {
    print("Result: \(returnValue)")
  }
} catch {
  // 4
  print(error)
}

What do your changes do?

  1. Wrap the code in a do block, so you can catch any errors.
  2. Run the process as before and wait until it’s finished.
  3. Then, read all the data from the standardOutput’s file handle, convert it to a string and print it.
  4. If there was a problem, print the error.

Run the playground again, and this time you’ll see that the returnValue variable now contains the expected result:

Getting returned data.
Getting returned data.

Supplying Arguments

There’s only one more complication, and that’s when a command needs more input. Remember how you pinged Apple’s servers earlier from Terminal? Now, you’ll do the same from your playground.

The first step is to locate the ping command, so type which ping in a Terminal window to get the full path: /sbin/ping. In the line that sets process.executableURL, replace /usr/bin/whoami with /sbin/ping:

process.executableURL = URL(fileURLWithPath: "/sbin/ping")

Next, you need to supply arguments as an array of strings. For each word that you’d type in Terminal, you add a separate string to the array.

Replace // arguments go here with:

process.arguments = ["-c", "5", "apple.com"]

In Terminal, you used ping -c 5 apple.com. The executableURL gets set to the full path to the ping command, and the other three words provide the three members of the arguments array. Even the numeric parameter must be a string.

Run the playground and, after about 5 seconds, you’ll see the result in the console:

Running a command with arguments.
Running a command with arguments.

The ping command is obviously working, but it’s a bit boring waiting until the end to see any results. How about reading data as it arrives?

Reading Data Sequentially

In the previous example, you waited until process finished and then used readToEnd() to get the complete output from the command in a single chunk. Now, you’re going to use availableData to read the output as it arrives.

Start by adding this function to your playground, below import Cocoa and above the let process declaration:

// 1
func getAvailableData(from fileHandle: FileHandle) -> String {
  // 2
  let newData = fileHandle.availableData
  // 3
  if let string = String(data: newData, encoding: .utf8) {
    return string
  }
  // 4
  return ""
}

Taking this bit by bit:

  1. Use this function to read data from a FileHandle. You already made a FileHandle to read from standardOutput.
  2. Get all the data it can from the FileHandle.
  3. Then, try to convert the incoming data into a string and return it.
  4. If there’s a problem, or if there are no data, return an empty string.

To use this function, replace the contents of the do block with:

// 1
try process.run()

// 2
while process.isRunning {
  // 3
  let newString = getAvailableData(from: outFile)
  print(newString.trimmingCharacters(in: .whitespacesAndNewlines))
}
// 4
let newString = getAvailableData(from: outFile)
print(newString.trimmingCharacters(in: .whitespacesAndNewlines))

And what’s happening here?

  1. Start process running, exactly as you did before.
  2. Then, set up a while loop to run as long as process is running.
  3. Inside the loop, use the new function to read all the available output and print it.
  4. When the loop has finished, do one last check to get the final chunk of data.

Run the playground now, and you’ll see the same ping results coming in, but this time you can read each line as soon as it arrives, which gives a much better user experience.

Finding Commands

You’ve probably spotted a flaw in this system. Using Terminal to find the path to each command is not a great solution. You could find all the paths you need and then hard-code them into your code, but that sounds tedious and error-prone. How about running the which command programmatically and using that?

But where is which? Run which which in Terminal. It feels like this might generate some sort of infinite loop, but it returns which: shell built-in command. Some commands are so important that they’re part of the shell and don’t have a separate file path.

So what’s the shell, and how can you access the which command programmatically?

The shell is the command that creates the terminal prompt. Look at the title bar of your Terminal window and you’ll see some interesting information:

Shell window
Shell window

This shows you:

  • The current directory.
  • The name of the shell.
  • Your Terminal window size in characters across and rows down.

Modern versions of macOS use zsh as the default shell, but you can also use bash, or you may have installed something completely different like fish. However, you can rely on your Mac having zsh installed.

Regardless of what shell you’re using, run this command in Terminal, to find zsh:

which zsh

This gives you /bin/zsh, and that’s the one file path you’re going to hard-code. Check the manual page for zsh. Scroll about half way down to find the section labeled INVOCATION. This tells you that you can use the -c flag to execute the next argument as if it was a regular command.

Try this in Terminal first:

zsh -c "which whoami"

And you’ll get /usr/bin/whoami exactly as you saw when you ran which whoami directly. The command you want zsh to run is inside quotes, so that zsh recognizes it as a single argument.

Now that you know how this works, go back to the playground and replace the lines that set the process executableURL and arguments with:

process.executableURL = URL(fileURLWithPath: "/bin/zsh")
process.arguments = ["-c", "which whoami"]

Run the playground to see /usr/bin/whoami in the console. So now you have a technique you can use to find the path to any executable command. And you know how to run built-in commands like which using zsh.

Wrapping it in Functions

You now have everything you need to run Terminal commands from your playground, but before you use this in an app, it makes sense to wrap it into reusable functions.

Start by adding these lines below func getAvailableData(from:) and immediately above where you declare process:

func runCommand(
  _ command: String,
  with arguments: [String] = []
) async -> String {
  // move all the process code below to here
  return ""
}

This sets up a function that takes in the command path and an array of arguments. The arguments default to an empty array if not supplied. The function is async so it can run without blocking the main thread, and it returns a String.

Next, select all the other lines of code below, starting with let process ... and ending with the catch closure. Then press Option-Command-[ enough times to move all this code into the body of runCommand(_:with:), above the default return "".

Now, to use your function’s parameters, replace the process configuration lines with:

process.executableURL = URL(fileURLWithPath: command)
process.arguments = arguments

This sets the process to use the supplied command path and arguments instead of hard-coded values.

Then, replace the contents of the do block with:

try process.run()

var returnValue = ""
while process.isRunning {
  let newString = getAvailableData(from: outFile)
  returnValue += newString
}
let newString = getAvailableData(from: outFile)
returnValue += newString

Instead of printing each line as it arrives, you merge it into a single returnValue string.

And now, still in the do block, add these lines to return this string:

return returnValue
  .trimmingCharacters(in: .whitespacesAndNewlines)

Terminal commands always return strings with trailing line feeds, so you strip these out, along with any excess spaces, before returning returnValue.

You’re no longer seeing the output as it arrives, but when you get to build this into an app, you’ll see how you can enable this again.

The return "" outside the do-catch code returns an empty string if anything goes wrong.

And now you’ve got a reusable function that can call Terminal commands.

This is a general function to run any command, but it would be useful to have a more specialized function to find the path to any command’s executable file.

Add this to the end of the playground:

func pathTo(command: String) async -> String {
  await runCommand("/bin/zsh", with: ["-c", "which \(command)"])
}

This uses runCommand(_:with:) to run zsh and gets it to find the path to the supplied command using which.

Now, to put it all together, add this code to your playground:

// 1
Task {
  // 2
  let commandPath = await pathTo(command: "cal")
  // 3
  let cal = await runCommand(commandPath, with: ["-h"])
  print(cal)
}

This code:

  1. Encloses the async function calls in a Task block so you can await their results.
  2. Gets the path to the cal command.
  3. Runs the command and prints the result. The -h flag turns off the bolding for today’s date, because that doesn’t display well in plain text.

Run the playground to see a printout of the calendar for the current month.

Using the functions.
Using the functions.

Once you’ve tested the command, comment out the Task block. You’re going to be running other commands, but you can leave this in place as a guide.

Manipulating Images

You’re about to build an app called ImageSipper, and it’ll use the sips or scriptable image processing system command.

Type sips in Terminal and press Return to see some help. Right-click the word and open its manual page for even more information. There is a lot of detail there, but sadly, no examples. This is a powerful utility, and you can edit single image files or batch process multiple images. But it’s not easy to use, and it’s definitely not easy to remember the syntax, so adding a user interface will make it much more usable.

First, you’ll test some sips commands in the playground. Add this line at the end:

let imagePath = ""

Now, you need to insert an image file path between the quotes on that line.

In the downloaded materials for this chapter, the assets folder contains a sample image called rosella.png. Right-click the file, hold down Option and select Copy “rosella.png” as Pathname:

Copy image path
Copy image path

Back in your playground, paste this copied file path between the quotes on the let imagePath line.

This gives you a file path to the image. In iOS and macOS apps, you’re used to working with URLs for files, but Terminal commands are all text-based, so you need the file path as a String.

Next, you have to find the path to the sips command, so add this below:

Task {
  let sipsPath = await runCommand("/bin/zsh", with: ["-c", "which sips"])

  // sips commands here
}

Finally, you’re ready to run your first sips command. Replace // sips commands here with:

// 1
let args = ["--getProperty", "all", imagePath]
// 2
let imageData = await runCommand(sipsPath, with: args)
print(imageData)

And what’s all this?

  1. sips has an argument called --getProperty that reads data from an image file. You can follow it with the name of the specific property you want to get, but using all makes it return all the information sips can read. The third string in the array tells sips which image file to use.
  2. Run the sips command with these arguments and print the result to the console.

Run the playground and you’ll see a list of information about the image:

Image information
Image information

Shrinking the Image

This is a large image, as you can see from the data you just read, so you’re going to use sips to make a smaller copy. So that you don’t overwrite the original, you’ll provide a new file path.

Duplicate the line with the original file path. Change the variable name to imagePathSmall and the last part of the file name to rosella_small.png, so you end up with something like this:

let imagePath = "/path/to/folder/rosella.png"
let imagePathSmall = "/path/to/folder/rosella_small.png"

You can use sips to change both the height and width of an image, but there are options that allow you to change only one dimension and have the other change automatically to maintain the same aspect ratio. You’ll reduce the width, and the height will adjust to match.

Below the last command inside the Task block, add these lines:

let resizeArgs = [
  // 1
  "--resampleWidth", "800",
  // 2
  imagePath,
  // 3
  "--out", imagePathSmall
]

// 4
let output = await runCommand(sipsPath, with: resizeArgs)
print("Output: \(output)")

What does this code do?

  1. Set up the array of arguments. The first arguments say to adjust the width of the image to 800 pixels.
  2. The next element supplies the path to the original image file.
  3. The third section of the arguments array tells sips to save the edited image to the new file path. If you left this out, the edited image would overwrite the original file.
  4. Finally, run the command and print the output to the console.

Run the playground now. The console shows the data for the original image again, and then the output of the resize operation, which is the original file path, followed by the new file path.

In Finder, look at the two image files. Check the Finder preview, or press Command-I to Get Info about rosella_small.png, and you’ll see its dimensions are 800 x 600 pixels, down from 3796 x 2850:

Resized image
Resized image

Calculating the aspect ratios, 2850 / 3796 = 0.751 while 600 / 800 = 0.75, so the ratio of height to width has remained virtually unchanged.

Formatting Arguments

Go back to the manual page for sips. The first entry in the FUNCTIONS section is -g or --getProperty. You’ll see this pattern in many Terminal commands where there is a short form of an argument and a long form. Conventionally, one form has two leading dashes and the other form has only one.

When you’re typing directly into Terminal, the short version makes much more sense, but that’s not the case when writing a utility app like this.

Always use the longer form when calling commands in an app. You only have to type it once, and it makes your code much easier to read and understand when you come back to it later, or when anyone else has to read it.

Challenges

Challenge 1: Use Another Terminal Command

Pick another Terminal command and run it in the playground. Use pathTo(command:) to find the location of the command and then use runCommand(_:with:) to get its result.

Don’t forget to wrap your function calls in a Task block so they can run asynchronously.

Challenge 2: Rotate or Flip the Sample Image

You can use sips to flip or rotate an image. The syntax you’d use in Terminal is:

sips --rotate 90 rosella.png --out rosella_rotated.png
sips --flip vertical rosella.png --out rosella_flipped.png

Convert these commands to run in your playground. Test out different rotation angles and try flipping horizontally as well as vertically.

Try to work this out for yourself, but if you get stuck, look in the playground in the challenge folder for this chapter.

Key Points

  • macOS is built on top of Unix and contains a lot of utility commands you can access through Terminal. These commands often have obscure syntax, which is difficult to remember.
  • You use Process to run these commands in Swift.
  • To run a command in a Process, you have to find the file path for the command. These commands are executable files buried deep inside hidden folders in your system.
  • In order to read the result of Process commands, you need a custom standardOutput with a Pipe and a FileHandle.

Where to Go From Here?

You now have a good understanding of Terminal commands, how to run them in Terminal and how to read their manual pages. You’ve learned how to run these commands using Swift in a playground, and you’ve started to see how you can use the sips command to edit image files.

In the next chapter, you’re going to take all this knowledge and use it to create a Mac app that will provide an easy interface to the power of the sips command.

© 2026 Kodeco Inc.