Relentless Coding

A Developer’s Blog

Create MacOS App Bundle from Script

I’m going to show how to create the smallest possible MacOS app bundle we can: one that simply executes a Bash script. Then, I’m going to expand a bit on that and add an icon to it. I’ll also show how open a Terminal app window so we can see the script’s output in it.

A MacOS app bundle is a directory that is interpreted in a special way by MacOS. It is an application name followed by the suffix .app (e.g. foo.app). A minimal app bundle would look like:

$ mkdir foo.app
$ cat > foo.app/foo
#!/bin/bash -i
zenity --calendar
$ chmod u+x foo.app/foo
$ tree foo.app
foo.app
`-- foo

We create an app bundle foo.app that contains an executable of the same name: if you name your app bundle baz.app, the executable should be named baz. You can now execute the bundle by typing open foo.app in your terminal, or double clicking on the bundle in Finder.

(Note that in order to display a Zenity calendar, I needed to make Bash interactive. Leaving out the -i flag would not display anything. I haven’t figured out yet why that is the case, but do send me a note if you know.)

If you want more control, add an icon, and so on, we need to get more elaborate.

Open a Terminal Window to Display the Output of a Shell Script

$ mkdir -p bar.app/Contents/{MacOS,Resources}
$ cat > bar.app/Contents/MacOS/wrapper
#!/bin/bash
script_path="$(dirname "$0")"/actual-script
open -a Terminal "$script_path"
$ cat > bar.app/Contents/MacOS/actual-script
#!/bin/bash
echo do something useful
$ chmod u+x bar.app/Contents/MacOS/{wrapper,actual-script}

Here, we created a wrapper script that will open the Terminal and execute the actual, useful script such that any output would be visible to the user.

(Important: make sure the names of the files and directories are spelled correctly. For example, it’s Contents (plural) and NOT Content. Failing to do this will make your life miserable because the app won’t work in Finder and won’t display a useful error message. Or, when opening it from your terminal, it will spit out weird errors like “executable is missing”.)

Then, add <name>.app/Contents/Info.plist:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>CFBundleName</key>
  <string>Bar</string>
  <key>CFBundleExecutable</key>
  <string>wrapper</string>
  <key>CFBundleIdentifier</key>
  <string>com.relentlesscoding.Bar</string>
  <key>CFBundleVersion</key>
  <string>1.2.3</string>
</dict>
</plist>

CFBundleExecutable contains the name of the executable located in <name>.app/Contents/MacOS. Reminder: do not forget to chmod u+x <executable>.

CFBundleIdentifier should contain a unique identifier for your app. Fail to make this unique, and your app will replace some other app in your Launcher.

For good measure, I added CFBundleVersion and put the app version in there.

Incidentally, you can read all about these properties in Apple’s documentation.

Add an Icon

If you search online, you’ll find recommendations for application icons and sizes. If you want to keep things simple, however, just download a 512x512 PNG icon from the internet, name it whatever you want, and put it in <name>.app/Contents/Resources.

Then, modify <name>.app/Contents/Info.plist to point to the icon. Here, I added an icon bar.app/Contents/Resources/bar.png:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <!-- ... snipped other tags... -->
  <key>CFBundleIconFile</key>
  <string>bar.png</string>
</dict>
</plist>

Tips

  • You can check the validity of the Info.plist file by running plutil path/to/Info.plist:

    $ plutil bar.app/Contents/Info.plist
    bar.app/Contents/Info.plist: OK
    
  • MacOS won’t immediately pick up changes you make to the app bundle. You can force its hand by touching the bundle:

    $ touch bar.app/
    
  • All of this was tested on MacOS Sanoma.