/blog/

2025 0808 Building .app bundles for launchd with AppleScript

In order to run a shell script with permission to access an external volume via launchd, I’m creating a .app bundle with osacompile, adding my shell script as a package resource, putting configuration values in the Info.plist, and creating a launch agent that runs the shell script. When the service starts, the app bundle requests permission to access the external volume, and once granted (or given Full Disk Access), it runs my script. (This ridiculous Rube Goldberg machine is necessary because of the way TCC works on macOS, which is something I’ll probably write about later.)

In this post I’ll show two examples: a very simple one that you can use as a base, and the more complex one that I wrote that handles my use case.

In these examples, the code we want launchd to run is a long running process like a webserver or similar.

A simple case

For this simple example, I’ll use python3 -m http.server as an example long-running server process which wants to read the contents of ~/Documents, which is protected by TCC.

It’s made up of:

  1. run.sh (download): Contains logic we want launchd to run, including starting the python3 HTTP server.

    display inline
    #!/bin/sh
          set -e
          
          # Read environment variables set by launchd plist
          # This is read first, and is less error prone, so simple, critical configuration makes sense here
          verbose="${EXAMPLE_SIMPLE_VERBOSE:-}"
          logpath="${EXAMPLE_SIMPLE_LOG_PATH:-}"
          
          set -u
          
          if test "$logpath"; then
              # Ensure the log directory exists
              mkdir -p "$(dirname "$logpath")"
              # Redirect stdout and stderr to the log file
              exec > "$logpath" 2>&1
          fi
          
          if test "$verbose"; then
              set -x
          fi
          
          # Assume this script is in $appbundle/Contents/Resources/run.sh
          appbundle="$(dirname "$(readlink -f "$0" 2>/dev/null || printf '%s' "$0")")/../.."
          echo "Using app bundle path: $appbundle"
          infoplist="$appbundle/Contents/Info.plist"
          
          # Read configuration variables from the app's Info.plist
          # Items here are available even when launched outside of launchd e.g. from the Finder.
          httproot="$(/usr/libexec/PlistBuddy -c "Print :ExampleApplicationHttpRoot" $infoplist)"
          httpport="$(/usr/libexec/PlistBuddy -c "Print :ExampleApplicationHttpPort" $infoplist)"
          
          # List the directory once, which ensures the app has access to it
          ls -alF "$httproot"
          
          # Run the service
          python3 -m http.server --directory "$httproot" "$httpport"
          
  2. main.applescript (download): Starts run.sh.

    display inline
    -- The main.scpt file is the entry point for the macOS app
          
          on run
              try
                  -- display dialog "Welcome to the Example Bundle Simple App!" buttons {"OK"} default button "OK"
          
                  -- Check if RUN_FROM_LAUNCHD is set as an environment variable.
                  -- We use this env var check to prevent unintentional double-click launches.
                  set reporoot to system attribute "RUN_FROM_LAUNCHD"
                  if reporoot is "" then
                      display dialog "This app is not designed to be launched by double-clicking. It should be run from its launchd agent." buttons {"OK"} default button "OK" with icon stop
                      return
                  end if
          
                  -- Run the bundled shell script.
                  set qScriptPath to quoted form of POSIX path of (path to resource "run.sh")
                  do shell script qScriptPath
                  return
          
              -- When an error occurs, display a dialog with the error message.
              on error errMsg number errNum
                  display dialog "AppleScript error: " & errMsg & " (" & errNum & ")" buttons {"OK"} default button "OK"
                  return
              end try
          end run
          
  3. com.example.Simple.plist (download): A is a Launch Agent that defines the service we want to run.

    display inline
    <?xml version="1.0" encoding="UTF-8"?>
          <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
          <plist version="1.0">
          <dict>
          	<!--The name of our service-->
          	<key>Label</key>
          	<string>com.example.Simple</string>
          
          	<!--The path to our .app bundle
          		We set this to empty and replace it in generate.sh
          		-->
          	<key>Program</key>
          	<string></string>
          
          	<!--A list of environment vars that the service will be launched with-->
          	<key>EnvironmentVariables</key>
          	<dict>
          		<key>RUN_FROM_LAUNCHD</key>
          		<string>true</string>
          		<key>EXAMPLE_SIMPLE_VERBOSE</key>
          		<string>false</string>
          		<key>EXAMPLE_SIMPLE_LOG_PATH</key>
          		<string></string>
          	</dict>
          
          	<!--Run the service when launchd loads this plistj-->
          	<key>RunAtLoad</key>
          	<true/>
          
          	<!--Restart the service if it crashes-->
          	<key>KeepAlive</key>
          	<true/>
          
          	<!--If the service crashes, wait this many seconds before restarting-->
          	<key>ThrottleInterval</key>
          	<integer>10</integer>
          </dict>
          </plist>
          
  4. generate.sh (download): Create the .app bundle by compiling main.applescript to the bundle and including run.sh and com.example.Simple.plist.

    display inline
    #!/bin/sh
          set -eu
          
          usage() {
              cat <<EOF
          $0: Generate a macOS .app bundle
          Usage: $0 [OPTIONS] APPBUNDLE httproot
          EOF
          }
          
          SCRIPT_DIR=$(dirname "$(readlink -f "$0" 2>/dev/null || printf '%s' "$0")")
          run_sh_src="$SCRIPT_DIR/run.sh"
          main_scpt_src="$SCRIPT_DIR/main.applescript"
          launchd_plist_src="$SCRIPT_DIR/com.example.Simple.plist"
          
          # Parse arguments
          appbundle=
          httproot=
          httpport=8000
          while test $# -gt 0; do
              case "$1" in
                  -h|--help) usage; exit 0;;
                  --port) httpport="$2"; shift 2;;
                  -*) printf 'Error: Unknown option: %s\n' "$1" >&2; usage >&2; exit 1;;
                  *)
                      if test -z "$appbundle"; then
                          appbundle="$1"
                      elif test -z "$httproot"; then
                          httproot="$1"
                      else
                          printf 'Error: Too many arguments\n' >&2
                          usage >&2
                          exit 1
                      fi
                      shift;;
              esac
          done
          
          if test -z "$httproot" || test -z "$appbundle"; then
              printf 'Error: Missing required argument\n' >&2
              usage >&2
              exit 1
          fi
          
          # Create the app bundle
          osacompile -o "$appbundle" "$main_scpt_src"
          
          appinfo_plist_gen="$appbundle/Contents/Info.plist"
          launchd_plist_gen="$appbundle/Contents/Resources/com.example.Simple.plist"
          launchd_log_path="$HOME/Library/Logs/com.example.Simple.log"
          
          # Set the app bundle Info.plist configuration settings
          # Set the app to an "agent" which does not show in the dock or menu bar
          /usr/libexec/PlistBuddy -c "Add :LSUIElement bool true" "$appinfo_plist_gen"
          # Set bundle identifiers and names
          /usr/libexec/PlistBuddy -c "Set :CFBundleName string ExampleBundleSimple" "$appinfo_plist_gen"
          # This shows in System Settings > Security & Privacy > Privacy > Full Disk Access, IF the bundle ID is set below
          # Otherwise the app will just be shown as `applet`
          /usr/libexec/PlistBuddy -c "Add :CFBundleDisplayName string ExampleBundleSimple" "$appinfo_plist_gen"
          # Set bundle ID
          /usr/libexec/PlistBuddy -c "Add :CFBundleIdentifier string com.example.ExampleBundleSimple" "$appinfo_plist_gen"
          # Add app-specific configuration
          /usr/libexec/PlistBuddy -c "Add :ExampleApplicationHttpPort integer $httpport" "$appinfo_plist_gen"
          /usr/libexec/PlistBuddy -c "Add :ExampleApplicationHttpRoot string $httproot" "$appinfo_plist_gen"
          
          # Install the resources
          cp "$run_sh_src" "$appbundle/Contents/Resources/"
          cp "$launchd_plist_src" "$appbundle/Contents/Resources/"
          
          # Modify the launchd plist to set the correct path to the app bundle
          /usr/libexec/PlistBuddy -c "Set :Program $appbundle/Contents/MacOS/applet" "$launchd_plist_gen"
          # Set environment variables for launchd
          /usr/libexec/PlistBuddy -c "Set :EnvironmentVariables:EXAMPLE_SIMPLE_VERBOSE true" "$launchd_plist_gen"
          /usr/libexec/PlistBuddy -c "Set :EnvironmentVariables:EXAMPLE_SIMPLE_LOG_PATH $launchd_log_path" "$launchd_plist_gen"
          
          # Codesign the app bundle with an ad-hoc signature
          codesign --deep --force --sign - "$appbundle"
          
  5. GNUmakefile (download): Tie everything together.

    display inline
    .PHONY: help
          help: ## Show this help
          	@egrep -h '\s##\s' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
          
          USERID := $(shell id -u)
          
          .PHONY: simple-clean
          simple-clean: ## Stop and remove the simple example
          	launchctl bootout gui/${USERID}/com.example.Simple || true
          	rm -rf ~/Applications/ExampleBundleSimple.app
          	rm -f ~/Library/LaunchAgents/com.example.Simple.plist
          	rm -f ~/Library/Logs/com.example.Simple.*
          
          .PHONY: simple-install
          simple-install: simple-clean ## Install and start the simple example
          	./generate.sh ~/Applications/ExampleBundleSimple.app ~/Documents
          	cp ~/Applications/ExampleBundleSimple.app/Contents/Resources/com.example.Simple.plist ~/Library/LaunchAgents/
          	chmod 600 ~/Library/LaunchAgents/com.example.Simple.plist
          	launchctl bootstrap gui/${USERID} ~/Library/LaunchAgents/com.example.Simple.plist
          

