Webview Bundle
Platform guides

iOS

Serve a WKWebView (iOS and macOS) from a .wvb bundle using the Swift bindings generated from the Rust core.

On iOS (and macOS), Webview Bundle ships as a Swift library generated from the Rust core with UniFFI. You build a BundleSource and serve files into a WKWebView through a BundleUrlHandler wired to a WKURLSchemeHandler.

A working test app lives in packages/ffi/apple, and the integration tests in TestRunner.swift exercise the full Swift API.

The Apple bindings are built from this repository (no published release artifact yet). The steps below build the xcframework and add the local Swift package.

What the library contains

  • WebViewBundleFFI.xcframework — the native static libraries for ios-arm64, ios-arm64_x86_64-simulator, and macos-arm64_x86_64, plus C headers.
  • WebViewBundleLibrary — the generated Swift API (WebViewBundleLibrary.swift) that wraps it.

The Swift package (packages/ffi/apple/Package.swift) ties them together and links SystemConfiguration, Security, and CoreFoundation. Minimum targets are iOS 14 / macOS 12.

1. Build and add the package

From the repo, build the Apple bindings (this compiles the Rust core for each Apple platform and generates the Swift bindings + xcframework):

# from packages/ffi
yarn build-ffi-apple   # → node ./cli/main.ts build apple --profile=release

Then add packages/ffi/apple as a local Swift package dependency in Xcode (File → Add Package Dependencies → Add Local…) and link the WebViewBundleLibrary product to your app target. In a Package.swift-based project:

.package(path: "path/to/webview-bundle/packages/ffi/apple")
// …then add "WebViewBundleLibrary" to your target's dependencies.

2. Ship your bundles as resources

Pack your web build and add the .wvb plus its manifest.json to your app bundle as resources, following the source layout:

npx wvb pack ./dist --outfile assets/bundles/builtin/app/app_1.0.0.wvb
assets/bundles/builtin/
├── app/
│   └── app_1.0.0.wvb
└── manifest.json

At runtime, resolve the builtin directory from the app bundle, and use a writable directory (e.g. under Application Support or a temp dir) for downloaded updates:

let builtinDir = Bundle.main.resourceURL!
    .appendingPathComponent("assets/bundles/builtin")
let remoteDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
    .appendingPathComponent("bundles/remote")
try? FileManager.default.createDirectory(at: remoteDir, withIntermediateDirectories: true)

3. Build a BundleSource

import WebViewBundleLibrary

let source = BundleSource(config: BundleSourceConfig(
    builtinDir: builtinDir.path,
    remoteDir: remoteDir.path,
    builtinManifestFilepath: nil, // defaults to manifest.json in the dir
    remoteManifestFilepath: nil
))

// Query what's available:
let bundle = try await source.fetch(bundleName: "app")     // load into memory
let html = try bundle.getData(path: "/index.html")          // Data? — bytes or nil

4. Serve a WKWebView from the bundle

WKWebView lets you handle a custom URL scheme with a WKURLSchemeHandler. Register a scheme (it cannot be a built-in one like https), then forward each request to a BundleUrlHandler:

import WebKit
import WebViewBundleLibrary

final class BundleSchemeHandler: NSObject, WKURLSchemeHandler {
    private let handler: BundleUrlHandler
    init(source: BundleSource) { self.handler = BundleUrlHandler(source: source) }

    func webView(_ webView: WKWebView, start task: WKURLSchemeTask) {
        let request = task.request
        let method: HttpMethod = (request.httpMethod == "HEAD") ? .head : .get
        let headers = request.allHTTPHeaderFields
        Task {
            do {
                let resp = try await handler.handle(
                    method: method,
                    uri: request.url!.absoluteString,
                    headers: headers
                )
                let contentType = resp.headers["content-type"] ?? "application/octet-stream"
                let response = HTTPURLResponse(
                    url: request.url!,
                    statusCode: Int(resp.status),
                    httpVersion: "HTTP/1.1",
                    headerFields: resp.headers
                )!
                task.didReceive(response)
                task.didReceive(resp.body)
                task.didFinish()
            } catch {
                task.didFailWithError(error)
            }
        }
    }

    func webView(_ webView: WKWebView, stop task: WKURLSchemeTask) {}
}

// Register the scheme and load your bundle:
let config = WKWebViewConfiguration()
config.setURLSchemeHandler(BundleSchemeHandler(source: source), forURLScheme: "app")
let webView = WKWebView(frame: .zero, configuration: config)
webView.load(URLRequest(url: URL(string: "app://app.wvb/index.html")!))

The handler resolves the bundle name from the first host label (app.wvb → bundle app) and treats a trailing slash or extension-less path as index.html. It returns 404 for unknown paths and supports GET/HEAD. handle(...) is async, so call it from a Task.

Development against a dev server

For hot reload during development, use LocalUrlHandler instead, mapping a host to your dev server:

let local = LocalUrlHandler(hosts: ["app.wvb": "http://localhost:5173"])

5. Build a bundle in-process (optional)

The same API can create bundles, which is handy for tests or tooling:

let builder = BundleBuilder(version: .v1)
_ = try builder.insertEntry(path: "/index.html", data: Data("<!DOCTYPE html>".utf8),
                            contentType: "text/html", headers: nil)
let bundle = try builder.build(options: nil)

let bytes = try writeBundleToBytes(bundle: bundle)   // serialize to Data
let loaded = try readBundleFromBytes(data: bytes)     // round-trip
_ = try await writeBundle(bundle: bundle, filepath: path) // or write to a file

Over-the-air updates

BundleSource exposes writeRemoteBundle(...) and updateVersion(...) to install a downloaded bundle and flip the current version. The download/verify orchestration mirrors the desktop updater; on iOS you typically fetch the bundle bytes yourself and call writeRemoteBundle to install them, then reload the WKWebView.

On this page