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:
-
run.sh
(download): Contains logic we wantlaunchd
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"
-
main.applescript
(download): Startsrun.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
-
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>
-
generate.sh
(download): Create the .app bundle by compilingmain.applescript
to the bundle and includingrun.sh
andcom.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"
-
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 ofPlistBuddy
. - 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:
-
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"
-
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
-
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>
-
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()
-
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:
-
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"
-
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
-
main.applescript
(download): Executsrun.sh
on app start andquit.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.)