Swift
When and how an app adds Swift to its macOS build, using a SwiftUI gauge as the worked case.
Most of AppKit needs no Swift: Rust calls it through objc2, and the map widget is written that way. Add Swift only when the control you want exists in Swift or SwiftUI alone, such as SwiftUI's Gauge or Swift Charts, or when it is a SwiftUI control Apple restyles with each OS release, so hosting it keeps the app current. The rest of the app stays Rust, and off macOS the widget's drawn version is used.
The Places example has a circular gauge written in SwiftUI and shown through NSHostingView.

Captured on macOS 27.0, 2026-10-14.
Wire it up
Three pieces connect an app's Swift package to the Rust build.
- List the package in
nativedesktop.toml:
[macos]
swift_packages = ["swift/Widgets"]
minimum_system_version = "14.0"
entitlements = ["network.client"] # map tiles
From examples/native-widget/nativedesktop.toml, line 8.
- Call
nd::build::app()frombuild.rs, withndas a build dependency using thebuildfeature.nd newwrites this line into every template. On other targets it does nothing.
nd::build::app();
From examples/native-widget/build.rs, line 5.
- Register the Swift view next to the drawn one with
Backends::swift:
pub struct GaugeWidget;
impl NativeWidget for GaugeWidget {
type Props = GaugeProps;
type Event = Infallible;
fn semantics(props: &GaugeProps) -> Semantics {
Semantics::new(Role::ProgressIndicator).label(props.label.clone()).value(SemanticValue::range(
props.value,
props.min,
props.max,
))
}
fn drawn(props: &GaugeProps, _emit: Emit<Infallible>, _window: &mut Window, cx: &mut App) -> AnyElement {
let theme = cx.theme();
let fraction = ((props.value - props.min) / (props.max - props.min)).clamp(0.0, 1.0) as f32;
let (track, fill) = (theme.border.subtle, theme.accent);
v_flex()
.items_center()
.gap_1()
.child(
div()
.relative()
.size(px(48.))
.flex()
.items_center()
.justify_center()
.child(
canvas(move |bounds, paint, _cx| {
let radius = bounds.size.width.min(bounds.size.height) / 2. - px(3.);
paint.stroke_arc(bounds.center(), radius, 0.0, 1.0, px(5.), track);
paint.stroke_arc(bounds.center(), radius, 0.0, fraction, px(5.), fill);
})
.absolute()
.inset_0(),
)
.child(text(format!("{:.0}", props.value)).font_weight(FontWeight::SEMIBOLD).tabular_nums()),
)
.child(text(props.label.clone()).text_size(theme.type_scale.caption).text_color(theme.text.secondary))
.into_any_element()
}
fn backends() -> Backends<Self> {
let backends = Backends::drawn_only();
#[cfg(target_os = "macos")]
let backends = backends.swift(SwiftWidget::named("Gauge"));
backends
}
}
From examples/native-widget/src/gauge/mod.rs, line 19.
nd::build::app() collects the framework's Swift packages and the app's own, generates one aggregate SwiftPM package in OUT_DIR, builds one static library and links it into the binary. Nothing is written into your source tree. The app's Package.swift declares a plain static library with no dependencies; the script passes the framework's own package (NDSwift) to the aggregate through ND_SWIFT_PATH.
The Swift view
The Swift side exports C functions with @_cdecl, the same shape the framework's own Swift uses: create returns a +1 retained view built from JSON props, update applies new props and measure reports the fitting size. Props cross as JSON, which is fine because a widget's props cross only when they change. GaugeProps has the same field names on both sides, and the Rust props derive serde::Serialize, which Backends::swift requires.
The name given to SwiftWidget::named picks the symbols: "Gauge" is nd_widget_gauge_create, nd_widget_gauge_update and nd_widget_gauge_measure, and a name of several words joins them with underscores ("MapView" is nd_widget_map_view_create). The AppKit backend looks the three up in the running binary the first time the widget mounts. When they are missing, because the package is not in swift_packages or build.rs does not call nd::build::app(), it prints the symbol it wanted and draws the widget instead.
struct GaugeBody: View {
let props: GaugeProps
var body: some View {
Gauge(value: props.value, in: props.min...props.max) {
Text(props.label)
} currentValueLabel: {
Text(props.value, format: .number.precision(.fractionLength(0)))
}
.gaugeStyle(.accessoryCircularCapacity)
}
}
private func decode(_ json: UnsafePointer<CChar>) -> GaugeProps {
let data = Data(String(cString: json).utf8)
return (try? JSONDecoder().decode(GaugeProps.self, from: data))
?? GaugeProps(value: 0, min: 0, max: 100, label: "")
}
@MainActor
private func hosting(_ view: UnsafeMutableRawPointer) -> NSHostingView<GaugeBody> {
Unmanaged<NSHostingView<GaugeBody>>.fromOpaque(view).takeUnretainedValue()
}
/// Returns a +1 retained `NSHostingView`; the caller releases it.
@MainActor @_cdecl("nd_widget_gauge_create")
public func placesGaugeCreate(_ props: UnsafePointer<CChar>) -> UnsafeMutableRawPointer {
Unmanaged.passRetained(NSHostingView(rootView: GaugeBody(props: decode(props)))).toOpaque()
}
@MainActor @_cdecl("nd_widget_gauge_update")
public func placesGaugeUpdate(_ view: UnsafeMutableRawPointer, _ props: UnsafePointer<CChar>) {
hosting(view).rootView = GaugeBody(props: decode(props))
}
@MainActor @_cdecl("nd_widget_gauge_measure")
public func placesGaugeMeasure(
_ view: UnsafeMutableRawPointer, _ width: UnsafeMutablePointer<Double>,
_ height: UnsafeMutablePointer<Double>
) {
let size = hosting(view).fittingSize
width.pointee = size.width
height.pointee = size.height
}
From examples/native-widget/swift/Widgets/Sources/Widgets/Gauge.swift, line 14.
Pass universal state (enabled, tooltip, focus) into SwiftUI state yourself: a hosting view is not the control the user sees.
Events do not cross from Swift yet: the C functions take no emitter, so a Swift widget's Event is Infallible. A widget that reports user input writes its AppKit side in objc2, like the map.
Toolchain
Swift and the macOS SDK come from the system Xcode or Command Line Tools, never from Nix. The minimum versions are pinned in swift-toolchain.toml (Swift 6, SDK 26). nd::build::app() fails with a message when the installed majors are lower. The Swift runtime is the OS's own in /usr/lib/swift, present from macOS 14, so the example declares minimum_system_version = "14.0".
Inside the Nix dev shell the build resolves the SDK with xcrun --sdk macosx --show-sdk-path and links with /usr/bin/clang, so no SDKROOT needs setting.
When not to use Swift
- A control AppKit already has (
NSButton,NSTableView,MKMapView): use objc2. It has no second toolchain and the call goes straight from Rust. - A look you can compose from elements: write a composed widget, which runs on every OS.
- Anything that must also run on Linux or Windows: write the drawn version anyway. Swift only adds the macOS implementation.