🎉 Black Friday Sale! Save 30% when you subscribe today.

Ever since Swift was open sourced in 2015 it has been possible to deploy Swift on non-Apple platforms, though it was mostly restricted to just Linux. Even this very site was built in Swift from the first day it launched in 2018 (and open-sourced!).

Over the years the developer experience for building server-side applications in Swift has greatly improved, thanks to a combination of effort from Apple and the greater Swift community. However, it wasn’t until relatively recently that building Swift apps for non-Apple and non-Linux platforms has become more of a possibility.

Thanks to herculean effects of Saleem Abdulrasool (and the Browser Company), Max Desiatov, @yonihemi, Yuta Saito, Carson Katri, and many others, Swift can now be deployed on Windows and WebAssembly. And each year there are more platforms being explored, such as embedded Swift.

However, we aren’t going to sugarcoat things. Building Swift applications for non-Apple platforms can be quite difficult. The tools are not as polished as they are for Apple’s platforms, most frameworks that we know and love are not available (e.g., SwiftUI 😢), and it takes significant work to prepare an app for sharing code across multiple platforms.

That is the topic of the new series of episodes we have just started, but in this blog post we want to describe the very basics of getting a simple Swift app running on a non-Apple platform, in particular in a browser. By the end of this article you will have a simple counter feature built in Swift and running in the browser.

WebAssembly

We are going to use WebAssembly, and in particular the SwiftWasm project, to compile a Swift project for the web and run it in the browser. WebAssembly is a binary format that can be embedded directly in a web browser, and other platforms, that allows one to run any language in the browser, not just JavaScript. Many languages support Wasm, including Python, Ruby, Rust, and C++, and even Swift has experimental support for Wasm.

We’ll start by creating a new Swift executable in a package and opening it in Xcode:

Important

In order to run the code in this article you must be on beta 4 of Xcode 16. The version of Swift that shipped in newer versions of Xcode unfortunately have a bug that needs to be fixed. It is also possible to use VSCode to build this project, but that takes more work to set up.

mkdir WasmCounter
cd WasmCounter
swift package init --type executable 
open Package.swift

Next we will add some dependencies to the Package.swift of this package. In order to build a Wasm application we will use the carton plugin from the SwiftWasm organization:

dependencies: [
  .package(url: "https://github.com/swiftwasm/carton", from: "1.0.0")
],

carton is a Swift plugin that can be run from the command line. But, before we can do that we must use a very specific snapshot of the Swift compiler. We can’t use the one that is included with Xcode.

Luckily carton makes this very easy to manage. Simply create a .swift-version file at the root of the package that describes the snapshot we want to use. The latest one we have found that works well is the following:

wasm-DEVELOPMENT-SNAPSHOT-2024-07-16-a

…but there may be other snapshots that work too.

With that done, we can run carton from the command line:

$ swift run carton dev

…and then carton will download and install the Swift snapshot, compile your executable, and automatically open a browser with the executable running. And it may not seem like much, but there is in fact a Swift executable running in the browser. In fact, if you open your browser’s developer console (cmd+option+I in Safari), then you will see that “Hello, world!” is printed, and that’s because the executable currently prints that message:

// The Swift Programming Language
// https://docs.swift.org/swift-book

print("Hello, world!")

So, Swift is indeed running in the browser, but there isn’t much functionality… yet.

A cross-platform counter feature

Let’s build a tiny Swift feature that can run in the browser, but that can also run on Apple’s platforms such as iOS. The feature will need to be pure Swift and it can’t use any Apple-specific frameworks (no SwiftUI or UIKit). Luckily Swift comes with a powerful observation framework that is built in pure Swift, and so is instantly available on all platforms supported by Swift.

So, let’s create a simple CounterModel feature that holds onto an integer and exposes some methods for incrementing and decrementing the count. And further, we will mark the class as @MainActor to make it safe to use concurrently, and we will mark it with the @Observable macro to make it possible to observe changes to the count state:

import Observation 