The makefile has these targets for deploying and removing the service:

> make
help                 Show this help
simple-clean         Stop and remove the simple example
simple-install       Install and start the simple example

More complex case

This is what I use for this site on macOS. It’s based on the above flow but has more complications (“features”).

  • It uses a Python script instead of a shell script to create the .app bundle. This is much nicer for more complex logic, and lets us use Python’s plistlib instead of PlistBuddy.
  • It supports deploying two apps and two services for the obverse and reverse sites, which are (spoiler) normally deployed to https://me.micahrl.com and https://com.micahrl.me.

It’s made up of:

  1. run.sh (download)

    display inline
    #!/bin/sh
          set -ex
          
          # Read environment variables set by launchd plist
          reporoot="${MICAHRL_REPOROOT}"
          logpath="${MICAHRL_LOG_PATH:-}"
          
          set -u
          
          # If log path is set, redirect output to it
          if test -n "$logpath"; then
              # Truncate the log file and redirect stdout and stderr to it
              exec > "$logpath" 2>&1
          fi
          
          # Assume this script is in $appbundle/Contents/Resources/run.sh
          appbundle="$(dirname "$(readlink -f "$0" 2>/dev/null || printf '%s' "$0")")/../.."
          echo "Using app bundle path: $appbundle"
          infoplist="$appbundle/Contents/Info.plist"
          
          # Read environment variables from the app's Info.plist
          hugo="$(/usr/libexec/PlistBuddy -c "Print :MicahrlHugo" "$infoplist")"
          svchost="$(/usr/libexec/PlistBuddy -c "Print :MicahrlServiceHost" "$infoplist")"
          svcport="$(/usr/libexec/PlistBuddy -c "Print :MicahrlServicePort" "$infoplist")"
          devport="$(/usr/libexec/PlistBuddy -c "Print :MicahrlDevPort" "$infoplist")"
          vedport="$(/usr/libexec/PlistBuddy -c "Print :MicahrlVedPort" "$infoplist")"
          environment="$(/usr/libexec/PlistBuddy -c "Print :MicahrlEnvironment" "$infoplist")"
          destination="$(/usr/libexec/PlistBuddy -c "Print :MicahrlDestination" "$infoplist")"
          # Test for required environment variables
          if test -z "$reporoot"; then
              echo "Error: Required environment variable(s) not set."
              echo "Please ensure that all required environment variables are set in the Launch Agent plist."
              exit 1
          fi
          if test -z "$hugo" \
              || test -z "$svchost" \
              || test -z "$svcport" \
              || test -z "$environment" \
              || test -z "$destination"
          then
              echo "Error: Required plist variable(s) not set."
              echo "Please ensure that all required variables are set in the application's Info.plist."
              exit 1
          fi
          
          # Check TCC permissions early by attempting to access both paths
          # This will trigger the permission dialog if needed
          echo "Reading required paths..."
          echo "Hugo path: $hugo"
          $hugo version
          echo "Repository root: $reporoot"
          ls -alF "$reporoot"
          cd "$reporoot"
          
          # Required to make the mirroring work
          export HUGO_DEV_HOST="$svchost"
          export HUGO_DEV_PORT="$devport"
          export HUGO_VED_PORT="$vedport"
          
          $hugo server \
              --buildDrafts \
              --buildFuture \
              --bind 0.0.0.0 \
              --printPathWarnings \
              --templateMetrics \
              --templateMetricsHints \
              --logLevel debug \
              --environment "$environment" \
              --destination "$destination" \
              --baseURL "$svchost" \
              --port "$svcport"
          
  2. main.applescript (download)

    display inline
    -- The main.scpt file is the entry point for the macOS app
          
          
          on run argv
              try
                  -- Check if RUN_FROM_LAUNCHD is set as an environment variable.
                  -- We use this env var check to prevent unintentional double-click launches.
                  set runFromLaunchd to system attribute "RUN_FROM_LAUNCHD"
                  if runFromLaunchd is not "true" then
                      display dialog "This app is not designed to be launched by double-clicking. Please install the launchd agent to run it instead." buttons {"OK"} default button "OK" with icon stop
                      return
                  end if
          
                  -- Always run the run.sh script when the app is launched
                  set qScriptPath to quoted form of POSIX path of (path to resource "run.sh")
                  do shell script qScriptPath
                  return
              on error errMsg number errNum
                  display dialog "AppleScript error: " & errMsg & " (" & errNum & ")" buttons {"OK"} default button "OK"
                  return
              end try
          end run
          
  3. launchd.plist (download)

    display inline
    <?xml version="1.0" encoding="UTF-8"?>
          <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
          <plist version="1.0">
          <dict>
              <key>Label</key>
              <string>PLACEHOLDER_LABEL</string>
              <key>Program</key>
              <string>PLACEHOLDER_PROGRAM</string>
              <key>EnvironmentVariables</key>
              <dict>
                  <key>RUN_FROM_LAUNCHD</key>
                  <string>true</string>
                  <key>MICAHRL_REPOROOT</key>
                  <string>PLACEHOLDER_REPOROOT</string>
                  <key>MICAHRL_LOG_PATH</key>
                  <string>PLACEHOLDER_LOG_PATH</string>
              </dict>
          
              <!--Start when the user logs in-->
              <key>RunAtLoad</key>
              <true/>
          
              <!--Restart the service if it crashes-->
              <key>KeepAlive</key>
              <true/>
          
              <!--If the service crashes, wait this many seconds before restarting-->
              <key>ThrottleInterval</key>
              <integer>10</integer>
          </dict>
          </plist>
  4. generate.py (download)

    display inline
    #!/usr/bin/env python3
          
          """Generate a user launch daemon plist for macOS to run the dev server."""
          
          import argparse
          from collections import OrderedDict
          from dataclasses import dataclass
          import json
          import os
          from pathlib import Path
          import plistlib
          import shutil
          import subprocess
          
          
          macos_root = Path(__file__).resolve().parent
          repo_root = macos_root.parent.parent
          run_sh = macos_root / "run.sh"
          main_scpt = macos_root / "main.applescript"
          
          
          @dataclass
          class Env:
              name: str
              destination: str
              devport: int
              appname: str
              appid: str
              svclabel: str
          
          
          constants = {
              "DevPort": 64224,
              "VedPort": 42246,
          }
          env_dev_obverse = Env(
              name="dev-obverse",
              destination="public/dev-obverse",
              devport=64224,
              appname="me.micahrl.com.dev-obverse.app",
              appid="com.micahrl.me.dev-obverse",
              svclabel="com.micahrl.me.dev-obverse.background",
          )
          env_dev_reverse = Env(
              name="dev-reverse",
              destination="public/dev-reverse",
              devport=42246,
              appname="me.micahrl.com.dev-reverse.app",
              appid="com.micahrl.me.dev-reverse",
              svclabel="com.micahrl.me.dev-reverse.background",
          )
          
          
          def get_tailscale_hostname():
              """Get the Tailscale hostname for the current machine."""
              try:
                  result = subprocess.run(
                      ["/Applications/Tailscale.app/Contents/MacOS/Tailscale", "status", "--json"],
                      capture_output=True,
                      text=True,
                      check=True,
                  )
                  status = json.loads(result.stdout)
                  return status["Self"]["DNSName"].rstrip(".")
              except (subprocess.CalledProcessError, KeyError, FileNotFoundError):
                  return None
          
          
          def build_app_bundle(bundlepath: str, hugo: str, devhost: str, env: Env, reporoot: str, force: bool = False):
              """Build the app bundle for the dev server."""
              if os.path.exists(bundlepath):
                  if force:
                      print(f"Removing existing app bundle at {bundlepath}")
                      shutil.rmtree(bundlepath)
                  else:
                      raise FileExistsError(f"App bundle already exists at {bundlepath}. Use --force to overwrite.")
              Path(bundlepath).parent.mkdir(parents=True, exist_ok=True)
          
              # Build the app bundle
              subprocess.run(["osacompile", "-o", bundlepath, main_scpt.as_posix()], check=True)
          
              plist_path = Path(bundlepath) / "Contents" / "Info.plist"
              with open(plist_path, "rb") as f:
                  plist_data = plistlib.load(f, dict_type=OrderedDict)
          
              # Set the app to an "agent" which does not show in the dock or menu bar
              plist_data["LSUIElement"] = True
              # Set bundle identifiers and names
              plist_data["CFBundleName"] = env.appname
              # This shows in System Settings > Security & Privacy > Privacy > Full Disk Access, IF the bundle ID is set below
              # Otherwise the app will just be shown as `applet`
              plist_data["CFBundleDisplayName"] = env.appname
              # Set bundle ID
              plist_data["CFBundleIdentifier"] = env.appid
              # Write the envirohnment configuration to the app bundle too, so that the run.sh script can read it directly
              plist_data["MicahrlHugo"] = hugo
              plist_data["MicahrlEnvironment"] = env.name
              plist_data["MicahrlDestination"] = env.destination
              plist_data["MicahrlServiceHost"] = devhost
              plist_data["MicahrlServicePort"] = env.devport
              plist_data["MicahrlDevPort"] = constants["DevPort"]
              plist_data["MicahrlVedPort"] = constants["VedPort"]
          
              # Write the plist data back to the Info.plist
              with open(plist_path, "wb") as f:
                  plistlib.dump(plist_data, f, sort_keys=False)
          
              # Embed the resources into the app bundle
          
              # The run script
              bundle_run_sh = Path(bundlepath) / "Contents" / "Resources" / "run.sh"
              shutil.copy(run_sh, bundle_run_sh)
              bundle_run_sh.chmod(0o755)
          
              # Copy and customize the launchd plist
              bundle_launchd_plist = Path(bundlepath) / "Contents" / "Resources" / "launchd.plist"
              with open(macos_root / "launchd.plist", "rb") as f:
                  launchd_data = plistlib.load(f, dict_type=OrderedDict)
          
              # Set the actual values in the launchd plist
              launchd_data["Label"] = env.svclabel
              launchd_data["Program"] = str(Path(bundlepath).resolve() / "Contents" / "MacOS" / "applet")
          
              # Set the environment variables
              log_path = os.path.expanduser(f"~/Library/Logs/{env.svclabel}.log")
              launchd_data["EnvironmentVariables"]["MICAHRL_REPOROOT"] = reporoot
              launchd_data["EnvironmentVariables"]["MICAHRL_LOG_PATH"] = log_path
          
              # Write the customized launchd plist
              with open(bundle_launchd_plist, "wb") as f:
                  plistlib.dump(launchd_data, f, sort_keys=False)
          
              # Code sign the whole thing
              subprocess.run(["codesign", "--deep", "--force", "--sign", "-", bundlepath], check=True)
          
          
          def main():
              parser = argparse.ArgumentParser(description="Generate a launch daemon plist for macOS to run the dev server.")
              parser.add_argument("environment", choices=["dev-obverse", "dev-reverse"], help="The environment to run the dev server for")
              parser.add_argument("appbundle", type=Path)
              parser.add_argument("reporoot", type=Path, help="Path to the repository root")
              parser.add_argument("--force", action="store_true", help="Force overwrite the app bundle if it exists")
              devhost_group = parser.add_mutually_exclusive_group(required=False)
              devhost_group.add_argument("--devhost", default="localhost", help="The dev server host (default: %(default)s)")
              devhost_group.add_argument(
                  "--devhost-tailscale", action="store_true", help="Use Tailscale to determine the dev server host"
              )
              parser.add_argument("--hugo", help="Path to the Hugo binary (default: found in PATH)")
              parsed = parser.parse_args()
          
              if parsed.devhost_tailscale:
                  devhost = get_tailscale_hostname()
                  if not devhost:
                      parser.error("Error: Could not determine Tailscale hostname.")
              else:
                  devhost = parsed.devhost
          
              hugo = parsed.hugo or shutil.which("hugo")
              if not hugo:
                  parser.error("Error: Hugo not found. Please specify --hugo or install Hugo to your $PATH.")
          
              if parsed.environment == "dev-obverse":
                  env = env_dev_obverse
              elif parsed.environment == "dev-reverse":
                  env = env_dev_reverse
              else:
                  parser.error(f"Unknown environment: {parsed.environment}")
          
              build_app_bundle(parsed.appbundle, hugo, devhost, env, str(parsed.reporoot.resolve()), force=parsed.force)
          
          
          if __name__ == "__main__":
              main()
          
  5. GNUmakefile (download)

    display inline
    .PHONY: help
          help: ## Show this help
          	@egrep -h '\s##\s' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
          
          USERID := $(shell id -u)
          
          .PHONY: macos-dev-obverse-clean
          macos-dev-obverse-clean: ## Clean up the macOS dev-obverse service/app
          	launchctl bootout gui/${USERID}/com.micahrl.me.dev-obverse.background || true
          	rm -rf ~/Applications/me.micahrl.com.dev-obverse.app
          	rm -f ~/Library/LaunchAgents/com.micahrl.me.dev-obverse.background.plist
          
          .PHONY: macos-dev-reverse-clean
          macos-dev-reverse-clean: ## Clean up the macOS dev-reverse service/app
          	launchctl bootout gui/${USERID}/com.micahrl.me.dev-reverse.background || true
          	rm -rf ~/Applications/me.micahrl.com.dev-reverse.app
          	rm -f ~/Library/LaunchAgents/com.micahrl.me.dev-reverse.background.plist
          
          .PHONY: macos-dev-obverse-install
          macos-dev-obverse-install: macos-dev-obverse-clean ## Install the macOS dev-obverse service/app
          	./generate.py \
          		"dev-obverse" \
          		~/Applications/me.micahrl.com.dev-obverse.app \
          		"$(REPOROOT)" \
          		--devhost-tailscale \
          		--force
          	cp ~/Applications/me.micahrl.com.dev-obverse.app/Contents/Resources/launchd.plist ~/Library/LaunchAgents/com.micahrl.me.dev-obverse.background.plist
          	launchctl bootstrap gui/${USERID} ~/Library/LaunchAgents/com.micahrl.me.dev-obverse.background.plist
          
          .PHONY: macos-dev-reverse-install
          macos-dev-reverse-install: macos-dev-reverse-clean ## Install the macOS dev-reverse service/app
          	./generate.py \
          		"dev-reverse" \
          		~/Applications/me.micahrl.com.dev-reverse.app \
          		"$(REPOROOT)" \
          		--devhost-tailscale \
          		--force
          	cp ~/Applications/me.micahrl.com.dev-reverse.app/Contents/Resources/launchd.plist ~/Library/LaunchAgents/com.micahrl.me.dev-reverse.background.plist
          	launchctl bootstrap gui/${USERID} ~/Library/LaunchAgents/com.micahrl.me.dev-reverse.background.plist
          
          .PHONY: macos-dev-obverse-restart
          macos-dev-obverse-restart: ## Restart the macOS dev-obverse service/app
          	launchctl kickstart -k gui/${USERID}/com.micahrl.me.dev-obverse.background
          
          .PHONY: macos-dev-reverse-restart
          macos-dev-reverse-restart: ## Restart the macOS dev-obverse service/app
          	launchctl kickstart -k gui/${USERID}/com.micahrl.me.dev-reverse.background
          
          .PHONY: macos-dev-install
          macos-dev-install: macos-dev-obverse-install macos-dev-reverse-install ## Install the macOS application and launch agent for the dev site
          

