Building a Full iOS App Stack with CMake: App Clip, Widgets, Notifications & Watch App

For years, Xcode was our go-to for developing iOS apps with advanced features like App Clips, Widgets, Notifications, and Watch Apps. While Xcode works well, maintaining large projects with many targets and extensions becomes increasingly painful over time:

  • Upgrading Xcode projects often drags legacy fields or drops support for target versions.

  • Keeping up with iOS/watchOS versions can cause compatibility headaches.

  • Updating build settings like bundle version or marketing version becomes a repetitive manual task.

We knew we needed a cleaner, more maintainable approach, especially as our app stack grew. So we moved to CMake—and here’s how we built our complete iOS app, including all the extensions, using CMake with precision and control.


🧱 Our App Architecture

We support the following components:

  • Main iOS App

  • App Clip

  • Notification Extension

  • Widget Extension

  • Watch App (watchOS) and its WatchKit Extension

Each of these requires its own target configuration and embedding rules.


📆 Feature Availability Timeline (iOS/watchOS)

Feature Introduced In
App Clip iOS 14
WidgetKit iOS 14, macOS 11
Notification Ext. iOS 10
Watch App (watchOS 2+) watchOS 2 (native), watchOS 6+ (SwiftUI support)

✨ Why CMake?

Xcode can get sluggish and error-prone when your .xcodeproj file bloats with updates. Some common issues we faced:

  • Old Xcode versions targeting deprecated SDKs

  • Unstable field migrations during project upgrades

  • Manual versioning across multiple targets

With CMake, we gained:

  • Scriptable target definitions

  • Repeatable, deterministic builds

  • Better version control on build metadata

  • Faster onboarding for new team members


🔧 Setting Up CMake for Extensions

Widgets & Notifications

Adding Widgets and Notification extensions was straightforward using the following property:

set_target_properties(${PROJECT_NAME} PROPERTIES
XCODE_EMBED_APP_EXTENSIONS "${ALL_APP_EXTENSIONS}"
XCODE_EMBED_APP_EXTENSION_CODE_SIGN_ON_COPY "YES"
)

Just list all your extensions in ALL_APP_EXTENSIONS, and CMake handles embedding and signing.


App Clip

App Clips behave like Widgets—they’re small apps embedded in the main app bundle. Creating the App Clip was simple:

add_executable(${APPCLIP_NAME} MACOSX_BUNDLE ...)
set_target_properties(${APPCLIP_NAME} PROPERTIES
XCODE_PRODUCT_TYPE "com.apple.product-type.application.on-demand-install-capable"
)

However, embedding it correctly required extra care. We used:

set_target_properties(${PROJECT_NAME} PROPERTIES
XCODE_EMBED_EXTENSIONKIT_EXTENSIONS "${APPCLIP_NAME}"
)
add_dependencies(${PROJECT_NAME} ${APPCLIP_NAME})

Then, we patched the xcodeproj manually to update the destination path:

dstPath = "$(CONTENTS_FOLDER_PATH)/AppClips";
dstSubfolderSpec = 16;
name = "Embed App Clips";

⌚️ watchOS App (The Tricky One)

WatchKit apps are bundled inside the iOS app, which adds complexity. The structure consists of:

  • Watch App: A minimal stub application

  • WatchKit Extension: Where the code actually runs

Step 1: Create WatchKit Extension

add_executable(${WATCH_EXT_TARGET} MACOSX_BUNDLE ${SWIFT_SOURCES} ${EXTENSION_XCASSETS}) 
target_compile_definitions(${WATCH_EXT_TARGET} PRIVATE IS_APP_CLIP=1)
set_target_properties(${WATCH_EXT_TARGET}
PROPERTIES
XCODE_ATTRIBUTE_SDKROOT "watchos"
XCODE_ATTRIBUTE_ARCHS "${ARCHS_STANDARD}"
XCODE_ATTRIBUTE_TARGETED_DEVICE_FAMILY "4" # Apple Watch
XCODE_ATTRIBUTE_WATCHOS_DEPLOYMENT_TARGET "${WATCHOS_DEPLOYMENT_TARGET}"
BUNDLE_EXTENSION "appex"
XCODE_PRODUCT_TYPE "com.apple.product-type.watchkit2-extension"
)

