swift-ros2 3.0.0 is out. It is the first stable 3.x release of the native Swift client library for ROS 2 that I introduced here in April.
swift-ros2 lets a Swift app join a ROS 2 graph directly, over Zenoh (round-trip compatible with rmw_zenoh_cpp) or DDS (rmw_cyclonedds_cpp). There is no bridge and no rcl cross-compile.

What’s new in 3.0
Three packages, mirroring upstream ROS 2. The project moved to the swift-ros2 GitHub organization:
| Package | What it holds |
|---|---|
| swift-ros2-rosidl 1.0.0 | XCDR v1 codec, message / service / action protocols, runtime interfaces, and the swift-ros2-gen generator with its SwiftPM build plugin |
| swift-ros2 3.0.0 | The runtime: ROS2Context, nodes, publishers, subscriptions, services, actions, parameters |
| swift-ros2-common-interfaces 1.0.0 | std_msgs, std_srvs, geometry_msgs, sensor_msgs, nav_msgs, shape_msgs, stereo_msgs, trajectory_msgs, visualization_msgs, diagnostic_msgs and tf2_msgs, generated from the IDL |
Wire interop
- Services and actions are keyed by their service-level type hashes.
- Over Zenoh a swift-ros2 node announces itself as a real node, with a GID per publisher and client.
- Wire DDS nodes appear in the ROS graph through
ros_discovery_info. - Late-joining action clients get the current status.
Verified against live ROS 2 Humble, Jazzy, Kilted and Lyrical hosts over Zenoh and DDS, through a fresh-user walkthrough of the docs, and in Conduit.
Looking back: the real ROS 2 stack since 2.0
swift-ros2 began in April as a pure-Swift implementation of the ROS 2 wire protocols over zenoh-pico and CycloneDDS. That made it portable, but it meant re-implementing type hashes, QoS rules and graph details and keeping them in step with upstream. The 2.x line changed the foundation:
- 1.3.0 (July) added a native RCL backend: upstream
rclwithrmw_zenoh_cpporrmw_cyclonedds_cpp. It ships as prebuilt xcframeworks on Apple and links a system ROS 2 install on Linux. - 1.4.0 turned it on by default on Apple.
- 2.0.0 (September) removed the wire clients from the public API.
ROS2Contextpicks the backend. The pure-Swift wire path stays as an internal fallback whererclis not available, such as Android, Windows and Humble hosts. - 2.2.0 rebuilt the Apple RCL stack from ROS 2 Lyrical.
On the RCL backend, type hashes, QoS semantics, the node graph and introspection match upstream by construction, because they are upstream. 3.0 builds on that foundation.
A taste of the API
Topics and services
import SwiftROS2
import CommonInterfaces
let context = try await ROS2Context(
transport: .zenoh(locator: "tcp/192.168.1.100:7447"), distro: .jazzy, domainId: 123)
let node = try await context.createNode(name: "sensor_node", namespace: "/ios")
let pub = try await node.createPublisher(Imu.self, topic: "imu")
try pub.publish(Imu(header: Header.now(frameId: "imu_link"),
linearAcceleration: Vector3(x: 0, y: 0, z: 9.81)))
let svc = try await node.createService(TriggerSrv.self, name: "/trigger") { _ in
TriggerSrv.Response(success: true, message: "ok")
}
Actions (example_interfaces/action/Fibonacci, server with feedback, and a client)
actor FibonacciHandler: ActionServerHandler {
typealias Action = FibonacciAction
func handleGoal(_ goal: FibonacciAction.Goal) async -> GoalResponse {
goal.order > 0 ? .accept : .reject
}
func handleCancel(_ handle: ActionGoalHandle<FibonacciAction>) async -> CancelResponse { .accept }
func execute(_ handle: ActionGoalHandle<FibonacciAction>) async throws -> FibonacciAction.Result {
var sequence: [Int32] = [0, 1]
for _ in 2..<10 {
sequence.append(sequence[sequence.count - 1] + sequence[sequence.count - 2])
try await handle.publishFeedback(FibonacciAction.Feedback(sequence: sequence))
}
return FibonacciAction.Result(sequence: sequence)
}
}
let server = try await node.createActionServer(
FibonacciAction.self, name: "/fibonacci", handler: FibonacciHandler())
let client = try await node.createActionClient(FibonacciAction.self, name: "/fibonacci")
try await client.waitForActionServer(timeout: .seconds(5))
let goal = try await client.sendGoal(FibonacciAction.Goal(order: 10))
Task { for await feedback in goal.feedback { print(feedback.sequence) } }
if case .succeeded(let result) = try await goal.result() { print(result.sequence) }
Parameters (visible to ros2 param list / get / set / describe)
_ = try await node.declareParameter(
"rate", default: Int64(30),
descriptor: ROS2ParameterDescriptor(
name: "rate", type: .integer, description: "publish rate in Hz",
integerRange: Int64(1)...Int64(120)))
_ = await node.setOnSetParametersCallback { proposed in
for p in proposed where p.name == "rate" {
if case .integer(let v) = p.value, !(1...120).contains(v) {
return .failure(reason: "rate must be in [1, 120]")
}
}
return .success()
}
Runnable versions of all of these live in Sources/Examples (swift run action-server zenoh, swift run parameter-demo zenoh, …).
Platforms
iOS / iPadOS 16+, macOS 13+, Mac Catalyst, visionOS, Linux (Ubuntu 22.04 / 24.04, x86_64 and aarch64), Windows 10 / 11, and Android API 28+ (Zenoh only).
Upgrading from 2.x? See MIGRATION.md.
See it live
I’m presenting swift-ros2 and Conduit at ROS By-The-Bay on Thursday, October 22, at Intrinsic HQ in Mountain View, with a live demo of Apple devices talking to ROS 2 nodes on Linux:
Feedback, issues and PRs are very welcome.