iOS

Setup

Serve and update Webview Bundle archives in a WKWebView using the webview-bundle-ios Swift package.

The webview-bundle-ios Swift package serves .wvb bundles to a WKWebView through a custom URL scheme and keeps them current with over-the-air (OTA) updates. Register a scheme, point a WKWebView at app://app.wvb, and the package answers every request from the bundle on disk — offline-first, with the updater pulling newer bundles in the background without an App Store release.

Requirements

RequirementValue
Minimum OSiOS 16, macOS 12 (platforms: [.macOS(.v12), .iOS(.v16)])
Swift tools6.1, language mode 6
SwiftPM productWebViewBundle

The package binds the Rust core through a UniFFI-generated module named WebViewBundleLibrary, both exposed under the WebViewBundle module. See Platform integration for how the shared core reaches each platform.

Add the package as a dependency:

Package.swift
dependencies: [
  .package(url: "https://github.com/webview-bundle/webview-bundle-ios", from: "0.1.0"),
],
targets: [
  .target(name: "App", dependencies: [
    .product(name: "WebViewBundle", package: "webview-bundle-ios"),
  ]),
]

In an Xcode app project, add the same URL through File → Add Package Dependencies… instead.

Quick start

Configure once, register the scheme on a WKWebViewConfiguration, load the entry URL. Use a custom schemehttp and https are reserved and rejected at init.

ContentView.swift
import SwiftUI
import WebKit
import WebViewBundle

let instance = try WebViewBundle.configure(
  WebViewBundleConfig(
    protocols: [.bundle(scheme: "app")],
    updater: WebViewBundleUpdaterConfig(
      remote: WebViewBundleRemoteConfig(endpoint: "https://bundles.example.com"),
      integrityPolicy: .strict,
      signatureVerifier: SignatureVerifierOptions(
        algorithm: .ed25519,
        key: SignatureVerifyingKey(format: .spkiDer, pem: nil, der: publicKeyDer)
      )
    )
  )
)

let config = WKWebViewConfiguration()
instance.install(on: config)            // registers the app:// scheme handler + JS bridge

let webView = WKWebView(frame: .zero, configuration: config)
webView.load(URLRequest(url: URL(string: "app://app.wvb")!))

configure(_:) builds the instance once and caches it process-wide. The entry URL app://app.wvb selects the bundle named app. To skip manual configuration, take a ready-made WKWebView:

let webView = instance.makeWebView()    // or: instance.makeConfiguration()
webView.load(URLRequest(url: URL(string: "app://app.wvb")!))

Read the configured singleton anywhere — shared traps if read before configure(_:), safeShared does not:

let wvb = WebViewBundle.shared           // precondition-fails if not yet configured
let maybe = WebViewBundle.safeShared     // nil if not yet configured

How serving works

Each scheme gets a WKURLSchemeHandler registered via setURLSchemeHandler(_:forURLScheme:); the handler maps each request to the Rust core, which answers from the bundle on disk.

  • Bundle name = first label of the request host. app://app.wvb/index.html resolves to bundle app, path /index.html.
  • Reserved schemes are rejected at init (registering a handler for one raises an uncatchable exception): http, https, file, ftp, ftps, ws, wss, about, blob, data, javascript.
  • A scheme must match ^[a-z][a-z0-9+.-]*$ and be unique. Empty, malformed, duplicate, or reserved schemes throw WebViewBundleError:
do {
  _ = try WebViewBundle.configure(
    WebViewBundleConfig(protocols: [.bundle(scheme: "https")]))
} catch WebViewBundleError.reservedScheme(let scheme) {
  print("\(scheme) is reserved")        // .emptyScheme / .invalidScheme / .duplicateScheme also exist
}

Two protocol kinds exist — .bundle serves bundle entries, .local proxies to a local HTTP server (matching the full request host against the hosts map). See Protocol handling for the model.

WebViewBundleConfig(protocols: [
  .bundle(scheme: "app"),
  .local(scheme: "dev", hosts: ["myapp": "http://localhost:8080"]),
])

The bridge

install(on:) also wires a JavaScript-to-native bridge. The web app posts to window.webkit.messageHandlers.wvbIos; the bridge accepts main-frame messages only (subframe and iframe messages are dropped) and exposes the source, remote, and updater commands.

web app
window.webkit.messageHandlers.wvbIos.postMessage({
  name: 'updaterGetUpdate',
  params: { bundleName: 'app' },
});

Sources

Each app reads from two sources. See Bundle sources for the full model.

  • Builtin — read-only bundles shipped inside the app. Default dir is the app bundle's /bundles folder; ship it as a folder reference so files land there.
  • Remote — the writable directory for downloaded updates. Default lives under Application Support, namespaced by the app's bundle identifier.

Override either path through SourceOptions on WebViewBundleConfig.source:

WebViewBundleConfig(
  source: SourceOptions(
    builtinDir: Foundation.Bundle.main.resourcePath.map { "\($0)/bundles" },
    remoteDir: nil,                       // nil => default Application Support dir
    builtinManifestFilepath: nil,
    remoteManifestFilepath: nil
  ),
  protocols: [.bundle(scheme: "app")]
)

Inside the WebViewBundle module, unqualified Bundle resolves to the FFI bundle class, not Foundation.Bundle. Write Foundation.Bundle explicitly when you mean the app bundle.

OTA updates

Set WebViewBundleConfig.updater and the package builds a Remote and an Updater for you, reachable as instance.remote and instance.updater.

let updaterConfig = WebViewBundleUpdaterConfig(
  remote: WebViewBundleRemoteConfig(endpoint: "https://bundles.example.com"),
  channel: "stable",
  integrityPolicy: .strict,
  signatureVerifier: SignatureVerifierOptions(
    algorithm: .ed25519,
    key: SignatureVerifyingKey(format: .spkiDer, pem: nil, der: publicKeyDer)
  )
)

integrityPolicy is .strict, .optional, or .none. Integrity values are SHA-2 digests serialized as sha256:<base64>. The signatureVerifier proves who published a bundle:

FieldValues
algorithm.ecdsaSecp256r1, .ecdsaSecp384r1, .ed25519, .rsaPkcs1V15, .rsaPss
key.format.spkiDer, .spkiPem, .pkcs1Der, .pkcs1Pem, .sec1, .raw
key.pem / key.derpem for text keys, der for binary keys

Drive the update cycle through the updater:

guard let updater = instance.updater else { return }

let info = try await updater.getUpdate(bundleName: "app")   // check; no download
if info.isAvailable {
  _ = try await updater.downloadUpdate(bundleName: "app", version: info.version)
  try await updater.install(bundleName: "app", version: info.version)
}

getUpdate checks the remote for a newer version without downloading. downloadUpdate downloads and persists a version (latest when version is nil). install activates a staged version, verifies integrity and signature when configured, then prunes stale versions. Reload to render the new bundle:

try await updater.install(bundleName: "app", version: info.version)
await webView.evaluateJavaScript("location.reload()")

A failed signature surfaces as the FFI error Error.Signature(message:).

For the remote HTTP contract, integrity, and signing details, see Remote bundles and Building a remote.

On this page