Davide De Rosa

Cross-platform Swift: Integration (pt. 2)

Cross-platform Swift: Integration (pt. 2)

part of a series: < prev | next >

Table of contents (ongoing)

  1. Introduction
  2. Combine
  3. Core libraries
  4. Platform specifics
  5. C interop (pt. 1)
  6. C interop (pt. 2)
  7. Build system (pt. 1)
  8. Build system (pt. 2)
  9. Build system (pt. 3)
  10. Integration (pt. 1)
  11. Integration (pt. 2)

In the previous post, I threw in a few ideas about how to integrate Swift logic with non-Swift code. We learned how to call Swift code from other languages with @_cdecl, but now we need to fill a few remaining gaps to build a modern non-Swift application on top of our Swift library.

You can find the source code of these examples on GitHub.

Asynchronous calls

There’s no such thing as concurrency keywords in C. The only tool we have at disposal to return an async result is with a callback. To some extent, this reminds of completion handlers before Swift Concurrency entered the scene. However, Swift completion handlers are still closures, as in they can capture variables from the enclosing context. This is not the case for C callbacks, which are stateless pointers to function and therefore closer to static functions. The common pattern to mimic Swift completion handlers in C is by providing a callback context. Let’s go step by step.

For example, we may want to get the result of this async function:

public func swiftSlowResult() async throws -> Int {
    ... // Code may throw here
    return 100 // Whatever works, we only care about the function signature
}

C types in arguments

We cannot attach a @_cdecl attribute as long as we use features that don’t transpose to C, and async is one of them. Let’s take an intermediate step and wrap the function like the preconcurrency counterpart of many standard Swift APIs:

public func swiftSlowResultWrapper(
    completion: @Sendable (Int?, Error?) -> Void
) -> Void {
    Task {
        do {
            let result = try await swiftSlowResult()
            completion(result, nil)
        } catch {
            // The first argument should be discarded on failure
            completion(0, error)
        }
    }
}

Cool, we got rid of async with the callback pattern. As we saw before, optionals and Error are other points of friction, so we introduce a user-defined error code enum, that is a plain int in C. All things considered, this is the first half-working version of our signature:

enum SlowError: Int, Error {
    case foo = 10
    case bar = 20
    case unknown = 1000
}

@_cdecl("swift_slow_result")
public func swiftSlowResultWrapper(
    completion: @Sendable (Int, Int) -> Void
) -> Void {
    Task {
        do {
            let result = try await swiftSlowResult()
            // Error code 0 means success
            completion(result, 0)
        } catch let error as SlowError {
            // The first argument must be ignored on failure
            completion(0, error.rawValue)
        } catch {
            completion(0, SlowError.unknown.rawValue)
        }
    }
}

And a trivial example of its usage:

#include <stdio.h>

typedef enum {
    slow_error_none = 0,
    slow_error_foo = 10,
    slow_error_bar = 20,
    slow_error_unknown = 1000
} slow_error;

static void my_result_callback(int result, slow_error error_code) {
    if (error_code != slow_error_none) {
        printf("Error: %d\n", error_code);
        return;
    }
    printf("Result: %d\n", result);
}

void invoke_swift_async_function() {
    /* ... */
    swift_slow_result(my_result_callback);
}

Providing context

Another problem to address is supporting captures behavior in the completion callback, because we probably want to do something useful upon completion. The de facto way to do it in C is, as mentioned before, by propagating a context:

public func swiftSlowResultWrapper(
    ctx: UnsafeMutableRawPointer?,
    completion: @Sendable (UnsafeMutableRawPointer?, Int, Int)
) -> Void {
    nonisolated(unsafe) let unsafeCtx = ctx
    Task {
        ...
        completion(unsafeCtx, result, errorCode)
    }
}

Unsafe and generic by nature, a context can be literally anything. Unsurprisingly, Swift UnsafeMutableRawPointer maps to void * in C.

This is the second version of our C program interacting with Swift async code:

/* Initialized elsewhere. */
typedef struct {
    int initial_value;
    void (*perform)(int);
    void (*report_failure)(slow_error);
} complex_object;

static void my_result_callback(void *ctx, int result, slow_error error_code) {
    complex_object *obj = (complex_object *)ctx;
    if (error_code != slow_error_none) {
        printf("Error: %d\n", error_code);
        obj->report_failure(error_code);
        return;
    }
    const int compound_result = obj->initial_value + result;
    printf("Compound result: %d\n", compound_result);
    /* Move on with our lives. */
    obj->perform(compound_result);
}

/* We assume `obj` to be the context to act upon, and we expect it
 * to survive the synchronous function call. A pointer to a local
 * variable cannot be the context of an async function, because
 * the callback would eventually refer to a dangling pointer.
 */
void invoke_swift_async_function(complex_object *obj) {
    swift_slow_result(obj, my_result_callback);
}

Calling convention

Nevertheless, the code lacks a final touch, also a very obscure one. As is, the signature still accepts a Swift closure (or ObjC block) for the completion argument, and this will inevitably lead to arcane compile issues or runtime crashes because of the wrong calling convention.

Obscure crash in C due to wrong calling convention

We must enforce the @convention(c) attribute to restrict the input to C-style pointers to function, i.e., without captures:

public func swiftSlowResultWrapper(
    ctx: UnsafeMutableRawPointer?,
    completion: @Sendable @convention(c) (UnsafeMutableRawPointer?, Int, Int)
) -> Void { ... }

