Ad

Our DNA is written in Swift
Jump

Swift Cross Platform

My passion for cross platform software development started around the year 2000 when I saw how a contractor at Austria’s Connect Austria – which eventually became the cellular network provider DREI – would create C++ utilities on PC with Visual Studio that then could be compiled and run in production on Compaq Tru64 Unix. I have fond memories of writing a lot of small CLI utilities the same way to solve data issues we encountered processing call record data for billing.

I always hated the approach of emulating a virtual machine to get code running of different platforms. This feels to me like watching the trojans celebrate their big wooden horse and nobody willing to listen to me shouting warnings of what ugliness might be inside.

Apple platforms and Linux share a common Unix ancestry and so it was an easy addition to compile Swift for Linux as well. Swift on Android and Windows only matured well enough quite recently so that – with the help of coding agents – we could get serious of targeting those platforms as well.

Lately I’ve discovered a new ambition: I want all open source Swift code of mine to run on the maximum diversity of platforms it can. This is possibly because GitHub grants uns OSS developers unlimited CI runs and together with my Claude that can babysit a PR all through review comments and CI failures until all platforms go green. I kept getting the same kinds of errors on those non-Apple platforms and every time my agents built yet another workaround, some hyper complete, others very sloppy.

Apple’s Foundation is generous. It ships a pile of convenience API that the open-source swift-corelibs-foundation — the Foundation you get on Linux, Windows, and Android — simply doesn’t have. The moment your code reaches for one of those conveniences, it stops compiling the instant it leaves Cupertino:

// Fine on Apple. On Linux: "value of type 'URLSession' has no member 'bytes'"
let (bytes, response) = try await URLSession.shared.bytes(for: request)
for try await line in bytes.lines { … }

// Fine on Apple. On Linux: "cannot find type 'UTType' in scope"
let mime = UTType(filenameExtension: "png")?.preferredMIMEType

The standard remedy is to scatter #if canImport(FoundationNetworking) through the file until the compiler stops complaining. So that’s what I did. Then I did it again in the next project. Somewhere around the third copy of the same AsyncBytes workaround, sitting in a third unrelated repo, I had the feeling every programmer knows: this is dumb, why is this not in one place?

Pulling the shims into one place

So I lifted all of them out of the individual projects and into one package. I called it SwiftCross — as in cross-platform — and gave it exactly two rules: it has no dependencies, and it must let the same Swift source compile and run on every platform the toolchain targets.

Using it is almost aggressively boring, which is the whole point:

import SwiftCross   // instead of: import Foundation

import SwiftCross is a drop-in replacement for import Foundation. It re-exports Foundation (plus FoundationNetworking where that’s a separate module, plus UniformTypeIdentifiers where it exists) and layers the missing pieces on top. On a platform that already has the real API, SwiftCross steps aside and hands you the native implementation. On one that doesn’t, you get the shim — and crucially, it’s the same call site either way. The #if dances don’t vanish; they just all move inside SwiftCross, once, instead of being smeared across your own code where you have to read past them every day.

A word on the name

I have to admit the name made me grin. Swift Cross Platform Utilities, in short SwiftCross — say it out loud — sounds an awful lot like Swiss Cross. And honestly that fits better than I have any right to expect. Switzerland is the proverbial neutral ground, and that’s precisely what this package is meant to be: neutral ground between Apple’s world and everyone else’s, a place where one body of code is welcome on every platform.

So the logo became the Swiss flag — the white cross on red — except the cross isn’t solid. It’s made of Swift’s little swallow, a whole flock of them in formation. Swiss swallows. I’m far too pleased with it.

The one I actually care about: URLSession.bytes, for real this time

The shim that set all of this in motion — and still the one I’d save first if the building were on fire — is async byte streaming on URLSession.

On Apple platforms you write this and you’re done:

let (bytes, response) = try await URLSession.shared.bytes(for: request)
for try await line in bytes.lines {
    print(line)
}

What you get is a genuine incremental stream of the response, line by line, as it arrives over the wire. That’s the whole game for something like Server-Sent Events, where the connection stays open and the server keeps pushing tokens at you for as long as it likes. SwiftMCP speaks exactly this kind of long-lived streaming HTTP.

swift-corelibs-foundation has none of it. No URLSession.bytes, no AsyncBytes, nothing. And here’s the part I’m not proud of: in the past, when I needed it on Linux, I faked it. I’d download the entire body, then turn around and hand it back chunk by chunk as if it had streamed. For a small JSON reply, nobody can tell the difference. For an SSE stream that’s supposed to stay open and deliver events for minutes — possibly forever — “buffer the whole body first, then start” isn’t a shim. It’s a hang with good manners.

SwiftCross does it properly. It spins up a one-shot URLSession with a data delegate, forwards each didReceive(data:) chunk straight into an AsyncThrowingStream, and resolves the response object the moment the headers land — not when the body finishes. The session and task are owned by the stream’s termination handler, so the whole thing tears itself down the instant you stop reading, whether you ran to the natural end or breaked out of the loop early. The .lines helper treats both \n and \r\n as breaks and doesn’t conjure a phantom empty line out of a trailing newline — the same fiddly little semantics Apple’s version has. It is a real stream, and SSE now behaves identically on Linux, Windows, and Android as it does on my Mac.

The bugs you can only meet on the platform

Here’s the thing nobody warns you about when you decide to support platforms you don’t run: the compiler errors are the easy half. The compiler errors are polite. They tell you exactly what’s missing and you go fill it in. The genuinely interesting failures only show up when the code actually executes over there — and you find those by reading a stack trace from a machine you’ll never log into.

This is the outer loop I’ve written about before, just pointed at portability instead of features: a red leg on some platform I can’t reproduce locally, a hypothesis, a fix, another push, and a slow hill-climb until every checkmark goes green. A few that left a mark:

The timeout that traps with SIGILL. swift-corelibs-foundation hands your URLSession timeout to libcurl by computing Int(timeout) * 1000. Utterly harmless — until you remember that Int(.infinity) in Swift does not return some large number, it traps the process with SIGILL. So does Int(.nan). So does any finite timeout big enough to overflow Int once it’s been multiplied by a thousand. A caller who’d set an effectively-infinite timeout — completely fine on Apple — would bring the whole thing down on Linux. The fix clamps only the values that can actually trap, and lets every realistic duration through untouched:

static func swiftCrossSafeTimeout(_ timeout: TimeInterval) -> TimeInterval {
    let maxSafe = TimeInterval(1 << 53)
    return (timeout.isFinite && timeout <= maxSafe) ? timeout : maxSafe
}

A minute, a week, a year — all preserved exactly, because silently shortening someone’s configured timeout would just be a quieter, nastier bug. Only the genuinely dangerous values get pinned to the largest interval that still survives the × 1000. There’s a little test suite holding both halves of that promise down.

Android isn’t just “Linux with a different name.” SwiftCross exposes ProcessInfo.localIPAddress — the machine’s own routable IP, something Foundation has no portable API for at all. On Apple, Linux, and Android it walks the interface list with getifaddrs. Except the Android leg fought me three separate times:

  • You have to import Android — the real platform overlay — not reach for the Bionic libc subset, or half the declarations you need simply aren’t there.
  • On that overlay, ifa_name arrives as a strict optional (it’s implicitly-unwrapped on Darwin and glibc), so the code that compiled everywhere else suddenly needs an explicit unwrap.
  • And getnameinfo wants its buffer length as size_t on Android but socklen_t on Darwin/glibc, so the length has to launder itself through numericCast to keep both compilers happy.

Windows doesn’t have getifaddrs at all. So on Windows the same localIPAddress opens a UDP socket and “connects” it to a documentation address — no packet is ever sent; the connect on a datagram socket exists purely to make the OS commit to a source address — and then reads that address straight back with getsockname. It feels like a parlor trick. It’s the cleanest portable way I found to ask the OS “if you were going to talk to the outside world, which of my addresses would you use?”

Not one of those was discoverable from a man page on my Mac. Every one of them came out of a failing CI run.

The rest of the collection

A handful of other shims rode along, every one of them extracted from a project where it had been quietly earning its keep:

UTType, lifted from SwiftMail. A stand-in for UniformTypeIdentifiers’ UTType, covering the filename-extension ↔ MIME-type mapping that portable code actually reaches for. The big lookup table came straight out of SwiftMail’s guts, with a curated layer of modern, preferred values sitting on top of the broad one (so png resolves to image/png and not some antique image/x-… spelling). On Apple the real UTType is re-exported untouched, so its full UTI hierarchy — conformances, supertypes, the lot — stays right where you left it; the shim deliberately only models the extension/MIME surface.

UTType(filenameExtension: "png")?.preferredMIMEType            // "image/png"
UTType(mimeType: "application/json")?.preferredFilenameExtension  // "json"

String.Encoding(ianaCharsetName:). Turn an IANA charset label — "utf-8", "ISO-8859-1", "windows-1252", "shift_jis" — into a String.Encoding, with the normalization and alias-folding the real world demands. CoreFoundation ships a complete table for this on Apple; everywhere else SwiftCross carries its own. You stop noticing it’s there right up until you’re decoding an email body or an HTTP response whose charset header someone typed by hand.

The half I can’t fix on my own

SwiftCross cleans up the Foundation-shaped half of the problem, and that half I could solve by myself. There’s another half I can’t. SwiftMCP still can’t reach Windows, and the blocker has nothing to do with Foundation — it’s SwiftNIO. SwiftMCP leans on NIO for its networking, and NIO’s Windows support has been a long time coming. The day that finally lands is the day the road to SwiftMCP-on-Windows actually opens. SwiftCross was the part within my reach; NIO is the part I’m waiting on, watching the tracker like everyone else.

In the meantime though, an agent is refactoring the SwiftMCP package structure so that you don’t have to include the MCP server portion – which is the one requiring SwiftNIO – if you are only doing client-side work. The idea is to use package traits that default to everything, but you can limit it to the client part if you don’t need the server.

If that works out then it unlocks SwiftAgents for Windows, as this new agents package only needs MCP client code and JSONValue for representing JSON schemas for LLM tool calls. I’ve gotten quite close to actually also pull out JSONValue into a separate repo which would have solved this immediately, but there are some other MCP parts I am using in there so it felt not worth the extra hassle at this time.

Write once for Apple, run everywhere Swift runs

That’s the entire philosophy, and it’s a small one: write the code you’d already write for Apple, change one import, and have it run everywhere Swift runs — with no #if dances cluttering the call site. SwiftCross is MIT-licensed, dependency-free, and every platform is built and tested on every push, because the whole reason it exists is that I can’t check those platforms any other way.

Now comes the satisfying part. I get to go back through SwiftMCP, SwiftBash, SwiftScript, SwiftPorts and the rest, delete every copy of that twenty-line shim I’ve been carrying around, and replace the lot with a single import SwiftCross. Five copies of a thing finally collapsing into one — there isn’t much in this job that feels better than that.

Speaking of SwiftBash, SwiftPorts and ShellKit… there are quite a few more system-level or shell-adjacent things that probably will also end up in SwiftCross. Often there are Posix-functions of different names doing the same thing so a lot of potential for further additions.

This was just the first step to get the initial bulk unified. As I discover more holes in non-Apple frameworks, SwiftCross will be the place for the canonical hole filler. Visit the repo at github.com/Cocoanetics/SwiftCross


Categories: Updates

Comments are closed.