# @swiftbrowser/cli

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

```sh
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](https://github.com/noya-app/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](https://www.swift.org/install/) (`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)

```json
{
  "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](https://swiftbrowser-docs.pages.dev/compatibility)), 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](https://github.com/twostraws/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:

```sh
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
```