The makefile has these targets for deploying and removing the services:

> make
help                           Show this help
macos-dev-install              Install the macOS application and launch agent for the dev site
macos-dev-obverse-clean        Clean up the macOS dev-obverse service/app
macos-dev-obverse-install      Install the macOS dev-obverse service/app
macos-dev-obverse-restart      Restart the macOS dev-obverse service/app
macos-dev-reverse-clean        Clean up the macOS dev-reverse service/app
macos-dev-reverse-install      Install the macOS dev-reverse service/app
macos-dev-reverse-restart      Restart the macOS dev-obverse service/app

Stuff I learned

Differences from doubleclickable app bundles

We use KeepAlive in the launch agent plist so that launchd restarts the script if the service stops or crashes, and this requires keeping it in the foreground.

With app bundles intended to be run from Finder, a script in the foreground causes the app to be “not responding”, and requires a force quit to stop it. Instead, you can background it and keep track of the PID and kill it when the app quits.

For this reason, we check in the AppleScript file for an environment variable that is set in the launchd plist, and tell the user not to launch it by doubleclicking if it’s not set.

An app designed for doubleclicking from Finder ends up looking really different:

  1. run.sh (download): Runs when the app starts, runs the service in the background, and keeps track of the PID.

    display inline
    #!/bin/sh
          set -e
          
          # Read environment variables set by launchd plist
          # This is read first, and is less error prone, so simple, critical configuration makes sense here
          verbose="${EXAMPLE_SIMPLE_VERBOSE:-}"
          logpath="${EXAMPLE_SIMPLE_LOG_PATH:-}"
          
          set -u
          
          if test "$logpath"; then
              # Ensure the log directory exists
              mkdir -p "$(dirname "$logpath")"
              # Redirect stdout and stderr to the log file
              exec > "$logpath" 2>&1
          fi
          
          if test "$verbose"; then
              set -x
          fi
          
          # Assume this script is in $appbundle/Contents/Resources/run.sh
          appbundle="$(dirname "$(readlink -f "$0" 2>/dev/null || printf '%s' "$0")")/../.."
          echo "Using app bundle path: $appbundle"
          infoplist="$appbundle/Contents/Info.plist"
          
          # Read configuration variables from the app's Info.plist
          # Items here are available even when launched outside of launchd e.g. from the Finder.
          httproot="$(/usr/libexec/PlistBuddy -c "Print :ExampleApplicationHttpRoot" $infoplist)"
          httpport="$(/usr/libexec/PlistBuddy -c "Print :ExampleApplicationHttpPort" $infoplist)"
          
          pidpath="$HOME/Library/Application Support/com.example.App/com.example.App.pid"
          
          # List the directory once, which ensures the app has access to it
          ls -alF "$httproot"
          
          # Run the service and write the PID to a file
          python3 -m http.server --directory "$httproot" "$httpport" &
          echo $! > "$pidpath"
          
  2. quit.sh (download): Runs when the app quits and kills the service from the tracked PID.

    display inline
    #!/bin/sh
          set -eu
          
          # This script is called when the app is quit from the Finder.
          # If the app is run from launchd, this script is not called.
          
          pidpath="$HOME/Library/Application Support/com.example.App/com.example.App.pid"
          if test -f "$pidpath"; then
              pid=$(cat "$pidpath")
              if kill -0 "$pid" 2>/dev/null; then
                  echo "Stopping dev server with PID $pid"
                  kill "$pid"
                  rm -f "$pidpath"
              else
                  echo "No running dev server found with PID $pid"
              fi
          else
              echo "No PID file found at $pidpath"
          fi
  3. main.applescript (download): Executs run.sh on app start and quit.sh on app quit.

    display inline
    on run
          	set scriptPath to quoted form of POSIX path of (path to resource "run.sh")
          	do shell script scriptPath
          end run
          
          on quit
          	set cleanupPath to quoted form of POSIX path of (path to resource "quit.sh")
          	do shell script cleanupPath
          	continue quit
          end quit
          

