SwiftBrowser docs
View as MarkdownEdit on GitHub

@swiftbrowser/cli#

Run an iOS SwiftUI app in the browser, from its Xcode project, without a Mac.

npx @swiftbrowser/cli doctor --install          # Swift 6.4, the wasm SDK, wasm-opt
npx @swiftbrowser/cli dev ~/Projects/MyApp      # http://localhost:5173, rebuilds on save
npx @swiftbrowser/cli build ~/Projects/MyApp    # ./dist: a static site to deploy anywhere

The app's sources compile unchanged against SwiftBrowser's SwiftUI shim and run as WebAssembly inside an iPhone frame (or full screen on a phone: open the dev server's QR code, then Share → Add to Home Screen). What the shim does not provide is a compile error, never a wrong rendering.

Commands#

Command What it does
doctor [--install] Checks Node ≥ 22, Swift 6.4, the Swift SDK for WebAssembly and wasm-opt; --install fetches the SDK (swift sdk install) and Binaryen into ~/.cache/swiftbrowser. Swift itself comes from swiftly (swiftly install 6.4.0), or point SWIFT_TOOLCHAIN_BIN at a toolchain's usr/bin.
check <path> Lists what the app imports (supported / rewritten / unsupported, with the usual way around each), then compiles the app natively against the shim. Seconds, no wasm; every error names your file and line.
dev <path> Builds the app for WebAssembly and serves it with the SwiftBrowser dev server: landing page with a QR code at /, the app at /app/?app=<Name>; saving a Swift file rebuilds and reloads with the app's state carried over. --port, --host (expose on the LAN for phones), --open.
build <path> [-o dist] A release build, optimized and stripped with wasm-opt (17.4 MB for a small app, 4.5 MB with brotli), and a complete static site around it. --no-optimize skips wasm-opt.

<path> is an .xcodeproj, a folder holding one (directly or one level down), or a plain folder of Swift sources. Options for all three: --target <name> when the project has several app targets, --config <file>, --verbose.

Building several apps in a row (a CI job, a script) can compile the SwiftBrowser package once for all of them: set SWIFTBROWSER_BUILD_ROOT to a directory, and every app is staged at <root>/<Name>/ and built with one SwiftPM scratch path, <root>/.build. After the first app, a check takes a few seconds instead of compiling the shim again. SwiftPM recompiles a dependency when the staging package's parent directory changes, which is why apps staged in their own projects cannot share it. One build at a time per root (SwiftPM locks the scratch path), and two apps with the same name take turns in the same staging directory.

What happens to your project#

Nothing, outside <project>/.swiftbrowser/ (add it to .gitignore), or outside $SWIFTBROWSER_BUILD_ROOT when that is set. The CLI reads the Xcode project (project.pbxproj, including Xcode 16 synchronized folders) for the app target's Swift sources, asset catalogs, display name and app icon, then writes a SwiftPM package under .swiftbrowser/<Name>/ whose one executable target is a copy of those sources, adjusted for the shim:

  • import SwiftUI also imports Foundation and the shim's FoundationBridge (Apple's SwiftUI re-exports Foundation and Combine, so UUID, JSONEncoder, UserDefaults, Timer.publish are in scope without an import). Both it and import Foundation also import Observation, so @Observable works in a file that imports only Foundation, as it does under Xcode.
  • A generated _SwiftBrowserFoundation.swift settles the names both the shim and Foundation declare (CGFloat, CGPoint, CGSize, CGRect, Timer, RunLoop, UserDefaults, Bundle) with typealiases, in the shim's favor.
  • The target's Copy Bundle Resources (or resources in swiftbrowser.json) are served next to the module and mounted at /bundle before the app starts, so Bundle.main.url(forResource:withExtension:) and Data(contentsOf:) read them. Asset catalogs are handled separately (Image("name"), Color("name")).
  • Each Core ML model becomes <Class>.mlmodel.swift, the class Xcode would generate for it with the model embedded (see "Core ML" below).
  • #Preview { … } blocks are blanked (the shim has no preview macro).
  • SWIFTBROWSER is defined, so platform-specific code can go behind #if SWIFTBROWSER / #if !SWIFTBROWSER, like #if os(iOS).

Every change keeps line numbers, so compiler diagnostics point at your files. The package depends on @swiftbrowser/swift (the shim at the version this CLI was released with).

Foundation#

Apps link the real Foundation (swift-corelibs-foundation, as the wasm SDK ships it): DateFormatter, NumberFormatter, NSString, Locale-aware formatted(), Measurement, NSRegularExpression, JSONEncoder, and the rest compile as on iOS. Measured through the pipeline (release, wasm-opt -Oz, stripped) on the Counter example:

ICU data Module gzipped brotli
trimmed to English (default), 3.9 MB of it 17.4 MB 6.5 MB 4.5 MB
"locales": ["*"], the whole 34 MB package 47 MB 19 MB 12 MB

The SDK's Foundation carries ICU's complete 34 MB locale data package; the CLI pulls that package out of the SDK's archive, keeps the locale-independent tables plus the bundles of the languages in "locales" (default ["en"]; the region part of a tag is ignored, every regional variant of a language stays), and links the trimmed package in the original's place. Formatting for a language that was dropped falls back to ICU's root locale (11/14/2023, 22:13 and € 0.00 for French instead of 14/11/2023 22:13 and 0,00 €); it never fails. Each language adds 100 to 300 kB, Chinese 1.7 MB (its collation table); ["*"] keeps everything. Dropped for good: the legacy character-set converters (String(data:encoding:) with Shift JIS, Windows-1252 and the like returns nil; UTF-8/16/32, ASCII and Latin-1 need none), the StringPrep profiles and the spoof-checker data. "collation": false also drops the collation tables (0.9 MB) when the app never compares strings in a locale: localizedCompare, localizedStandardCompare and .localizedStandard sorting then order by Unicode scalar. The trimmed package is cached per SDK, language set and collation choice under ~/.cache/swiftbrowser/icu/; the SDK itself is not modified.

What Foundation lacks on WASI, the shim's FoundationBridge supplies: Timer (corelibs' needs a run loop WASI does not have) with Timer.publish + onReceive, and RunLoop.main for its signature; UserDefaults, which Foundation's cannot persist on WASI, saved by the browser per app (localStorage) and restored at the next launch; Bundle, whose Foundation counterpart traps, reading the app's resources; and URLSession, URLRequest, HTTPURLResponse, URLError and AsyncImage over the browser's fetch (corelibs moved networking to FoundationNetworking, which the SDK does not ship). Known gaps of corelibs Foundation on WASI: NotificationCenter's closure observers and OperationQueue are missing.

Earlier releases had a lean mode (FoundationEssentials plus a small formatting layer, about a quarter of the size). It is gone: --foundation and "foundation" in swiftbrowser.json are accepted and ignored with a warning, and an import FoundationLite becomes import FoundationBridge.

Locale and time zone#

The app runs in the viewer's time zone and, within the languages it ships, the viewer's language, as an iOS app follows the device: Locale.current, TimeZone.current, formatted(), DateFormatter and Text(_:format:) all follow. The browser's first preferred language whose data the module carries ("locales") is used with its region, so a British visitor to an app shipping ["en"] gets 14/11/2023; a visitor whose language is not shipped gets the first shipped language rather than a half-formatted root locale. Add ?locale=fr-FR to the app URL to try a locale without changing the browser (an Xcode scheme's "App Language"). The headless harness pins en_001 and GMT unless SB_LOCALE / TZ are set, so snapshots stay stable. Lean mode formats in US English regardless.

The hook behind this: swift-foundation hard-codes en_001 on WASI, so the CLI redirects that one function to FoundationBridge at link time (--wrap); it depends on the SDK's swift-foundation, which the CLI pins and CI checks.

swiftbrowser.json (optional)#

{
  "name": "MyApp",
  "displayName": "My App",
  "target": "MyApp",
  "sources": ["MyApp"],
  "exclude": ["MyApp/Watch"],
  "catalogs": ["MyApp/Assets.xcassets"],
  "resources": ["MyApp/missions.json", "MyApp/Data"],
  "icon": "MyApp/Assets.xcassets/AppIcon.appiconset/icon-1024.png",
  "swiftLanguageMode": "5",
  "locales": ["en"],
  "collation": true,
  "defines": ["DEMO"]
}

Everything is optional; sources, catalogs and resources replace what the Xcode project says when set (a plain folder has resources only through the config); locales and collation pick the ICU data that ships (see above).

Frameworks#

Supported: SwiftUI (see the shim's compatibility matrix), Observation, Swift Charts, Foundation, networking (URLSession, URLRequest, HTTPURLResponse, URLError and AsyncImage run over the browser's fetch, so the servers an app talks to must allow its origin, CORS), and SwiftData: @Model, ModelContainer, ModelContext, @Query, #Predicate, relationships with their delete rules, autosave. The models live in memory while the app runs and the page keeps them in IndexedDB, one database per app (sb-swiftdata:<App>); ?swiftdata=reset on the app's URL starts it with no data. An app that imports SwiftData also builds SwiftData's macros, which need swift-syntax: SwiftPM fetches it on the first build and uses its prebuilt release where swift.org publishes one (Swift 6.4 on Linux and macOS), else compiles it once (about a minute). Files an app writes in its home (URL.documentsDirectory and the other iOS directories, through Data, String or FileManager) are kept the same way, in sb-files:<App>; ?files=reset starts with none. check names the frameworks it knows have no browser counterpart yet (UIKit, Core Data, Combine, MapKit, AVFoundation, WidgetKit, WebKit, …) with the usual way out: keep that code behind #if !SWIFTBROWSER and give the browser a stand-in behind #if SWIFTBROWSER. StoreKit, Core Location and LocalAuthentication import fine, as far as apps usually use them: requestReview does nothing, CLLocationCoordinate2D is a plain struct, and an LAContext evaluation always succeeds. PhotosUI's PhotosPicker opens the browser's file chooser, and the picked files' bytes load with loadTransferable(type: Data.self). Core Image (import CoreImage, or import CoreImage.CIFilterBuiltins, which the staging turns into import CoreImage) has its common built-in filters and the QR code generator, drawn by the page; UIImage and CGImage come with SwiftUI, as on iOS. UserNotifications runs over the browser's Notification API: the app asks for permission with the browser's prompt, schedules its requests with timers of its own and the page shows each one as it comes due. Notifications are delivered only while the page is open (a closed tab delivers nothing), and Safari on iPhone shows them only for an app added to the Home Screen.

Core ML#

The target's Core ML models (.mlmodel files and .mlpackage bundles in its Sources phase or synchronized folders; in a plain folder, every one found, or those "sources" names) compile into the classes Xcode generates for them: SleepCalculator.mlmodel becomes SleepCalculator, SleepCalculatorInput and SleepCalculatorOutput, with init(configuration:), prediction(wake:estimatedSleep:coffee:) (each input typed as Xcode types it), prediction(input:), predictions(inputs:), the load functions, model and urlOfModelInThisBundle. The CLI reads the model's protobuf and embeds its specification in the class; the shim's CoreML module evaluates it (MLModel, MLFeatureValue, MLMultiArray, MLDictionaryFeatureProvider and friends). That covers what Create ML makes from tables: linear and logistic regression, boosted trees, random forests and decision trees, and the one-hot encoders, imputers, normalizers, scalers and vectorizers around them. Any other model (a neural network, an ML program, an image, sound or text model) stops the build with its type named, for example Classifier.mlmodel: the model is a neural network (neuralNetwork), which the browser cannot run. dev rebuilds when a Swift file changes; a retrained model is picked up by the next rebuild.

Swift packages#

The Xcode target's package dependencies (File › Add Package Dependencies) are added to the staging package: each remote package with the version rule Xcode records (up to next major or minor, exact, a range, a branch or a revision), each local package by path. SwiftPM fetches and builds them for the browser with the app, so a pure-Swift package works if it builds for WASI. A package that cannot work in the browser and that SwiftBrowser knows is replaced by a module of the same name instead, and never fetched; today that is CodeScanner, whose CodeScannerView sends back its simulatedData (as in the iOS simulator) and scans with the camera where the browser has BarcodeDetector (Chrome on Android and macOS). check lists each package and which way it went:

✓ package CodeScanner (github.com/twostraws/CodeScanner, up to next major from 2.4.1): SwiftBrowser's stand-in, not fetched (the package wraps AVFoundation and UIKit)
✓ package swift-algorithms (github.com/apple/swift-algorithms, up to next major from 1.2.0): a SwiftPM dependency of the build (Algorithms)

A package that needs a platform framework fails to compile with an error naming it; keep its uses behind #if !SWIFTBROWSER.

Example#

Eighteen of the nineteen "Hacking with SwiftUI" projects run from their Xcode projects without a change (WeSplit, Guess the Flag, Views and Modifiers, BetterRest, WordScramble, Animations, iExpense, Moonshot, Navigation, Cupcake Corner, Bookworm, SwiftDataProject, Instafilter, Accessibility, Hot Prospects, Flashzilla, Layout and Geometry, SnowSeeker); check names what stops the other, Bucket List, which needs MapKit, a framework the browser has no counterpart for yet:

git clone --depth 1 https://github.com/twostraws/HackingWithSwift
npx @swiftbrowser/cli dev HackingWithSwift/SwiftUI/project4      # BetterRest: Core ML
npx @swiftbrowser/cli dev HackingWithSwift/SwiftUI/project13     # Instafilter: PhotosPicker, Core Image
npx @swiftbrowser/cli dev HackingWithSwift/SwiftUI/project16     # Hot Prospects: CodeScanner, notifications
npx @swiftbrowser/cli dev HackingWithSwift/SwiftUI/project17     # Flashzilla
npx @swiftbrowser/cli dev HackingWithSwift/SwiftUI/project11     # Bookworm: SwiftData