This guide shows how to run a SwiftUI macOS demo app without an Xcode project. The app is built with Swift Package Manager (SPM), then bundled into a .app package and launched using open, just like a normal macOS application.
Below is a practical walkthrough based on a working setup.
Why skip Xcode projects?
Keeping a demo app alongside a Swift package can be awkward when Xcode workspaces conflict with local dependencies. Using SPM + a thin packaging script keeps the repo clean and avoids Xcode's project-level quirks.
Folder layout
Examples/MyDemoApp/
Package.swift
Sources/MyDemoApp/MyDemoApp.swift
scripts/
compile_and_run.sh
package_app.shThis is a normal SwiftPM executable target that imports the local package using .package(path: "../..").
Step 1: Build the SwiftPM app
SPM already builds a binary for you:
cd Examples/MyDemoApp
swift build -c debugThe compiled binary lands at:
.build/debug/MyDemoAppThat binary is not enough to show a normal macOS app window when launched via open. For a proper app (dock icon, lifecycle, window scene), you need a .app bundle.
Step 2: Package a .app bundle (no Xcode)
The script scripts/package_app.sh does three things:
- Runs
swift build. - Creates a
.appbundle structure:MyDemoApp.app/Contents/MacOS/MyDemoAppMyDemoApp.app/Contents/Info.plist
- Copies the compiled binary into the bundle and optionally signs it (adhoc).
This is enough for macOS to treat it like a normal app.
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
APP_NAME="MyDemoApp"
BUNDLE_ID="com.actondon.mydemoapp"
BUILD_MODE="${1:-debug}"
if [[ "${BUILD_MODE}" != "debug" && "${BUILD_MODE}" != "release" ]]; then
echo "Usage: $(basename "$0") [debug|release]" >&2
exit 1
fi
cd "${ROOT_DIR}"
export CLANG_MODULE_CACHE_PATH="${TMPDIR:-/tmp}/swiftpm-module-cache"
export XDG_CACHE_HOME="${TMPDIR:-/tmp}/swiftpm-cache"
export SWIFTPM_DISABLE_SANDBOX=1
swift build -c "${BUILD_MODE}"
BUILD_DIR="${ROOT_DIR}/.build/${BUILD_MODE}"
PRODUCT_PATH="${BUILD_DIR}/${APP_NAME}"
APP_BUNDLE="${ROOT_DIR}/${APP_NAME}.app"
CONTENTS_DIR="${APP_BUNDLE}/Contents"
MACOS_DIR="${CONTENTS_DIR}/MacOS"
if [[ ! -f "${PRODUCT_PATH}" ]]; then
echo "ERROR: built product not found at ${PRODUCT_PATH}" >&2
exit 1
fi
rm -rf "${APP_BUNDLE}"
mkdir -p "${MACOS_DIR}" "${CONTENTS_DIR}/Resources"
cp "${PRODUCT_PATH}" "${MACOS_DIR}/${APP_NAME}"
chmod +x "${MACOS_DIR}/${APP_NAME}"
cat > "${CONTENTS_DIR}/Info.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>${APP_NAME}</string>
<key>CFBundleDisplayName</key>
<string>${APP_NAME}</string>
<key>CFBundleIdentifier</key>
<string>${BUNDLE_ID}</string>
<key>CFBundleExecutable</key>
<string>${APP_NAME}</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>13.0</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<key>NSHighResolutionCapable</key>
<true/>
</dict>
</plist>
PLIST
if command -v codesign >/dev/null 2>&1; then
codesign --force --deep --sign - "${APP_BUNDLE}" >/dev/null 2>&1 || true
fi
printf '%s\n' "${APP_BUNDLE}"Step 3: Launch the app
Use the wrapper script:
Examples/MyDemoApp/scripts/compile_and_run.shIt:
- Kills old instances.
- Packages the app.
- Launches via
open MyDemoApp.app.
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
APP_NAME="MyDemoApp"
APP_BUNDLE="${ROOT_DIR}/${APP_NAME}.app"
APP_PROCESS_PATTERN="${APP_NAME}.app/Contents/MacOS/${APP_NAME}"
DEBUG_PROCESS_PATTERN="${ROOT_DIR}/.build/debug/${APP_NAME}"
RELEASE_PROCESS_PATTERN="${ROOT_DIR}/.build/release/${APP_NAME}"
BUILD_MODE="debug"
log() { printf '%s\n' "$*"; }
for arg in "$@"; do
case "${arg}" in
--release) BUILD_MODE="release" ;;
--debug) BUILD_MODE="debug" ;;
--help|-h)
log "Usage: $(basename "$0") [--debug|--release]"
exit 0
;;
*)
;;
esac
done
log "==> Killing existing ${APP_NAME} instances"
pkill -f "${APP_PROCESS_PATTERN}" 2>/dev/null || true
pkill -f "${DEBUG_PROCESS_PATTERN}" 2>/dev/null || true
pkill -f "${RELEASE_PROCESS_PATTERN}" 2>/dev/null || true
pkill -x "${APP_NAME}" 2>/dev/null || true
log "==> Packaging ${APP_NAME} (${BUILD_MODE})"
"${ROOT_DIR}/scripts/package_app.sh" "${BUILD_MODE}" >/dev/null
log "==> Launching ${APP_NAME}"
if ! open "${APP_BUNDLE}"; then
log "WARN: open failed; falling back to direct launch."
"${APP_BUNDLE}/Contents/MacOS/${APP_NAME}" >/dev/null 2>&1 &
disown
fi
for _ in {1..12}; do
if pgrep -f "${APP_PROCESS_PATTERN}" >/dev/null 2>&1; then
log "OK: ${APP_NAME} is running."
exit 0
fi
sleep 0.25
done
log "WARN: ${APP_NAME} did not appear to stay running. Check Console.app for crash logs."
exit 1Handling SwiftPM sandbox/cache issues
If SwiftPM fails with sandbox or cache permissions, you can set these environment variables (already done in package_app.sh):
export CLANG_MODULE_CACHE_PATH="${TMPDIR:-/tmp}/swiftpm-module-cache"
export XDG_CACHE_HOME="${TMPDIR:-/tmp}/swiftpm-cache"
export SWIFTPM_DISABLE_SANDBOX=1This forces module caches into writable temp locations and disables the SwiftPM sandbox if it blocks manifest compilation.
Customizing the app bundle
You can extend package_app.sh to include:
- App icon (
CFBundleIconFile+.icnsin Resources) - Entitlements
- Proper code signing (Developer ID)
- Version metadata
The existing script is intentionally minimal to keep the demo lightweight.
Summary
You can ship a SwiftUI macOS demo without Xcode by:
- Building with SwiftPM.
- Packaging a
.appbundle via a script. - Launching with
open.
This gives you a clean, reproducible demo workflow directly in the repo.