Skip to main content

Adding an iOS shell to a BlazorNative app

There is no dotnet new template for iOS. This is a manual procedure against a reference implementation, and it is more work than the Android path — where dotnet new blazornative gives you a runnable Gradle tree with the shell already in it. Budget an afternoon.

Both slices compile; only the simulator slice is executed by CI. Since Phase 14.4 the required lane builds iossimulator-arm64 and ios-arm64 — the slice that actually ships — so a change that breaks the device build reds a pull request. What CI still cannot do is run anything on real hardware: the XCTest suite executes on a booted simulator only.

A real device has been exercised once, by hand — an iPhone 17 Pro Max in September 2026 — and the shell needed no source change to build and run there. Signing is still yours to arrange: this repo builds unsigned, so a device build needs a Development Team, a provisioning profile and the team-prefixed keychain group. docs/ios-device-verification-handover.md is the written recipe. §8 below covers the one device-only thing that is not obvious: seeing Debug and Verbose output at all.


What this document is, and what it deliberately is not​

It does not transcribe the recipe. project.yml links against $(SRCROOT)/vendor/… and names no publish directory and no app anywhere: the link between "your .NET app" and "the iOS shell" is a directory of frozen-name files, and the only thing that populates it today is CI — the bash in its staging and Yoga steps. There is no script and no Makefile target. (That absence is a known gap: scripts/stage-ios.ps1 is on the backlog precisely because it would make this recipe executable and testable instead of prose.)

So this document points at the live files and tells you what to change in them. Those files are compiled by the ios-build-slice matrix on every pull request, and its aggregate is the required check ios-build — which means the material this guide sends you to is kept true by a gate rather than by someone remembering to update a document. A transcription here would be a fourth copy of a recipe that already exists twice in YAML, and it would rot the day ci.yml moved.

The executable truth is .github/workflows/ci.yml, job ios-build-slice. When this prose and that job disagree, the job is right. Read its steps in order — they are commented heavily, and they are the procedure:

StepWhat it does
Publish ${{ matrix.rid }} (NativeLib=Static; assert exactly 4 IL2072)your app → a static archive
Stage the link inputs (runtime .a + bootstrapperdll.o + support archive)the vendor/ recipe
Yoga: build libyoga.a (…) for ${{ matrix.rid }}the flexbox engine, from source
Generate BnHost.xcodeproj / xcodebuild build-for-testing (${{ matrix.rid }} …)the Xcode side

Why looking up ios-build shows you one line of bash. ios-build-slice is a two-leg matrix — simulator and device — and a matrix reports one check per leg, so it cannot itself carry the required context name. ios-build is therefore a tiny aggregator: it needs: the matrix, asserts its aggregate result is success, and fails on anything else including a skip. The name still means what it always meant, only stronger — it used to say "the simulator slice compiled" and now says "both slices compiled". Which slices those are is pinned in the required .NET suite by IosSliceMatrixDriftTests, not by the aggregator.

One honest warning about this guide as a whole. No lane executes this procedure. ios-build-slice proves the reference shell compiles and links; it does not prove that a reader following these steps arrives at a running app. Every file and line referenced below was verified against the tree when this was written, but line numbers drift — each citation names the code to search for, so use the text, not the number.


1. Copy the shell​

Copy src/BlazorNative.Apple/BnHost/ into your tree — the whole directory. BnWidgetMapper.swift alone is several thousand lines.

The shell is not packaged. There is no .framework, no CocoaPod, no SwiftPM product. The Android side has the same duplication problem and the same eventual answer (an .aar / a package); until then, copying is the mechanism, and your copy will not track upstream fixes. Unlike the Android template — whose Kotlin is byte-identity-pinned against the reference in CI, so a shell fix that skips the template turns a required check red — your iOS copy is pinned by nothing. Diff it against the reference when you update.

You also need project.yml (the XcodeGen spec) and its vendor/ layout. Read it: it is the most load-bearing file in the iOS build and it is thoroughly commented.