launchd doesn’t trigger the quit event, so apps it starts will never trigger the on quit handler.

This is still a pretty limited implementation, because it doesn’t cover service crashes.

A more complete implementation might include two app bundles: one that is designed to be doubleclickable, and another inside the first bundle (hidden from the user) which is designed to be run from launchd, and have the doubleclickable app install a launchd service and uninstall it on quit.

(At some point in this continuum, AppleScript might stop making sense.)

You can’t use launchd logging for shell scripts run from AppleScript

launchd agents can set StandardOutPath and StandardErrorPath keys to redirect stdout/err to files for simple logging.

	<key>StandardErrorPath</key>
	<string>/Users/mrled/Library/Logs/com.example.App.err.log</string>
	<key>StandardOutPath</key>
	<string>/Users/mrled/Library/Logs/com.example.App.out.log</string>

However, when running a shell script from AppleScript, we use do shell script, which does not pass stdout/err of the shell script through to launchd, so this won’t work.

Instead, you have to have your shell script write to a log file directly.

You cannot use on run and on run argv in the same app bundle

Both Claude and ChatGPT really wanted me to use on run to handle application launches that did not provide arguments like when double clicking on the .app bundle from Finder, and on run argv to handle application launches that did provide arguments like using ProgramArguments in a launchd agent.

