Skip to content

Commit f4031b4

Browse files
docs(examples): add iOS (Swift) example over the native C ABI
A SwiftPM package wrapping the timer library's native ABI behind an idiomatic `TimerNode` Swift class. `build-xcframework.sh` cross-compiles the Nim library to a static MyTimer.xcframework with three slices — ios-arm64 (device), ios-arm64-simulator, and macos-arm64 — assembling the .xcframework by hand so it works without a functioning Simulator toolchain (CI-friendly). The wrapper bridges the async FFI-thread callback to a synchronous Swift API with a semaphore and reads the typed EchoResponse struct out of the callback. The macos-arm64 slice makes the wrapper testable on the host: `swift test` passes against it. Device/simulator slices are the real iOS deployment artifacts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent edf6f50 commit f4031b4

9 files changed

Lines changed: 629 additions & 0 deletions

File tree

examples/timer/ios/.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
/MyTimer.xcframework/
2+
/.build-slices/
3+
/.build/
4+
*.o

examples/timer/ios/Package.swift

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// swift-tools-version:5.9
2+
import PackageDescription
3+
4+
// SwiftPM package wrapping the timer library for iOS (and macOS, so the Swift
5+
// wrapper is testable on the host with `swift test`).
6+
//
7+
// `MyTimer.xcframework` is produced by ./build-xcframework.sh and bundles the
8+
// static library for ios-arm64 (device), ios-arm64-simulator, and macos-arm64,
9+
// each with the C headers + module map. Run the build script before
10+
// `swift build` / `swift test`.
11+
let package = Package(
12+
name: "MyTimer",
13+
platforms: [.iOS(.v13), .macOS(.v12)],
14+
products: [
15+
.library(name: "MyTimer", targets: ["MyTimer"])
16+
],
17+
targets: [
18+
.binaryTarget(name: "CMyTimer", path: "MyTimer.xcframework"),
19+
.target(name: "MyTimer", dependencies: ["CMyTimer"]),
20+
.testTarget(name: "MyTimerTests", dependencies: ["MyTimer"]),
21+
]
22+
)

