iOS/macOS
This is the first of the shell chapters. We'll walk through how the Swift side talks to the Rust core, how each effect gets carried out, and how the views consume the view model. The other shell chapters follow the same structure in their own idioms.
The WeatherKit package
The Apple shell is split into two Swift targets:
WeatherApp(the app target) — just a few files: the@mainstruct, theLiveBridgethat talks to Rust, andContentViewas the root view.WeatherKit(a local Swift Package) — everything else:Core, every effect handler, every screen.
The split exists because building Swift is much faster than rebuilding the whole Rust framework, and SPM gives you the kind of iteration loop you'd expect from cargo. When you're tweaking a view, you only recompile the package. When you're iterating on effect handlers, same — the Rust library (and the Swift bindings it emits) only recompile when the core changes.
Everything in WeatherKit is written against a CoreBridge protocol rather than talking to the Rust FFI directly. That's what lets SwiftUI previews construct a Core with a FakeBridge; they don't need the Rust framework loaded. More on that at the end.
Booting the Core
Here's the app entry point:
init() {
let bridge = LiveBridge()
let core = Core(bridge: bridge)
_core = State(wrappedValue: core)
updater = CoreUpdater { core.update($0) }
core.update(.start)
}
Five lines: construct the bridge, build the Core, bind it to SwiftUI state, wire up an updater, and send Event::Start to kick the lifecycle. After that, the core starts fetching the API key and favourites — everything we described in chapter 3.
The FFI bridge
LiveBridge is the thin Swift type that carries events and effect responses across the FFI boundary:
import App
import Foundation
import os
import Shared
import WeatherKit
private let logger = Logger(subsystem: "com.crux.examples.weather", category: "live-bridge")
/// Wraps `CoreFfi` to communicate with the Rust core. Handles bincode
/// serialization/deserialization so that `Core` works with Swift types only.
/// This lives in the app target (not WeatherKit) so that SwiftUI previews
/// don't need to load the Rust framework.
struct LiveBridge: CoreBridge {
private let ffi: CoreFfi
init() {
ffi = CoreFfi()
}
func processEvent(_ event: Event) -> [Request] {
let eventBytes = try! event.bincodeSerialize() // swiftlint:disable:this force_try
logger.debug("sending \(eventBytes.count) event bytes")
let effects = [UInt8](ffi.update(Data(eventBytes)))
logger.debug("received \(effects.count) effect bytes")
return deserializeRequests(effects)
}
func resolve(requestId: UInt32, responseBytes: [UInt8]) -> [Request] {
logger.debug("resolve: id=\(requestId) sending \(responseBytes.count) bytes")
let effects = [UInt8](ffi.resolve(requestId, Data(responseBytes)))
return deserializeRequests(effects)
}
func currentView() -> ViewModel {
// swiftlint:disable:next force_try
try! .bincodeDeserialize(input: [UInt8](ffi.view()))
}
private func deserializeRequests(_ bytes: [UInt8]) -> [Request] {
if bytes.isEmpty { return [] }
if bytes.count < 8 {
logger.error("response too short (\(bytes.count) bytes)")
return []
}
return try! .bincodeDeserialize(input: bytes) // swiftlint:disable:this force_try
}
}
Three responsibilities:
processEvent(_:)serialises a SwiftEventwith bincode, callsCoreFfi.update(_:), and deserialises the returned effect requests.resolve(requestId:responseBytes:)does the same for effect responses — and, importantly, can return further effect requests (async commands produce more effects after each resolve).currentView()deserialises the current view model.
This is the only place that knows about bincode or CoreFfi. Everything else in the Swift code works with Swift types.
Handling effects
The Core class in WeatherKit owns the bridge and dispatches effect requests:
func processEffect(_ request: Request) {
switch request.effect {
case .render:
view = bridge.currentView()
case let .time(timeRequest):
resolveTime(request: timeRequest, requestId: request.id)
case let .secret(secretRequest):
resolveSecret(request: secretRequest, requestId: request.id)
case let .http(httpRequest):
resolveHttp(request: httpRequest, requestId: request.id)
case let .keyValue(kvRequest):
resolveKeyValue(request: kvRequest, requestId: request.id)
case let .location(locationRequest):
resolveLocation(request: locationRequest, requestId: request.id)
}
}
An exhaustive match on the effect type. Each branch delegates to a resolve<Capability> function defined in its own file (http.swift, kv.swift, location.swift, secret.swift, time.swift). The handlers are implemented as Swift extensions on Core, so they share state (like the KeyValueStore and the active timer list) without needing to pass it around.
Here's the HTTP handler in full:
import App
import Foundation
private let logger = Log.http
extension Core {
func resolveHttp(request: HttpRequest, requestId: UInt32) {
Task {
logger.debug("sending \(request.method) \(request.url)")
let result = await performHttpRequest(request)
resolve(requestId: requestId, serialize: { try result.bincodeSerialize() })
}
}
private func performHttpRequest(_ request: HttpRequest) async -> HttpResult {
guard let url = URL(string: request.url) else {
return .err(.url("Invalid URL"))
}
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = request.method
for header in request.headers {
urlRequest.addValue(header.value, forHTTPHeaderField: header.name)
}
if !request.body.isEmpty {
urlRequest.httpBody = Data(request.body)
}
do {
let (data, response) = try await URLSession.shared.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
return .err(.io("Not an HTTP response"))
}
logger.debug("received \(httpResponse.statusCode) from \(request.url)")
let headers = (httpResponse.allHeaderFields as? [String: String] ?? [:])
.map { HttpHeader(name: $0.key, value: $0.value) }
return .ok(
HttpResponse(
status: UInt16(httpResponse.statusCode),
headers: headers,
body: [UInt8](data)
)
)
} catch let error as URLError where error.code == .timedOut {
logger.debug("request timed out: \(request.url)")
return .err(.timeout)
} catch {
logger.warning("request failed: \(error.localizedDescription)")
return .err(.io(error.localizedDescription))
}
}
}
resolveHttp starts a Task to run off the main actor, performs the request with URLSession, serialises the result, and calls resolve(requestId:serialize:). That call is where things get interesting:
func resolve(requestId: UInt32, serialize: () throws -> [UInt8]) {
let responseBytes = try! serialize() // swiftlint:disable:this force_try
let requests = bridge.resolve(requestId: requestId, responseBytes: responseBytes)
for request in requests {
processEffect(request)
}
}
It passes the bytes to the bridge and then loops over any new effect requests that came back. This is a direct consequence of Command's async nature: a command written with .await points produces its next effect only after the previous one is resolved. The shell has to keep processing until the command's task finishes.
The other effect handlers follow the same shape — run the work, serialise the response, call resolve(requestId:serialize:).
Views driven by the ViewModel
The Core class exposes its view model via @Observable, so SwiftUI views can read it directly. @Observable signals at the property level: when one property changes from render to render, only views attached to that property re-render. The rest of the view hierarchy stays exactly as it was, rather than rebuilding wholesale each time the model updates.
The root ContentView dispatches on the top-level ViewModel variants:
import SwiftUI
import WeatherKit
struct ContentView: View {
@Environment(Core.self) var core
var body: some View {
switch core.view {
case .loading:
ProgressView("Loading...")
case let .onboard(onboard):
OnboardView(model: onboard)
case let .active(active):
ActiveView(model: active)
case let .failed(message):
FailedView(message: message)
}
}
}
Four lifecycle states, four views. ActiveView in turn dispatches on the active sub-variants (Home vs Favorites), and so on down the tree — each level of the model has a corresponding layer of view.
When the user taps a button, the view sends an event via the CoreUpdater that was injected into the environment at the app root. The event travels through the bridge, the core updates its state, and the @Observable property re-renders the view.
Previewing with FakeBridge
Because WeatherKit is written against CoreBridge, we can construct a Core for SwiftUI previews without loading the Rust framework:
import App
import Foundation
/// Abstraction over the Rust FFI boundary. Production uses `LiveBridge` (defined in the app);
/// previews use `FakeBridge` to avoid loading the Rust framework.
public protocol CoreBridge {
func processEvent(_ event: Event) -> [Request]
func resolve(requestId: UInt32, responseBytes: [UInt8]) -> [Request]
func currentView() -> ViewModel
}
/// No-op bridge for SwiftUI previews. Returns a static view model
/// and ignores all events.
#if DEBUG
public struct FakeBridge: CoreBridge {
let view: ViewModel
public init(view: ViewModel) {
self.view = view
}
public func processEvent(_: Event) -> [Request] { [] }
public func resolve(requestId _: UInt32, responseBytes _: [UInt8]) -> [Request] { [] }
public func currentView() -> ViewModel { view }
}
#endif
FakeBridge returns a static ViewModel and ignores everything else. Combined with the Core.forPreviewing helper, any view can be previewed with whatever ViewModel state you want — previews run as fast as regular SwiftUI previews, no FFI boundary to cross.
What's next
That's one shell end-to-end. The core doesn't know or care what platform it's on; everything platform-specific lives here. The other shell chapters walk through the same story — booting the core, the bridge, the effect handlers, the views — in Kotlin, Rust with Leptos, and TypeScript with React.
Happy building!