Skip to content

Commit 23b2aaf

Browse files
committed
feat(ffi): add Swift support
1 parent 5b755ac commit 23b2aaf

9 files changed

Lines changed: 4239 additions & 0 deletions

File tree

.github/workflows/ci.yml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,30 @@ jobs:
198198
./gradlew :examples:simple_client:build
199199
./gradlew :examples:quic_client:build
200200
201+
swift-bindings:
202+
name: Swift Bindings
203+
runs-on: macos-latest
204+
steps:
205+
- name: Checkout code
206+
uses: actions/checkout@v4
207+
208+
- name: Install Rust
209+
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8
210+
211+
- name: Cache Cargo dependencies
212+
uses: actions/cache@v4
213+
with:
214+
path: |
215+
~/.cargo/registry
216+
~/.cargo/git
217+
target
218+
key: ${{ runner.os }}-cargo-swift-${{ hashFiles('**/Cargo.lock') }}
219+
restore-keys: |
220+
${{ runner.os }}-cargo-swift-
221+
222+
- name: Build Rust, Generate Swift Bindings, and Verify swift build
223+
run: ./scripts/build_swift_bindings.sh --test
224+
201225
coverage:
202226
name: Code Coverage
203227
runs-on: ubuntu-latest
@@ -286,6 +310,8 @@ jobs:
286310
os: ubuntu-latest
287311
- target: x86_64-apple-darwin
288312
os: macos-latest
313+
- target: aarch64-apple-darwin
314+
os: macos-latest
289315
- target: x86_64-pc-windows-msvc
290316
os: windows-latest
291317

.gitignore

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,14 @@ Cargo.lock
88
*.profraw
99
*.profdata
1010

11+
# Swift: runtime library copies and XCFramework build artifacts (populated by build_swift_bindings.sh)
12+
swift/.build/
13+
swift/.swiftpm/
14+
swift/lib/
15+
swift/Generated/
16+
swift/xcframework-intermediates/
17+
swift/FlowSDK.xcframework/
18+
1119

1220
# FFI generated code
1321
python/package/flowsdk/flowsdk_ffi.py
@@ -22,6 +30,7 @@ python/examples/*.so
2230
python/examples/*.dll
2331

2432
python/**/*.pyc
33+
python/package/flowsdk.egg-info/
2534

2635
# Kotlin Examples (generated files)
2736
kotlin/package/src/main/resources/libflowsdk_ffi.dylib

