diff --git a/examples/timer/ios/.gitignore b/examples/timer/ios/.gitignore new file mode 100644 index 0000000..d488beb --- /dev/null +++ b/examples/timer/ios/.gitignore @@ -0,0 +1,4 @@ +/MyTimer.xcframework/ +/.build-slices/ +/.build/ +*.o diff --git a/examples/timer/ios/Package.swift b/examples/timer/ios/Package.swift new file mode 100644 index 0000000..d41a970 --- /dev/null +++ b/examples/timer/ios/Package.swift @@ -0,0 +1,22 @@ +// swift-tools-version:5.9 +import PackageDescription + +// SwiftPM package wrapping the timer library for iOS (and macOS, so the Swift +// wrapper is testable on the host with `swift test`). +// +// `MyTimer.xcframework` is produced by ./build-xcframework.sh and bundles the +// static library for ios-arm64 (device), ios-arm64-simulator, and macos-arm64, +// each with the C headers + module map. Run the build script before +// `swift build` / `swift test`. +let package = Package( + name: "MyTimer", + platforms: [.iOS(.v13), .macOS(.v12)], + products: [ + .library(name: "MyTimer", targets: ["MyTimer"]) + ], + targets: [ + .binaryTarget(name: "CMyTimer", path: "MyTimer.xcframework"), + .target(name: "MyTimer", dependencies: ["CMyTimer"]), + .testTarget(name: "MyTimerTests", dependencies: ["MyTimer"]), + ] +) diff --git a/examples/timer/ios/README.md b/examples/timer/ios/README.md new file mode 100644 index 0000000..97ea17f --- /dev/null +++ b/examples/timer/ios/README.md @@ -0,0 +1,55 @@ +# iOS example — Swift over the native C ABI + +A SwiftPM package that wraps the timer library's **native** (zero-serialization) +C ABI behind an idiomatic Swift class, `TimerNode`. The library is cross-compiled +to a static `.xcframework` and consumed from Swift; struct returns come back as +typed Swift values. + +```swift +let node = try TimerNode(name: "my-app") +print(try node.version()) // "nim-timer v0.1.0" +let r = try node.echo("hello", delayMs: 5) // EchoResult +print(r.echoed, r.timerName) +``` + +## Layout + +| Path | Description | +|------|-------------| +| `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. | +| `cheaders/` | The native C header (`my_timer.h`), CBOR header, and `module.modulemap` (module `CMyTimer`). | +| `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. | +| `Package.swift` | SwiftPM: a binary target (the xcframework) + the Swift wrapper + tests. | +| `Tests/` | Unit tests, runnable on the host via the macOS slice. | + +## Build & test + +```sh +cd examples/timer/ios +./build-xcframework.sh # builds the 3 slices into MyTimer.xcframework +swift test # runs on the host (macos-arm64 slice) +``` + +`build-xcframework.sh` assembles the `.xcframework` directly (no +`xcodebuild -create-xcframework`), so it works in headless / CI environments. +The **macos-arm64** slice exists only so the wrapper is testable with +`swift test`; real iOS deployment uses the **ios-arm64** (device) and +**ios-arm64-simulator** slices. + +## Use it in an iOS app + +1. Run `./build-xcframework.sh`. +2. Add this directory as a local Swift package (Xcode → *Add Package + Dependencies… → Add Local…*), or depend on it in your own `Package.swift`. +3. `import MyTimer` and use `TimerNode`. + +## Notes + +- This is the **native, same-process** path — the app links the library directly. + The CBOR ABI is for inter-process communication only (see [`../ipc`](../ipc)). +- Each call is dispatched on the library's background FFI thread; `TimerNode` + blocks on a semaphore until the result callback fires. A struct return (e.g. + `EchoResponse`) is read inside the callback — it is valid only for the + callback's lifetime — and copied into the returned Swift value. +- Regenerate `cheaders/my_timer.h` from the repo root with `nimble genbindings_c` + if the library's API changes. diff --git a/examples/timer/ios/Sources/MyTimer/MyTimer.swift b/examples/timer/ios/Sources/MyTimer/MyTimer.swift new file mode 100644 index 0000000..33fe74e --- /dev/null +++ b/examples/timer/ios/Sources/MyTimer/MyTimer.swift @@ -0,0 +1,121 @@ +// Generated by nim-ffi Swift codegen. Do not edit by hand. +// +// Idiomatic Swift wrapper over the library's native (zero-serialization) C ABI. +// Each call is dispatched on the library's background FFI thread; we block on a +// DispatchSemaphore until the result callback fires. A struct return is read out +// of the typed C-POD inside the callback — valid only for the callback's +// lifetime — and copied into a native Swift value. +import CMyTimer +import Foundation + +public enum TimerError: Error, CustomStringConvertible { + case failed(String) + public var description: String { + switch self { case let .failed(m): return m } + } +} + +public struct EchoResponse: Equatable { + public let echoed: String + public let timerName: String +} + +public final class MyTimerNode { + private let ctx: UnsafeMutableRawPointer + + public init(name: String) throws { + let box = Box() + let ud = Unmanaged.passUnretained(box).toOpaque() + var c_config = CMyTimer.TimerConfig() + let c_config_name = strdup(name) + defer { free(c_config_name) } + c_config.name = UnsafePointer(c_config_name) + guard let c = my_timer_create(c_config, ackCallback, ud) else { + throw TimerError.failed("create returned null") + } + box.sem.wait() + guard box.ret == 0 else { throw TimerError.failed(box.text) } + ctx = c + } + + public func echo(_ message: String, delayMs: Int = 0) throws -> EchoResponse { + let box = MyTimerEchoBox() + let ud = Unmanaged.passUnretained(box).toOpaque() + var c_req = CMyTimer.EchoRequest() + let c_req_message = strdup(message) + defer { free(c_req_message) } + c_req.message = UnsafePointer(c_req_message) + c_req.delayMs = Int64(delayMs) + guard my_timer_echo(ctx, my_timer_echoCallback, ud, c_req) == 0 else { + throw TimerError.failed("echo dispatch failed") + } + box.sem.wait() + guard box.ret == 0 else { throw TimerError.failed(box.text) } + return EchoResponse(echoed: box.echoed, timerName: box.timerName) + } + + public func version() throws -> String { + let box = Box() + let ud = Unmanaged.passUnretained(box).toOpaque() + guard my_timer_version(ctx, stringCallback, ud) == 0 else { + throw TimerError.failed("version dispatch failed") + } + box.sem.wait() + guard box.ret == 0 else { throw TimerError.failed(box.text) } + return box.text + } + + deinit { my_timer_destroy(ctx) } + +} + +// MARK: - shared callback plumbing +final class Box { + var ret: Int32 = -1 + var text = "" + let sem = DispatchSemaphore(value: 0) +} + +func rawText(_ msg: UnsafePointer?, _ len: Int) -> String { + guard let m = msg, len > 0 else { return "" } + let bytes = UnsafeRawPointer(m).assumingMemoryBound(to: UInt8.self) + return String(decoding: UnsafeBufferPointer(start: bytes, count: len), as: UTF8.self) +} + +func ackCallback(_ ret: Int32, _ msg: UnsafePointer?, + _ len: Int, _ ud: UnsafeMutableRawPointer?) { + let box = Unmanaged.fromOpaque(ud!).takeUnretainedValue() + box.ret = ret + if ret != 0 { box.text = rawText(msg, len) } + box.sem.signal() +} + +func stringCallback(_ ret: Int32, _ msg: UnsafePointer?, + _ len: Int, _ ud: UnsafeMutableRawPointer?) { + let box = Unmanaged.fromOpaque(ud!).takeUnretainedValue() + box.ret = ret + box.text = rawText(msg, len) + box.sem.signal() +} + + +final class MyTimerEchoBox { + var ret: Int32 = -1 + var text = "" + var echoed = "" + var timerName = "" + let sem = DispatchSemaphore(value: 0) +} +private func my_timer_echoCallback(_ ret: Int32, _ msg: UnsafePointer?, + _ len: Int, _ ud: UnsafeMutableRawPointer?) { + let box = Unmanaged.fromOpaque(ud!).takeUnretainedValue() + box.ret = ret + if ret == 0, let m = msg { + let resp = UnsafeRawPointer(m).assumingMemoryBound(to: CMyTimer.EchoResponse.self) + box.echoed = resp.pointee.echoed.map { String(cString: $0) } ?? "" + box.timerName = resp.pointee.timerName.map { String(cString: $0) } ?? "" + } else { + box.text = rawText(msg, len) + } + box.sem.signal() +} diff --git a/examples/timer/ios/Tests/MyTimerTests/MyTimerTests.swift b/examples/timer/ios/Tests/MyTimerTests/MyTimerTests.swift new file mode 100644 index 0000000..195306e --- /dev/null +++ b/examples/timer/ios/Tests/MyTimerTests/MyTimerTests.swift @@ -0,0 +1,22 @@ +import XCTest +@testable import MyTimer + +final class MyTimerTests: XCTestCase { + func testCreateVersionEcho() throws { + let node = try MyTimerNode(name: "ios-demo") + + XCTAssertEqual(try node.version(), "nim-timer v0.1.0") + + let r = try node.echo("hello from Swift", delayMs: 2) + XCTAssertEqual(r.echoed, "hello from Swift") + XCTAssertEqual(r.timerName, "ios-demo") // proves the lib's own state round-tripped + } + + func testManyEchoes() throws { + let node = try MyTimerNode(name: "loop") + for i in 0..<200 { + let r = try node.echo("m\(i)") + XCTAssertEqual(r.echoed, "m\(i)") + } + } +} diff --git a/examples/timer/ios/build-xcframework.sh b/examples/timer/ios/build-xcframework.sh new file mode 100755 index 0000000..083e56b --- /dev/null +++ b/examples/timer/ios/build-xcframework.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# Build MyTimer.xcframework from the Nim timer library: +# - ios-arm64 (device) +# - ios-arm64-simulator (Apple-silicon simulator) +# - macos-arm64 (so the Swift wrapper is testable with `swift test`) +# +# Each slice is a static library cross-compiled by Nim with the matching SDK, +# then bundled with the C headers + module map. Requires Xcode + Nim. +# +# ./build-xcframework.sh +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$HERE/../../.." && pwd)" +NIM_SRC="$REPO_ROOT/examples/timer/timer.nim" +STAGE="$HERE/.build-slices" +OUT="$HERE/MyTimer.xcframework" + +CLANG="$(xcrun -f clang)" +COMMON_NIM=(--mm:orc -d:release -d:chronicles_log_level=WARN --threads:on + --os:macosx --cpu:arm64 --app:staticlib --noMain + --nimMainPrefix:libmy_timer --cc:clang + "--clang.exe:$CLANG" "--clang.linkerexe:$CLANG") + +# build_slice +build_slice() { + local name="$1" sdk="$2" minflag="$3" + local sysroot; sysroot="$(xcrun --sdk "$sdk" --show-sdk-path)" + local dir="$STAGE/$name" + mkdir -p "$dir" + echo ">> building slice: $name ($sdk)" + ( cd "$REPO_ROOT" && nim c "${COMMON_NIM[@]}" \ + --nimcache:"$dir/nimcache" \ + --passC:"-isysroot $sysroot -arch arm64 $minflag" \ + --passL:"-isysroot $sysroot -arch arm64 $minflag" \ + -o:"$dir/libmy_timer.a" "$NIM_SRC" >/dev/null ) +} + +rm -rf "$STAGE" "$OUT" +build_slice device iphoneos "-miphoneos-version-min=13.0" +build_slice simulator iphonesimulator "-mios-simulator-version-min=13.0" +build_slice macos macosx "-mmacosx-version-min=12.0" + +echo ">> assembling $OUT" +# Assemble the .xcframework by hand (a directory + Info.plist) rather than via +# `xcodebuild -create-xcframework` — same on-disk format, but no dependency on a +# working Simulator toolchain, so it builds in headless / CI environments too. +add_slice() { # + local dir="$OUT/$2" + mkdir -p "$dir/Headers" + cp "$STAGE/$1/libmy_timer.a" "$dir/libmy_timer.a" + cp "$HERE/cheaders/"* "$dir/Headers/" +} +mkdir -p "$OUT" +add_slice device ios-arm64 +add_slice simulator ios-arm64-simulator +add_slice macos macos-arm64 + +cat > "$OUT/Info.plist" <<'PLIST' + + + + + AvailableLibraries + + + LibraryIdentifierios-arm64 + LibraryPathlibmy_timer.a + HeadersPathHeaders + SupportedArchitecturesarm64 + SupportedPlatformios + + + LibraryIdentifierios-arm64-simulator + LibraryPathlibmy_timer.a + HeadersPathHeaders + SupportedArchitecturesarm64 + SupportedPlatformios + SupportedPlatformVariantsimulator + + + LibraryIdentifiermacos-arm64 + LibraryPathlibmy_timer.a + HeadersPathHeaders + SupportedArchitecturesarm64 + SupportedPlatformmacos + + + CFBundlePackageTypeXFWK + XCFrameworkFormatVersion1.0 + + +PLIST + +echo ">> done. Slices:" +ls "$OUT" diff --git a/examples/timer/ios/cheaders/module.modulemap b/examples/timer/ios/cheaders/module.modulemap new file mode 100644 index 0000000..038bc86 --- /dev/null +++ b/examples/timer/ios/cheaders/module.modulemap @@ -0,0 +1,5 @@ +module CMyTimer { + header "my_timer.h" + header "my_timer_cbor.h" + export * +} diff --git a/examples/timer/ios/cheaders/my_timer.h b/examples/timer/ios/cheaders/my_timer.h new file mode 100644 index 0000000..cd50e82 --- /dev/null +++ b/examples/timer/ios/cheaders/my_timer.h @@ -0,0 +1,121 @@ +// Generated by nim-ffi C codegen. Do not edit by hand. +// +// Native (zero-serialization) C ABI. Each call delivers its result to the +// callback. On RET_OK: +// - string-returning procs: (msg, len) is the raw string bytes (not +// NUL-terminated; use len). +// - struct-returning procs: msg is a pointer to the returned C struct — cast +// it to `const *` (len is sizeof). It is valid ONLY for the duration +// of the callback; copy out anything you need before returning. The library +// deep-frees it right after the callback (you free nothing). +// On RET_ERR, (msg, len) is the raw error text. A `_cbor` variant of each +// proc also exists for generic/cross-language callers that prefer CBOR. +#ifndef NIM_FFI_GEN_MY_TIMER_H +#define NIM_FFI_GEN_MY_TIMER_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#ifndef NIM_FFI_RET_CODES +#define NIM_FFI_RET_CODES +#define RET_OK 0 +#define RET_ERR 1 +#define RET_MISSING_CALLBACK 2 +#endif + +#ifndef NIM_FFI_CALLBACK_T +#define NIM_FFI_CALLBACK_T +typedef void (*FFICallBack)(int callerRet, const char *msg, size_t len, void *userData); +#endif + + +// --- {.ffi.}-annotated types, exposed as C structs ---------- +typedef struct { + const char* name; +} TimerConfig; + +typedef struct { + const char* message; + int64_t delayMs; +} EchoRequest; + +typedef struct { + const char* echoed; + const char* timerName; +} EchoResponse; + +typedef struct { + EchoRequest *messages; + size_t messages_len; + const char* *tags; + size_t tags_len; + int note_present; + const char* note; + int retries_present; + int64_t retries; +} ComplexRequest; + +typedef struct { + const char* summary; + int64_t itemCount; + int hasNote; +} ComplexResponse; + +typedef struct { + const char* message; + int64_t echoCount; +} EchoEvent; + +typedef struct { + const char* name; + const char* *payload; + size_t payload_len; + int64_t priority; +} JobSpec; + +typedef struct { + int64_t maxAttempts; + int64_t backoffMs; + const char* *retryOn; + size_t retryOn_len; +} RetryPolicy; + +typedef struct { + int64_t startAtMs; + int64_t intervalMs; + int jitter_present; + int64_t jitter; +} ScheduleConfig; + +typedef struct { + const char* jobId; + int64_t willRunCount; + int64_t firstRunAtMs; + int64_t effectiveBackoffMs; +} ScheduleResult; + + +void *my_timer_create(TimerConfig config, FFICallBack callback, void *userData); + +int my_timer_echo(void *ctx, FFICallBack callback, void *userData, EchoRequest req); + +int my_timer_version(void *ctx, FFICallBack callback, void *userData); + +int my_timer_complex(void *ctx, FFICallBack callback, void *userData, ComplexRequest req); + +int my_timer_schedule(void *ctx, FFICallBack callback, void *userData, JobSpec job, RetryPolicy retry, ScheduleConfig schedule); + +int my_timer_destroy(void *ctx); + +uint64_t my_timer_add_event_listener(void *ctx, const char *eventName, FFICallBack callback, void *userData); +int my_timer_remove_event_listener(void *ctx, uint64_t listenerId); + +#ifdef __cplusplus +} // extern "C" +#endif + +#endif /* NIM_FFI_GEN_MY_TIMER_H */ \ No newline at end of file diff --git a/examples/timer/ios/cheaders/my_timer_cbor.h b/examples/timer/ios/cheaders/my_timer_cbor.h new file mode 100644 index 0000000..04e8c93 --- /dev/null +++ b/examples/timer/ios/cheaders/my_timer_cbor.h @@ -0,0 +1,178 @@ +// Generated by nim-ffi C codegen. Do not edit by hand. +// +// CBOR ABI (`_cbor`). Use this for callers that cross a process or machine +// boundary (the request has to be serialized anyway) or any generic / cross- +// language caller. Build the request with the FfiCbor helpers below — a CBOR map +// whose keys are the Nim parameter names (listed per proc) — call the matching +// `_cbor`, and decode the RET_OK response (a CBOR-encoded value; for +// string-returning procs a CBOR text string) with ffi_decode_text. RET_ERR +// delivers raw error text. For same-process callers, prefer the native `` +// ABI in the companion .h header. +#ifndef NIM_FFI_GEN_MY_TIMER_CBOR_H +#define NIM_FFI_GEN_MY_TIMER_CBOR_H + +#include +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#ifndef NIM_FFI_RET_CODES +#define NIM_FFI_RET_CODES +#define RET_OK 0 +#define RET_ERR 1 +#define RET_MISSING_CALLBACK 2 +#endif + +#ifndef NIM_FFI_CALLBACK_T +#define NIM_FFI_CALLBACK_T +typedef void (*FFICallBack)(int callerRet, const char *msg, size_t len, void *userData); +#endif + +#ifndef NIM_FFI_CBOR_HELPERS +#define NIM_FFI_CBOR_HELPERS +// --- minimal growable CBOR request encoder -------------------------------- +typedef struct { + uint8_t *buf; + size_t cap; + size_t len; +} FfiCbor; + +static inline FfiCbor ffi_cbor_new(void) { + FfiCbor c; + c.cap = 256; + c.len = 0; + c.buf = (uint8_t *)malloc(c.cap); + return c; +} +static inline void ffi_cbor_free(FfiCbor *c) { + free(c->buf); + c->buf = NULL; +} +static inline void ffi_cbor_put(FfiCbor *c, uint8_t b) { + if (c->len >= c->cap) { + c->cap *= 2; + c->buf = (uint8_t *)realloc(c->buf, c->cap); + } + c->buf[c->len++] = b; +} +static inline void ffi_cbor_head(FfiCbor *c, uint8_t major, uint64_t arg) { + uint8_t mt = (uint8_t)(major << 5); + if (arg < 24) { + ffi_cbor_put(c, mt | (uint8_t)arg); + } else if (arg <= 0xff) { + ffi_cbor_put(c, mt | 24); + ffi_cbor_put(c, (uint8_t)arg); + } else if (arg <= 0xffff) { + ffi_cbor_put(c, mt | 25); + ffi_cbor_put(c, (uint8_t)(arg >> 8)); + ffi_cbor_put(c, (uint8_t)arg); + } else if (arg <= 0xffffffffULL) { + ffi_cbor_put(c, mt | 26); + ffi_cbor_put(c, (uint8_t)(arg >> 24)); + ffi_cbor_put(c, (uint8_t)(arg >> 16)); + ffi_cbor_put(c, (uint8_t)(arg >> 8)); + ffi_cbor_put(c, (uint8_t)arg); + } else { + ffi_cbor_put(c, mt | 27); + for (int s = 56; s >= 0; s -= 8) ffi_cbor_put(c, (uint8_t)(arg >> s)); + } +} +static inline void ffi_cbor_map(FfiCbor *c, size_t n) { ffi_cbor_head(c, 5, n); } +static inline void ffi_cbor_text(FfiCbor *c, const char *s) { + size_t n = s ? strlen(s) : 0; + ffi_cbor_head(c, 3, n); + for (size_t i = 0; i < n; i++) ffi_cbor_put(c, (uint8_t)s[i]); +} +static inline void ffi_cbor_kv_text(FfiCbor *c, const char *k, const char *v) { + ffi_cbor_text(c, k); + ffi_cbor_text(c, v); +} +static inline void ffi_cbor_kv_uint(FfiCbor *c, const char *k, uint64_t v) { + ffi_cbor_text(c, k); + ffi_cbor_head(c, 0, v); +} +static inline void ffi_cbor_kv_int(FfiCbor *c, const char *k, int64_t v) { + ffi_cbor_text(c, k); + if (v >= 0) + ffi_cbor_head(c, 0, (uint64_t)v); + else + ffi_cbor_head(c, 1, (uint64_t)(-(v + 1))); +} + +// --- response decoding ----------------------------------------------------- +// Zero-copy view of a top-level CBOR text string (the RET_OK payload). Sets +// *out/*outLen to point INTO `data` (no allocation; valid only while `data` is) +// and returns 1; returns 0 for a non-text-string payload. +static inline int ffi_text_view(const uint8_t *data, size_t len, + const uint8_t **out, size_t *outLen) { + if (len < 1 || (data[0] >> 5) != 3) return 0; + uint8_t info = data[0] & 0x1f; + size_t p = 1; + uint64_t slen = 0; + if (info < 24) { + slen = info; + } else if (info == 24) { + if (len < p + 1) return 0; + slen = data[p++]; + } else if (info == 25) { + if (len < p + 2) return 0; + slen = ((uint64_t)data[p] << 8) | data[p + 1]; + p += 2; + } else if (info == 26) { + if (len < p + 4) return 0; + slen = ((uint64_t)data[p] << 24) | ((uint64_t)data[p + 1] << 16) | + ((uint64_t)data[p + 2] << 8) | data[p + 3]; + p += 4; + } else { + return 0; + } + if (len < p + slen) return 0; + *out = data + p; + *outLen = (size_t)slen; + return 1; +} + +// Owning variant: malloc a NUL-terminated copy. NULL for a non-text payload. +// Caller frees. +static inline char *ffi_decode_text(const uint8_t *data, size_t len) { + const uint8_t *view; + size_t slen; + if (!ffi_text_view(data, len, &view, &slen)) return NULL; + char *out = (char *)malloc(slen + 1); + if (!out) return NULL; + memcpy(out, view, slen); + out[slen] = '\0'; + return out; +} +#endif // NIM_FFI_CBOR_HELPERS + + +// request map keys: {"config": TimerConfig} +void *my_timer_create_cbor(const uint8_t *reqCbor, size_t reqCborLen, FFICallBack callback, void *userData); + +// request map keys: {"req": EchoRequest} +int my_timer_echo_cbor(void *ctx, FFICallBack callback, void *userData, const uint8_t *reqCbor, size_t reqCborLen); + +// request: empty CBOR map (0xA0) +int my_timer_version_cbor(void *ctx, FFICallBack callback, void *userData, const uint8_t *reqCbor, size_t reqCborLen); + +// request map keys: {"req": ComplexRequest} +int my_timer_complex_cbor(void *ctx, FFICallBack callback, void *userData, const uint8_t *reqCbor, size_t reqCborLen); + +// request map keys: {"job": JobSpec, "retry": RetryPolicy, "schedule": ScheduleConfig} +int my_timer_schedule_cbor(void *ctx, FFICallBack callback, void *userData, const uint8_t *reqCbor, size_t reqCborLen); + +int my_timer_destroy(void *ctx); + +uint64_t my_timer_add_event_listener(void *ctx, const char *eventName, FFICallBack callback, void *userData); +int my_timer_remove_event_listener(void *ctx, uint64_t listenerId); + +#ifdef __cplusplus +} // extern "C" +#endif + +#endif /* NIM_FFI_GEN_MY_TIMER_CBOR_H */ \ No newline at end of file diff --git a/ffi.nimble b/ffi.nimble index d6b3215..a1e6250 100644 --- a/ffi.nimble +++ b/ffi.nimble @@ -160,6 +160,17 @@ task genbindings_c, "Generate C bindings for the timer example": " -d:ffiSrcPath=../timer.nim" & " -o:/dev/null examples/timer/timer.nim" +task genbindings_swift, "Generate the Swift wrapper for the iOS timer example": + # Emits Sources/MyTimer/MyTimer.swift over the native C ABI. The C headers it + # imports (cheaders/) come from `nimble genbindings_c`; run that too if the + # library's types or procs changed. + exec "nim c " & nimFlagsOrc & + " --app:lib --noMain --nimMainPrefix:libmy_timer" & + " -d:ffiGenBindings -d:targetLang=swift" & + " -d:ffiOutputDir=examples/timer/ios/Sources/MyTimer" & + " -d:ffiSrcPath=../timer.nim" & + " -o:/dev/null examples/timer/timer.nim" + task genbindings_go, "Generate Go (cgo) bindings for the timer example": exec "nim c " & nimFlagsOrc & " --app:lib --noMain --nimMainPrefix:libmy_timer" & diff --git a/ffi/codegen/swift.nim b/ffi/codegen/swift.nim new file mode 100644 index 0000000..75c243f --- /dev/null +++ b/ffi/codegen/swift.nim @@ -0,0 +1,479 @@ +## Swift binding generator for the nim-ffi framework. +## +## Emits an idiomatic Swift wrapper (`.swift`) over the *native* +## (zero-serialization) C ABI declared in `.h` (produced by the C +## generator, see `c.nim`). The Swift side imports the C header through a +## `C` clang module (see the package's module map) and never touches CBOR. +## +## Shape mirrors the proven hand-written iOS example: each call is dispatched on +## the library's background FFI thread and its result arrives on a callback; we +## bridge that to a synchronous Swift API with a `DispatchSemaphore`. The wrapper +## blocks on the semaphore until the callback fires, so a by-value request struct +## and any `strdup`'d C strings stay alive on the caller's stack for the whole +## call. +## +## Result decoding follows the native ABI's three callback shapes, picked per +## proc from its metadata: +## - ctor / void proc -> only `ret` (+ raw error text on RET_ERR) +## - `string` return -> `(msg, len)` is the raw UTF-8 string bytes +## - struct return -> `msg` is a `const *`; fields are copied out +## INSIDE the callback (valid only for its lifetime) into a native Swift +## value. +## +## Scope (first increment): procs whose parameters are scalars / strings / bools +## / floats, or a single `{.ffi.}` struct whose fields are all of those. Procs +## that need `seq[T]` / `Option[T]` parameter marshaling (or more than one struct +## parameter) are skipped with a logged notice — that marshaling is the next +## increment, kept out so the generated wrapper always compiles. + +import std/[os, strutils] +import ./meta, ./string_helpers + +proc swiftModuleName(libName: string): string = + ## The clang-module name the wrapper imports, e.g. "my_timer" -> "CMyTimer". + ## Must match the package's module map. + return "C" & snakeToPascalCase(libName) + +proc isSimpleScalar(typeName: string): bool = + ## A field/param type that crosses by value with no array/option marshaling. + let t = typeName.strip() + case t + of "string", "cstring", "bool", "float", "float32", "float64", "int", "int64", + "int32", "int16", "int8", "clong", "cint", "uint", "uint64", "uint32", + "uint16", "uint8", "byte", "csize_t": + return true + else: + return false + +proc swiftType(typeName: string): string = + ## Maps a simple Nim type to the Swift type used in the public API surface. + let t = typeName.strip() + case t + of "string", "cstring": "String" + of "bool": "Bool" + of "float", "float32": "Float" + of "float64": "Double" + of "uint", "uint64", "uint32", "uint16", "uint8", "byte", "csize_t": "UInt" + else: "Int" # all the signed integer aliases + +proc swiftDefault(typeName: string): string = + ## Default value so flattened numeric/bool params are optional at the call + ## site (matches the hand-written `delayMs: Int = 0`). Strings get no default. + let t = typeName.strip() + if t == "bool": + return "false" + if t in ["string", "cstring"]: + return "" + return "0" + +type FieldPlan = object + name: string + typeName: string + +proc structFields(types: seq[FFITypeMeta], typeName: string): seq[FieldPlan] = + var plan: seq[FieldPlan] = @[] + for t in types: + if t.name == typeName: + for f in t.fields: + plan.add(FieldPlan(name: f.name, typeName: f.typeName)) + return plan + +proc allFieldsSimple(types: seq[FFITypeMeta], typeName: string): bool = + let fields = structFields(types, typeName) + if fields.len == 0: + return false # not a known {.ffi.} struct + for f in fields: + if not isSimpleScalar(f.typeName): + return false + return true + +proc isKnownStruct(types: seq[FFITypeMeta], typeName: string): bool = + for t in types: + if t.name == typeName: + return true + return false + +proc canEmit(p: FFIProcMeta, types: seq[FFITypeMeta]): bool = + ## True when the proc's parameters are entirely covered by this increment: + ## zero params, all-scalar params, or exactly one struct param of simple + ## fields. Multiple struct params or seq/Option fields are deferred. + var structParams = 0 + for ep in p.extraParams: + if isSimpleScalar(ep.typeName): + continue + if isKnownStruct(types, ep.typeName): + inc structParams + if not allFieldsSimple(types, ep.typeName): + return false + else: + return false # ptr / unknown type + return structParams <= 1 + +# --- Swift value emitted for a flattened parameter list ----------------------- + +proc flattenedParams( + p: FFIProcMeta, types: seq[FFITypeMeta] +): seq[FieldPlan] = + ## The Swift method's parameters: scalar params verbatim, plus the fields of + ## the single struct param flattened in (matching the example's + ## `echo(_ message:, delayMs:)`). + var params: seq[FieldPlan] = @[] + for ep in p.extraParams: + if isSimpleScalar(ep.typeName): + params.add(FieldPlan(name: ep.name, typeName: ep.typeName)) + else: + params.add(structFields(types, ep.typeName)) + return params + +proc swiftParamList(params: seq[FieldPlan], labelFirst: bool): string = + ## `labelFirst` keeps the first parameter labeled (ctors read `init(name:)`); + ## methods leave it unlabeled for an idiomatic `echo("msg")` call site. + var parts: seq[string] = @[] + for i, fp in params: + let label = if i == 0 and not labelFirst: "_ " & fp.name else: fp.name + var decl = label & ": " & swiftType(fp.typeName) + if fp.typeName.strip() notin ["string", "cstring"]: + decl &= " = " & swiftDefault(fp.typeName) + parts.add(decl) + return parts.join(", ") + +# --- Marshaling a flattened param into its C representation ------------------- + +proc emitParamMarshal( + lines: var seq[string], p: FFIProcMeta, types: seq[FFITypeMeta], modName: string +): seq[string] = + ## Emits the C-side locals for the call and returns the list of C argument + ## expressions to pass after `(ctx, cb, ud, ...)`. String params are `strdup`'d + ## and freed via `defer`; struct params are built field by field. + var cArgs: seq[string] = @[] + for ep in p.extraParams: + if isSimpleScalar(ep.typeName): + if ep.typeName.strip() in ["string", "cstring"]: + let cvar = "c_" & ep.name + lines.add(" let " & cvar & " = strdup(" & ep.name & ")") + lines.add(" defer { free(" & cvar & ") }") + cArgs.add("UnsafePointer(" & cvar & ")") + else: + cArgs.add(swiftType(ep.typeName) & "(" & ep.name & ")") + else: + # single struct param: build it field by field from the flattened args + let sv = "c_" & ep.name + lines.add(" var " & sv & " = " & modName & "." & ep.typeName & "()") + for f in structFields(types, ep.typeName): + if f.typeName.strip() in ["string", "cstring"]: + let cvar = "c_" & ep.name & "_" & f.name + lines.add(" let " & cvar & " = strdup(" & f.name & ")") + lines.add(" defer { free(" & cvar & ") }") + lines.add(" " & sv & "." & f.name & " = UnsafePointer(" & cvar & ")") + elif f.typeName.strip() == "bool": + lines.add(" " & sv & "." & f.name & " = " & f.name & " ? 1 : 0") + else: + let ct = + if f.typeName.strip() in [ + "uint", "uint64", "uint32", "uint16", "uint8", "byte", "csize_t" + ]: "UInt64" + elif f.typeName.strip() in ["float", "float32"]: "Float" + elif f.typeName.strip() == "float64": "Double" + else: "Int64" + lines.add(" " & sv & "." & f.name & " = " & ct & "(" & f.name & ")") + cArgs.add(sv) + return cArgs + +# --- Result structs (one Swift value type per struct-returning proc) ---------- + +proc emitResultStruct(lines: var seq[string], typeName: string, fields: seq[FieldPlan]) = + lines.add("public struct " & typeName & ": Equatable {") + for f in fields: + lines.add(" public let " & f.name & ": " & swiftType(f.typeName)) + lines.add("}") + lines.add("") + +# --- Per-proc method bodies --------------------------------------------------- + +proc returnsString(p: FFIProcMeta): bool = + return p.returnTypeName.strip() == "string" + +proc returnsStruct(p: FFIProcMeta, types: seq[FFITypeMeta]): bool = + return isKnownStruct(types, p.returnTypeName.strip()) + +proc boxName(p: FFIProcMeta): string = + return snakeToPascalCase(p.procName) & "Box" + +proc callbackName(p: FFIProcMeta): string = + return p.procName & "Callback" + +proc stripLibPrefix(procName, libName: string): string = + ## "my_timer_echo" -> "echo"; collapses the remaining snake_case to camelCase. + var base = procName + if base.startsWith(libName & "_"): + base = base[libName.len + 1 .. ^1] + let parts = base.split('_') + var s = parts[0] + for i in 1 ..< parts.len: + s &= capitalizeFirstLetter(parts[i]) + return s + +proc emitMethod( + lines: var seq[string], p: FFIProcMeta, types: seq[FFITypeMeta], modName: string +) = + let params = flattenedParams(p, types) + let paramList = swiftParamList(params, labelFirst = p.kind == FFIKind.CTOR) + case p.kind + of FFIKind.CTOR: + lines.add(" public init(" & paramList & ") throws {") + lines.add(" let box = Box()") + lines.add(" let ud = Unmanaged.passUnretained(box).toOpaque()") + var marshalLines: seq[string] = @[] + let cArgs = emitParamMarshal(marshalLines, p, types, modName) + lines.add(marshalLines) + let lead = if cArgs.len > 0: cArgs.join(", ") & ", " else: "" + lines.add( + " guard let c = " & p.procName & "(" & lead & "ackCallback, ud) else {" + ) + lines.add(" throw TimerError.failed(\"create returned null\")") + lines.add(" }") + lines.add(" box.sem.wait()") + lines.add(" guard box.ret == 0 else { throw TimerError.failed(box.text) }") + lines.add(" ctx = c") + lines.add(" }") + lines.add("") + of FFIKind.DTOR: + lines.add(" deinit { " & p.procName & "(ctx) }") + lines.add("") + of FFIKind.FFI: + var marshalLines: seq[string] = @[] + let cArgs = emitParamMarshal(marshalLines, p, types, modName) + let tail = if cArgs.len > 0: ", " & cArgs.join(", ") else: "" + let methodName = stripLibPrefix(p.procName, p.libName) + if returnsStruct(p, types): + let retFields = structFields(types, p.returnTypeName) + lines.add( + " public func " & methodName & "(" & paramList & ") throws -> " & + p.returnTypeName & " {" + ) + lines.add(" let box = " & boxName(p) & "()") + lines.add(" let ud = Unmanaged.passUnretained(box).toOpaque()") + lines.add(marshalLines) + lines.add( + " guard " & p.procName & "(ctx, " & callbackName(p) & ", ud" & tail & + ") == 0 else {" + ) + lines.add( + " throw TimerError.failed(\"" & methodName & " dispatch failed\")" + ) + lines.add(" }") + lines.add(" box.sem.wait()") + lines.add(" guard box.ret == 0 else { throw TimerError.failed(box.text) }") + var ctorArgs: seq[string] = @[] + for f in retFields: + ctorArgs.add(f.name & ": box." & f.name) + lines.add(" return " & p.returnTypeName & "(" & ctorArgs.join(", ") & ")") + lines.add(" }") + lines.add("") + elif returnsString(p): + lines.add( + " public func " & methodName & "(" & paramList & ") throws -> String {" + ) + lines.add(" let box = Box()") + lines.add(" let ud = Unmanaged.passUnretained(box).toOpaque()") + lines.add(marshalLines) + lines.add( + " guard " & p.procName & "(ctx, stringCallback, ud" & tail & + ") == 0 else {" + ) + lines.add( + " throw TimerError.failed(\"" & methodName & " dispatch failed\")" + ) + lines.add(" }") + lines.add(" box.sem.wait()") + lines.add(" guard box.ret == 0 else { throw TimerError.failed(box.text) }") + lines.add(" return box.text") + lines.add(" }") + lines.add("") + else: + lines.add(" public func " & methodName & "(" & paramList & ") throws {") + lines.add(" let box = Box()") + lines.add(" let ud = Unmanaged.passUnretained(box).toOpaque()") + lines.add(marshalLines) + lines.add( + " guard " & p.procName & "(ctx, ackCallback, ud" & tail & + ") == 0 else {" + ) + lines.add( + " throw TimerError.failed(\"" & methodName & " dispatch failed\")" + ) + lines.add(" }") + lines.add(" box.sem.wait()") + lines.add(" guard box.ret == 0 else { throw TimerError.failed(box.text) }") + lines.add(" }") + lines.add("") + +# --- Per-struct-return callback + box ----------------------------------------- + +proc emitStructCallback( + lines: var seq[string], p: FFIProcMeta, types: seq[FFITypeMeta], modName: string +) = + let retFields = structFields(types, p.returnTypeName) + lines.add("final class " & boxName(p) & " {") + lines.add(" var ret: Int32 = -1") + lines.add(" var text = \"\"") + for f in retFields: + var init = ": " & swiftType(f.typeName) & " = 0" + if f.typeName.strip() in ["string", "cstring"]: + init = " = \"\"" + elif f.typeName.strip() == "bool": + init = " = false" + lines.add(" var " & f.name & init) + lines.add(" let sem = DispatchSemaphore(value: 0)") + lines.add("}") + lines.add( + "private func " & callbackName(p) & + "(_ ret: Int32, _ msg: UnsafePointer?," + ) + lines.add(" _ len: Int, _ ud: UnsafeMutableRawPointer?) {") + lines.add( + " let box = Unmanaged<" & boxName(p) & ">.fromOpaque(ud!).takeUnretainedValue()" + ) + lines.add(" box.ret = ret") + lines.add(" if ret == 0, let m = msg {") + lines.add( + " let resp = UnsafeRawPointer(m).assumingMemoryBound(to: " & modName & "." & + p.returnTypeName & ".self)" + ) + for f in retFields: + if f.typeName.strip() in ["string", "cstring"]: + lines.add( + " box." & f.name & " = resp.pointee." & f.name & + ".map { String(cString: $0) } ?? \"\"" + ) + elif f.typeName.strip() == "bool": + lines.add(" box." & f.name & " = resp.pointee." & f.name & " != 0") + else: + lines.add( + " box." & f.name & " = " & swiftType(f.typeName) & "(resp.pointee." & + f.name & ")" + ) + lines.add(" } else {") + lines.add(" box.text = rawText(msg, len)") + lines.add(" }") + lines.add(" box.sem.signal()") + lines.add("}") + lines.add("") + +# --- Static preamble (shared error type, Box, rawText, ack/string callbacks) -- + +proc preamble(modName: string): string = + return ( + """ +// Generated by nim-ffi Swift codegen. Do not edit by hand. +// +// Idiomatic Swift wrapper over the library's native (zero-serialization) C ABI. +// Each call is dispatched on the library's background FFI thread; we block on a +// DispatchSemaphore until the result callback fires. A struct return is read out +// of the typed C-POD inside the callback — valid only for the callback's +// lifetime — and copied into a native Swift value. +import """ & modName & "\n" & + """import Foundation + +public enum TimerError: Error, CustomStringConvertible { + case failed(String) + public var description: String { + switch self { case let .failed(m): return m } + } +} +""" + ) + +const SharedPlumbing = + """ +// MARK: - shared callback plumbing +final class Box { + var ret: Int32 = -1 + var text = "" + let sem = DispatchSemaphore(value: 0) +} + +func rawText(_ msg: UnsafePointer?, _ len: Int) -> String { + guard let m = msg, len > 0 else { return "" } + let bytes = UnsafeRawPointer(m).assumingMemoryBound(to: UInt8.self) + return String(decoding: UnsafeBufferPointer(start: bytes, count: len), as: UTF8.self) +} + +func ackCallback(_ ret: Int32, _ msg: UnsafePointer?, + _ len: Int, _ ud: UnsafeMutableRawPointer?) { + let box = Unmanaged.fromOpaque(ud!).takeUnretainedValue() + box.ret = ret + if ret != 0 { box.text = rawText(msg, len) } + box.sem.signal() +} + +func stringCallback(_ ret: Int32, _ msg: UnsafePointer?, + _ len: Int, _ ud: UnsafeMutableRawPointer?) { + let box = Unmanaged.fromOpaque(ud!).takeUnretainedValue() + box.ret = ret + box.text = rawText(msg, len) + box.sem.signal() +} +""" + +proc generateSwiftWrapper*( + procs: seq[FFIProcMeta], + types: seq[FFITypeMeta], + libName: string, +): string = + let modName = swiftModuleName(libName) + let className = snakeToPascalCase(libName) & "Node" + var lines: seq[string] = @[] + lines.add(preamble(modName)) + + # Emit a Swift result struct for every struct that appears as a return type. + var emittedResults: seq[string] = @[] + for p in procs: + if not canEmit(p, types): + continue + let rt = p.returnTypeName.strip() + if isKnownStruct(types, rt) and rt notin emittedResults: + emittedResults.add(rt) + emitResultStruct(lines, rt, structFields(types, rt)) + + # The wrapper class. + lines.add("public final class " & className & " {") + lines.add(" private let ctx: UnsafeMutableRawPointer") + lines.add("") + for p in procs: + if not canEmit(p, types): + continue + emitMethod(lines, p, types, modName) + lines.add("}") + lines.add("") + + lines.add(SharedPlumbing) + lines.add("") + + # Per-struct-return callbacks + their boxes. + for p in procs: + if not canEmit(p, types): + continue + if returnsStruct(p, types): + emitStructCallback(lines, p, types, modName) + + return lines.join("\n") + +proc generateSwiftBindings*( + procs: seq[FFIProcMeta], + types: seq[FFITypeMeta], + libName: string, + outputDir: string, + nimSrcRelPath: string, + events: seq[FFIEventMeta] = @[], +) = + # Report procs deferred to the seq/Option marshaling increment so coverage is + # never silently dropped. + for p in procs: + if not canEmit(p, types): + echo "swift codegen: skipping '" & p.procName & + "' (needs seq/Option or multi-struct param marshaling — not yet supported)" + writeFile( + outputDir / (snakeToPascalCase(libName) & ".swift"), + generateSwiftWrapper(procs, types, libName), + ) diff --git a/ffi/internal/ffi_macro.nim b/ffi/internal/ffi_macro.nim index 5179bf7..b632b66 100644 --- a/ffi/internal/ffi_macro.nim +++ b/ffi/internal/ffi_macro.nim @@ -9,6 +9,7 @@ when defined(ffiGenBindings): import ../codegen/cddl import ../codegen/c import ../codegen/go + import ../codegen/swift # --------------------------------------------------------------------------- # String helpers used by multiple macros @@ -1988,10 +1989,15 @@ macro genBindings*( ffiProcRegistry, ffiTypeRegistry, libName, outputDir, nimSrcRelPath, ffiEventRegistry, ) + of "swift": + generateSwiftBindings( + ffiProcRegistry, ffiTypeRegistry, libName, outputDir, nimSrcRelPath, + ffiEventRegistry, + ) else: error( "genBindings: unknown targetLang '" & lang & - "'. Use 'c', 'go', 'rust', 'cpp', or 'cddl'." + "'. Use 'c', 'go', 'swift', 'rust', 'cpp', or 'cddl'." ) return newEmptyNode()