@MainActor
@Observable
class CounterModel {
  var count = 0
  func decrementButtonTapped() {
    count -= 1 
  }
  func incrementButtonTapped() {
    count += 1 
  }
}
Note

This code, and all code in this article, can be put in the main.swift file in the executable target created by SPM.

This is 100% pure Swift code and can compile on any platform that Swift supports, including Linux, Windows, Wasm, and more. Of course the model is quite simple, but in the future it may accrue more responsibilities, such as network requests, persistence, and more.

And now we would like to build out the HTML view that actually interacts with this model in order to display the count and invoke the methods on the model.

JavaScriptKit

WebAssembly does get direct access to the Document Object Model (DOM) in the browser for adding and removing HTML nodes. One must go through JavaScript to do this, and there is an additional library from the SwiftWasm organization to make this easier, called JavaScriptKit.

So, let’s add that to the Package.swift file:

dependencies: [
  .package(url: "https://github.com/swiftwasm/carton", from: "1.0.0"),
  .package(url: "https://github.com/swiftwasm/javascriptkit", exact: "0.19.2")
],
Important

You must depend on exactly version 0.19.2 of JavaScriptKit to work around some compilation issues.

Next we will add the JavaScriptKit and JavaScriptEventLoop products to our WasmCounter executable so that we can access those libraries:

.executableTarget(
  name: "WasmCounter",
  dependencies: [
    .product(name: "JavaScriptKit", package: "javascriptkit"),
  ]
),

Now we can write some Swift code that will generate DOM elements for display in the browser. To do this we will use the JavaScriptKit library:

import JavaScriptKit

The JavaScriptKit library allows you to write Swift code that secretly calls invokes JavaScript in the browser. For example, in JavaScript one can create a DOM element to hold the count value and append it to the document’s body like so:

let countLabel = document.createElement("div")
countLabel.innerText = "Count: 0"
document.body.appendChild(countLabel)

It’s simple enough, but also at the end of the day we don’t want to write JavaScript. Since we want to reuse Swift code across platforms we want to keep as much of our code in Swift as possible. And this is where JavaScriptKit comes into play.

Thanks to a novel use of the string-based dynamic member lookup, JavaScriptKit allows us to write Swift code that looks very similar to JavaScript, and invokes actual JavaScript APIs under the hood:

let document = JSObject.global.document

var countLabel = document.createElement("div")
countLabel.innerText = "Count: 0"
_ = document.body.appendChild(countLabel)

Character-for-character, this Swift code is nearly identical to the JavaScript version. And with that little bit of code written you can refresh the browser to see “Count: 0” showing. This means that our Swift code is running in the browser and manipulating the DOM.

Next we will create a button that when clicked invokes the decrementButtonTapped method on the model:

let model = CounterModel()

var decrementButton = document.createElement("button")
decrementButton.innerText = "-"
decrementButton.onclick = .object(
  JSClosure { _ in
    model.decrementButtonTapped()
    return .undefined
  }
)
_ = document.body.appendChild(decrementButton)

And we will do the same for the increment button:

var incrementButton = document.createElement("button")
incrementButton.innerText = "+"
incrementButton.onclick = .object(
  JSClosure { _ in
    model.incrementButtonTapped()
    return .undefined
  }
)
_ = document.body.appendChild(incrementButton)

And with that little bit of work done we now have a rudimentary view implemented in the browser:

It isn’t the prettiest view in the world, but it gets the job done, and of course it’s possible to put in extra work to make it look better. And it’s worth mentioning that the work it takes to build this HTML view isn’t much different from the work it takes to build the equivalent UIKit view:

let countLabel = UILabel()
view.addSubview(countLabel)

let decrementButton = UIButton(type: .system, primaryAction: UIAction { _ in
  model.decrementButtonTapped() 
})
decrementButton.setTitle("-", for: .normal)
view.addSubview(decrementButton)

let incrementButton = UIButton(type: .system, primaryAction: UIAction { _ in
  model.incrementButtonTapped() 
})
incrementButton.setTitle("+", for: .normal)
view.addSubview(incrementButton)