scripts/build_swift_bindings.sh

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
#!/bin/bash
2+
set -e
3+
4+
# Default to debug build
5+
PROFILE="debug"
6+
CARGO_PROFILE="dev"
7+
TARGET_DIR="target/debug"
8+
9+
if [[ "$1" == "--release" ]]; then
10+
PROFILE="release"
11+
CARGO_PROFILE="release"
12+
TARGET_DIR="target/release"
13+
shift
14+
fi
15+
16+
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
17+
cd "$REPO_ROOT"
18+
19+
# Temporary generation dir; final files are distributed below
20+
SWIFT_GEN_DIR="swift/Generated"
21+
# SPM target paths (must stay in sync with Package.swift)
22+
SWIFT_C_TARGET="swift/Sources/flowsdk_ffi"
23+
SWIFT_TARGET="swift/Sources/FlowSDK"
24+
SWIFT_LIB_DIR="swift/lib"
25+
26+
echo "Building flowsdk_ffi ($PROFILE)..."
27+
cargo build -p flowsdk_ffi --profile "$CARGO_PROFILE" --features quic
28+
29+
echo "Generating Swift bindings..."
30+
mkdir -p "$SWIFT_GEN_DIR"
31+
cargo run -p flowsdk_ffi --features=uniffi/cli,quic --bin uniffi-bindgen generate \
32+
--library "$TARGET_DIR/libflowsdk_ffi.dylib" \
33+
--language swift \
34+
--out-dir "$SWIFT_GEN_DIR"
35+
36+
echo "Distributing generated files to SPM target directories..."
37+
# C module target: only the header (SPM auto-generates the module map)
38+
mkdir -p "$SWIFT_C_TARGET"
39+
cp "$SWIFT_GEN_DIR/flowsdk_ffiFFI.h" "$SWIFT_C_TARGET/flowsdk_ffi.h"
40+
# Swift target: the generated Swift bindings
41+
mkdir -p "$SWIFT_TARGET"
42+
cp "$SWIFT_GEN_DIR/flowsdk_ffi.swift" "$SWIFT_TARGET/"
43+
# Normalize the SPM module name so consumers import `flowsdk_ffi` instead of `flowsdk_ffiFFI`.
44+
perl -0pi -e 's/canImport\(flowsdk_ffiFFI\)/canImport(flowsdk_ffi)/g; s/import flowsdk_ffiFFI/import flowsdk_ffi/g' "$SWIFT_TARGET/flowsdk_ffi.swift"
45+
# Clean up temp dir
46+
rm -rf "$SWIFT_GEN_DIR"
47+
48+
echo "Copying library for Swift package..."
49+
mkdir -p "$SWIFT_LIB_DIR"
50+
cp "$TARGET_DIR/libflowsdk_ffi.dylib" "$SWIFT_LIB_DIR/"
51+
52+
if [[ "$1" == "--xcframework" ]]; then
53+
echo "Building multi-arch XCFramework..."
54+
55+
# Ensure required targets are installed
56+
rustup target add aarch64-apple-darwin x86_64-apple-darwin aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios 2>/dev/null || true
57+
58+
# Build macOS slices
59+
echo " Building aarch64-apple-darwin..."
60+
cargo build -p flowsdk_ffi --profile "$CARGO_PROFILE" --features quic --target aarch64-apple-darwin
61+
echo " Building x86_64-apple-darwin..."
62+
cargo build -p flowsdk_ffi --profile "$CARGO_PROFILE" --features quic --target x86_64-apple-darwin
63+
64+
# Build iOS device
65+
echo " Building aarch64-apple-ios..."
66+
cargo build -p flowsdk_ffi --profile "$CARGO_PROFILE" --features quic --target aarch64-apple-ios
67+
68+
# Build iOS simulator (universal: arm64 sim + x86_64 sim)
69+
echo " Building aarch64-apple-ios-sim..."
70+
cargo build -p flowsdk_ffi --profile "$CARGO_PROFILE" --features quic --target aarch64-apple-ios-sim
71+
echo " Building x86_64-apple-ios (simulator)..."
72+
cargo build -p flowsdk_ffi --profile "$CARGO_PROFILE" --features quic --target x86_64-apple-ios
73+
74+
MACOS_ARM="target/aarch64-apple-darwin/$PROFILE/libflowsdk_ffi.a"
75+
MACOS_X86="target/x86_64-apple-darwin/$PROFILE/libflowsdk_ffi.a"
76+
IOS_ARM="target/aarch64-apple-ios/$PROFILE/libflowsdk_ffi.a"
77+
IOS_SIM_ARM="target/aarch64-apple-ios-sim/$PROFILE/libflowsdk_ffi.a"
78+
IOS_SIM_X86="target/x86_64-apple-ios/$PROFILE/libflowsdk_ffi.a"
79+
80+
mkdir -p swift/xcframework-intermediates
81+
82+
# Universal macOS .a
83+
echo " Lipo-ing macOS slices..."
84+
lipo -create "$MACOS_ARM" "$MACOS_X86" \
85+
-output swift/xcframework-intermediates/libflowsdk_ffi-macos.a
86+
87+
# Universal iOS simulator .a
88+
echo " Lipo-ing iOS simulator slices..."
89+
lipo -create "$IOS_SIM_ARM" "$IOS_SIM_X86" \
90+
-output swift/xcframework-intermediates/libflowsdk_ffi-ios-sim.a
91+
92+
echo " Creating XCFramework..."
93+
rm -rf swift/FlowSDK.xcframework
94+
xcodebuild -create-xcframework \
95+
-library swift/xcframework-intermediates/libflowsdk_ffi-macos.a \
96+
-headers "$SWIFT_C_TARGET" \
97+
-library "$IOS_ARM" \
98+
-headers "$SWIFT_C_TARGET" \
99+
-library swift/xcframework-intermediates/libflowsdk_ffi-ios-sim.a \
100+
-headers "$SWIFT_C_TARGET" \
101+
-output swift/FlowSDK.xcframework
102+
103+
echo " XCFramework created at swift/FlowSDK.xcframework"
104+
fi
105+
106+
if [[ "$1" == "--test" ]]; then
107+
echo "Running Swift build verification..."
108+
LIBRARY_PATH="$PWD/$SWIFT_LIB_DIR" swift build --package-path swift
109+
fi
110+
111+
echo "Done!"
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// FlowSDK QUIC MQTT client example (macOS / Linux)
3+
//
4+
// Usage:
5+
// LIBRARY_PATH=swift/lib swift run QuicClientExample [host] [port]
6+
//
7+
// For Wireshark key logging:
8+
// SSLKEYLOGFILE=~/tmp/sslkeylog.txt LIBRARY_PATH=swift/lib swift run QuicClientExample
9+
//
10+
// Default broker: broker.emqx.io:14567
11+
12+
import Foundation
13+
import FlowSDK
14+
15+
#if canImport(Darwin)
16+
import Darwin
17+
#elseif canImport(Glibc)
18+
import Glibc
19+
#endif
20+
21+
// MARK: - Configuration
22+
23+
private let brokerHost = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "broker.emqx.io"
24+
private let brokerPort = CommandLine.arguments.count > 2 ? Int(CommandLine.arguments[2])! : 14567
25+
private let runDurationMs: UInt64 = 10_000
26+
private let tickIntervalMs: UInt64 = 10
27+
private let recvBufSize = 65536
28+
29+
// MARK: - POSIX UDP helpers
30+
31+
private func resolveHostAddr(_ host: String, port: Int) -> sockaddr_in {
32+
var hints = addrinfo()
33+
hints.ai_family = AF_INET
34+
hints.ai_socktype = SOCK_DGRAM
35+
var res: UnsafeMutablePointer<addrinfo>?
36+
guard getaddrinfo(host, String(port), &hints, &res) == 0, let ai = res else {
37+
fatalError("getaddrinfo failed for \(host):\(port)")
38+
}
39+
defer { freeaddrinfo(res) }
40+
var addr = sockaddr_in()
41+
withUnsafeMutableBytes(of: &addr) {
42+
$0.copyMemory(from: UnsafeRawBufferPointer(start: ai.pointee.ai_addr,
43+
count: Int(ai.pointee.ai_addrlen)))
44+
}
45+
return addr
46+
}
47+
48+
private func makeNonBlockingUdpSocket() -> Int32 {
49+
let fd = socket(AF_INET, SOCK_DGRAM, 0)
50+
guard fd >= 0 else { fatalError("socket() failed") }
51+
let flags = fcntl(fd, F_GETFL)
52+
_ = fcntl(fd, F_SETFL, flags | O_NONBLOCK)
53+
return fd
54+
}
55+
56+
private func sendDatagram(_ fd: Int32, data: Data, to addr: inout sockaddr_in) {
57+
guard !data.isEmpty else { return }
58+
data.withUnsafeBytes { ptr in
59+
withUnsafeBytes(of: &addr) { addrPtr in
60+
_ = sendto(fd, ptr.baseAddress!, data.count, 0,
61+
addrPtr.baseAddress!.assumingMemoryBound(to: sockaddr.self),
62+
socklen_t(MemoryLayout<sockaddr_in>.size))
63+
}
64+
}
65+
}
66+
67+
private func recvDatagram(_ fd: Int32, buf: inout [UInt8]) -> Data? {
68+
var src = sockaddr_in()
69+
var srcLen = socklen_t(MemoryLayout<sockaddr_in>.size)
70+
let n = withUnsafeMutableBytes(of: &src) { srcPtr in
71+
recvfrom(fd, &buf, buf.count, 0,
72+
srcPtr.baseAddress!.assumingMemoryBound(to: sockaddr.self),
73+
&srcLen)
74+
}
75+
guard n > 0 else { return nil }
76+
return Data(buf[0..<n])
77+
}
78+
79+
private func waitReadable(_ fd: Int32, timeoutMs: Int) -> Bool {
80+
var pfd = pollfd(fd: fd, events: Int16(POLLIN), revents: 0)
81+
return poll(&pfd, 1, Int32(timeoutMs)) > 0
82+
}
83+
84+
// MARK: - Engine helpers
85+
86+
func nowMs(since startMs: UInt64) -> UInt64 {
87+
return UInt64(Date().timeIntervalSince1970 * 1000) - startMs
88+
}
89+
90+
/// Addr string expected by the FFI engine: "1.2.3.4:14567"
91+
func addrString(addr: sockaddr_in, port: Int) -> String {
92+
var a = addr.sin_addr
93+
var buf = [CChar](repeating: 0, count: Int(INET_ADDRSTRLEN))
94+
inet_ntop(AF_INET, &a, &buf, socklen_t(INET_ADDRSTRLEN))
95+
return "\(String(cString: buf)):\(port)"
96+
}
97+
98+
func sendOutgoing(_ engine: QuicMqttEngineFfi, fd: Int32, to addr: inout sockaddr_in) {
99+
for dgram in engine.takeOutgoingDatagrams() {
100+
sendDatagram(fd, data: dgram.data, to: &addr)
101+
}
102+
}
103+
104+
// MARK: - Entry point
105+
106+
print("Initializing FlowSDK QUIC Swift Example...")
107+
108+
let opts = MqttOptionsFfi(
109+
clientId: "swift_quic_\(Int(Date().timeIntervalSince1970) % 100_000)",
110+
mqttVersion: 5,
111+
cleanStart: true,
112+
keepAlive: 30, // Must match QUIC idle timeout (30 s)
113+
username: nil,
114+
password: nil,
115+
reconnectBaseDelayMs: 1_000,
116+
reconnectMaxDelayMs: 10_000,
117+
maxReconnectAttempts: 3
118+
)
119+
120+
let engine = QuicMqttEngineFfi(opts: opts)
121+
print("QUIC Engine created.")
122+
123+
// Enable TLS key logging when SSLKEYLOGFILE is set (for Wireshark)
124+
let enableKeyLog = ProcessInfo.processInfo.environment["SSLKEYLOGFILE"] != nil
125+
let tlsOpts = MqttTlsOptionsFfi(
126+
caCertFile: nil,
127+
clientCertFile: nil,
128+
clientKeyFile: nil,
129+
insecureSkipVerify: true, // Demo broker only — do not use in production
130+
alpnProtocols: [],
131+
enableKeyLog: enableKeyLog
132+
)
133+
134+
// Open non-blocking UDP socket and resolve broker
135+
var brokerAddr = resolveHostAddr(brokerHost, port: brokerPort)
136+
let fd = makeNonBlockingUdpSocket()
137+
let serverAddrStr = addrString(addr: brokerAddr, port: brokerPort)
138+
139+
// Track relative time from engine creation (required by tick API)
140+
let engineStartMs = UInt64(Date().timeIntervalSince1970 * 1000)
141+
142+
print("Connecting to QUIC broker at \(serverAddrStr) (host: \(brokerHost))...")
143+
engine.connect(serverAddr: serverAddrStr, serverName: brokerHost,
144+
tlsOpts: tlsOpts, nowMs: nowMs(since: engineStartMs))
145+
// Tick immediately to generate initial QUIC handshake packets
146+
_ = engine.handleTick(nowMs: nowMs(since: engineStartMs))
147+
sendOutgoing(engine, fd: fd, to: &brokerAddr)
148+
149+
// Main event loop
150+
var recvBuf = [UInt8](repeating: 0, count: recvBufSize)
151+
var subscribed = false
152+
var published = false
153+
154+
while nowMs(since: engineStartMs) < runDurationMs {
155+
// Drain all received datagrams
156+
if waitReadable(fd, timeoutMs: Int(tickIntervalMs)) {
157+
while let data = recvDatagram(fd, buf: &recvBuf) {
158+
engine.handleDatagram(data: data, remoteAddr: serverAddrStr,
159+
nowMs: nowMs(since: engineStartMs))
160+
}
161+
}
162+
163+
// Tick the engine (drives QUIC timers + MQTT keepalive)
164+
let events = engine.handleTick(nowMs: nowMs(since: engineStartMs))
165+
166+
for event in events {
167+
switch event {
168+
case .connected(let r):
169+
print("Connected! sessionPresent=\(r.sessionPresent)")
170+
if !subscribed {
171+
let pid = engine.subscribe(topicFilter: "test/swift/quic", qos: 1)
172+
print("Subscribed to 'test/swift/quic' (PID \(pid))")
173+
subscribed = true
174+
sendOutgoing(engine, fd: fd, to: &brokerAddr)
175+
}
176+
case .subscribed(let r):
177+
print("Subscribe ack PID \(r.packetId)")
178+
if !published {
179+
let payload = Data("Hello from Swift QUIC!".utf8)
180+
let pid = engine.publish(topic: "test/swift/quic", payload: payload, qos: 1)
181+
print("Published to 'test/swift/quic' (PID \(pid))")
182+
published = true
183+
// Tick immediately so QUIC frames are generated before the next sendOutgoing
184+
_ = engine.handleTick(nowMs: nowMs(since: engineStartMs))
185+
sendOutgoing(engine, fd: fd, to: &brokerAddr)
186+
}
187+
case .messageReceived(let m):
188+
let msg = String(data: m.payload, encoding: .utf8) ?? "<binary>"
189+
print("✅ Message on '\(m.topic)': \(msg)")
190+
case .published(let r):
191+
print("✅ Publish ack PID \(r.packetId.map(String.init) ?? "none")")
192+
case .disconnected(let reasonCode):
193+
print("⚠️ Disconnected. reasonCode=\(String(describing: reasonCode))")
194+
case .error(let message):
195+
print("❌ Error: \(message)")
196+
case .reconnectNeeded:
197+
print("ℹ Reconnect needed")
198+
case .reconnectScheduled(let attempt, let delayMs):
199+
print("ℹ Reconnect attempt \(attempt) in \(delayMs)ms")
200+
case .pingResponse(let success):
201+
print("ℹ Ping response: success=\(success)")
202+
case .unsubscribed(_):
203+
break
204+
}
205+
}
206+
207+
// Forward engine-generated outgoing datagrams
208+
sendOutgoing(engine, fd: fd, to: &brokerAddr)
209+
}
210+
211+
// Graceful disconnect
212+
print("Run time elapsed, disconnecting...")
213+
engine.disconnect()
214+
sendOutgoing(engine, fd: fd, to: &brokerAddr)
215+
Darwin.close(fd)
216+
print("Done.")

0 commit comments

Comments
 (0)