examples/timer/ios/README.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# iOS example — Swift over the native C ABI
2+
3+
A SwiftPM package that wraps the timer library's **native** (zero-serialization)
4+
C ABI behind an idiomatic Swift class, `TimerNode`. The library is cross-compiled
5+
to a static `.xcframework` and consumed from Swift; struct returns come back as
6+
typed Swift values.
7+
8+
```swift
9+
let node = try TimerNode(name: "my-app")
10+
print(try node.version()) // "nim-timer v0.1.0"
11+
let r = try node.echo("hello", delayMs: 5) // EchoResult
12+
print(r.echoed, r.timerName)
13+
```
14+
15+
## Layout
16+
17+
| Path | Description |
18+
|------|-------------|
19+
| `build-xcframework.sh` | Cross-compiles the Nim lib to `MyTimer.xcframework` (ios-arm64 device, ios-arm64 simulator, macos-arm64) and bundles the C headers + module map. |
20+
| `cheaders/` | The native C header (`my_timer.h`), CBOR header, and `module.modulemap` (module `CMyTimer`). |
21+
| `Sources/MyTimer/MyTimer.swift` | The Swift wrapper. Bridges the async FFI-thread callback to a synchronous Swift API with a semaphore; reads the typed `EchoResponse` struct out of the callback. |
22+
| `Package.swift` | SwiftPM: a binary target (the xcframework) + the Swift wrapper + tests. |
23+
| `Tests/` | Unit tests, runnable on the host via the macOS slice. |
24+
25+
## Build & test
26+
27+
```sh
28+
cd examples/timer/ios
29+
./build-xcframework.sh # builds the 3 slices into MyTimer.xcframework
30+
swift test # runs on the host (macos-arm64 slice)
31+
```
32+
33+
`build-xcframework.sh` assembles the `.xcframework` directly (no
34+
`xcodebuild -create-xcframework`), so it works in headless / CI environments.
35+
The **macos-arm64** slice exists only so the wrapper is testable with
36+
`swift test`; real iOS deployment uses the **ios-arm64** (device) and
37+
**ios-arm64-simulator** slices.
38+
39+
## Use it in an iOS app
40+
41+
1. Run `./build-xcframework.sh`.
42+
2. Add this directory as a local Swift package (Xcode → *Add Package
43+
Dependencies… → Add Local…*), or depend on it in your own `Package.swift`.
44+
3. `import MyTimer` and use `TimerNode`.
45+
46+
## Notes
47+
48+
- This is the **native, same-process** path — the app links the library directly.
49+
The CBOR ABI is for inter-process communication only (see [`../ipc`](../ipc)).
50+
- Each call is dispatched on the library's background FFI thread; `TimerNode`
51+
blocks on a semaphore until the result callback fires. A struct return (e.g.
52+
`EchoResponse`) is read inside the callback — it is valid only for the
53+
callback's lifetime — and copied into the returned Swift value.
54+
- Regenerate `cheaders/my_timer.h` from the repo root with `nimble genbindings_c`
55+
if the library's API changes.
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
// Idiomatic Swift wrapper over the timer library's native C ABI.
2+
//
3+
// Each call is dispatched on the library's background FFI thread and its result
4+
// arrives on a callback; we bridge that to a synchronous Swift API with a
5+
// semaphore. A struct return (EchoResponse) is read out of the typed C-POD
6+
// inside the callback — it is valid only for the callback's lifetime.
7+
import CMyTimer
8+
import Foundation
9+
10+
public enum TimerError: Error, CustomStringConvertible {
11+
case failed(String)
12+
public var description: String {
13+
switch self { case let .failed(m): return m }
14+
}
15+
}
16+
17+
public struct EchoResult: Equatable {
18+
public let echoed: String
19+
public let timerName: String
20+
}
21+
22+
public final class TimerNode {
23+
private let ctx: UnsafeMutableRawPointer
24+
25+
/// Creates the timer context (TimerConfig by value).
26+
public init(name: String) throws {
27+
let box = Box()
28+
let ud = Unmanaged.passUnretained(box).toOpaque()
29+
let cName = strdup(name)
30+
defer { free(cName) }
31+
var cfg = TimerConfig()
32+
cfg.name = UnsafePointer(cName)
33+
guard let c = my_timer_create(cfg, ackCallback, ud) else {
34+
throw TimerError.failed("create returned null")
35+
}
36+
box.sem.wait()
37+
guard box.ret == 0 else { throw TimerError.failed(box.text) }
38+
ctx = c
39+
}
40+
41+
/// String-returning call: the raw bytes are the version string.
42+
public func version() throws -> String {
43+
let box = Box()
44+
let ud = Unmanaged.passUnretained(box).toOpaque()
45+
guard my_timer_version(ctx, stringCallback, ud) == 0 else {
46+
throw TimerError.failed("version dispatch failed")
47+
}
48+
box.sem.wait()
49+
guard box.ret == 0 else { throw TimerError.failed(box.text) }
50+
return box.text
51+
}
52+
53+
/// Struct param in, typed struct (EchoResponse) out.
54+
public func echo(_ message: String, delayMs: Int = 0) throws -> EchoResult {
55+
let box = EchoBox()
56+
let ud = Unmanaged.passUnretained(box).toOpaque()
57+
let cMsg = strdup(message)
58+
defer { free(cMsg) }
59+
var req = EchoRequest()
60+
req.message = UnsafePointer(cMsg)
61+
req.delayMs = Int64(delayMs)
62+
guard my_timer_echo(ctx, echoCallback, ud, req) == 0 else {
63+
throw TimerError.failed("echo dispatch failed")
64+
}
65+
box.sem.wait()
66+
guard box.ret == 0 else { throw TimerError.failed(box.text) }
67+
return EchoResult(echoed: box.echoed, timerName: box.timerName)
68+
}
69+
70+
deinit { my_timer_destroy(ctx) }
71+
}
72+
73+
// MARK: - callback plumbing
74+
// The library calls back on its FFI thread; we keep the Box alive on the caller
75+
// stack (passUnretained) because the caller blocks on the semaphore until the
76+
// callback fires.
77+
78+
final class Box {
79+
var ret: Int32 = -1
80+
var text = ""
81+
let sem = DispatchSemaphore(value: 0)
82+
}
83+
final class EchoBox {
84+
var ret: Int32 = -1
85+
var text = ""
86+
var echoed = ""
87+
var timerName = ""
88+
let sem = DispatchSemaphore(value: 0)
89+
}
90+
91+
private func rawText(_ msg: UnsafePointer<CChar>?, _ len: Int) -> String {
92+
guard let m = msg, len > 0 else { return "" }
93+
let bytes = UnsafeRawPointer(m).assumingMemoryBound(to: UInt8.self)
94+
return String(decoding: UnsafeBufferPointer(start: bytes, count: len), as: UTF8.self)
95+
}
96+
97+
private func ackCallback(_ ret: Int32, _ msg: UnsafePointer<CChar>?,
98+
_ len: Int, _ ud: UnsafeMutableRawPointer?) {
99+
let box = Unmanaged<Box>.fromOpaque(ud!).takeUnretainedValue()
100+
box.ret = ret
101+
if ret != 0 { box.text = rawText(msg, len) }
102+
box.sem.signal()
103+
}
104+
105+
private func stringCallback(_ ret: Int32, _ msg: UnsafePointer<CChar>?,
106+
_ len: Int, _ ud: UnsafeMutableRawPointer?) {
107+
let box = Unmanaged<Box>.fromOpaque(ud!).takeUnretainedValue()
108+
box.ret = ret
109+
box.text = rawText(msg, len)
110+
box.sem.signal()
111+
}
112+
113+
private func echoCallback(_ ret: Int32, _ msg: UnsafePointer<CChar>?,
114+
_ len: Int, _ ud: UnsafeMutableRawPointer?) {
115+
let box = Unmanaged<EchoBox>.fromOpaque(ud!).takeUnretainedValue()
116+
box.ret = ret
117+
if ret == 0, let m = msg {
118+
// Native ABI: msg is a const EchoResponse* (typed struct return).
119+
let resp = UnsafeRawPointer(m).assumingMemoryBound(to: EchoResponse.self)
120+
box.echoed = resp.pointee.echoed.map { String(cString: $0) } ?? ""
121+
box.timerName = resp.pointee.timerName.map { String(cString: $0) } ?? ""
122+
} else {
123+
box.text = rawText(msg, len)
124+
}
125+
box.sem.signal()
126+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import XCTest
2+
@testable import MyTimer
3+
4+
final class MyTimerTests: XCTestCase {
5+
func testCreateVersionEcho() throws {
6+
let node = try TimerNode(name: "ios-demo")
7+
8+
XCTAssertEqual(try node.version(), "nim-timer v0.1.0")
9+
10+
let r = try node.echo("hello from Swift", delayMs: 2)
11+
XCTAssertEqual(r.echoed, "hello from Swift")
12+
XCTAssertEqual(r.timerName, "ios-demo") // proves the lib's own state round-tripped
13+
}
14+
15+
func testManyEchoes() throws {
16+
let node = try TimerNode(name: "loop")
17+
for i in 0..<200 {
18+
let r = try node.echo("m\(i)")
19+
XCTAssertEqual(r.echoed, "m\(i)")
20+
}
21+
}
22+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
#!/usr/bin/env bash
2+
# Build MyTimer.xcframework from the Nim timer library:
3+
# - ios-arm64 (device)
4+
# - ios-arm64-simulator (Apple-silicon simulator)
5+
# - macos-arm64 (so the Swift wrapper is testable with `swift test`)
6+
#
7+
# Each slice is a static library cross-compiled by Nim with the matching SDK,
8+
# then bundled with the C headers + module map. Requires Xcode + Nim.
9+
#
10+
# ./build-xcframework.sh
11+
set -euo pipefail
12+
13+
HERE="$(cd "$(dirname "$0")" && pwd)"
14+
REPO_ROOT="$(cd "$HERE/../../.." && pwd)"
15+
NIM_SRC="$REPO_ROOT/examples/timer/timer.nim"
16+
STAGE="$HERE/.build-slices"
17+
OUT="$HERE/MyTimer.xcframework"
18+
19+
CLANG="$(xcrun -f clang)"
20+
COMMON_NIM=(--mm:orc -d:release -d:chronicles_log_level=WARN --threads:on
21+
--os:macosx --cpu:arm64 --app:staticlib --noMain
22+
--nimMainPrefix:libmy_timer --cc:clang
23+
"--clang.exe:$CLANG" "--clang.linkerexe:$CLANG")
24+
25+
# build_slice <name> <sdk> <min-flag>
26+
build_slice() {
27+
local name="$1" sdk="$2" minflag="$3"
28+
local sysroot; sysroot="$(xcrun --sdk "$sdk" --show-sdk-path)"
29+
local dir="$STAGE/$name"
30+
mkdir -p "$dir"
31+
echo ">> building slice: $name ($sdk)"
32+
( cd "$REPO_ROOT" && nim c "${COMMON_NIM[@]}" \
33+
--nimcache:"$dir/nimcache" \
34+
--passC:"-isysroot $sysroot -arch arm64 $minflag" \
35+
--passL:"-isysroot $sysroot -arch arm64 $minflag" \
36+
-o:"$dir/libmy_timer.a" "$NIM_SRC" >/dev/null )
37+
}
38+
39+
rm -rf "$STAGE" "$OUT"
40+
build_slice device iphoneos "-miphoneos-version-min=13.0"
41+
build_slice simulator iphonesimulator "-mios-simulator-version-min=13.0"
42+
build_slice macos macosx "-mmacosx-version-min=12.0"
43+
44+
echo ">> assembling $OUT"
45+
# Assemble the .xcframework by hand (a directory + Info.plist) rather than via
46+
# `xcodebuild -create-xcframework` — same on-disk format, but no dependency on a
47+
# working Simulator toolchain, so it builds in headless / CI environments too.
48+
add_slice() { # <stage-name> <library-identifier>
49+
local dir="$OUT/$2"
50+
mkdir -p "$dir/Headers"
51+
cp "$STAGE/$1/libmy_timer.a" "$dir/libmy_timer.a"
52+
cp "$HERE/cheaders/"* "$dir/Headers/"
53+
}
54+
mkdir -p "$OUT"
55+
add_slice device ios-arm64
56+
add_slice simulator ios-arm64-simulator
57+
add_slice macos macos-arm64
58+
59+
cat > "$OUT/Info.plist" <<'PLIST'
60+
<?xml version="1.0" encoding="UTF-8"?>
61+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
62+
<plist version="1.0">
63+
<dict>
64+
<key>AvailableLibraries</key>
65+
<array>
66+
<dict>
67+
<key>LibraryIdentifier</key><string>ios-arm64</string>
68+
<key>LibraryPath</key><string>libmy_timer.a</string>
69+
<key>HeadersPath</key><string>Headers</string>
70+
<key>SupportedArchitectures</key><array><string>arm64</string></array>
71+
<key>SupportedPlatform</key><string>ios</string>
72+
</dict>
73+
<dict>
74+
<key>LibraryIdentifier</key><string>ios-arm64-simulator</string>
75+
<key>LibraryPath</key><string>libmy_timer.a</string>
76+
<key>HeadersPath</key><string>Headers</string>
77+
<key>SupportedArchitectures</key><array><string>arm64</string></array>
78+
<key>SupportedPlatform</key><string>ios</string>
79+
<key>SupportedPlatformVariant</key><string>simulator</string>
80+
</dict>
81+
<dict>
82+
<key>LibraryIdentifier</key><string>macos-arm64</string>
83+
<key>LibraryPath</key><string>libmy_timer.a</string>
84+
<key>HeadersPath</key><string>Headers</string>
85+
<key>SupportedArchitectures</key><array><string>arm64</string></array>
86+
<key>SupportedPlatform</key><string>macos</string>
87+
</dict>
88+
</array>
89+
<key>CFBundlePackageType</key><string>XFWK</string>
90+
<key>XCFrameworkFormatVersion</key><string>1.0</string>
91+
</dict>
92+
</plist>
93+
PLIST
94+
95+
echo ">> done. Slices:"
96+
ls "$OUT"
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
module CMyTimer {
2+
header "my_timer.h"
3+
header "my_timer_cbor.h"
4+
export *
5+
}

0 commit comments

Comments
 (0)