Blog

Running a SwiftUI macOS App Without an Xcode Project

Published on Feb 7, 2026

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.sh

This 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 debug

The compiled binary lands at:

.build/debug/MyDemoApp

That 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:

  1. Runs swift build.
  2. Creates a .app bundle structure:
    • MyDemoApp.app/Contents/MacOS/MyDemoApp
    • MyDemoApp.app/Contents/Info.plist
  3. 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.sh

It:

  • 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 1

Handling 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=1

This 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 + .icns in 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:

  1. Building with SwiftPM.
  2. Packaging a .app bundle via a script.
  3. Launching with open.

This gives you a clean, reproducible demo workflow directly in the repo.