A small macOS app that manages shell scripts and runs the enabled ones at login.
Add scripts, toggle them on and off, run them on demand, and see the exit status
of the last run — including runs that happened at login while you weren't
watching. The UI borrows its palette and chrome from
rawbridge so the two read as the
same family of tool: dynamic light/dark colors, blue section rules, monospaced
body text, and the frosted menu-bar glass behind the window.
~/Applications/Script Runner.app the installed app
~/Documents/scripts/ScriptRunner/ this source tree
The bundle ships two executables, both compiled from the same
Model.swift:
| Binary | Role |
|---|---|
Contents/MacOS/ScriptRunner |
The SwiftUI manager window |
Contents/MacOS/scriptrunner-run |
Headless runner invoked by the LaunchAgent at login |
An earlier design had a single app that ran its script on launch. That fights with having a UI: double-clicking the app in Finder would fire your scripts instead of opening the manager, and there's no reliable way for an app to tell "launched by the user" from "launched at login."
Splitting them removes the ambiguity. The .app only ever opens the window; the
LaunchAgent only ever calls scriptrunner-run --login. A login run never loads
AppKit, never shows a Dock icon, and doesn't need a display session. Because
both binaries share the model layer, they agree on one scripts.json — the GUI
shows the exit status of a login run it never observed.
ScriptRunner/
├── Sources/
│ ├── Model.swift ScriptEntry, persistence, execution, logging
│ ├── LoginAgent.swift writes/removes the LaunchAgent via launchctl
│ ├── RunnerMain.swift @main for the headless CLI
│ ├── App.swift @main for the SwiftUI app
│ ├── ScriptListView.swift the manager window
│ ├── Theme.swift palette + GlassBackground (from rawbridge)
│ └── Components.swift SectionHeader, StatusBadge, Note
├── Resources/
│ └── Info.plist bundle metadata
├── Scripts/
│ └── build-app.sh compiles both binaries, assembles + installs
└── build/ output (safe to delete; regenerated)
Model.swift is compiled into both binaries. LoginAgent.swift,
Theme.swift, Components.swift, ScriptListView.swift and App.swift are
GUI-only; RunnerMain.swift is CLI-only. The split is declared explicitly in
build-app.sh as GUI_SRC and CLI_SRC — if you add a file, add it to the
right list.
./Scripts/build-app.sh # build, ad-hoc sign, install to ~/Applications
DEST=/Applications ./Scripts/build-app.sh # install elsewhereThere is no Xcode project. build-app.sh compiles with swiftc and assembles
the bundle by hand, the way notarized indie apps outside the App Store are
commonly built. This mirrors rawbridge/app/Scripts/build-app.sh.
SwiftUI's @State / @StateObject / @main are macros, and the macro
plugin (SwiftUIMacros) ships only inside full Xcode. The bare Command Line
Tools cannot expand them — you get a wall of "macro plugin not found" errors.
rawbridge's build script tells you to fix this with sudo xcode-select -s.
This one doesn't need sudo: if the active developer directory isn't full
Xcode, it looks for /Applications/Xcode.app or /Applications/Xcode-*.app
and sets DEVELOPER_DIR for that build only, leaving your system-wide
toolchain selection untouched.
# override explicitly if you have several Xcodes
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer ./Scripts/build-app.shIf no full Xcode is found the script fails early with guidance rather than a cryptic compiler error.
| Path | What |
|---|---|
~/Library/Application Support/ScriptRunner/scripts.json |
The script list + last-run bookkeeping |
~/Library/Logs/script-runner.log |
Appended output of every run |
~/Library/LaunchAgents/com.xxmeiwei.script-runner.plist |
Only exists while "run at login" is ON |
{
"scripts": [
{
"id": "5B1F…",
"name": "hello.sh",
"path": "/Users/you/bin/hello.sh",
"enabled": true,
"lastRun": 776000000.0,
"lastStatus": 0,
"lastDurationMs": 41
}
]
}lastRun is a Date encoded the way JSONEncoder does by default — seconds
since the 2001 reference date, not the Unix epoch. (807549297 is
2026-08-04, not 1995.) Hand-editing the file is fine — it's plain JSON and the
decoder accepts any key order or indentation; the app reloads it whenever its
window becomes active.
If the file exists but can't be parsed, reload() deliberately does not
reset to an empty list — that would let the next toggle save an empty config
straight over your scripts. Instead the unreadable file is renamed to
scripts.corrupt-<unixtime>.json, a warning goes to the log, and whatever was
already in memory is kept:
warning: scripts.json unreadable — moved to scripts.corrupt-1785856538.json
Recover by fixing the JSON in that file and renaming it back to
scripts.json.
The headless binary is useful on its own:
RUN="$HOME/Applications/Script Runner.app/Contents/MacOS/scriptrunner-run"
"$RUN" --list # show configured scripts and last exit status
"$RUN" --login # run every enabled script (what the LaunchAgent calls)
"$RUN" hello.sh # run one script by name or path
"$RUN" --helpExit codes: the script's own status when running one by name, 127 if the file
is missing, 126 if it couldn't be launched, 2 if no configured script
matches the name.
The toggle in the LOGIN section writes
~/Library/LaunchAgents/com.xxmeiwei.script-runner.plist with RunAtLoad, then
launchctl bootstraps it. Turning it off does a bootout and deletes the
plist — nothing is left behind.
It appears in System Settings › General › Login Items under "Allow in the Background" (not the top "Open at Login" list, which is for apps that open a window). You can disable it there too; the app's toggle reflects whether the plist exists, so re-check it after changing things outside the app.
To inspect it directly:
launchctl print gui/$(id -u)/com.xxmeiwei.script-runner
launchctl kickstart gui/$(id -u)/com.xxmeiwei.script-runner # fire it now
tail -f ~/Library/Logs/script-runner.logClick the lock icon on a row to mark it. Marked scripts show a ROOT badge
and behave differently in the two contexts:
| Context | Behaviour |
|---|---|
| Run now (the ▶ button) | Runs as root behind the standard macOS authentication dialog |
| At login | Skipped, with a line in the log explaining why |
The login agent is a LaunchAgent — it runs as you, in your session, with no way to elevate. It is also headless, so an authentication dialog would either never appear or hang the job indefinitely. Skipping is the only correct behaviour; the log says so explicitly rather than failing quietly:
skipped disable-audio-ducking.sh: marked as needing root —
run it from the app, or install it as a LaunchDaemon
ScriptStore.executeElevated shells out to AppleScript's
do shell script … with administrator privileges. That's the only elevation
route available to an ad-hoc signed app — the proper alternative (SMJobBless
plus a privileged helper tool) needs a Developer ID and matching code-signing
requirements.
Two details worth knowing if you touch that code:
do shell scriptthrows on a non-zero exit, which would lose the status. The generated wrapper therefore ends withecho "__EXIT:$?"so the command always succeeds, and the real status is parsed back out of the output.- Quoting a path through AppleScript and the shell is a minefield, so the command is staged into a temporary wrapper script and only that generated path crosses the AppleScript boundary. The wrapper is deleted afterwards.
Cancelling the dialog is recorded as exit 125; the script does not run.
If something must run as root without anyone present — at boot, every boot —
Script Runner is the wrong tool. Install a LaunchDaemon in
/Library/LaunchDaemons/, which launchd runs as root at boot:
sudo install -o root -g wheel -m 755 yourscript.sh /usr/local/sbin/
# then a plist in /Library/LaunchDaemons with RunAtLoadIf you instead reach for a NOPASSWD sudoers rule, the target must be
root-owned and not writable by you (hence /usr/local/sbin, not ~/bin).
A NOPASSWD rule pointing into your home directory is a trivial privilege
escalation: anything running as you can rewrite the file and get root without
authenticating.
defaults write <domain> … writes to the invoking user's preference file —
~/Library/Preferences/ as you, /var/root/Library/Preferences/ as root. The
same script run at two privilege levels writes to two different files, which is
a common reason a "fix" appears not to stick. Write to an explicit path when the
setting is system-wide:
defaults write /Library/Preferences/com.apple.audio.coreaudiod.plist Ducking -bool falseThese are the reasons a script that "works fine in Terminal" fails at login. Both are handled, but it's worth knowing why.
LaunchAgents inherit a minimal PATH — /usr/bin:/bin:/usr/sbin:/sbin. No
Homebrew. A script calling brew, jq, python3 or anything else under
/opt/homebrew/bin dies with "command not found" at login while working
perfectly when you run it yourself.
ScriptStore.execute prepends /opt/homebrew/bin, /opt/homebrew/sbin,
/usr/local/bin and /usr/local/sbin before running anything.
macOS protects ~/Documents, ~/Desktop, ~/Downloads and iCloud Drive. A
process may read them only if its own TCC identity has been granted access.
This is launch-path dependent, which makes it genuinely confusing to debug:
- Run a script from Terminal → inherits Terminal's grant → works
- Run the same script from a LaunchAgent or app bundle → its own identity, no
grant → denied,
Operation not permitted, exit 126
So a script in ~/Documents/scripts/ will pass every test you do by hand and
then fail silently at every login.
Scripts in a protected location get a ⚠︎ next to their name in the UI. The fix
is to move the script somewhere unprotected — ~/bin, ~/Library/Application Support/… — or grant the app Full Disk Access in System Settings › Privacy &
Security.
build-app.sh signs with codesign --sign - (ad-hoc). That's fine for personal
use, with one consequence worth knowing: the signature changes on every
rebuild, so any TCC grant you give the app (Full Disk Access, etc.) is
attached to the old signature and has to be re-granted after a rebuild. Same
caveat rawbridge's dev builds carry.
For a stable identity, sign with a Developer ID:
codesign --force --deep --options runtime --timestamp \
--sign "Developer ID Application: Your Name (TEAMID)" \
"$HOME/Applications/Script Runner.app"# 1. turn the login toggle OFF in the app first (removes the LaunchAgent), or:
launchctl bootout gui/$(id -u)/com.xxmeiwei.script-runner 2>/dev/null
rm -f ~/Library/LaunchAgents/com.xxmeiwei.script-runner.plist
# 2. the app, its config and its log
rm -rf ~/Applications/"Script Runner.app"
rm -rf ~/Library/"Application Support"/ScriptRunner
rm -f ~/Library/Logs/script-runner.logYour own scripts are never touched — removing an entry with the trash button takes it out of the list, it does not delete the file.