June 26, 2026

Shipping a 150 KB Mac App With No Xcode Project, No Dependencies, No Electron

How ChargeBeep goes from one main.swift file to a drag-to-Applications .dmg with a single swiftc call and a 60-line bash script — and where code signing actually bites.

  • Swift
  • macOS
  • Distribution
Illustration of an app bundle being packaged into a disk image

The whole app is one file

ChargeBeep is a commercial menu-bar app I sell on Gumroad. It has a settings window, a sound picker, login-item autostart, power management, and a battery readout in the menu bar. It is also a single main.swift file of about 730 lines, with no Xcode project, no .xcodeproj, no Swift Package Manager manifest, and no third-party code.

That’s not minimalism for its own sake — it’s what the app actually needs. A menu-bar utility that talks to IOKit and AppKit doesn’t need a build system with a dependency graph. It needs a compiler. So the “build” is literally:

swiftc -O main.swift -o ChargeBeep

One command, a ~150 KB binary. For comparison, the same app in Electron would ship an entire Chromium runtime — tens of megabytes — to draw a menu-bar icon and play a sound.

Hand-rolling the .app bundle

A macOS app isn’t a binary, it’s a bundle — a directory with a specific layout. You don’t need Xcode to make one; you need mkdir and a plist. The build script assembles it by hand:

ChargeBeep.app/
  Contents/
    MacOS/ChargeBeep          # the swiftc output
    Resources/
      microwave-done.wav      # built-in sound
      AppIcon.icns
      Sounds/                 # extra bundled sounds, auto-discovered at runtime
    Info.plist

The Info.plist is generated inline in the script. Two keys matter most for a menu-bar app:

  • LSUIElement = true — this is what makes the app run with no Dock icon and no menu bar of its own; it lives only as a status item. Without it you’d get a bouncing Dock icon for a utility that has no window most of the time.
  • LSMinimumSystemVersion = 11.0 — sets the floor so the login-item and power APIs are guaranteed present.

The extra sounds are a nice detail: the app enumerates Resources/Sounds/ at runtime and populates the picker from whatever .wav/.aiff/.caf files are there, so adding a sound to the product is “drop a file in the folder and rebuild” — no code change.

Packaging the .dmg

Distribution is a disk image with a shortcut to /Applications, so users get the familiar drag-to-install window:

mkdir -p "$STAGE"
cp -R "ChargeBeep.app" "$STAGE/"
ln -s /Applications "$STAGE/Applications"
hdiutil create -volname "ChargeBeep" -srcfolder "$STAGE" \
    -ov -format UDZO "ChargeBeep.dmg"

The ln -s /Applications is the whole “drag the app onto the Applications folder” convention — it’s just a symlink sitting next to the app inside the image.

Where signing actually bites

This is the part nobody warns you about until Gatekeeper does. The build does an ad-hoc signature:

codesign --force --deep --sign - "ChargeBeep.app"

The - means “sign with no identity” — it satisfies the code must be signed at all requirement on Apple Silicon, but it is not a Developer ID signature and it is not notarized. The practical consequences, which you have to be honest with customers about:

  • On first launch the user must right-click → Open once, because Gatekeeper blocks double-click launch of un-notarized apps. After that first approval it launches normally.
  • For a frictionless “just double-click it” experience you need a paid Apple Developer account, a Developer ID certificate, and to run the app through notarytool for notarization. That’s a real cost-and-process decision, not a code one.

For a low-price indie utility, ad-hoc signing plus a clear “right-click → Open the first time” instruction is a legitimate trade-off — you’re trading a one-time click of friction for skipping the whole Apple Developer Program overhead. Just don’t pretend the friction isn’t there; put the instruction on the download page.

Why do it this way

The temptation with any Mac app is to open Xcode and let it generate a project, a scheme, an asset catalog, and a dependency manifest. For something this size, all of that is scaffolding you now have to maintain around 730 lines of actual program. A main.swift and a bash script means the entire buildable, sellable product is two files a person can read start to finish in an afternoon — and that legibility is worth more than any convenience Xcode was offering.