NativeDesktop

Swift

Add Swift to your app's macOS build when you need it, with a SwiftUI gauge as the worked example.

Most of AppKit needs no Swift. Rust calls it through objc2, and the map widget works that way. Add Swift only in these cases:

The rest of the app stays in Rust, and on other platforms, nd uses the widget's drawn version.

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

Places on Linux

Captured on NixOS 26.11 (Zokor), Hyprland, 2026-10-22.

Places on Windows

Captured on Windows 11 26H2, 2026-10-22.

Wire it up

To connect your 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 that uses 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 your app's own, and generates one aggregate SwiftPM package in OUT_DIR. It builds one static library and links it into the binary. It writes nothing into your source tree. Your app's Package.swift declares a plain static library with no dependencies. The build 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, in the same shape that the framework's own Swift uses:

Props cross as JSON. That's 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 that you give SwiftWidget::named picks the symbols. "Gauge" becomes nd_widget_gauge_create, nd_widget_gauge_update, and nd_widget_gauge_measure. A name of several words joins them with underscores: "MapView" becomes nd_widget_map_view_create. The AppKit backend looks up the three symbols in the running binary the first time the widget mounts. If they're missing, because the package isn't in swift_packages or build.rs doesn't call nd::build::app(), the backend 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, because a hosting view isn't the control that the user sees.

Events don't cross from Swift yet. The C functions take no emitter, so a Swift widget's Event is Infallible. If your widget reports user input, write 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. crates/nd/swift-toolchain.toml pins the minimum versions (Swift 6, SDK 26). When the installed major versions are lower, nd::build::app() fails with a message. The Swift runtime is the OS's own in /usr/lib/swift, which is present from macOS 14 on, 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 you don't need to set SDKROOT.

When not to use Swift