This does not work. The two are mutually exclusive.

If you need to do this, just use on run argv and test how many arguments were passed in AppleScript.

AppleScript compiled to .app bundles do not accept arguments

If you are running an AppleScript compiled to a .app bundle from osacompile, it doesn’t matter what you do, the script will never see arguments.

If you call open /path/to/Your.app/Contents/MacOS/applet --args arg1 arg2 from the shell, it will not see them.

If you have a launchd agent with

  <key>ProgramArguments</key>
  <array>
    <string>/path/to/Your.app/Contents/MacOS/applet</string>
    <string>arg1</string>
    <string>arg2</string>
  </array>

In your AppleScript the following will be true:

if argv = current application then
  -- ...

argv will never be a list, it will not have a class (you cannot see class of argv), it will not have a count (you cannot do count of argv). The arg1 and arg2 are simply not available to you at all. Also, the statement argv is missing value is false.

Also, you can’t get fancy with

set arguments to (current application's NSProcessInfo's processInfo's arguments) as list

This sees your compiled script binary file at /path/to/Your.app/Contents/MacOS/applet, and no other arguments.

(Perhaps try passing in values from environment variables instead.)

Responses

Webmentions

Hosted on remote sites, and collected here via Webmention.io (thanks!).

Comments

Comments are hosted on this site and powered by Remark42 (thanks!).