SwiftPM App Bundle
Overview
Turns a SwiftPM .executableTarget into a real macOS .app bundle plus an
unsigned .dmg, with no Xcode project involved. This is the common path for
small AppKit/SwiftUI utilities (menu bar apps, single-window tools) that are
built with swift build and never had an .xcodeproj to begin with.
Prerequisites
- macOS 12+ with Xcode Command Line Tools (
swift, codesign, xcrun).
hdiutil (bundled with macOS) for DMG creation.
- A
Package.swift with at least one .executable product.
When to use this skill
- The repo has a
Package.swift with an .executable product but no .app
bundle, no .xcodeproj/.xcworkspace, and the user wants something they
(or testers) can double-click, or a .dmg to hand out.
- The user mentions: "package .app", "make a DMG", "SwiftPM executable to app
bundle", "menu bar app distribution", "ship this as a Mac app".
What it does NOT do
- Does not sign or notarize anything (see
references/signing-notarization.md
for that — it requires a paid Apple Developer account).
- Does not create an Xcode project. If the user actually wants a proper
Xcode-managed app target, that's a different task — ask before assuming.
Usage
From the repo root (the directory containing Package.swift):
scripts/package_app.sh
With no flags it will:
- Auto-detect the app name from the first
.executable(name: "...") product
in Package.swift.
swift build -c release that product.
- Assemble
dist/<App>-<version>-unsigned-<timestamp>/<App>.app.
- Stage a DMG with the
.app plus an /Applications symlink and build
<App>-<version>-unsigned.dmg in the same output directory.
Common flags (scripts/package_app.sh --help for the full list):
| Flag |
Purpose |
--app-name NAME |
Override auto-detection |
--bundle-id ID |
Default is com.example.<lowercased-app-name> — always override this for a real release |
--version STRING |
Default 0.1.0 |
--min-os STRING |
LSMinimumSystemVersion, default 13.0 |
--menu-bar |
Sets LSUIElement=true (no Dock icon / no app switcher entry) |
--icon PATH |
Embed a .icns; auto-picked up from assets/<AppName>.icns if present |
--skip-dmg |
Build just the .app, skip DMG staging |
Both flags and env vars work (APP_NAME=, BUNDLE_ID=, VERSION=, etc. —
see the script's --help output for the full mapping).
Input rules
Every value that ends up in a path or in Info.plist is validated before the
script touches the filesystem, and the run stops with a clear error: line
if any of them fails:
| Value |
Accepted |
--app-name, --product |
1–255 chars of letters, digits, space, ., _, -; starts with a letter or digit; no trailing space or .; never . or ..; no /, \, : or control characters |
--bundle-id |
reverse-DNS: two or more dot-separated labels of letters, digits and - (com.example.myapp) |
--version, --min-os |
1–3 dot-separated numbers (1.2.3, 13.0) |
--icon |
must be an existing regular file |
Values are then XML-escaped when written into the plist (no sed/awk
interpolation), and the rendered plist is checked with plutil -lint when
plutil is available. If a user wants a display name outside these rules
(non-ASCII, punctuation), keep the bundle name ASCII and localize
CFBundleDisplayName via InfoPlist.strings instead of loosening the check.
Examples
# Menu bar app with a real bundle id and an icon
scripts/package_app.sh --menu-bar --bundle-id com.example.tokenscope \
--version 0.2.0 --icon assets/TokenScope.icns
# Just the .app, no DMG, for quick local testing
scripts/package_app.sh --skip-dmg
Output
A timestamped dist/<App>-<version>-unsigned-<timestamp>/ directory containing
the assembled <App>.app bundle and (unless --skip-dmg) a compressed
<App>-<version>-unsigned.dmg staged with the standard drag-to-Applications
layout.
Detecting the app name from Package.swift
The script greps for the first .executable(name: "X" occurrence. When
briefing a user or writing this by hand, the same rule applies: open
Package.swift, find the products: [ .executable(name: "X", ...) ] entry.
If there are multiple executable products, ask the user which one they mean,
or pass --app-name / --product explicitly (--product matters when the
Swift product name differs from the desired display/bundle name).
When to set LSUIElement (menu-bar-only apps)
Set --menu-bar (→ LSUIElement=true) when the app:
- Lives in the menu bar via
NSStatusItem / MenuBarExtra and has no main
window the user is meant to switch to via Cmd-Tab, or
- Should not show a Dock icon or appear in the Cmd-Tab app switcher.
Leave it unset (LSUIElement=false, the default) for a normal windowed app
that should appear in the Dock and app switcher as usual. Check the source for
NSStatusBar.system.statusItem or MenuBarExtra in SwiftUI as a strong signal
the app is menu-bar-only.
DMG staging layout
The script stages the DMG contents in a scratch dmg/ folder before calling
hdiutil create -format UDZO:
dmg/
├── YourApp.app (copy of the built bundle)
└── Applications -> /Applications (symlink)
This is the standard "drag YourApp.app onto Applications" layout users expect
from a Mac installer DMG. Don't skip the symlink — without it, users have to
manually drag the app to /Applications via Finder navigation instead of a
single drag within the mounted window.
Failure modes
- Missing
NSHighResolutionCapable — omitting this key (or leaving it
false) makes the app render blurry on Retina displays. The template
always sets it true; don't remove it when hand-editing a plist.
- Forgetting
chmod 755 on the binary — cp preserves source
permissions, which can be 644 from some build/copy pipelines, silently
producing an app that Finder shows a "no entry" cursor for on launch. The
script always does chmod 755 on the copied executable; verify this if the
bundling steps are done manually instead of via the script.
- Cached icon not updating on rebuild — macOS's icon cache can keep
showing an old icon for an app bundle after replacing its
.icns or
Info.plist, especially when the app path is reused (same dist folder,
overwritten in place). The script mitigates this by giving each build its
own timestamped output directory and by running xattr -cr + touch on
the finished bundle. If a stale icon still appears after installing a new
build, tell the user to either (a) move/rename the .app once, or (b) run
killall Finder and/or qlmanage -r cache and relaunch.
CFBundleIdentifier left as the placeholder default — the script
defaults to com.example.<name>, which is fine for local testing but
should always be overridden with a real reverse-DNS identifier before
distributing to anyone else (needed later for signing/notarization too).
- Building the wrong product — if
Package.swift defines multiple
executables, --app-name alone builds a product of that same name; use
--product when the binary/product name differs from the desired app
display name.
- Running from the wrong directory — the script requires
Package.swift
at the repo root it's given (--repo-root, defaults to cwd). If it errors
with "no Package.swift found", cd to the repo root or pass --repo-root.
- A build or
hdiutil step fails partway — the script exits non-zero,
deletes the DMG staging folder, and removes the partially assembled
timestamped output directory so no half-built .app is left looking
distributable. Fix the cause and re-run; nothing needs cleaning by hand.
- Distributing the unsigned DMG and getting "damaged" complaints — this
is expected Gatekeeper behavior for unsigned software, not a packaging bug.
Point users to
references/signing-notarization.md for both the
right-click-to-open workaround and the real signing/notarization path.
Resources
scripts/package_app.sh — the packaging script described above.
assets/Info.plist.template — parameterized plist consumed by the script
(placeholders: __APP_NAME__, __BUNDLE_ID__, __VERSION__,
__MIN_MACOS_VERSION__, __LSUIELEMENT_BOOL__, and an @@ICON_BLOCK@@
marker line that expands to the CFBundleIconFile key/value pair or is
dropped entirely when no icon is supplied). Placeholders are filled with
XML-escaped, pre-validated values.
tests/ (repo root) — behavioral tests that run this script against
stubbed swift/hdiutil/plutil and check rejected inputs, bundle
layout, cleanup, and failure propagation.
references/signing-notarization.md — what unsigned distribution means for
end users, and the full codesign/notarytool/stapler upgrade path.
1---2name: swiftpm-app-bundle3description: Package a Swift Package Manager (SwiftPM) executable target — an AppKit or SwiftUI app built without an Xcode project — into a distributable macOS .app bundle and an unsigned .dmg. Use when the user wants to package .app, build a DMG, turn a SwiftPM executable to app bundle, prepare a menu bar app distribution, ship a Swift command-line/GUI target as a double-clickable Mac app, or asks about Info.plist / .icns / LSUIElement / codesign / notarization for a package-based (no .xcodeproj) macOS app. Trigger with "/swiftpm-app-bundle".4license: MIT5---67# SwiftPM App Bundle89## Overview1011Turns a SwiftPM `.executableTarget` into a real macOS `.app` bundle plus an12unsigned `.dmg`, with no Xcode project involved. This is the common path for13small AppKit/SwiftUI utilities (menu bar apps, single-window tools) that are14built with `swift build` and never had an `.xcodeproj` to begin with.1516## Prerequisites1718- macOS 12+ with Xcode Command Line Tools (`swift`, `codesign`, `xcrun`).19- `hdiutil` (bundled with macOS) for DMG creation.20- A `Package.swift` with at least one `.executable` product.2122## When to use this skill2324- The repo has a `Package.swift` with an `.executable` product but no `.app`25 bundle, no `.xcodeproj`/`.xcworkspace`, and the user wants something they26 (or testers) can double-click, or a `.dmg` to hand out.27- The user mentions: "package .app", "make a DMG", "SwiftPM executable to app28 bundle", "menu bar app distribution", "ship this as a Mac app".2930## What it does NOT do3132- Does not sign or notarize anything (see `references/signing-notarization.md`33 for that — it requires a paid Apple Developer account).34- Does not create an Xcode project. If the user actually wants a proper35 Xcode-managed app target, that's a different task — ask before assuming.3637## Usage3839From the repo root (the directory containing `Package.swift`):4041```bash42scripts/package_app.sh43```4445With no flags it will:461. Auto-detect the app name from the first `.executable(name: "...")` product47 in `Package.swift`.482. `swift build -c release` that product.493. Assemble `dist/<App>-<version>-unsigned-<timestamp>/<App>.app`.504. Stage a DMG with the `.app` plus an `/Applications` symlink and build51 `<App>-<version>-unsigned.dmg` in the same output directory.5253Common flags (`scripts/package_app.sh --help` for the full list):5455| Flag | Purpose |56|---|---|57| `--app-name NAME` | Override auto-detection |58| `--bundle-id ID` | Default is `com.example.<lowercased-app-name>` — always override this for a real release |59| `--version STRING` | Default `0.1.0` |60| `--min-os STRING` | `LSMinimumSystemVersion`, default `13.0` |61| `--menu-bar` | Sets `LSUIElement=true` (no Dock icon / no app switcher entry) |62| `--icon PATH` | Embed a `.icns`; auto-picked up from `assets/<AppName>.icns` if present |63| `--skip-dmg` | Build just the `.app`, skip DMG staging |6465Both flags and env vars work (`APP_NAME=`, `BUNDLE_ID=`, `VERSION=`, etc. —66see the script's `--help` output for the full mapping).6768### Input rules6970Every value that ends up in a path or in `Info.plist` is validated before the71script touches the filesystem, and the run stops with a clear `error:` line72if any of them fails:7374| Value | Accepted |75|---|---|76| `--app-name`, `--product` | 1–255 chars of letters, digits, space, `.`, `_`, `-`; starts with a letter or digit; no trailing space or `.`; never `.` or `..`; no `/`, `\`, `:` or control characters |77| `--bundle-id` | reverse-DNS: two or more dot-separated labels of letters, digits and `-` (`com.example.myapp`) |78| `--version`, `--min-os` | 1–3 dot-separated numbers (`1.2.3`, `13.0`) |79| `--icon` | must be an existing regular file |8081Values are then XML-escaped when written into the plist (no `sed`/`awk`82interpolation), and the rendered plist is checked with `plutil -lint` when83`plutil` is available. If a user wants a display name outside these rules84(non-ASCII, punctuation), keep the bundle name ASCII and localize85`CFBundleDisplayName` via `InfoPlist.strings` instead of loosening the check.8687## Examples8889```bash90# Menu bar app with a real bundle id and an icon91scripts/package_app.sh --menu-bar --bundle-id com.example.tokenscope \92 --version 0.2.0 --icon assets/TokenScope.icns9394# Just the .app, no DMG, for quick local testing95scripts/package_app.sh --skip-dmg96```9798## Output99100A timestamped `dist/<App>-<version>-unsigned-<timestamp>/` directory containing101the assembled `<App>.app` bundle and (unless `--skip-dmg`) a compressed102`<App>-<version>-unsigned.dmg` staged with the standard drag-to-Applications103layout.104105## Detecting the app name from Package.swift106107The script greps for the first `.executable(name: "X"` occurrence. When108briefing a user or writing this by hand, the same rule applies: open109`Package.swift`, find the `products: [ .executable(name: "X", ...) ]` entry.110If there are multiple executable products, ask the user which one they mean,111or pass `--app-name` / `--product` explicitly (`--product` matters when the112Swift product name differs from the desired display/bundle name).113114## When to set LSUIElement (menu-bar-only apps)115116Set `--menu-bar` (→ `LSUIElement=true`) when the app:117- Lives in the menu bar via `NSStatusItem` / `MenuBarExtra` and has no main118 window the user is meant to switch to via Cmd-Tab, or119- Should not show a Dock icon or appear in the Cmd-Tab app switcher.120121Leave it unset (`LSUIElement=false`, the default) for a normal windowed app122that should appear in the Dock and app switcher as usual. Check the source for123`NSStatusBar.system.statusItem` or `MenuBarExtra` in SwiftUI as a strong signal124the app is menu-bar-only.125126## DMG staging layout127128The script stages the DMG contents in a scratch `dmg/` folder before calling129`hdiutil create -format UDZO`:130131```132dmg/133├── YourApp.app (copy of the built bundle)134└── Applications -> /Applications (symlink)135```136137This is the standard "drag YourApp.app onto Applications" layout users expect138from a Mac installer DMG. Don't skip the symlink — without it, users have to139manually drag the app to `/Applications` via Finder navigation instead of a140single drag within the mounted window.141142## Failure modes143144- **Missing `NSHighResolutionCapable`** — omitting this key (or leaving it145 `false`) makes the app render blurry on Retina displays. The template146 always sets it `true`; don't remove it when hand-editing a plist.147- **Forgetting `chmod 755` on the binary** — `cp` preserves source148 permissions, which can be `644` from some build/copy pipelines, silently149 producing an app that Finder shows a "no entry" cursor for on launch. The150 script always does `chmod 755` on the copied executable; verify this if the151 bundling steps are done manually instead of via the script.152- **Cached icon not updating on rebuild** — macOS's icon cache can keep153 showing an old icon for an app bundle after replacing its `.icns` or154 `Info.plist`, especially when the app path is reused (same dist folder,155 overwritten in place). The script mitigates this by giving each build its156 own timestamped output directory and by running `xattr -cr` + `touch` on157 the finished bundle. If a stale icon still appears after installing a new158 build, tell the user to either (a) move/rename the `.app` once, or (b) run159 `killall Finder` and/or `qlmanage -r cache` and relaunch.160- **`CFBundleIdentifier` left as the placeholder default** — the script161 defaults to `com.example.<name>`, which is fine for local testing but162 should always be overridden with a real reverse-DNS identifier before163 distributing to anyone else (needed later for signing/notarization too).164- **Building the wrong product** — if `Package.swift` defines multiple165 executables, `--app-name` alone builds a product of that same name; use166 `--product` when the binary/product name differs from the desired app167 display name.168- **Running from the wrong directory** — the script requires `Package.swift`169 at the repo root it's given (`--repo-root`, defaults to cwd). If it errors170 with "no Package.swift found", cd to the repo root or pass `--repo-root`.171- **A build or `hdiutil` step fails partway** — the script exits non-zero,172 deletes the DMG staging folder, and removes the partially assembled173 timestamped output directory so no half-built `.app` is left looking174 distributable. Fix the cause and re-run; nothing needs cleaning by hand.175- **Distributing the unsigned DMG and getting "damaged" complaints** — this176 is expected Gatekeeper behavior for unsigned software, not a packaging bug.177 Point users to `references/signing-notarization.md` for both the178 right-click-to-open workaround and the real signing/notarization path.179180## Resources181182- `scripts/package_app.sh` — the packaging script described above.183- `assets/Info.plist.template` — parameterized plist consumed by the script184 (placeholders: `__APP_NAME__`, `__BUNDLE_ID__`, `__VERSION__`,185 `__MIN_MACOS_VERSION__`, `__LSUIELEMENT_BOOL__`, and an `@@ICON_BLOCK@@`186 marker line that expands to the `CFBundleIconFile` key/value pair or is187 dropped entirely when no icon is supplied). Placeholders are filled with188 XML-escaped, pre-validated values.189- `tests/` (repo root) — behavioral tests that run this script against190 stubbed `swift`/`hdiutil`/`plutil` and check rejected inputs, bundle191 layout, cleanup, and failure propagation.192- `references/signing-notarization.md` — what unsigned distribution means for193 end users, and the full codesign/notarytool/stapler upgrade path.