Step 2: Create Watch App

add_executable(${WATCH_APP_TARGET} MACOSX_BUNDLE)
target_sources(${WATCH_APP_TARGET} PRIVATE ${DUMMY_SWIFT} ${IMAGES_XCASSETS})
set_target_properties(${WATCH_APP_TARGET} PROPERTIES
BUNDLE_EXTENSION "app"
XCODE_ATTRIBUTE_SDKROOT "watchos"
XCODE_ATTRIBUTE_WATCHOS_DEPLOYMENT_TARGET "${WATCHOS_DEPLOYMENT_TARGET}"
XCODE_ATTRIBUTE_ASSETCATALOG_COMPILER_APPICON_NAME "AppIcon"
XCODE_ATTRIBUTE_ARCHS "${ARCHS_STANDARD}"
XCODE_ATTRIBUTE_TARGETED_DEVICE_FAMILY "4"
XCODE_PRODUCT_TYPE "com.apple.product-type.application.watchapp2"
XCODE_EMBED_APP_EXTENSIONS "${WATCH_EXT_TARGET}"
)

💡 Note: Watch apps must not contain sources. So we added a dummy .swift file, then removed the Sources phase with a post-process script.

Watch App Patch Script

# Remove Sources build phase
import re, sys
PBXPROJ_PATH, TARGET_NAME = sys.argv[1], sys.argv[2]
with open(PBXPROJ_PATH) as f: lines = f.readlines()

out, inside, found = [], False, False
for i, line in enumerate(lines):
if f'/* {TARGET_NAME} */ = {{' in line and 'isa = PBXNativeTarget' in lines[i+1]:
inside = True
if inside and re.search(r'/\* Sources \*/', line):
print(f"Removing Sources from: {TARGET_NAME}")
found = True
continue
if inside and '};' in line:
inside = False
out.append(line)

with open(PBXPROJ_PATH, 'w') as f: f.writelines(out)


Step 3: Embed Watch App in Main App

We had to repurpose the PlugIns phase as a Watch embed phase:

set_target_properties(${PROJECT_NAME} PROPERTIES
XCODE_EMBED_PLUGINS "${WATCH_APP_NAME}"
)
add_dependencies(${PROJECT_NAME} ${WATCH_APP_NAME})

Then we patched the PBXCopyFilesBuildPhase:

dstPath = "$(CONTENTS_FOLDER_PATH)/Watch";
dstSubfolderSpec = 16;
name = "Embed Watch Content";

Here’s the patching script:

# Patch Embed PlugIns to Watch
import sys
PBXPROJ_PATH = sys.argv[1]
with open(PBXPROJ_PATH) as f: lines = f.readlines()

out, inside, patching = [], False, False
for line in lines:
if '/* Embed PlugIns */' in line:
patching = True
if patching and '};' in line.strip():
patching = False
if patching:
line = line.replace('dstPath = "";', 'dstPath = "$(CONTENTS_FOLDER_PATH)/Watch";')
line = line.replace('dstSubfolderSpec = 13;', 'dstSubfolderSpec = 16;')
line = line.replace('name = "Embed PlugIns";', 'name = "Embed Watch Content";')
out.append(line)

with open(PBXPROJ_PATH, 'w') as f: f.writelines(out)
print(f"✅ Patched {PBXPROJ_PATH} with updated Embed Watch section.")


✅ Conclusion

CMake isn’t officially endorsed by Apple for managing full iOS+watchOS app stacks, but with a bit of scripting and Xcode project patching, it absolutely works—and works well.

Benefits we gained:

  • Fully scriptable, reproducible builds

  • CI/CD-friendly architecture

  • Freedom from legacy Xcode UI issues

  • Easier multi-target versioning

Until Apple adds native support for CMake-based watchOS targets, this is the way. 🛠️