I might be wrong, but as far as I can tell, the Swift compiler is unable to enforce the C calling convention in @_cdecl-enabled functions, because I believe it should.

Event handling

Another crux of a modern application is delivering and handling asynchronous events at scale. In the recent years, Combine was the most used tool for this purpose, and the SwiftUI onReceive() function certainly favored Combine publishers for emitting events. But… remember? Combine is not available outside Apple.

Regardless of how we emit events, though, that is a concern of the Swift library. When we build a non-Swift app on top of a Swift library, we only need a way to observe the events that the library emits. No magic involved, C is a simple language: we can register to events with a callback. The inherent difference with completion handlers, for example, is that an event callback will be a pointer to function that follows the application lifecycle.

Emit the events from Swift

Let’s start with a dumb events emitter. Here we emit events represented by integer codes from 1 to 3 at a 500 milliseconds interval. We use the same callback mechanism with an optional context. The consumer will start the events emitter and subscribe to it contextually.

enum EventCode: Int, CaseIterable {
    case one = 1
    case two
    case three
}

@_cdecl("swift_start_events_emitter")
public func swiftStartEventsEmitter(
    ctx: UnsafeMutableRawPointer?,
    callback: (@Sendable @convention(c) (UnsafeMutableRawPointer?, Int) -> Void)?
) {
    nonisolated(unsafe) let unsafeCtx = ctx
    Task { @Sendable in
        while true {
            guard let randomEvent = EventCode.allCases.randomElement() else { continue }
            callback?(unsafeCtx, randomEvent.rawValue)
            try await Task.sleep(for: .milliseconds(500))
        }
    }
}

Subscribe to the events from C

We reuse the same object from the previous example as the context, after adding a new method:

typedef struct {
    ...
    void (*on_event)(int);
} complex_object;

So that the callback can be simply:

static void my_event_callback(void *ctx, int event_code) {
    complex_object *obj = (complex_object *)ctx;
    obj->on_event(event_code);
}

When the application starts, we also start the emitter and register to it with our callback:

swift_start_events_emitter(obj, my_event_callback);

And that’s basically it. The on_event() method can then forward the event to other areas of the application as it pleases.

Data exchange

Okay, this can be really annoying, especially if your application is not a greenfield: exchanging strongly typed data across the Swift boundary. There’s a fundamental issue with any form of FFI, which is exchanging data between different programming languages. Keep in mind that returning opaque C pointers to Swift objects with Unmanaged is not comparable, because the C code is not meant to understand the contents of the memory. Such pointers are treated like integer identifiers. The trouble begins when we expect C code to understand the layout of Swift structures.

The single source of truth

The best way to deal with data is to pick a common format like JSON or Google Protocol Buffers (protobuf), that is, formats that are easy to encode and decode in any programming language. The approach is as follows:

  • Define your application entities in an Intermediate Representation (IR).
  • Autogenerate the Swift and non-Swift models from the IR, the single source of truth.
  • Pass text (JSON) or binary data (protobuf) whenever a @_cdecl function requires a complex entity.
  • Encode and decode entities to the native representation as needed.

For what it’s worth, Quicktype and JSON Schemas can be a simpler IR alternative to protobuf.

Example flow

Below we define a Swift function taking an entity input encoded as JSON:

/* The entity is autogenerated. */
public struct Entity: Codable {
    let num: Int
    let ch: Character
}

@_cdecl("swift_function")
public func swift_function(obj: UnsafePointer<CChar>?) {
    guard let obj, let json = obj.data(using: .utf8) else { return }
    do {
        let entity = try JSONDecoder().decode(Entity.self, from: json)
        print("Entity: \(entity)")
    } catch {
        print("Failure: \(error)")
    }
}

This is how we invoke the Swift function in C:

/* The entity and the JSON encoder are autogenerated. */
typedef struct {
    int num;
    char ch;
} entity;
extern char *encode_json(const entity *);

func c_function() {
    entity obj = { 100, 'a' };
    char *json = encode_json(obj);
    swift_function(json);
    free(json);
}

Limitations

Unfortunately, the IR approach is unfriendly to apps that were written with a Swift-only mindset, and both Passepartout and Partout have a long-standing set of Codable domain entities. The switch to a JSON or protobuf codegen can be very challenging if you like me have relied on:

  • RawRepresentable
  • Enums with associated values
  • Custom Codable implementations
  • Other strongly typed semantics

Because there are often no 1:1 counterparts in other languages. Especially if the entities are involved in a persistence layer, you must be extremely careful with any refactoring of the data model to avoid data loss or crashes.

In my case, I tried to rewrite my domain with JSON Schema or swift-protobuf, but eventually chose to write my own codegen with SwiftSyntax to keep Swift as the source of truth. The codegen parses an IR from Swift and proceeds to transpile it to Kotlin for Android and C++ for Linux and Windows (later). It was tiring but safer because I didn’t need to change much of the application code, whereas a strict JSON domain would have been a potentially disruptive change. Better be safe than sorry.

Bottom line

It’s been a long journey, but this article should wrap up everything you need to know to bring your Apple-only Swift/SwiftUI app to any other platform that Swift supports. It’s now time to make sense of all this yapping and build a real application with what we learned from Passepartout/Partout.

Going forward, we’ll build a simple –but thorough– non-Swift application on top of a Swift library. The sample application will feature a native UI (both SwiftUI and non-Swift) interacting with Swift business logic through a C ABI layer. In other words, a scalable architecture to port a SwiftUI app to Linux, Android, and Windows.

Last but not least, even friendly to AI agentic coding. See you in the next article.