The minimum is iOS 15.0. project.yml sets deploymentTarget.iOS: "15.0", and the Yoga build in §6 compiles with the matching -miphoneos-version-min=15.0 (device) or -mios-simulator-version-min=15.0 (simulator). Kingfisher, the shell's image loader, requires iOS 15 from 8.13 on, so the floor cannot go lower without pinning an older Kingfisher. Keep the deployment target and both flags equal in your copy.


2. What to delete​

BnHostTests/ — the whole directory. It is the reference shell's own XCTest surface (the fixture image server, the widget-mapper tests, the runtime tests). Delete the directory, its target in project.yml, and its scheme's test action.

The ATS exemption — and this is the one you would not guess. BnHost/Info.plist carries:

<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>

That exists only so the test fixture server works. BnHostTests's image tests serve fixtures over cleartext loopback, and BnImageDemoTests.testCleartextLoopbackIsPermittedByATS is what turns the exemption from an assumption into a checked fact. It is narrow — it is NSAllowsLocalNetworking, not NSAllowsArbitraryLoads, so it permits loopback, link-local and .local destinations and nothing else; public-internet HTTP stays blocked. But if you deleted BnHostTests above, you have inherited an exemption you never asked for. Delete it too, unless you are talking to a local dev server on purpose.

BnYogaProbe (.h/.mm/.swift) is vestigial — a Phase 6.0 Yoga-spike artifact that survives because it doubles as a launch smoke test. A new app does not need it. It is on the backlog to retire on purpose; delete it if you want the smaller surface.


3. What to edit — and these are source edits, not configuration​

The shell hardcodes the reference app's component names in several places. There is no setting for this; you are editing the shell's source, which is the honest word for it. Search for the text, not a line number:

FileThe codeChange it to
BnHost/HostViewController.swift?? "BnDemo" — the fallback when no deep link or notification tap chose the first pageyour root component's registered name
BnHost/BnRuntime.swiftfunc start(component: String = "BnDemo", os: String = "ios", apiLevel: Int32 = 0) throwsthe default — same name
BnHost/BnDeepLink.swiftstatic let routeComponents — the routes a cold-launch deep link can mount directly, mapped to the reference app's demo pagesyour own routes and the names they register, or an empty map

The name must match a page your app registers. On the .NET side that is the name argument in your AppPages.All manifest — BlazorNativePage.Routed<BnStarterPage>(BlazorNativeApp.DefaultRoute, "BnStarterPage") registers "BnStarterPage". A mismatch is not a compile error: the mount fails at runtime.

A generated Android app has exactly this seam, and it is templated: MainActivity's ?: "BnStarterPage" fallback is rewritten for you at dotnet new time, and a drift test pins it against AppPages.All. On iOS you are the drift test.

Also yours to change: the bundle id and display name in BnHost/Info.plist and project.yml (PRODUCT_BUNDLE_IDENTIFIER, the target/scheme names).

And the capability usage-description strings — rewrite the copy, keep the keys. BnHost/Info.plist carries NSCameraUsageDescription, NSLocationWhenInUseUsageDescription and NSFaceIDUsageDescription. iOS SIGABRTs at the capability call if a key is absent, so copying the shell hands you the keys you need for free. But the strings are BlazorNative demo copy ("BlazorNative uses your camera to take a photo in the camera demo") — they are the sentence your user reads under the system prompt, and the App Store rejects empty or boilerplate text. Rewrite each to your app's real purpose; the key is what matters to the OS, the sentence is app-specific and yours. Drop a key only if your app never uses that capability.


4. What is a stub, not a feature​

BnHost/AppleShellBridge.swift — fetchBegin fails every request, synchronously:

func fetchBegin(_ requestId: Int64) -> Int32 {
BnLog.warn("AppleShellBridge", "fetchBegin id=\(requestId) — unsupported (5.3 stub), returning -1")
return -1
}

The reference app does no fetch, so the bridge does not implement one. The stub is deliberately honest — it returns -1 (host error) immediately rather than accepting the request and never completing it, so a stray fetch surfaces at once instead of hanging.

