Just over a year ago, the official Swift SDK began supporting cross-compiling Swift to WebAssembly (WASM). Since then, almost no one has found any truly wide-ranging production use case for it. Until now... In this post, we're going to cross compile a small Swift function into WASM for the purposes of something that - if you traversed the depths of Reddit - you would think was very impossible and very prohibited by Apple (it's not!).
The background goes something like this: most people who ship a paid or business-critical iOS app have hit the same wall at least once. A one-character bug reaches production, the fix is trivial, and yet shipping it still means a new build, a new submission, and a wait on review. React Native and Flutter teams reach for tools like Expo's EAS Update or Shorebird to skip that wait. The recurring question is whether the same thing is possible for an app written in ordinary Swift, without rewriting the UI in JavaScript. The answer is yes, with exactly the same approach, and this article shows the mechanism.
It is worth being precise about where Apple draws the line, since that is the first question most people have. The distinction Apple makes is between downloaded native code, which is not allowed, and downloaded interpreted code, which is. App Store Review Guideline 2.5.2 asks that an app stay self-contained and not download code that changes its primary purpose or its core features, and the carve-out, written into Apple's Developer Program License Agreement, is that interpreted code may be downloaded and run as long as it stays within those bounds. Fixing a bug, changing a calculation, adjusting a layout, or updating a string sits comfortably inside that line. Shipping what is effectively a different app does not. This is the same allowance that Expo's EAS Update relies on to send JavaScript to React Native apps, and that Shorebird relies on to send Dart to Flutter apps. What follows is the native-Swift version of the same idea, with WasmKit as the interpreter and WebAssembly as the payload. Staying inside that line is the developer's responsibility, whatever the tool. For the full history, including the 2017 enforcement that drew the boundary, see what Apple allows.
Part one: the whole loop, working
The goal for this part is narrow. We will build the smallest thing that is unmistakably an over-the-air update: an app shows a greeting that comes from a downloaded WebAssembly module, and then a second downloaded module changes that greeting without the app being rebuilt! Every command and file here is meant to be pasted as is so you can follow along.
The guest: a greeting function in Swift
Create a file greeting.swift with two exported functions:
// greeting.swift
@_cdecl("greeting_len")
public func greeting_len() -> Int32 {
Int32(Array(message.utf8).count)
}
@_cdecl("greeting_byte")
public func greeting_byte(_ i: Int32) -> Int32 {
Int32(Array(message.utf8)[Int(i)])
}
private let message = "Hello from version one"We are returning a string one byte at a time on purpose. WebAssembly function parameters and results are numbers only (i32, i64, f32, f64), so for this first pass we move the string across as a length plus a byte-at-a-time read. It is the simplest thing that works for a short string, and it keeps Part one free of memory bookkeeping. Note that it is quadratic: greeting_byte rebuilds Array(message.utf8) on every call, so reading an N-byte string does O(N^2) work. That is harmless for a 22-byte greeting and wrong for anything large. We fix it properly in Part two with a real pointer-and-length ABI.
Compile it to WebAssembly. The toolchain that targets wasm is the swift.org toolchain plus a WebAssembly SDK, and the order of things on PATH matters, so set it explicitly like so:
# WASM compile: the swiftly-managed swift.org toolchain comes FIRST on PATH
export PATH="$HOME/.swiftly/bin:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin"
hash -r
# Confirm the WebAssembly SDK is installed and note its id
swift sdk list
# e.g. swift-6.3.2-RELEASE_wasmThe easiest way (though by no means is this required) to build a single .wasm is through a throwaway SwiftPM package. The reason is that the WebAssembly SDK ships a full WASI sysroot, a resource directory, and a static-stdlib flag set, and SwiftPM assembles all of that for you from the SDK id. A raw swiftc -target wasm32-unknown-wasip1 ... line has to reproduce the sysroot wiring by hand and usually fails to find it. So we let swift build --swift-sdk <id> do the work.
Lay out a tiny package:
GreetingGuest/
Package.swift
Sources/GreetingGuest/greeting.swiftPackage.swift:
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "GreetingGuest",
targets: [
.executableTarget(
name: "GreetingGuest",
swiftSettings: [ .swiftLanguageMode(.v5) ],
linkerSettings: [
.unsafeFlags([
// Build a reactor module: callable exports, no `_start`.
"-Xclang-linker", "-mexec-model=reactor",
// Keep the host-called exports in the export table.
"-Xlinker", "--export=greeting_len",
"-Xlinker", "--export=greeting_byte",
])
]
)
]
)Finally, we can build it against the WebAssembly SDK - the part we've been waiting for:
swift build --swift-sdk swift-6.3.2-RELEASE_wasm -c release
cp .build/wasm32-unknown-wasip1/release/GreetingGuest.wasm ../greeting_v1.wasm
ls -lh ../greeting_v1.wasmYou now have greeting_v1.wasm, a real WebAssembly module containing compiled Swift. The output path uses the wasm32-unknown-wasip1 triple, which is the one the current swift.org WebAssembly SDK emits. No app involved yet.
The host: run the module in an iOS app with WasmKit
We have the WASM module, but no one is consuming it. Here, we'll set up a Swift package for the app side. WasmKit is a pure-Swift WebAssembly runtime, so it builds for iOS as a normal package dependency with nothing extra to bundle:
// Package.swift (excerpt)
dependencies: [
.package(url: "https://github.com/swiftwasm/WasmKit.git", exact: "0.2.2"),
// Pin swift-system below 1.7.0. WasmKit 0.2.2 fails to build against 1.7.0
// (a `var stat: stat` shadowing break).
.package(url: "https://github.com/apple/swift-system", "1.5.0" ..< "1.7.0"),
],
targets: [
.target(
name: "MiniPatch",
dependencies: [
.product(name: "WasmKit", package: "WasmKit"),
.product(name: "WasmKitWASI", package: "WasmKit"),
.product(name: "SystemPackage", package: "swift-system"),
]
),
]Here is the runner that loads a module's bytes, instantiates it, and reads the greeting back out:
import WasmKit
import WasmKitWASI
import Foundation
final class GreetingModule {
private let instance: Instance
init(wasmBytes: [UInt8]) throws {
let engine = Engine()
let store = Store(engine: engine)
// A WASI environment satisfies the clock/random/fd imports that the
// Swift standard library pulls in at link time.
let wasi = try WASIBridgeToHost()
var imports = Imports()
wasi.link(to: &imports, store: store)
let module = try parseWasm(bytes: wasmBytes)
self.instance = try module.instantiate(store: store, imports: imports)
// Reactor modules expose an `_initialize` that must run once before any
// other export. Nothing runs it for us; the host must call it, and that
// is what this line does. `initialize` is a no-op if the module has none.
try wasi.initialize(instance)
}
func greeting() throws -> String {
guard let lenFn = instance.exports[function: "greeting_len"],
let byteFn = instance.exports[function: "greeting_byte"] else {
throw RunnerError.exportMissing
}
guard case .i32(let lenRaw) = try lenFn.invoke([]).first else {
throw RunnerError.badResult
}
let count = Int(Int32(bitPattern: lenRaw))
var bytes = [UInt8]()
bytes.reserveCapacity(count)
for i in 0..<count {
guard case .i32(let b) = try byteFn.invoke([.i32(UInt32(i))]).first else {
throw RunnerError.badResult
}
bytes.append(UInt8(truncatingIfNeeded: Int32(bitPattern: b)))
}
return String(decoding: bytes, as: UTF8.self)
}
}
enum RunnerError: Error {
case exportMissing
case badResult
}A note on the Value handling, since it is easy to get wrong. invoke returns [Value], and a 32-bit result arrives as .i32(let raw) where raw is a UInt32. Swift functions returning Int32 need Int32(bitPattern: raw) to read the signed value back, which is why both the length and the byte go through bitPattern:.
Now we'll wire up a SwiftUI screen that holds the current module and shows its greeting. The screen does not know or care where the bytes came from. That is the whole point - from the UI's perspective, it's dealing with a native Swift String:
import SwiftUI
@MainActor
final class GreetingStore: ObservableObject {
@Published var text: String = "loading..."
func load(from bytes: [UInt8]) {
do {
let module = try GreetingModule(wasmBytes: bytes)
self.text = try module.greeting()
} catch {
self.text = "failed: \(error)"
}
}
}
struct GreetingScreen: View {
@StateObject private var store = GreetingStore()
var body: some View {
VStack(spacing: 16) {
Text(store.text)
.font(.title2)
Button("Check for update") {
Task { await UpdateClient.shared.fetchLatest(into: store) }
}
}
.padding()
.task {
// First run: load the version we shipped inside the app bundle.
if let url = Bundle.main.url(forResource: "greeting_v1", withExtension: "wasm"),
let data = try? Data(contentsOf: url) {
store.load(from: [UInt8](data))
}
}
}
}Build and run this on a simulator or device with greeting_v1.wasm added to the app bundle. The screen reads Hello from version one. That string came out of the WebAssembly interpreter, not out of the signed binary.
The patch: change the greeting without rebuilding the app
This is the exciting part - or at least, the part that wouldn't be possible without the legwork we just put in. Specifically: here is where what we did becomes an over-the-air update. We make a second module with a different greeting, serve it from anywhere reachable over HTTP, and have the running app pick it up.
Make a second guest with the same two exports and a different message. Reuse the GreetingGuest package, change the message line, and build again:
// Sources/GreetingGuest/greeting.swift (changed message)
private let message = "Hello from version two, shipped without review"export PATH="$HOME/.swiftly/bin:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin"
hash -r
swift build --swift-sdk swift-6.3.2-RELEASE_wasm -c release
cp .build/wasm32-unknown-wasip1/release/GreetingGuest.wasm ../greeting_v2.wasmServe it. For a local test, any static file server works. From the directory holding the file:
python3 -m http.server 8080
# greeting_v2.wasm is now at http://localhost:8080/greeting_v2.wasmWe'll add an update client in the app, which downloads whatever module the server points at and hands the bytes to the same GreetingStore.load we already have:
import Foundation
actor UpdateClient {
static let shared = UpdateClient()
// Point this at your server. On the simulator, localhost reaches your Mac.
private let latestURL = URL(string: "http://localhost:8080/greeting_v2.wasm")!
func fetchLatest(into store: GreetingStore) async {
do {
let (data, _) = try await URLSession.shared.data(from: latestURL)
let bytes = [UInt8](data)
await store.load(from: bytes)
} catch {
// On any failure, the previously loaded module keeps running.
print("update failed, keeping current module:", error)
}
}
}Run the app. It shows Hello from version one. Tap Check for update. The screen changes to Hello from version two, shipped without review. The app binary on disk is byte for byte the same as before. You changed what the app says by downloading compiled Swift and running it.
That is the entire loop: compile changed Swift to WebAssembly, download it, run it on device, and the behavior changes. Rollback is the same path in reverse. The server just points latestURL back at greeting_v1.wasm and the next check loads it.
If you got the greeting to flip, stop and notice what is missing from that experience. There was no Xcode rebuild after greeting_v1. There was no resubmission. There was no waiting. Everything from here is about understanding why it worked and where it stops working.
I'll admit - this example seems slightly too trivial. You're probably thinking: why not just serve the string from a backend like normal? In this case, yes, that's the obvious option. But we now have the ability to run code via WASM, giving us control of not just simple string values but the ability to write and re-write entire functions.
Part two: now take it apart
So, the loop runs. Time to go back through each piece and explain what it is actually doing, because the constraints are where the real engineering lives.
Why PATH order is load-bearing
Your Mac already has a Swift compiler, the one inside Xcode. That Apple toolchain cannot cross-compile to WebAssembly. The swift.org toolchain that swiftly installs can. Both put a swift and swiftc on disk, and whichever appears first on PATH wins.
So the rule is: when compiling to wasm, the swiftly-managed toolchain comes first; when building the host iOS app, the Apple toolchain comes first. The wasm-compile setup at the top of Part one does exactly that:
export PATH="$HOME/.swiftly/bin:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin"
hash -rThe hash -r matters as much as the export. Your shell caches the resolved location of swift and swiftc from the last time you ran them. Change PATH without clearing that cache and the shell keeps calling the old binary. If a wasm build produces nothing usable, or swift sdk list shows no WebAssembly SDK when you know one is installed, check which swift before anything else.
Install both the toolchain and the SDK through swiftly, the Swift toolchain manager. swift sdk install verifies the WebAssembly SDK bundle against a published checksum, so that is the path to use rather than hand-downloading anything.
Why we build through SwiftPM and not a raw swiftc
The compile in Part one goes through a one-target SwiftPM package rather than a direct swiftc line, and the reason is the sysroot. The WebAssembly SDK is an artifact bundle that carries a WASI sysroot, a Swift resource directory, and the static-stdlib settings the wasm target needs. swift build --swift-sdk <id> reads the SDK id, finds that bundle, and passes every one of those paths to the compiler and linker for you. A bare swiftc -target wasm32-unknown-wasip1 greeting.swift does not know where the sysroot is, so it fails to resolve the standard library. You can wire all of it by hand, but it is brittle and changes between SDK releases. Letting SwiftPM assemble it from the SDK id is the stable path, which is why the SDK id from swift sdk list is the thing you actually pass to the build.
The package also pins the target to an executableTarget. That is deliberate. A library target compiles to a .o and not a standalone module you can instantiate. An executable target, built in reactor mode, is what produces a self-contained <name>.wasm with callable exports.
What @_cdecl and the export flags do
WebAssembly modules expose functions by name. By default Swift mangles symbol names, so the host cannot look them up by a readable string. @_cdecl("greeting_len") gives the function an unmangled C symbol name, which is exactly the name the host passes to instance.exports[function: "greeting_len"].
@_cdecl alone is enough here. We don't need @_expose(wasm:). These underscored attributes change behavior between Swift releases, and on Swift 6.3.2 the single @_cdecl form is the one that compiles and exports cleanly. If you are on a different toolchain and the export goes missing, the attribute set is the first thing to check, not the linker flags.
The linker flags are the second half of making the export visible. -Xlinker --export=greeting_len tells the linker to keep that symbol in the final module's export table even though nothing inside the module calls it. Without it, dead-code elimination drops a function that only the host intends to call. You need one --export= per symbol you want to reach from outside.
A word on a flag you will see in real engines: --export-if-defined=greeting_len. It does almost the same thing, but it does not error if the symbol was already stripped by optimization, whereas --export hard-errors. For a large codebase that aggressively eliminates dead code and wants the build to keep going, --export-if-defined is the pragmatic choice. For a hand-written tutorial export, --export is the better default, because if optimization removes your function you want a loud failure, not a module that silently ships without the export and then throws exportMissing at runtime.
If your module also imports host functions it does not define (it does not here, but it will once you add a real bridge), you also pass -Xlinker --allow-undefined so the link does not fail on those unsatisfied imports. The host satisfies them at instantiation time. The greeting module has no such imports, so the two --export= flags are all it needs.
Why the module must be a reactor
There are two kinds of WebAssembly module Swift can produce. A command module has a _start entry point, runs once, and exits, like a command-line program. A reactor module exposes functions meant to be called repeatedly after the module is initialized, like a library.
We instantiate the greeting module once and then call its exports as many times as the screen re-renders. That is library usage, so we need a reactor. Swift defaults to a command module, which is why the build passes:
-Xclang-linker -mexec-model=reactorDrop that flag and Swift emits a command module with a _start and no reusable exports to instantiate against, and the runner finds nothing to call. The flag is the difference between a one-shot program and a callable library, and for an over-the-air patch you always want the library.
The reactor model also explains this line in the runner:
try wasi.initialize(instance)Reactor modules have an _initialize function that has to run once, before any other export, to set up the module's global state. Nothing runs it automatically. The host has to call it, and WASIBridgeToHost.initialize is the call that does so for a reactor module (and does nothing for a command module). Do not hand-roll the lookup of _initialize. Let the helper run it, because getting the ordering wrong produces failures that look like memory corruption rather than a missing init call. The thing to remember is that this is your responsibility to call, not a property of the module that happens on its own.
The WasmKit object graph
The runner's init builds four things in order, and each has a distinct job:
let engine = Engine() // the runtime configuration + execution machinery
let store = Store(engine: engine) // owns all instantiated state for this run
let module = try parseWasm(bytes: wasmBytes) // parsed, not yet runnable
let instance = try module.instantiate(store: store, imports: imports) // runnableThe Engine holds the runtime. The Store owns the live state of everything instantiated against it. parseWasm turns bytes into a validated Module, which is inert until you instantiate it. instantiate binds the module to the store, resolves its imports, and produces an Instance whose exports you can finally call. The separation matters for the swap we are about to discuss: parsing and instantiating a new module produces a brand new Instance with its own isolated state, which is what makes hot-swapping safe.
Moving real data across the Swift/WASM boundary
The byte-at-a-time trick in Part one works, but it is one host-to-guest call per byte and quadratic in the payload, which is fine for a demo and wrong for anything real. The correct way to move a string, a struct, or a JSON payload is through the module's linear memory.
WebAssembly memory is a single contiguous byte array the host can read and write directly. The pattern for passing data in is fixed: ask the guest to allocate a region, write your bytes into that region from the host, then call the real function with the pointer and length. To read data out, the guest returns a pointer and length and the host reads that range out of the same memory.
The guest exposes an allocator and a function that takes a pointer plus a length:
// greeting_mem.swift
import Foundation
@_cdecl("guest_alloc")
public func guest_alloc(_ size: Int32) -> Int32 {
let ptr = UnsafeMutableRawPointer.allocate(
byteCount: Int(size), alignment: 1
)
return Int32(Int(bitPattern: ptr))
}
@_cdecl("guest_free")
public func guest_free(_ ptr: Int32, _ size: Int32) {
UnsafeMutableRawPointer(bitPattern: Int(ptr))?.deallocate()
}
// Takes a UTF-8 name (ptr,len), returns a packed (ptr,len) of the greeting.
// We pack two i32s into one i64 so the function returns both at once.
@_cdecl("make_greeting")
public func make_greeting(_ namePtr: Int32, _ nameLen: Int32) -> Int64 {
let nameBytes = UnsafeBufferPointer(
start: UnsafePointer<UInt8>(bitPattern: Int(namePtr)),
count: Int(nameLen)
)
let name = String(decoding: nameBytes, as: UTF8.self)
let out = Array("Hello, \(name)".utf8)
let outPtr = UnsafeMutableRawPointer.allocate(byteCount: out.count, alignment: 1)
outPtr.copyMemory(from: out, byteCount: out.count)
let p = Int64(Int(bitPattern: outPtr))
let l = Int64(out.count)
return (p << 32) | l // high 32 bits = pointer, low 32 bits = length
}On the host, WasmKit exposes the instance's memory so you can write the input and read the output:
extension GreetingModule {
func makeGreeting(name: String) throws -> String {
guard let alloc = instance.exports[function: "guest_alloc"],
let make = instance.exports[function: "make_greeting"],
let free = instance.exports[function: "guest_free"],
let memory = instance.exports[memory: "memory"] else {
throw RunnerError.exportMissing
}
let nameBytes = Array(name.utf8)
// 1. Ask the guest for a region and write the name into it.
guard case .i32(let inPtr) =
try alloc.invoke([.i32(UInt32(nameBytes.count))]).first else {
throw RunnerError.badResult
}
memory.withUnsafeMutableBufferPointer(offset: UInt(inPtr),
count: nameBytes.count) { buf in
buf.copyBytes(from: nameBytes)
}
// 2. Call the real function. It returns a packed (ptr,len).
guard case .i64(let packed) = try make.invoke([
.i32(inPtr), .i32(UInt32(nameBytes.count))
]).first else {
throw RunnerError.badResult
}
let outPtr = UInt(packed >> 32)
let outLen = Int(packed & 0xFFFF_FFFF)
// 3. Read the result out of guest memory.
var result = [UInt8](repeating: 0, count: outLen)
memory.withUnsafeMutableBufferPointer(offset: outPtr, count: outLen) { buf in
result = Array(buf)
}
// 4. Hand the input region back. (The output region is leaked here for
// brevity; a real client frees it too once it has copied the bytes.)
_ = try? free.invoke([.i32(inPtr), .i32(UInt32(nameBytes.count))])
return String(decoding: result, as: UTF8.self)
}
}The exact memory accessor names track the WasmKit version, so check them against 0.2.2 if a call does not resolve. The shape is what matters and it does not change: the host owns the bytes outside, the guest owns the layout inside, and a pointer plus a length is the contract between them. Once you have this, you are no longer limited to numbers. Serialize a struct to JSON on one side and decode it on the other, and the patch can carry whatever data the changed code needs.
What the runtime swap really is
The "patch" in Part one was almost too simple to notice as a swap, so here it is stated plainly. GreetingStore.load builds a fresh GreetingModule from new bytes every time it is called:
func load(from bytes: [UInt8]) {
do {
let module = try GreetingModule(wasmBytes: bytes) // new Engine/Store/Instance
self.text = try module.greeting()
} catch {
self.text = "failed: \(error)"
}
}Each call produces a new Engine, Store, and Instance. The old instance and all its state are dropped when the old GreetingModule is released. There is no in-place mutation of running code and no shared mutable state between versions, which is what makes the swap safe to do at any point. A failed download or a corrupt module never replaces the working one, because load only assigns self.text after the new module has instantiated and produced a value. That property is the entire rollback story: keep the last known-good bytes, and reverting is loading them again.
A production client adds the parts a demo skips: cache the downloaded module to disk so the app starts on the latest version offline, verify a signature on the bytes before trusting them, and gate which devices receive which module so a bad patch reaches a small percentage first. None of those change the core. They wrap it.
The hard part: patching SwiftUI views
Everything so far patches a function whose result the app reads and displays. The host looks the export up by name and calls it. That model only works for code the app was written to call indirectly, which is the limitation I flagged at the top. Real apps nowadays are mostly SwiftUI view bodies, and a view body is not a function the app calls and reads. SwiftUI owns the call. The framework decides when body runs and what it does with the result. You cannot download a new body and ask the app to use it through the manual lookup we have been doing, because nothing in your code is positioned to substitute it.
The mechanism that solves this is the one Xcode Previews uses for hot reload: dynamic replacement. You mark a function dynamic, which tells the compiler to route every call to it through an indirection table instead of binding it at compile time. Then a separate piece of code registers a replacement with @_dynamicReplacement(for:), and from that point every call goes to the replacement instead of the original, with no change at the call site. That last part is the difference from Part one: the call site does not know it is being redirected, so existing native code that calls body is now running your replacement.
Applied to a view, the build-time scaffolding marks the body dynamic and generates a replacement that, instead of running the original Swift, runs the downloaded WebAssembly module and reconstructs a SwiftUI view from its output:
struct HomeView: View {
// The body is marked `dynamic` at build time so it can be replaced.
dynamic var body: some View {
VStack {
Text("Original native body")
}
}
}
// Generated scaffolding, compiled into the signed app, registers a replacement.
extension HomeView {
@_dynamicReplacement(for: body)
var patchedBody: some View {
// If a downloaded module covers this view, render from it.
// Otherwise fall back to the original native body.
PatchedBodyHost(typeName: "HomeView", fallback: { self.originalBody })
}
}That @_dynamicReplacement(for:) extension is the load-bearing trick. It is compiled into the signed binary, so it ships through review like any other code. At runtime it checks whether a downloaded module covers the view, and if so it renders from the module's output rather than the original body. If no patch covers the view, it renders the original.
Dynamic replacement solves who gets called. It does not solve what the replacement returns, and that is the harder half of the problem. A view body is not a value you can serialize and ship the way we shipped a String. some View is an opaque, generic, framework-private type, so you cannot send a live View across the WebAssembly boundary and ask the guest to hand one back. The guest has no SwiftUI linked into it, and the host cannot reconstruct an arbitrary opaque type out of bytes.
The general approach is to stop trying to ship a view and ship a description of one instead. At build time the view body is analyzed and lowered into a small, serializable representation: a tree of nodes that says render a VStack here, a Text with this content, this modifier with these values, this list bound to that data. The WebAssembly module emits that tree, and a renderer on the device walks it and builds real SwiftUI back up. The view's state, its stored properties and its @State and bindings, moves across the same linear-memory boundary from Part two, so the rebuilt view stays live and interactive rather than being a static snapshot. The dynamic-replacement thunk is what connects the two: it marshals the instance's state in, runs the module, and renders the tree the module hands back.
What lowers cleanly is a view assembled from constructs the engine understands, which is most everyday SwiftUI: the standard leaves like Text, Image, and Button, the standard containers like VStack, List, and ForEach, a wide range of modifiers, and the common state, navigation, and selection patterns. What does not lower stays native rather than being dropped. A custom child view, a modifier the engine has not been taught, or a closure that calls into native code is kept as a native slot that the build-time scaffolding fills in, so a view that is only partly expressible still renders, with the un-lowerable parts running as the original Swift. When a whole view cannot be expressed at all, the replacement falls back to the original native body, the fallback: in the example above, and the wall from earlier still stands: a view that needs a native symbol or an entitlement absent from the signed binary cannot be reached by any module. That lowering step, the renderer that rebuilds SwiftUI from the tree, and the scaffolding that generates the thunks are the substantial engineering, and they are where a demo like this one ends and a production system begins. The mechanics shown here, dynamic replacement and the data path across the boundary, are the foundation it sits on.
Where this leaves you
You compiled Swift to WebAssembly, ran it inside an iOS app with a pure-Swift runtime, moved structured data across the boundary, and changed the app's behavior at runtime by downloading a new module, with the signed binary untouched the whole time. That is the core mechanism for over-the-air updates to native Swift, minus the lowering engine that turns arbitrary SwiftUI into something the guest can emit.
There are obviously some limitations. This approach can change logic and UI that lower to your own code: a calculation, a formatting rule, a layout, a view body built from constructs the engine understands. But, it runs interpreted under WasmKit 0.2.2, so it is not the place for a hot loop that needs native speed, though it is fast enough for view logic and business rules. The two things it categorically cannot do are reach a native symbol or an entitlement that is not already in the signed binary. If a patch wants to call a framework API the original app never linked, the symbol is not there to call, and no downloaded module produces it.
If you want to keep going, the next pieces are the ones a real client needs and a demo skips: on-disk caching so the app boots on the latest module offline, signature verification before trusting downloaded bytes, and staged rollout so a bad patch hits a small fraction of devices first. The core you already have. The rest wraps it.
Or, try our managed platform Patch.