@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 SwiftUIalso imports Foundation and the shim'sFoundationBridge(Apple's SwiftUI re-exports Foundation and Combine, soUUID,JSONEncoder,UserDefaults,Timer.publishare in scope without an import). Both it andimport Foundationalso import Observation, so@Observableworks in a file that imports only Foundation, as it does under Xcode.- A generated
_SwiftBrowserFoundation.swiftsettles 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
resourcesinswiftbrowser.json) are served next to the module and mounted at/bundlebefore the app starts, soBundle.main.url(forResource:withExtension:)andData(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).SWIFTBROWSERis 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