If your app uses HttpClient (BlazorNative.Http), you must implement this. Wire a real URLSession and call back through blazornative_fetch_complete. A setup guide that let you discover this at runtime would be a bad guide.

Images are not affected: BnImage goes through Kingfisher (BnImageLoader), not through this bridge. Android's equivalent path uses Coil.

Two things that used to be missing on iOS and are not any more:

The blazornative:// URL scheme works. Info.plist declares it under CFBundleURLTypes and AppDelegate routes it — warm (the app is alive) dispatches the navigate host event and lets .NET resolve the route; cold (the link launched the app) stashes the route so the right page mounts directly rather than flashing the default one. Try it against a booted simulator:

xcrun simctl openurl booted "blazornative://geolocation"
On iOS 26 this recipe needs a tap

simctl openurl used to hand the URL straight to the app when it was already in the foreground. On iOS 26 it always raises an Open in "BnHost"? confirmation sheet first, whatever the foreground app is. The link still routes correctly once you confirm — but the command no longer completes unattended, so do not put it in a script and expect a result.

Universal links are still not supported. They need an associated-domains entitlement, a domain you own, and an apple-app-site-association file served from it — the same Apple Developer account that gates real-device iOS. The custom scheme needs none of that, which is why one shipped and the other did not.

Lifecycle events fire, with Android's names so your components branch on one vocabulary: onResume ← applicationDidBecomeActive, onPause ← applicationWillResignActive, onDestroy ← applicationWillTerminate.

Name them with BnHostEvents, not string literals. Both shells and BnHostEvents (BlazorNative.Core) are pinned against the same src/wire-vocabulary.json manifest, so e.Name == BnHostEvents.OnPause cannot drift from what a shell actually sends the way e.Name == "onPause" can:

IMobileBridge bridge = default!; // however you obtained it — [Inject] in a component
bridge.NativeEvents += e =>
{
if (e.Name == BnHostEvents.OnPause)
{
// PERSIST HERE — see the warning below.
}
};

willResignActive rather than didEnterBackground is deliberate: Android's onPause fires whenever the Activity leaves the foreground including partial obscuring, and willResignActive is that same moment on iOS (Control Centre, an incoming call). didEnterBackground would fire only on the full transition, so a timer paused on onPause would keep running through a phone call on iOS and not on Android.

warning
onDestroy is best-effort, and weaker on iOS

iOS routinely terminates a suspended app without calling applicationWillTerminate. Android's onDestroy is not guaranteed either, but it is far more reliable in practice. Persist on BnHostEvents.OnPause — it fires every time, on both platforms; BnHostEvents.OnDestroy is best-effort and must not be where persistence lives.


5. The staging — the shape of it, and the one thing that will bite you​

The recipe lives in ios-build-slice's Stage the link inputs step. Its shape:

  1. Publish your app for iossimulator-arm64, or ios-arm64 for a device. The static archive lands under bin/Release/net10.0/<rid>/ — publish/ or native/, depending on the publish shape; the CI step checks both, and so should you.
  2. bootstrapperdll.o comes out of the NuGet runtime pack for the same RID (Microsoft.NETCore.App.Runtime.NativeAOT.<rid>), not out of your publish.
  3. The support archive: the rest of the runtime pack's native/*.a, merged with xcrun libtool -static into one libBnRuntimeSupport.a — minus exactly three members: libRuntime.ServerGC.a, libeventpipe-enabled.a, libstandalonegc-enabled.a. Your app is ILC-compiled for WorkstationGC with eventpipe and standalone-GC disabled; the -enabled variants define the same symbols and would be ambiguously pulled.

The load-bearing fix — read this one twice​

bootstrapperdll.o must be linked as a DIRECT OBJECT, never -force_load'd and never merged into the support archive.

In project.yml's OTHER_LDFLAGS it is a bare path, and the -force_load below it applies to the app archive:

OTHER_LDFLAGS:
- $(SRCROOT)/vendor/bootstrapperdll.o # DIRECT — always included
- -force_load
- $(SRCROOT)/vendor/libBlazorNative.Runtime.a
- $(SRCROOT)/vendor/libBnRuntimeSupport.a # stays ON-DEMAND
- $(SRCROOT)/vendor/libyoga.a
- -lc++
- -lz

Its __attribute__((constructor)) must land in the app's init_array so the runtime initializes before blazornative_init. As an archive member it would be on-demand and never pulled — a constructor is not a "referenced symbol" — leaving RuntimeInstance null.

And here is why this is the paragraph that matters: you do not get a link error. You get a SIGSEGV inside ThreadStore::AttachCurrentThread, at runtime, pointing at nothing you wrote. Do not "tidy" that bare .o into the merge.

The support archive stays on-demand on purpose: the linker pulls the library-appropriate runtime objects without dragging in the exe bootstrapper or the Network-framework transport.


6. Yoga, from source​

The Android shell gets Yoga from Maven (com.facebook.yoga:yoga). iOS builds it from source — C++20, against the SDK of the slice being built, merged with libtool -static into libyoga.a, headers copied to vendor/yoga-include/. See ios-build-slice's Yoga: build libyoga.a step, which compiles once per leg: iphonesimulator for the simulator slice, iphoneos for the device slice. A simulator-SDK libyoga.a linked into a device app is a silent failure, so the two builds are cached under separate keys.

Then delete vendor/yoga-include/yoga/module.modulemap:

rm -f src/BlazorNative.Apple/vendor/yoga-include/yoga/module.modulemap

With it present, Clang's explicit-module scanner tries to build a yoga module and cannot resolve the module's own headers. Removing it forces plain textual includes via HEADER_SEARCH_PATHS. This is not optional and it is not cosmetic.

Use the same Yoga version both shells use. It is one engine, and identical frames on Android and iOS is the entire reason this framework chose Yoga — two versions lay out differently, silently. The repo pins it in several files and asserts them equal in the required lane's first step after checkout; your copy is one more that nothing checks.


7. The csproj — and the asymmetry you are entitled to know about​

Your app's csproj needs an iOS PropertyGroup. The reference is samples/BlazorNative.SampleApp/BlazorNative.SampleApp.csproj — copy RuntimeFrameworkVersion's exact value from there (one home for that number, so this page cannot go stale the day it moves; the placeholder below is deliberately not a real version, so an unedited copy fails loudly instead of silently pinning nothing):

<PropertyGroup Condition="$(RuntimeIdentifier.StartsWith('iossimulator')) Or $(RuntimeIdentifier.StartsWith('ios-'))">
<NativeLib>Static</NativeLib>
<RuntimeFrameworkVersion>REPLACE-WITH-SAMPLEAPP-VALUE</RuntimeFrameworkVersion>
<!-- … -->
</PropertyGroup>

You also need UnmanagedEntryPointsAssembly, TrimmerRootAssembly and the CanonicalizeNativeArtifactName target — a dotnet new blazornative app already has all three, so if you started from the template, you are copying only the iOS PropertyGroup.

TrimmerRootAssembly is not optional and its failure is silent. Without it ILC trims your entire app module — green build, the expected trim warnings drop to zero, your page names vanish from the binary, and the first thing you see is a failed mount. The template's csproj carries the line with a comment explaining it; do not drop it on the way to iOS.

The asymmetry, stated plainly: the Android side ships build/BionicNativeAot.targets — a vendored, reusable MSBuild shim that a generated app gets in its tree. iOS has no equivalent. Every property above is hand-copied, and nothing tells you when the reference changes them. Moving that recipe into the BlazorNative.Runtime package (where NuGet would auto-import it for every consumer, on both platforms) is the right architecture and is on the backlog; it costs a shipped package's inventory shape, which is why it has not happened yet.


8. Seeing Debug and Verbose on a real device​

Without the one environment variable below, you cannot. That is not a figure of speech — it was measured during the device-verification run on an iPhone 17 Pro Max, and it cost that run real time before anyone worked out why.

Here is the whole chain. BnLog maps both Debug and Verbose onto OSLogType.debug, which the unified log drops unless the subsystem has been enabled for capture — and log config, the command that enables one, has no --device flag. On macOS 26 log stream lost device support, so you cannot watch the log live either. That leaves stdio: devicectl device process launch --console carries fd 1 and fd 2, and setting OS_ACTIVITY_DT_MODE=YES asks the OS to mirror os_log output onto fd 2 so it arrives there too.

Except the shell's BnStderrPump claims fd 2 with dup2 inside HostViewController.viewDidLoad, immediately after the XCTest guard, so the mirror used to land in the pump's own pipe. One UIKit line arrived before the install, and then nothing, even at Verbose.

The shell now stands aside. When OS_ACTIVITY_DT_MODE is set in the environment, BnStderrPump.install() returns immediately without creating the pipe or touching fd 2, and the mirror reaches your console.

The recipe, then:

  1. Set OS_ACTIVITY_DT_MODE=YES in the scheme's Run → Arguments → Environment Variables — or pass it at launch. Set BN_LOG_LEVEL too, or the threshold is warn and there is nothing at these levels to see (Logging).

  2. Launch with the console attached:

    xcrun devicectl device process launch \
    --device <udid> --console \
    --environment '{"OS_ACTIVITY_DT_MODE":"YES","BN_LOG_LEVEL":"Verbose"}' \
    com.example.myapp
  3. Expect two kinds of line interleaved: the mirrored os_log entries — the shell's own io.blazornative categories, at every level including Debug and Verbose — and whatever the process writes to stdout and stderr directly.

What it costs. With the pump standing aside, the .NET runtime's Console.Error output is no longer parsed into levelled, categorised unified-log entries; it arrives as plain console text. That is a fair trade for one debugging launch and a bad default, which is why it is opt-in.

Without the variable, these two levels are invisible on a device

The pump is installed, it owns fd 2, and Debug/Verbose reach neither the console nor a log collected off-device. error, warn and info are unaffected — they map onto OSLogType levels the unified log keeps. This applies to a real device only; on the simulator xcrun simctl launch --console and log stream both still work.


Reference: the files that matter​

PathWhat it is
src/BlazorNative.Apple/BnHost/the shell — copy the whole directory
src/BlazorNative.Apple/project.ymlXcodeGen spec: targets, link flags, SwiftPM deps. Heavily commented; read it
src/BlazorNative.Apple/BnHostTests/the reference's tests — delete the directory
src/BlazorNative.Apple/vendor/the frozen-name link inputs, produced by the staging step: bootstrapperdll.o, libBlazorNative.Runtime.a, libBnRuntimeSupport.a, libyoga.a + yoga-include/. The .a/.o are git-ignored — they are build outputs, never committed
.github/workflows/ci.yml → ios-build-slicethe executable truth. A two-leg matrix, simulator + device, run on every PR; the required check ios-build aggregates it
.github/workflows/ios.ymlthe advisory execution lane — runs the XCTests on a booted simulator, and compiles the device slice alongside

Third-party dependencies (from project.yml): Kingfisher, pinned there with exactVersion: — the ONE home for that number, on purpose — via SwiftPM, the iOS twin of Android's Coil, driving BnImage. Exactly one file in the shell imports it.


When it does not work​

  • SIGSEGV in ThreadStore::AttachCurrentThread at launch → bootstrapperdll.o is not a direct object. See §5.
  • Undefined blazornative_* symbols at link → the app archive is not -force_load'd, or the publish did not produce it.
  • The app launches and the screen is empty / the mount fails → the component name in HostViewController.swift does not match a page your AppPages.All registers (§3) — or TrimmerRootAssembly is missing and your app was trimmed away (§7).
  • A fetch hangs or fails with -1 → the bridge's fetch is a stub. See §4.
  • Clang cannot build a yoga module → delete module.modulemap. See §6.