engineering notes

Building iOS apps on Windows: what it actually takes

OpenFruit builds unmodified Xcode projects into signed iOS apps natively on Windows. This page is about the parts that turned out to be hard, and how each one is solved.

00The problem

Compiling Swift for an iPhone on Windows is, by itself, the easy part. The Swift toolchain for Windows exists, and Clang has targeted arm64-apple-ios for years. What a real app needs on top of the compiler is everything Xcode does around it:

OpenFruit has one rule that shapes everything below: the project is never patched. No edits to sources, Package.swift, .pbxproj or .xcconfig, no per-project switches, no hand-written replacements for a macro that does not run. When something fails, the fix has to be the missing piece of Xcode-compatible behaviour, implemented generically. Real apps such as Nextcloud, VLC, Delta and Swiftfin are regression tests, not special cases.

01Reading projects the way Xcode does

A .pbxproj is easy to parse. Deciding what value a build setting really has is not: Xcode merges defaults, the project, the target and their .xcconfig files, and every level can refer to the previous one.

Layers, includes and $(inherited)

Each configuration can point to a base .xcconfig through baseConfigurationReference. Those files can #include others, or #include? them, which silently skips a missing file. $(inherited) and ${inherited} expand to the value from the level below, and values go through Xcode's modifiers, for example $(PRODUCT_NAME:c99extidentifier) to turn a product name into a valid identifier.

Conditional settings

Settings can be qualified, and several qualified forms of the same key can coexist:

OTHER_SWIFT_FLAGS = -DBASE
OTHER_SWIFT_FLAGS[sdk=iphoneos*] = $(inherited) -DDEVICE
OTHER_SWIFT_FLAGS[config=Debug][arch=arm64] = $(inherited) -DDEBUG_ARM64

OpenFruit evaluates config, sdk, arch and variant against the real build (for example iphoneos, arm64, normal). When several forms match, the most specific one wins: more conditions first, then more literal characters in the patterns. Configuration names are matched case-sensitively, as Swift Build does with fnmatch; SDK and architecture patterns are not. An unknown or malformed condition stops the build with an error instead of being guessed.

Small rules that break real apps

02Getting Apple's SDK without a Mac

OpenFruit does not ship any Apple software. Each user downloads Xcode with their own Apple account: they sign in on Apple's own page, and the resulting session is stored encrypted with Windows DPAPI. OpenFruit then picks the matching Xcode from Apple's live catalog and reads the iOS SDK out of it.

A .xip is three formats nested inside each other:

XAR→PBZX→CPIO→iPhoneOS.sdk

Everything is streamed, with limits on table size, decoder memory and entry counts. Before an SDK is registered, the XAR digest and signature, Apple's certificate chain and revocation are all checked. The SDK version comes from the original SDKSettings.json, never from a file name, and the tree is installed as one unit so that Xcode's relative symbolic links keep working.

03Running Apple's original Swift macros on Windows

This is the hardest part of the project. Swift macros run as compiler plugins, separate programs that the compiler talks to. There are two kinds:

Writing replacements that produce "the same" expansions would break the rule above, and would be wrong the first time Apple changes a macro. So OpenFruit runs Apple's original binaries.

An ARM64 CPU and a slice of Darwin

OpenFruit does not emulate a whole Mac. It runs the original Mach-O code on the Unicorn 2.1.4 AArch64 CPU core, built unchanged on Windows, and implements the part of Darwin that the plugin server actually uses:

A system call that is not implemented fails with an explicit error; it never returns a fake success. Each piece of Darwin behaviour is tracked as a reviewed contract with its own scope and tests, rather than as a single "percent compatible" figure.

A small detail from this part: Unicorn and zlib both define a crc32 symbol, and linking them statically failed. Rather than patch either library, the CPU core is built as its own unicorn.dll.

macOS system libraries, from Apple's public restore image

