NativeDesktop

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.

Example: Places. App-defined native widgets: an objc2 MapKit view and a Swift-hosted SwiftUI gauge, each with a drawn fallback. Source in examples/native-widget.
Places on macOS

Captured on macOS 27.0, 2026-10-14.

Wire it up

Three pieces connect an app's Swift package to the Rust build.

  1. 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.

  1. Call nd::build::app() from build.rs, with nd as a build dependency using the build feature. nd new writes this line into every template. On other targets it does nothing.
nd::build::app();

From examples/native-widget/build.rs, line 5.

  1. 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