This shows that one could approach building a Swift app in the browser much like one would approach building a UIKit app on iOS. It of course would take more work to be able to build HTML views in a style similar to SwiftUI, but it is technically possible with some hard work.

Updating the DOM when the model changes

We now have a basic view displayed in the browser, but there’s no behavior. Clicking on the “-” and “+” button doesn’t cause the count label to update. To implement this functionality we need to make use of Swift’s powerful observation tools.

Unfortunately, the tools in the Observation framework are quite barebones right now. It takes a little extra work to make them usable outside of SwiftUI, and that is where our powerful Swift Navigation library comes into play. It provides a suite of tools that can be used to power any Swift application, even ones being deployed on non-Apple platforms.

So, let’s add a dependence on Swift Navigation in the Package.swift:

dependencies: [
  .package(url: "https://github.com/swiftwasm/carton", from: "1.0.0"),
  .package(url: "https://github.com/swiftwasm/javascriptkit", exact: "0.19.2"),
  .package(url: "https://github.com/pointfreeco/swift-navigation", from: "2.1.0"),
],

…and add the SwiftNavigation product to the WasmCounter executable:

.executableTarget(
  name: "WasmCounter",
  dependencies: [
    .product(name: "JavaScriptEventLoop", package: "javascriptkit"),
    .product(name: "JavaScriptKit", package: "javascriptkit"),
    .product(name: "SwiftNavigation", package: "swift-navigation"),
  ]
),

Now we can import it:

import SwiftNavigation

…and make use of its most powerful tool, observe:

observe {

}

This tool automatically tracks changes to any field accessed from an observable model. When it detects a change, the trailing closure is immediately invoked again, giving us the chance to update the UI with the freshest state.

And so all we have to do is update the countLabel’s innerText to display the freshest count from the model:

observe {
  countLabel.innerText = .string("Count: \(model.count)")
}

The mere act of us accessing model.count in this closure means we subscribe to any changes to that field. If count is ever mutated, the closure will be called again, and the UI will be updated with the newest value. And further, if any state in model changes that is not accessed in this closure, the closure will not be invoked because we are not subscribed to those changes.

However, there are two things to be mindful of with this code. First, to keep the observation alive we must store the token that is returned from observe:

var tokens: Set<ObserveToken> = []
observe {
  countLabel.innerText = .string("Count: \(model.count)")
}
.store(in: &tokens)

And second, in order for us to be able to continue executing logic after the last line of this file has finished executing we need to install a run loop in the executable:

JavaScriptEventLoop.installGlobalExecutor()

This line should be the very first thing executed in this file. And with that done we now have a dynamic counter running in a browser that is powered by 100% pure Swift code”

It is absolutely incredible to see!

Swift Navigation 2.2

The primary reason it was so easy to use the CounterModel observable class in a WebAssembly app is thanks to the observe function that comes with our Swift Navigation library. It provides many foundational tools that can be used in any Swift app deployed to any platform. But it also provides specific tools for SwiftUI, UIKit, and starting today with the 2.2 release, we are now providing some rudimentary tools for AppKit. The observe tool now works on macOS, and further it has been integrated with AppKit’s animation APIs.

For example, an AppKit app can introduce animated changes to a model using withAppKitAnimation, which can be thought of as an AppKit-friendly version of SwiftUI’s withAnimation:

func incrementButtonTapped() {
  withAppKitAnimation {
    count += 1
  }
}

And then any observe block that observes this change will automatically apply it to the view in an animation.

Explore cross-platform Swift today

We hope that you have found the prospects of cross-platform Swift as exciting as we have! If you want to learn more, then be sure to check out our new series of episodes that covers more advanced topics, such as networking, dependencies, navigation, bindings, and a lot more.

Get started with our free plan

Our free plan includes 1 subscriber-only episode of your choice, access to 64 free episodes with transcripts and code samples, and weekly updates from our newsletter.

View plans and pricing