Packages an already-built executable into a real macOS AppName.app
bundle — the same shape Xcode produces, built by hand instead. Point it
at any compiled binary (Spinel or otherwise) and it gives you the
directory tree, Info.plist, optional icon, optional Terminal-hosted
mode for text CLIs, and optional codesigning that Finder, Spotlight,
and the Dock all expect from a real app.
Built with, and requires, Spinel
(spin). See DEVELOPMENT_PLAN.md for the full design record.
spin buildCompiles both bin/*.rb targets into build/bin/: app_bundler
itself, and terminal_launcher, a small stub binary --terminal mode
copies into bundles it creates (see Terminal-hosted mode
below for why that's a separate compiled binary rather than a written-out
shell script). Keep both together — spin install (which copies
bin/* to ~/.local/bin) does this for you automatically.
app_bundler --executable PATH --name NAME --identifier ID [options]| flag | required | meaning |
|---|---|---|
--executable=PATH |
yes | the built executable to wrap |
--name=NAME |
yes | app display name; produces NAME.app |
--identifier=ID |
yes | reverse-DNS CFBundleIdentifier, e.g. com.you.app |
--output=DIR |
directory to write NAME.app into (default: .) |
|
--version=VERSION |
CFBundleVersion / CFBundleShortVersionString (default: 1.0.0) |
|
--icon=PATH |
an .icns file, a .iconset directory, or a source image (PNG etc.) |
|
--terminal |
wrap a text-mode CLI so it opens in Terminal.app instead of running headless | |
--sign=IDENTITY |
codesign the bundle; - for ad-hoc |
|
--copyright=TEXT |
NSHumanReadableCopyright |
|
--min-os-version=VER |
LSMinimumSystemVersion (default: this machine's major OS version) |
|
--force |
overwrite an existing NAME.app |
|
-h, --help |
show usage |
sdl_ui is a sibling project in this workspace with several
SDL-based GUI demo binaries under bin/*_demo.rb. Bundling one of its
built demos end to end:
cd ../sdl_ui
spin build widgets_demo
cd ../app_bundler
./build/bin/app_bundler \
--executable=../sdl_ui/build/bin/widgets_demo \
--name="Widgets Demo" \
--identifier=com.example.widgetsdemo \
--sign=-
open "Widgets Demo.app"This produces Widgets Demo.app with a real Info.plist
(NSHighResolutionCapable set, so it renders at full sharpness on
Retina displays), an ad-hoc code signature, and CFBundleExecutable
pointing straight at widgets_demo — it's already a GUI app, so no
Terminal shim is needed. open launches it windowed, with a Dock icon,
and no flashing terminal — confirmed via mdls -name kMDItemKind
reporting Application and the process running under its own name, not
as a child of Terminal.
Some executables are text-mode CLIs that expect a real TTY — Finder
gives launched apps none by default. --terminal handles this by
pointing CFBundleExecutable at a small stub (terminal_launcher,
built alongside app_bundler itself — see Build) instead of
the real binary. At launch, the stub resolves its own bundle-relative
location, then asks Terminal.app to run the real binary (shipped
alongside it in Contents/MacOS/ under its own name) via AppleScript's
do script.
Why a compiled stub and not a written-out #!/bin/sh shim (the
obvious-looking alternative): confirmed by hand that it doesn't work.
A bundle whose CFBundleExecutable is a plain shell script fails to
launch via open/Finder outright — even ad-hoc codesigned — while the
identical bundle with a real Mach-O executable in that slot launches
fine. LaunchServices' launch validation requires a genuine executable
format there, not a script. terminal_launcher sidesteps this by being
itself a tiny compiled Spinel program, the same trick tools like
Platypus use for their own stub.
First launch prompts for permission. The first time a newly built
--terminal app tries to control Terminal.app, macOS shows a one-time
"App wants to control Terminal" Automation permission dialog — this
is standard OS privacy behavior for any app that scripts another app,
not something app_bundler can (or should) suppress. Click Allow once;
subsequent launches are silent.
--sign=IDENTITY runs codesign --force --deep --sign IDENTITY on the
finished bundle. --sign=- (a literal hyphen) requests ad-hoc
signing — enough to clear Gatekeeper's harshest local-run block, and
what every example above uses. It is not a substitute for a real
Developer ID signature: ad-hoc-signed bundles won't survive
redistribution or notarization. Omit --sign entirely (the default) for
local use that doesn't need it.
--icon accepts three input shapes:
- An
.icnsfile — copied straight intoContents/Resources/. - An Apple
.iconsetdirectory (the multi-resolution PNG folder convention) — converted viaiconutil -c icns. - A single source image (PNG etc.) — resized into a full iconset via
sipsat each of the 10 required sizes/scales, then converted the same way.
Omit --icon and Finder shows the generic app icon; no placeholder is
shipped by default.
spin testtest/bundle_basic.rb exercises the bundle-creation path directly
(skipping ARGV/OptionParser): directory tree, binary copy +
permissions, Info.plist contents, --force overwrite behavior, and
the missing-executable error path — against a throwaway fixture
executable (the tool doesn't care what's inside it, so a placeholder
file is enough for this kind of test).
Icon conversion (iconutil/sips), codesigning, and the Terminal
launcher were verified by hand against real inputs rather than in the
snapshot suite, since they shell out to macOS system tools and, in the
Terminal case, involve real GUI/Automation-permission state that isn't
practical to assert against in a headless test run.