The plugin server needs macOS's own system libraries, which live in the dyld shared cache. OpenFruit gets them from Apple's public macOS restore image, without downloading all of it:

  1. The restore image is a 26.6 GB ZIP64 file. HTTP range requests read its directory and only the members that are needed.
  2. The system volume inside is an encrypted Apple Archive (.dmg.aea, about 2.4 GB). Its key comes from Apple's public key server and is checked as a P-256 key. Every cluster and segment is then authenticated while it is decrypted, and the payloads are decoded with Apple's own LZFSE reference code.
  3. The result is an APFS disk image, which OpenFruit reads natively. It verifies the sealed volume's hash tree from the root down, walks the directory and extent trees, and decompresses files stored with LZBITMAP compression.
  4. From there it extracts the dyld shared cache: the main file plus 79 subcaches, about 7 GB, with 429,920 data-hash records checked on the way.

Making the compiler talk to it

On Windows, the Swift compiler only discovers plugins named .dll, so it cannot find Apple's libSomething.dylib files by scanning a folder. The official frontend option -load-resolved-plugin <library>#<host>#<module> binds an explicit library and module without renaming anything. It also has to be passed through -Xfrontend: the driver accepts the bare option and then silently drops it from the frontend jobs.

One more obstacle: the compiler sends the plugin's path to the host, and the original dyld rejects a Windows path with backslashes. A small broker between the two rewrites exactly one field, loadPluginLibrary.libraryPath, to the plugin's virtual macOS path. Every other message and every expansion passes through byte for byte.

The order of preference is fixed: a native Windows plugin built from source; if that is impossible, the original Mach-O plugin on the emulated runtime; otherwise an explicit error. Two plugin processes run at a time and stay warm between compilations.

04Storyboards without Interface Builder

Apple compiles .storyboard and .xib files with ibtool, which only runs on a Mac. OpenFruit uses OpenIB, a clean-room C++20 compiler that writes the binary NIBArchive files UIKit loads at runtime (.storyboardc and .nib).

A NIB is a 50-byte header followed by four tables:

objectskeysvaluesclasses

Counts and indices use a variable-length integer with seven payload bits per byte, least significant group first, where bit 7 marks the last byte, not a continuation as in LEB128. Arrays and dictionaries are not separate types: they are inlined objects marked NSInlinedValue, followed by references under the empty key UINibEncoderEmptyKey. OpenIB treats all this as a compatibility model checked against real apps, not as an Apple specification. It compiles the storyboards of Nextcloud and Delta, which then load on a real iPhone.

05Signing and installing from Windows

Signing starts from the same build settings as everything else. Entitlements come from CODE_SIGN_ENTITLEMENTS, expanded like any other setting, for the app and for each extension and framework inside it. The certificate, stored encrypted with DPAPI, and the provisioning profile are validated first, so a wrong profile is reported before any work starts.

The signature itself is written by the open-source zsign. OpenFruit then verifies the result independently. For every Mach-O it parses the CodeDirectory, checks that the code hash slots cover the binary and that the special slots match Info.plist, the resources and the entitlements, and computes the CDHash (truncated to 20 bytes, as Darwin expects).

Installing does not need iTunes or a Python stack. OpenFruit talks to the device directly:

usbmux→lockdown (TLS)→AFC upload→installation_proxy

The only external piece is Apple's USB driver, which comes with the Apple Devices app.

06Where it stands

These open-source apps build from a clean checkout into a signed IPA that installs and runs on an iPhone, with no changes to their repositories (git status stays empty), arm64, iOS 27.2 SDK:

AppWhat it exercisesResult
NextcloudLarge Swift and Objective-C app, many SwiftPM packages, storyboards, extensionsSigned IPA, installed and launched on an iPhone
DeltaWorkspace, C and C++ emulator cores, alternate iconsSigned IPA, installed and launched on an iPhone
VLCCocoaPods, XCFrameworks, a Swift 4 extensionSigned IPA, installed and launched on an iPhone
SwiftfinSwiftUI and Apple macros, Icon Composer iconsSigned IPA, installed and launched on an iPhone

It is still experimental. Some resources inside SwiftPM packages are not built yet, Icon Composer icons are flattened without Liquid Glass effects, and capabilities tied to another team's account cannot be provisioned. The full list is in the README.

If you want to try it, or to point it at an app that breaks, the code is on GitHub. A failing open-source app, reported with its first error, is the most useful contribution there is.

Details for every section, with the exact versions and checks, are in the repository's docs folder and in OpenIB.