Skip to content

Instantly share code, notes, and snippets.

@dnicolson
Created September 30, 2026 19:46
Show Gist options
  • Select an option

  • Save dnicolson/d3542e7a69f9e8d1c4e1efbd3538dfe7 to your computer and use it in GitHub Desktop.

Select an option

Save dnicolson/d3542e7a69f9e8d1c4e1efbd3538dfe7 to your computer and use it in GitHub Desktop.
Build a minimal unsigned iOS IPA with CMake and Ninja that includes a raw arm64 Mach-O .so in Frameworks/, loads it at launch, and crashes if loading fails.
#!/bin/bash
# Build an unsigned arm64 iOS IPA containing an iOS shared library named .so.
# Requirements: macOS, Xcode with the iOS SDK, CMake >= 3.24, and Ninja.
set -euo pipefail
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
cd "$script_dir"
for tool in cmake ninja xcrun zip; do
command -v "$tool" >/dev/null 2>&1 || {
echo "Missing required tool: $tool" >&2
exit 1
}
done
[ "$(uname -s)" = Darwin ] || { echo "Run this script on macOS with Xcode installed." >&2; exit 1; }
source_dir="$script_dir/build/ios-source"
build_dir="$script_dir/build/standalone-ios"
mkdir -p "$source_dir/src" "$build_dir"
# Keep the example project inside this script so it is the only source file needed.
cat > "$source_dir/CMakeLists.txt" <<'CMAKE'
cmake_minimum_required(VERSION 3.24)
project(SoDemo LANGUAGES C OBJC)
if(NOT CMAKE_SYSTEM_NAME STREQUAL "iOS")
message(FATAL_ERROR "Configure this project for iOS.")
endif()
# Emit a raw Mach-O plugin, matching the .so entries in the reference IPA.
add_library(demo_library MODULE src/demo_library.c)
set_target_properties(demo_library PROPERTIES
PREFIX ""
OUTPUT_NAME "demo_library"
SUFFIX ".so"
)
add_executable(SoDemo MACOSX_BUNDLE src/main.m)
set_target_properties(SoDemo PROPERTIES
MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_SOURCE_DIR}/src/Info.plist.in"
MACOSX_BUNDLE_GUI_IDENTIFIER "com.example.sodemo"
)
target_compile_options(SoDemo PRIVATE -fobjc-arc)
target_link_libraries(SoDemo PRIVATE "-framework UIKit" "-framework Foundation")
# Produce an unsigned IPA for later signing/sideloading.
target_link_options(demo_library PRIVATE "LINKER:-no_adhoc_codesign")
target_link_options(SoDemo PRIVATE "LINKER:-no_adhoc_codesign")
# Copy the unsigned plugin beside other app resources, as in the reference IPA.
add_custom_target(embed_library ALL
COMMAND ${CMAKE_COMMAND} -E make_directory
"$<TARGET_BUNDLE_DIR:SoDemo>/Frameworks"
COMMAND ${CMAKE_COMMAND} -E copy
"$<TARGET_FILE:demo_library>"
"$<TARGET_BUNDLE_DIR:SoDemo>/Frameworks/demo_library.so"
DEPENDS SoDemo demo_library
VERBATIM
)
CMAKE
cat > "$source_dir/src/demo_library.c" <<'C'
const char *demo_message(void)
{
return "Hello from the .so library!";
}
C
cat > "$source_dir/src/Info.plist.in" <<'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>CFBundleDevelopmentRegion</key><string>en</string>
<key>CFBundleDisplayName</key><string>SO Demo</string>
<key>CFBundleExecutable</key><string>SoDemo</string>
<key>CFBundleIdentifier</key><string>com.example.sodemo</string>
<key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
<key>CFBundleName</key><string>SoDemo</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>1.0</string>
<key>CFBundleVersion</key><string>1</string>
<key>CFBundleSupportedPlatforms</key><array><string>iPhoneOS</string></array>
<key>MinimumOSVersion</key><string>15.0</string>
<key>UIDeviceFamily</key><array><integer>1</integer><integer>2</integer></array>
<key>LSRequiresIPhoneOS</key><true/>
<key>UILaunchScreen</key><dict/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key><false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array><dict>
<key>UISceneConfigurationName</key><string>Default</string>
<key>UISceneDelegateClassName</key><string>SceneDelegate</string>
</dict></array>
</dict>
</dict>
</dict>
</plist>
PLIST
cat > "$source_dir/src/main.m" <<'OBJC'
#import <UIKit/UIKit.h>
#include <dlfcn.h>
static NSString *loadMessage(void)
{
NSString *path = [NSBundle.mainBundle.privateFrameworksPath
stringByAppendingPathComponent:@"demo_library.so"];
void *library = dlopen(path.fileSystemRepresentation, RTLD_NOW);
if (!library) {
NSLog(@"dlopen failed: %s", dlerror());
abort();
}
dlerror();
const char *(*message)(void) = (const char *(*)(void))dlsym(library, "demo_message");
const char *error = dlerror();
if (error || !message) {
NSLog(@"demo_message lookup failed: %s", error ? error : "symbol is null");
dlclose(library);
abort();
}
NSString *result = [NSString stringWithUTF8String:message()];
dlclose(library);
return result;
}
@interface SceneDelegate : UIResponder <UIWindowSceneDelegate>
@property(nonatomic, strong) UIWindow *window;
@end
@implementation SceneDelegate
- (void)scene:(UIScene *)scene
willConnectToSession:(UISceneSession *)session
options:(UISceneConnectionOptions *)options
{
self.window = [[UIWindow alloc] initWithWindowScene:(UIWindowScene *)scene];
UIViewController *controller = [[UIViewController alloc] init];
controller.view.backgroundColor = UIColor.systemBackgroundColor;
UILabel *label = [[UILabel alloc] initWithFrame:controller.view.bounds];
label.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
label.textAlignment = NSTextAlignmentCenter;
label.numberOfLines = 0;
label.text = loadMessage();
[controller.view addSubview:label];
self.window.rootViewController = controller;
[self.window makeKeyAndVisible];
}
@end
@interface AppDelegate : UIResponder <UIApplicationDelegate>
@end
@implementation AppDelegate
@end
int main(int argc, char *argv[])
{
@autoreleasepool {
return UIApplicationMain(argc, argv, nil, NSStringFromClass(AppDelegate.class));
}
}
OBJC
cmake -S "$source_dir" -B "$build_dir" -G Ninja \
-DCMAKE_SYSTEM_NAME=iOS \
-DCMAKE_OSX_SYSROOT=iphoneos \
-DCMAKE_OSX_ARCHITECTURES=arm64 \
-DCMAKE_OSX_DEPLOYMENT_TARGET=15.0 \
-DCMAKE_TRY_COMPILE_TARGET_TYPE=STATIC_LIBRARY \
-DCMAKE_BUILD_TYPE=Release
cmake -E rm -rf "$build_dir/SoDemo.app/Frameworks"
cmake --build "$build_dir"
app="$build_dir/SoDemo.app"
library="$app/Frameworks/demo_library.so"
cp "$source_dir/src/Info.plist.in" "$app/Info.plist"
[ -f "$app/SoDemo" ] && [ -f "$library" ] || {
echo "Build did not produce the app and embedded .so library." >&2
exit 1
}
package_root="$(mktemp -d "$build_dir/package.XXXXXX")"
trap 'rm -rf -- "$package_root"' EXIT
mkdir -p "$package_root/Payload"
cp -R "$app" "$package_root/Payload/"
(cd "$package_root" && zip -qry SoDemo-unsigned.ipa Payload)
mv "$package_root/SoDemo-unsigned.ipa" "$build_dir/SoDemo-unsigned.ipa"
# Keep just the deliverables after the IPA is safely written. The standalone
# library stays beside it; app bundles, Ninja state, generated sources, and
# outputs from the earlier framework build are disposable.
find "$build_dir" -mindepth 1 -maxdepth 1 \
! -name SoDemo-unsigned.ipa ! -name demo_library.so \
-exec /bin/rm -rf {} +
find "$script_dir/build" -mindepth 1 -maxdepth 1 ! -name standalone-ios \
-exec /bin/rm -rf {} +
/bin/rm -rf "$script_dir/dist"
echo "Created unsigned IPA: $build_dir/SoDemo-unsigned.ipa"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment