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:
| Step | What 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-buildshows you one line of bash.ios-build-sliceis 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-buildis therefore a tiny aggregator: itneeds:the matrix, asserts its aggregate result issuccess, 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 byIosSliceMatrixDriftTests, not by the aggregator.
One honest warning about this guide as a whole. No lane executes this procedure.
ios-build-sliceproves 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:
| File | The code | Change it to |
|---|---|---|
BnHost/HostViewController.swift | ?? "BnDemo" — the fallback when no deep link or notification tap chose the first page | your root component's registered name |
BnHost/BnRuntime.swift | func start(component: String = "BnDemo", os: String = "ios", apiLevel: Int32 = 0) throws | the default — same name |
BnHost/BnDeepLink.swift | static let routeComponents — the routes a cold-launch deep link can mount directly, mapped to the reference app's demo pages | your 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 atdotnet newtime, and a drift test pins it againstAppPages.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:
BnImagegoes through Kingfisher (BnImageLoader), not through this bridge. Android's equivalent path uses Coil.
Deep links and lifecycle — now real, with one honest gap
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"
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.
onDestroy is best-effort, and weaker on iOSiOS 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:
- Publish your app for
iossimulator-arm64, orios-arm64for a device. The static archive lands underbin/Release/net10.0/<rid>/—publish/ornative/, depending on the publish shape; the CI step checks both, and so should you. bootstrapperdll.ocomes out of the NuGet runtime pack for the same RID (Microsoft.NETCore.App.Runtime.NativeAOT.<rid>), not out of your publish.- The support archive: the rest of the runtime pack's
native/*.a, merged withxcrun libtool -staticinto onelibBnRuntimeSupport.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-enabledvariants 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.
TrimmerRootAssemblyis 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:
-
Set
OS_ACTIVITY_DT_MODE=YESin the scheme's Run → Arguments → Environment Variables — or pass it at launch. SetBN_LOG_LEVELtoo, or the threshold iswarnand there is nothing at these levels to see (Logging). -
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 -
Expect two kinds of line interleaved: the mirrored
os_logentries — the shell's ownio.blazornativecategories, 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.
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
| Path | What it is |
|---|---|
src/BlazorNative.Apple/BnHost/ | the shell — copy the whole directory |
src/BlazorNative.Apple/project.yml | XcodeGen 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-slice | the executable truth. A two-leg matrix, simulator + device, run on every PR; the required check ios-build aggregates it |
.github/workflows/ios.yml | the 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
SIGSEGVinThreadStore::AttachCurrentThreadat launch →bootstrapperdll.ois 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.swiftdoes not match a page yourAppPages.Allregisters (§3) — orTrimmerRootAssemblyis 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
yogamodule → deletemodule.modulemap. See §6.