Launching macOS applications from the command-line
TL;DR: This article describes in detail how to launch macOS application bundles from the command line
A quick tour on how to run a macOS application bundle in the foreground while also inheriting standard output and standard error. The executive summary is:
$ open -W --stdout $(tty) --stderr $(tty) <path/to/Bundle.app>
An overview of application bundles
The macOS platform enforces a predefined directory layout for
graphical applications referred to as the Application
Bundle. We will not go into the details of this directory
structure in this post, but you can see examples of it by
inspecting macOS built-in applications. Here is what the
high-level directory structure of the
Calculator.app
application bundle looks like:
$ tree -L 3 /System/Applications/Calculator.app
/System/Applications/Calculator.app
└── Contents
├── Info.plist
├── MacOS
│ └── Calculator
├── PkgInfo
├── PlugIns
│ ├── BasicAndSci.calcview
│ └── Hexadecimal.calcview
├── Resources
│ ├── AppIcon.icns
│ ├── Assets.car
│ ├── Base.lproj
│ ├── Calculator.loctable
│ ├── ConversionCategories.plist
│ ├── ConversionSheet-BBBAA77A32-C4EBFEA440.loctable
│ ├── ConversionSheet.loctable
│ ├── ConversionsFromBase.plist
│ ├── ConversionsToBase.plist
│ ├── InfoPlist.loctable
│ ├── Localizable.loctable
│ ├── Speakable.plist
│ ├── UnitNames.loctable
...
├── _CodeSignature
│ └── CodeResources
└── version.plist
47 directories, 17 files
With regards to execution, the application’s main executable
is always located inside the Contents/MacOS
subdirectory. By convention, the executable name matches the
bundle name. For example, the executable of
Calculator.app
is Calculator
. However,
the application binary is arbitrary as long as it is properly
declared on the CFBundleExecutable
entry of the
Contents/Info.plist
file. Plist properties can be
easily read using the PlistBuddy(8)
built-in
tool:
$ /usr/libexec/PlistBuddy -c "print CFBundleExecutable" /System/Applications/Calculator.app/Contents/Info.plist
Calculator
Cocoa and the
open
command
Native macOS applications are typically built using the Cocoa set of frameworks. In comparison to other operating systems, native macOS applications do not steal focus by default. If you attempt to run a Cocoa-based application by invoking its main executable directly on the terminal, the shell will run the app as a child process, capture its standard input, standard output and standard error, but the application will run on the background.
Try this out on the Calculator.app
application.
You should see application logs in your terminal, but the
calculator window will not steal focus:
$ /System/Applications/Calculator.app/Contents/MacOS/Calculator
While this behaviour might be surprising at first, it allows
the operating system to have control of whether an application
runs in the foreground or in the background rather than
delegating this responsibility to each application. To support
this model, macOS provides the built-in open(1)
that dates back to NextStep, the
pre-cursor of Mac OS X. The open(1)
command
provides a plethora of options for running application bundles
or load documents in application bundles. By default, it runs an
application and brings their windows to the foreground, but this
behaviour may be omitted using the -g
option.
Try this out on the Calculator.app
:
# Run the application on the foreground
$ open /System/Applications/Calculator.app
# Run the application on the background
$ open -g /System/Applications/Calculator.app
Improving on the defaults
If you paid attention when running the last examples, you
noticed two important distinctions of open(1)
’s
default behaviour compared to directly running the application
bundle’s executable:
open(1)
immediately returns control to the shellopen(1)
does not pipe standard output and standard error back to the shell
Process hierarchy
When directly running the application’s executable,
ps(1)
allows us to confirm that the shell owns the
application process. In my setup, the application’s parent
process id (PPID) equals the process id (PID) of my shell:
$ ps -f
UID PID PPID C STIME TTY TIME CMD
...
501 38133 747 0 8:42PM ttys005 0:00.31 -zsh
501 38343 38133 0 8:42PM ttys005 0:00.43 /System/Applications/Calculator.app/Contents/MacOS/Calculator
...
In comparison, if we run the application using
open(1)
, the application process is owned by the
process with an id (PID) of 1: the launchd
init process. In other words, open(1)
spawns the
application process in a detached mode:
$ ps -A -x -f
UID PID PPID C STIME TTY TIME CMD
0 1 0 0 8:26AM ?? 3:09.23 /sbin/launchd
...
501 39528 1 0 8:45PM ?? 0:00.52 /System/Applications/Calculator.app/Contents/MacOS/Calculator
...
As a solution, the open(1)
command supports the
-W
option to wait for the application to quit
before exiting:
$ open -W /System/Applications/Calculator.app
Interestingly enough, this does not cause
open(1)
to run the application as a child process.
The application is still ran in detached mode, but
open(1)
will wait on the application’s process id
using a system call such as waitpid(2)
:
$ ps -A -x -f
UID PID PPID C STIME TTY TIME CMD
0 1 0 0 8:26AM ?? 3:12.89 /sbin/launchd
...
501 42889 1 0 8:54PM ?? 0:00.42 /System/Applications/Calculator.app/Contents/MacOS/Calculator
...
501 42796 747 0 8:54PM ttys003 0:00.12 -zsh
...
501 42888 42796 0 8:54PM ttys003 0:00.04 open -W /System/Applications/Calculator.app
...
TTYs and Standard I/O
We know that open(1)
will never run the
application bundle as a child process. This explains why
open(1)
does not pipe standard I/O to our shell and
why we cannot see the applications logs anymore as we could by
running the executable directly. However, open(1)
supports options called --stdin
,
--stdout
and --stderr
to pipe standard
input, standard output and standard error to paths provided by
the user, respectively.
When a shell starts, its process is associated to a
pseudo-teletype (TTY) special character device. For example, my
current zsh
session is associated with the
ttys004
device located in
/dev/ttys004
. The tty(1)
command is a
handy utility to print the TTY device associated with the
current process’ standard I/O. If you need to get this value
programmatically, you can use the ttyname(3)
function from the standard C library.
$ ps
PID TTY TIME CMD
...
47136 ttys004 0:00.17 -zsh
$ file /dev/ttys004
/dev/ttys004: character special (16/4)
$ tty
/dev/ttys004
Unsurprisingly, I can print to my current terminal session by piping data to this special character device:
$ echo "Hello" > /dev/ttys004
Hello
Leaving standard input aside, which is uncommon for macOS
graphical applications to read, we can pipe standard output and
error to the caller terminal by pointing --stdout
and --stderr
to the shell’s TTY. We can pair this
with -W
to ensure we get clean output:
$ open -W --stdout $(tty) --stderr $(tty) /System/Applications/Calculator.app
What about Chromium?
Interestingly enough, many Chromium-based apps like Google
Chrome and Brave always steal focus, even when executing their
application bundle binaries directly, and Electron-based apps
are no exception. As a consequence, standard
open(1)
flags like -g
to run an
application on the background do not work at all with these
apps:
# Wrong behaviour! The app still opens in the foreground
$ open -g /Applications/Google\ Chrome.app
Why this happens is a topic for a future post!