Skip to content
Blog
macOS·4 min

A 26,000-line Swift app with no Xcode: what bare `swiftc` taught us

Our menu-bar assistant daemon is a Swift/AppKit app that grew to ~26K lines - and it has never opened Xcode. It's compiled by a shell script calling swiftc, signed with a self-made cert, deployed by launchctl. That combination is barely documented, so here is what actually matters when you build this way.

The build is the typecheck

With one big file, swiftc either compiles everything or tells you precisely why not - and it exits before your deploy script touches the running daemon. That turns the compile itself into the safety gate: a failed build leaves the old daemon running, untouched. We leaned into that and dropped the separate typecheck round entirely for iteration builds, saving a minute per cycle. The general lesson: when a pipeline step fails atomically-before-side-effects, you don't need a rehearsal of it.

-O is not a free upgrade - it's a machine-eating one

The single most important flag decision: -Onone for iteration, -O almost never.

Swift's -O on a large module means whole-module optimization: the optimizer holds the entire module in memory at once. On our 26K-line file, swift-frontend spikes to multiple gigabytes of RAM at 100% CPU for ~45-65 seconds - enough to make the whole machine crawl if it's also someone's live workstation. -Onone compiles the same file in ~12-30 seconds at a fraction of the memory.

For an event-driven daemon that idles between user interactions, -Onone runtime performance is indistinguishable. We now build -O only for genuinely hot-path performance changes. And note: splitting the module into more files does not reduce the -O cost - WMO optimizes all files together regardless of file count. File-splitting buys you faster incremental/debug builds only.

Multi-file quirk: someone must be main.swift

A single-file program may have top-level statements anywhere. The moment you compile multiple files together, Swift demands the file containing top-level code be named exactly main.swift. If your project grew from one file (ours did), your build script ends up doing a temp rename before invoking swiftc file1.swift file2.swift .... Silly, mechanical, and it will absolutely eat an afternoon if you don't know.

The lazy-global landmine when you split files

Splitting code out of the main file changes semantics, not just organization: globals in non-main files are initialized lazily, on first access (main-file globals initialize at startup). Any global whose initializer is time-anchored or side-effectful - let processStartDate = Date(), a log-file creation - now runs at some arbitrary later moment, silently. The fix is a one-line force-touch at startup for each such global:

_ = processStartDate   // force init at launch, preserve original semantics

Audit every global you move. The failure is invisible until a duration calculation is subtly wrong.

Swift compile errors worth pre-knowing

Three that repeatedly cost us build cycles, all legal-looking code:

  1. ?? binds looser than comparison. a ?? 0 > 0 parses as a ?? (0 > 0) - an Int?-vs-Bool type error pages away from the real intent. Parenthesize: (a ?? 0) > 0.
  2. A single-expression closure returning Void?. A () -> Void closure whose only line is self?.foo() infers Void? and produces a cryptic "cannot resolve member without contextual type" - make the body multi-statement (guard let self else { return }; foo()).
  3. Read the first error, not the last. With a file this size, one genuine error cascades into dozens of phantom ones below it. grep error: and start at the top.

Signing and deploying without Xcode

swiftc output + codesign with a self-signed cert + a fixed bundle identifier gives you a stable identity that keeps TCC permissions (Accessibility etc.) across rebuilds - covered in depth in a companion post. Deployment is launchctl bootout + bootstrap of a LaunchAgent pointing at the new binary. One operational scar worth repeating: never pipe your rebuild script into head or grep - SIGPIPE can kill it between bootout and bootstrap, leaving no daemon at all. Redirect to a file.

Takeaways

  • No-Xcode Swift is entirely viable; the build script is ~100 lines and you understand every one.
  • -Onone by default; -O is a deliberate, rare event - and file-splitting doesn't make it cheaper.
  • Multi-file = main.swift rename + lazy-global force-touch audit.
  • The compile is your typecheck; let its atomicity work for you, and read compile errors top-down.
We use cookies
We use analytics to understand how visitors use our site. No data is sold or shared.