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:
??binds looser than comparison.a ?? 0 > 0parses asa ?? (0 > 0)- anInt?-vs-Booltype error pages away from the real intent. Parenthesize:(a ?? 0) > 0.- A single-expression closure returning
Void?. A() -> Voidclosure whose only line isself?.foo()infersVoid?and produces a cryptic "cannot resolve member without contextual type" - make the body multi-statement (guard let self else { return }; foo()). - 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.
-Ononeby default;-Ois a deliberate, rare event - and file-splitting doesn't make it cheaper.- Multi-file =
main.swiftrename + lazy-global force-touch audit. - The compile is your typecheck; let its atomicity work for you, and read compile errors top-down.
Related posts
Ad-hoc code signing silently orphans your macOS TCC permissions
The root cause is how TCC (Transparency, Consent, and Control) decides whether the thing asking for access is "the same program" you approved last time.
launchd traps: WatchPaths ignores `touch`, and your keychain is locked over SSH
The failures below all come from running a real automation stack on launchd for months.
NSWindow released me twice: a crash from 1998 and the window-level ladder nobody documents
Two AppKit lessons from building a menu-bar assistant daemon that puts overlays, toasts, and cards on screen all day.