Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Script Runner

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

Why two binaries

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.


Layout

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.


Building

./Scripts/build-app.sh                 # build, ad-hoc sign, install to ~/Applications
DEST=/Applications ./Scripts/build-app.sh    # install elsewhere

There 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.

The Xcode requirement

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.sh

If no full Xcode is found the script fails early with guidance rather than a cryptic compiler error.


Files at runtime

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.json

{
  "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.


CLI

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" --help

Exit 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.


Run at login

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.log

Running a script as root

Click 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

How the elevation works

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 script throws on a non-zero exit, which would lose the status. The generated wrapper therefore ends with echo "__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.

For unattended root, use a LaunchDaemon instead

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 RunAtLoad

If 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.

A defaults write gotcha

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 false

Two things that will bite you

These are the reasons a script that "works fine in Terminal" fails at login. Both are handled, but it's worth knowing why.

1. PATH

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.

2. TCC (the important one)

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.


Ad-hoc signing

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"

Uninstall

# 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.log

Your own scripts are never touched — removing an entry with the trash button takes it out of the list, it does not delete the file.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages