SwiftBrowser docs
View as MarkdownEdit on GitHub

History#

How SwiftBrowser got here, phase by phase, oldest first. What works today is summarized in Compatibility; how to use it is in Getting started.

Phases 1 to 4#

Phase 1 proved the pipeline, Phase 2 added navigation, lists, input and observation, Phase 3 added the layout engine, animation, sheets and Dynamic Type. Phase 4 adds:

  • TabView with tabItem and tag, drawn with an iOS tab bar; every tab stays mounted so its state survives switching.
  • Picker (segmented and menu styles) and Form, plus list styles.
  • Value-based navigation: NavigationStack(path:), NavigationPath, navigationDestination(for:) and NavigationLink(value:), with programmatic pushes and pops through the path binding.
  • Swift concurrency on Wasm: a host-driven executor so Task, .task, Task.sleep and ContinuousClock run and their state changes reach the screen. Headless snapshots drive it with a virtual clock.
  • GeometryReader with the laid-out size reported back from the renderer.
  • A fourth example, Examples/Settings, exercising all of it.

Phase 5: a real app (Food Truck, step 1)#

  • Examples/FoodTruck: Apple's Food Truck sample, vendored and trimmed (its README lists every change). The donut model, orders, cities and the Truck / Orders / Donuts screens run; the store, weather, maps, charts and widgets do not.
  • Foundation on Wasm: apps link FoundationEssentials (Date, Calendar, Decimal) plus Sources/FoundationLite, which supplies formatted() and friends without Foundation's 40 MB of ICU data (docs/architecture.md).
  • Asset catalogs: Image("name") and Color("name") read *.xcassets under the example, served and bundled by Web/plugins/asset-catalogs.ts (one PNG per image set, light/dark color sets, custom symbol sets).
  • Combine-style observation: ObservableObject, @Published, @StateObject, @ObservedObject, @EnvironmentObject.
  • .aspectRatio / .scaledToFit / .scaledToFill, .id, .compositingGroup, Image.resizable() for catalog images, .offset transitions, Animation.spring(response:dampingFraction:).

Phase 6: Food Truck, step 2#

  • The app-level screens of Apple's sample run from their own sources: the sidebar, Truck cards (brand header, orders, donuts, social feed), the donut gallery grid and editor, orders with sections and badges, the completion sheet with the donut box, the social feed with flow-layout tags.
  • Custom Layout: the renderer measures the subviews (min, ideal, max) and sends the proposal to Swift, which runs sizeThatFits / placeSubviews and returns frames plus its own min / ideal / max, so nested layouts measure each other correctly; one frame of latency, like GeometryReader.
  • Grid/GridRow, LazyVGrid, Gauge, Menu, .toolbar, .searchable, .onChange, TimelineView, LabelStyle, .overlay/.background with views and shapes, ShapeStyle (hierarchical levels, linear and radial gradients, Color.gradient, materials), .offset/.rotationEffect/ .scaleEffect/.shadow/.clipped/.position, .badge, .listRowInsets, .tint, .imageScale, .onTapGesture, .sheet(item:), asymmetric transitions, @ScaledMetric, LocalizedStringKey.

Phase 7: Food Truck, step 3#

  • Swift Charts: a Charts module with Chart, BarMark, LineMark, AreaMark, RectangleMark, PointMark, RuleMark, axes (AxisMarks, grid lines, ticks, text or view labels), legends, scales and date bins. Swift maps data to unit coordinates; the renderer measures labels, lays out the plot and draws the marks as SVG. Sales History, Top 5 and the Truck weather card run from Apple's sources.
  • NavigationSplitView with List(selection:): a stack on phones, two columns from 700 pt (the renderer reports horizontalSizeClass); tablets now fill the viewport instead of showing the phone bezel.
  • ViewThatFits, .hidden(), gradient .mask, shadowed shape styles, two-tone foregroundStyle, and the City panel (parking showcase on a drawn map, weather and parking cards).

Phase 8: interaction#

  • Gestures: DragGesture, LongPressGesture, TapGesture with .gesture/.highPriorityGesture/.simultaneousGesture, .onLongPressGesture, allowsHitTesting; the renderer tracks pointers and streams drag events once per frame.
  • Lists: swipeActions (tints, destructive, full swipe), contextMenu, onDelete/onMove with swipe to delete, EditButton and editMode with the minus / selection circles, List(selection:) on tagged rows for single and multi-selection, refreshable pull to refresh, swipe-back navigation.
  • Presentation: alert (iOS alert card), confirmationDialog (action sheet), fullScreenCover.
  • Controls: Stepper, Slider, DatePicker (native pickers behind compact capsules), TextEditor, Text(_:format:) and TextField(_:value:format:) with FoundationLite's .currency/.number.
  • Effects: rotation3DEffect, blur, real ignoresSafeArea, repeatCount/repeatForever animations, Binding.animation, ViewModifier, scenePhase, the accessibility environment.
  • The driver is Examples/HackingWithSwift: five of Paul Hudson's 100 Days of SwiftUI projects (Guess the Flag, BetterRest, Animations, iExpense, Hot Prospects, Flashzilla) running from their sources.

Phase 9: incremental rendering#

  • Swift: a state change re-runs only the bodies that depend on it. Each mounted view tracks its own @State, @Observable reads and observed objects; clean subtrees are skipped whole and their cached elements reused, and the reconciler skips unchanged subtrees by identity instead of diffing them.
  • Web: the layout pass lives across commits. Only the subtrees an op touched (and their ancestors) are measured and placed again; the DOM pass visits only what changed.
  • On Food Truck's 20 fps header: Swift ~24 ms → ~2 ms per tick, renderer ~5 ms → ~1.5 ms per commit, main thread ~36% → ~6% busy. Protocol unchanged.

Phase 10: bring your own app#

  • npx @swiftbrowser/cli dev ~/Projects/MyApp runs an iOS app from its Xcode project (project.pbxproj, Xcode 16 synchronized folders included) or a folder of sources: doctor installs the toolchain pieces, check reports the imports and compiles against the shim in seconds, dev serves it with rebuild on save, build writes a static site. See packages/cli/README.md.
  • The project is never modified: a staging SwiftPM package under <project>/.swiftbrowser/ holds a copy of the sources adjusted for the shim (import SwiftUI gains the Foundation modules Apple's SwiftUI re-exports, import Foundation becomes FoundationEssentials + FoundationLite, #Preview blocks are blanked, SWIFTBROWSER is defined), with line numbers intact.
  • Foundation measured: the wasm SDK's full Foundation adds about 42 MB (36 MB of ICU data) as soon as anything from it is used; FoundationEssentials adds 4 MB. Lean mode is the default, and FoundationLite grows with what apps need (UserDefaults, Timer.publish + onReceive, format styles, IndexSet, CharacterSet).
  • Full Foundation made viable: --foundation full trims ICU's 34 MB data package to the project's "locales" (English by default, 5.6 MB) and links it in the SDK's place through a data-only target of the staged package, so an app that formats dates and currency with the real Foundation is 19 MB (6.6 MB gzipped) instead of 47 MB (18.5 MB).
  • npm packages: @swiftbrowser/cli, @swiftbrowser/web (the renderer and dev server, now with an app registry), @swiftbrowser/xcodeproj (a dependency-free Xcode project reader) and @swiftbrowser/swift (the Swift package, so the CLI builds against the shim version it ships with).
  • Acceptance: check over all nineteen Hacking with SwiftUI projects, run from their Xcode projects unchanged. Five compile today (Guess the Flag, Views and Modifiers, Animations, iExpense, Flashzilla); the rest stop at a named gap: unsupported frameworks (SwiftData, Core ML, Core Image, PhotosUI, StoreKit) or shim API still missing (@FocusState, Path / InsettableShape, Bundle, onSubmit, scrollTargetBehavior, visualEffect, accessibilityInputLabels). CI runs the CLI on six of them.

Phase 11: the real Foundation by default#

  • The CLI now builds against the wasm SDK's own Foundation (swift-corelibs) unless asked for --foundation lean: DateFormatter, NumberFormatter, NSString, locale-aware formatted(), Measurement, NSRegularExpression and UserDefaults compile as on iOS. With ICU's data trimmed to English (4.7 MB) a small app is 18 MB, 4.6 MB over the wire with brotli; lean stays for apps that must be small (4.5 MB, 1.2 MB brotli).
  • FoundationBridge, a new module of the Swift package, is the shim's glue to the real Foundation: IndexSet for onDelete / onMove, Date for DatePicker, Foundation's FormatStyles in Text(_:format:) and TextField(_:value:format:), and the Timer + RunLoop that corelibs Foundation does not have on WASI (Timer.publish + onReceive included). The shim itself still never imports Foundation. The names both declare (CGFloat, CGPoint, CGSize, CGRect, Timer, RunLoop) are settled by typealiases in a file the CLI generates into the app.
  • FoundationLite is the lean mode's module; its Timer and the bridge's share one executor-driven core in the shim (_IntervalPublisher, _ExecutorTimer), and a scheduled timer now lives until it fires, as Foundation's does.
  • CI builds the six examples and two Hacking with SwiftUI projects in both modes and runs them headless; the lean builds keep the op-stream snapshots.
  • The app follows the viewer: the browser's time zone, and its language when the module ships that language's data (else the first shipped one), with ?locale= to try another. swift-foundation hard-codes en_001 on WASI, so the CLI redirects that function to FoundationBridge at link time.
  • UserDefaults persists: the module sends its defaults as a defaults op, the page keeps them in localStorage per app and hands them back at launch.
  • Bundle.main works: the target's Copy Bundle Resources are served next to the module and mounted at /bundle in the WASI filesystem before main, so url(forResource:withExtension:) + Data(contentsOf:) read them. Both classes live in _FoundationCommon, shared by FoundationLite and the bridge.

Phase 12: the rest of the Hacking with SwiftUI list#

  • The survey's remaining gaps, closed: @FocusState with .focused(_:) and .focused(_:equals:) (the field's focused prop; focus events back), .onSubmit and .submitLabel (submit events on Enter, enterkeyhint), .preferredColorScheme (a root update the page applies as its theme), navigationTitle with a Binding or Text, .accessibilityInputLabels, .scrollTargetBehavior + .scrollTargetLayout() (CSS scroll snapping on the target stack's children), .containerRelativeFrame (an affine size against the nearest container: the screen or the scroll viewport), .visualEffect (its effects apply once with an empty proxy; the browser does not report frames per scroll step), @Environment(Type.self) with .environment(_:) for @Observable objects, dynamicTypeSize(_ range:), Angle / Double, .scrollBounceBehavior, and a UITextChecker stand-in that passes every word (full mode).
  • CLI: import Foundation also imports Observation, as Apple's does (an @Observable model in a Foundation-only file compiles), and file references whose case differs from the disk are repaired (a project made on macOS).
  • Twelve of the nineteen Hacking with SwiftUI projects now compile unchanged (WeSplit, Guess the Flag, Views and Modifiers, WordScramble, Animations, iExpense, Moonshot, Navigation, AccessibilitySandbox, Flashzilla, LayoutAndGeometry, SnowSeeker). The rest wait on frameworks: SwiftData (11, 12, 16), Core ML (4), Core Image / PhotosUI / StoreKit (13), MapKit / Core Location (14), and networking (URLSession, AsyncImage; 10).

Phase 13: networking#

The browser is the network. URLSession (data(from:), data(for:), upload(for:from:), dataTask(with:completionHandler:)), URLRequest, URLResponse / HTTPURLResponse and URLError exist in both Foundation modes and run over the page's fetch: a request becomes a request op the page performs and answers with a response event, and the awaiting task resumes through the executor. AsyncImage (all three initializers, AsyncImagePhase) asks the page to decode the picture and shows an <img> the browser already has. Cross-origin servers must allow the page (CORS), as for any web app. Headless runs answer from SB_RESPONSES fixtures, so an app's network paths snapshot deterministically. Cupcake Corner (project 10) is the thirteenth Hacking with SwiftUI project to run unchanged, and the new Examples/CupcakeCorner.

Phase 14: one Foundation#

Lean mode is gone. Every app links the wasm SDK's Foundation with its ICU data trimmed to the app's languages, the way the CLI has built apps by default since Phase 11; 4 of the 13 Hacking with SwiftUI projects that run today compiled in lean mode, and each new Foundation API an app touched had to be re-implemented in FoundationLite. With one Foundation the shim imports it directly: onDelete takes an IndexSet, DatePicker a Date, Text(_:format:) a FormatStyle, and the three protocols that stood in for them, FoundationLite, _FoundationCommon and the bridge's conformance layer are deleted. FoundationBridge keeps what corelibs Foundation lacks on WASI: Timer and RunLoop, a persisting UserDefaults, Bundle, URLSession, AsyncImage, the viewer's Locale. The examples are now built through the CLI like any app (scripts/build-wasm.sh), their sources say import Foundation and nothing else, and the headless snapshots are recorded in en_US with ICU's formatting. --foundation and "foundation" in swiftbrowser.json are accepted and ignored with a warning. A small app is 18 MB, 4.6 MB over the wire with brotli.

Phase 15: smaller modules#

Where the 18 MB went, and why the linker cannot drop more, is in docs/ops-protocol.md, "Phase 15": the app is 6 kB of it; the rest is the standard library, Foundation, ICU and their protocol conformances, which stay reachable through dynamic casts. What shrank: the ICU trimming also drops the legacy character-set converters, StringPrep profiles and spoof-checker data (0.8 MB), "collation": false drops the collation tables (0.9 MB) for apps that never compare strings in a locale, wasm-opt runs -Oz (as fast as -Os here, 1.3% smaller) and the Swift driver's autolink record is removed (108 kB). Counter: 17.4 MB (4.5 MB brotli), from 18.4 MB (4.6 MB). Loading the ICU data from a content-named file beside the module, which every app on a site shares and an update never re-downloads, was built and measured too (13.6 MB module plus a 3.9 MB file) and parked on the icu-sidecar branch: for a single-app site it saves a fifth of each update's download at the cost of a second file the module cannot start without.

Phase 16: skipping unchanged views#

A body that re-runs no longer re-runs every view below it. Like SwiftUI, the runtime compares each child's new view value and the environment it receives with last time's, and skips the child and its subtree when they are the same (docs/ops-protocol.md, "Phase 16", has the rules; a closure or binding always counts as a change). In Food Truck a new order re-rendered the whole Truck screen, a task long enough to stall the animated truck header; now only the orders card and what changed in it re-run.

Phase 17: cheaper passes#

Each render pass does less (docs/ops-protocol.md, "Phase 17"). The runtime works out once per view type which of its protocols the type conforms to, instead of two dozen dynamic casts per node per pass. A chain of modifiers no longer doubles its work with every modifier. A screen hoists its toolbar items only from elements that are new. The renderer no longer reads every updated element's computed style in passes that cannot animate, and it reads the screen size only after a resize. Food Truck's Truck screen, with its header animating, takes about 95 ms of script per second instead of about 140. The new-order task's longest piece drops from 55–74 ms to 50–55 ms. Two behaviors were fixed along the way: a modifier inside an if was applied twice, so onAppear and .task ran twice. A long modifier chain took time exponential in its length; thirty .paddings never finished rendering.

Phase 18: SwiftData on IndexedDB#

import SwiftData works: @Model, ModelContainer, ModelConfiguration, ModelContext (insert, delete, delete(model:where:), fetch with a FetchDescriptor, fetchCount, save, rollback, autosave), @Query (sorted, filtered, made in init from a view's inputs), #Predicate, SortDescriptor, @Attribute(.unique), @Relationship with its delete rules and inverse (declared or inferred), @Transient, Codable attribute types and .modelContainer / .modelContext on views and scenes. The models live in memory while the app runs; the page keeps them in IndexedDB, one database per app, reads it before the module starts (SB_SWIFTDATA) and writes every save back (a swiftdata op), so the data survives reloads and new builds. The main context saves at the end of each event that changed it.

@Model is a real macro that expands as Apple's does (Sources/SwiftDataMacros, on swift-syntax 604, which SwiftPM downloads prebuilt for Swift 6.4), so an app links SwiftData only when it imports it. #Predicate gets SwiftData's copy of Foundation's macro, which also accepts localizedStandardContains, localizedCompare and caseInsensitiveCompare (the open-source macro rejects them). Bookworm and SwiftDataProject (Hacking with SwiftUI 11 and 12) now run from their Xcode projects unchanged, fifteen of the nineteen, and Examples/Bookshelf exercises all of it. docs/ops-protocol.md, "Phase 18", has the details.

Smaller additions: sharing, empty states, framework stand-ins#

  • ContentUnavailableView (title and symbol or image, description, actions, .search and .search(text:)), built from the shim's own views.

  • ShareLink (one item or several, String, URL and asset-catalog Image items, SharePreview, subject and message) asks the page to share through the Web Share API. Where the browser has none or refuses, it downloads instead: an image as its file, text and links as a .txt. A share the viewer dismisses downloads nothing (docs/ops-protocol.md, "Sharing").

  • Three Apple frameworks, as far as apps usually use them:

    • import StoreKit: @Environment(\.requestReview) and SKStoreReviewController.requestReview() do nothing (there is no store page to review).
    • import CoreLocation: CLLocationCoordinate2D is a plain struct, with no location services behind it.
    • import LocalAuthentication: an LAContext whose every policy can be evaluated and whose every evaluation succeeds. An app that locks content behind Face ID opens as if the viewer had authenticated.

    Each is a small module the CLI links only into apps that import it. Examples/Gallery has a Sharing screen.

Phase 19: the app's files#

An app gets an iOS-like home directory that the page keeps across launches. URL.documentsDirectory, URL.applicationSupportDirectory, URL.cachesDirectory and URL.libraryDirectory (which corelibs Foundation does not declare) point into it, and so do FileManager.urls(for:in:), so Data.write(to:), Data(contentsOf:), String(contentsOf:) and FileManager work as on a phone. Data.WritingOptions.atomic, which corelibs marks unavailable on WASI, is accepted (writes there are always in place). The page mounts the home at /data from the app's IndexedDB database (sb-files:<App>) and, after any event that wrote to a file, saves what changed; /tmp is a separate mount nothing keeps; ?files=reset starts with an empty home. Headless runs map the home to a directory (SB_FILES_DIR). Examples/Journal keeps its entries in Documents the way Hacking with SwiftUI's BucketList does. docs/ops-protocol.md, "Phase 19", has the details.

Core ML for small Create ML models#

import CoreML works for the models Create ML makes from tables: linear and logistic regressions, boosted trees, random forests and decision trees, with the feature engineering their pipelines wrap them in (one-hot encoders, imputers, normalizers, scalers, vectorizers). Xcode turns each .mlmodel of a target into a Swift class; the CLI does the same when it stages the app. It reads the model's protobuf (a small wire-format reader, no dependency), checks every model in it is one the browser can run, and generates the class with Xcode's API (init(configuration:), prediction(<inputs>), prediction(input:), predictions(inputs:), load, model, urlOfModelInThisBundle) and the model's specification embedded as JSON. A new CoreML module (MLModel, MLModelConfiguration, MLFeatureProvider, MLFeatureValue, MLDictionaryFeatureProvider, MLMultiArray, MLModelDescription, MLModelError) evaluates it, synchronously and in double precision; it is linked only into apps that have a model or import CoreML. A model of another kind (a neural network, an ML program, an image or text model) stops the build with its type named. BetterRest (Hacking with SwiftUI 4) now runs from its Xcode project unchanged, sixteen of the nineteen: at its defaults SleepCalculator predicts 8 h 21 min 48 s of sleep and the alert says 10:38 PM, which CI checks headless. docs/ops-protocol.md, "Core ML models", has the details.

Photos picker#

import PhotosUI works: PhotosPicker (one item or several, with maxSelectionCount, a PHPickerFilter such as .images, .videos, .any(of:) or .not(_:), a photoLibrary, a label or a title) and .photosPicker(isPresented:selection:). The browser's file chooser stands in for the photo library. A tap on the picker sends a pickFiles request, and the page opens an <input type="file"> within that same tap, since browsers only show a chooser while the user is tapping. The page answers with every picked file's bytes. PhotosPickerItem is Hashable, and loadTransferable(type: Data.self) returns the bytes at once. loadTransferable(type: Image.self) shows the picture. A dismissed chooser leaves the selection as it was. supportedContentTypes are UTTypes from a small UniformTypeIdentifiers module. Headless runs answer picks from SB_RESPONSES fixtures. A task an onChange(of:) action starts during a render, such as one loading the picked item, now runs right away instead of at the next event. docs/ops-protocol.md, "Photos picker", has the details, and Examples/PhotoPicker uses all of it. Instafilter (Hacking with SwiftUI project 13) also uses Core Image and @AppStorage, both below.

Bitmaps and Core Image#

UIImage, CGImage and import CoreImage (with CoreImage.CIFilterBuiltins) work as Hacking with SwiftUI's Instafilter (13) and Hot Prospects (16) use them: UIImage(data:) from an image file's bytes, CIImage(image:), the built-in filters sepiaTone, crystallize, edges, gaussianBlur, pixellate, unsharpMask and vignette (typed, or by name with setValue(_:forKey:) and inputKeys), qrCodeGenerator (byte mode, error correction L, M, Q or H), CIContext().createCGImage(_:from:), UIImage(cgImage:) and Image(uiImage:) with .interpolation(.none). ShareLink shares such an image as a PNG.

Swift never holds the pixels. A UIImage or CIImage is a recipe: the file's bytes (or a pixel buffer, for a QR code) plus the filter steps to run, which the page carries out on a canvas at the size the image is shown (Web/src/imageOps.ts, in plain TypeScript). UIImage(data:) stays synchronous, as on iOS, because Swift only reads the size from the file's header (PNG, JPEG with its EXIF rotation, GIF, WebP, BMP, HEIC, AVIF). The QR code generator makes its modules in Swift. UIImage and CGImage live in the shim, since SwiftUI re-exports them on iOS; Core Image is a module of its own, linked only into apps that import it, like the stand-ins above. Examples/Filters runs Instafilter's processing code on a bundled photo next to Hot Prospects' QR code. docs/ops-protocol.md, "Bitmaps and Core Image", has the protocol and each filter's formula.

Swift packages, notifications and a code scanner#

  • An Xcode project's Swift package dependencies now reach the build. The CLI used to drop them (an app importing one failed with "no such module"); now each remote package becomes a real SwiftPM dependency of the staging package with the version rule the project records (local packages by path), so a pure-Swift package that builds for WASI is fetched and compiled in. Packages that cannot work in the browser are replaced by a SwiftBrowser module of the same name instead; check says which way each package went (docs/ops-protocol.md, "Swift package dependencies").
  • import CodeScanner (Paul Hudson's camera package) gets such a stand-in: CodeScannerView with the package's whole initializer, ScanResult, ScanError, ScanMode and AVMetadataObject.ObjectType. It shows what the package shows in the iOS simulator, a screen that sends back the simulatedData, and where the browser has BarcodeDetector and a camera (Chrome on Android and macOS) it can scan for real (docs/ops-protocol.md, "Code scanning").
  • import UserNotifications works over the browser's Notification API: authorization and settings, time-interval and calendar triggers (repeating too), pending and delivered requests, the badge. The app schedules its own timers and the page shows each notification as it comes due, so they are delivered only while the page is open (docs/ops-protocol.md, "Notifications").
  • Examples/HotProspects is Hacking with SwiftUI project 16 without its Me tab: scan the simulated QR code, swipe for "Remind Me", and the notification appears five seconds later. With Core Image and @AppStorage (below) the project itself builds unchanged, Me tab and all.

@AppStorage#

@AppStorage("key") var value = default reads and writes UserDefaults, so it is kept across launches like the rest of the app's defaults (Phase 11's defaults op, in localStorage). It takes what SwiftUI's does: Bool, Int, Double, String, URL, Data and Date, RawRepresentable enums over Int or String, their optional forms (@AppStorage("key") var value: String?, where nil removes the key) and store:. A read registers with the observation tracking each body runs under, and every write to the key, through any @AppStorage or through UserDefaults.set, re-renders the views that read it, and only them. With it Instafilter (13) and Hot Prospects (16) build from their Xcode projects unchanged: eighteen of the nineteen Hacking with SwiftUI projects, all but Bucket List (14, MapKit). CI runs Hot Prospects headless: a name typed into the Me tab is saved, its QR code is made again, and a relaunch shows the name.

That run found a runtime bug: state an onChange(of:) action set while its body ran (Hot Prospects' updateCode) re-ran the body in the next pass but never reached the page when the view sat below a clean ancestor, such as a tab of a TabView. The pass that marked it cached the ancestors' elements again afterwards; the reconciler now drops the cache of every node it re-enters.