KMPwright

iOS

Drive a WKWebView with KMPwright on iOS, including IOSPlatformContext, threading, calling from Swift, delegate ownership, network interception limits, the domain filter and screenshots.

On iOS, KMPwright drives a WKWebView in your app. It either attaches to a web view you already show or creates its own, off screen.

// iosMain
fun startAutomation(webView: WKWebView, scope: CoroutineScope) {
    val browser = Browser(IOSPlatformContext(webView = webView))
    val page = browser.page()
    scope.launch(Dispatchers.Main) {
        page.goto("https://example.com")
        page.getByRole("link", name = "Learn more").click()
    }
}

Requirements: iOS 15.0 or later, arm64 devices and arm64 simulators (no Intel simulator slice). See Installation.

IOSPlatformContext

public class IOSPlatformContext(
    public val webView: WKWebView? = null,
    public val websiteDataStore: WKWebsiteDataStore? = null
) : PlatformContext

Prop

Type

Which data store a Browser uses:

  1. the webView's own configuration.websiteDataStore, if you passed a web view,
  2. otherwise websiteDataStore, if you passed one,
  3. otherwise a new WKWebsiteDataStore.nonPersistentDataStore() that the Browser owns and clears on close()/dispose().

browser.newPage() creates a new WKWebView that shares that data store. KMPwright-created web views are never added to a window; add your own web view to the hierarchy when the user should see the page.

Threading

WebKit objects may only be touched on the main thread. KMPwright's suspend APIs switch to Dispatchers.Main themselves. Its synchronous entry points (the Browser constructor, page.dispose(), and a few getters) run inline on the main thread, or hop to the main queue with dispatch_sync and wait.

  • Create the Browser on the main thread.
  • Never block the main thread (for example with runBlocking) while a background coroutine waits on a KMPwright call. That deadlocks.

Calling from Swift

The release ships a static KmpwrightCore.xcframework; the Swift module is KmpwrightCore. See docs/releasing.md and Installation for adding it to Xcode.

KMPwright's API is plain Kotlin exported through Kotlin/Native's Objective-C interop, so the usual Kotlin-to-Swift rules apply:

KotlinIn Swift
Browser(context, config)Browser(context:config:)
IOSPlatformContext(webView, websiteDataStore)IOSPlatformContext(webView:websiteDataStore:) (add import WebKit: its parameters are WebKit types)
interface Page, interface LocatorProtocols Page, Locator
suspend funasync throws (the Objective-C header has a completion handler; Swift imports it as async). Call with try await
Non-suspending functions annotated with @Throws (browser.page(), TimeoutConfig(...))throws. Call with try
Parameter namesArgument labels, as named parameters in Kotlin: goto(url:waitUntil:timeoutMs:)
Functions whose name starts with new (newPage, newContext, newWindow)Prefixed with do: doNewPage(), doNewContext(), doNewWindow()
Extension and top-level functions in PageConvenience.kt, PageWaits.kt, Expect.kt...Static methods on PageConvenienceKt, PageWaitsKt, ExpectKt..., receiver as the first argument: ExpectKt.expect(locator:)
Default argumentsNot available. Pass every parameter, including the optional named parameters of methods such as click(...) and every field of data classes such as KmpwrightConfig (14 parameters)
Long?KotlinLong? (KotlinLong(value: 200))
Enum entriesLower-case static members: .left, .hybrid, .load_ (load is renamed because it clashes with an Objective-C method)

Three declarations from the generated KmpwrightCore.h, as Swift sees them:

- (void)gotoUrl:(NSString *)url waitUntil:(KmpwrightCoreLoadState *)waitUntil timeoutMs:(KmpwrightCoreLong * _Nullable)timeoutMs completionHandler:(void (^)(KmpwrightCoreNavigationResult * _Nullable, NSError * _Nullable))completionHandler __attribute__((swift_name("goto(url:waitUntil:timeoutMs:completionHandler:)")));

- (void)clickButton:(KmpwrightCoreMouseButton *)button clickCount:(int32_t)clickCount delayMs:(int64_t)delayMs position:(KmpwrightCorePosition * _Nullable)position force:(BOOL)force timeoutMs:(KmpwrightCoreLong * _Nullable)timeoutMs completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("click(button:clickCount:delayMs:position:force:timeoutMs:completionHandler:)")));

- (void)toHaveTextText:(NSString *)text timeoutMs:(KmpwrightCoreLong * _Nullable)timeoutMs useInnerText:(BOOL)useInnerText completionHandler:(void (^)(NSError * _Nullable))completionHandler __attribute__((swift_name("toHaveText(text:timeoutMs:useInnerText:completionHandler:)")));
// func goto(url: String, waitUntil: LoadState, timeoutMs: KotlinLong?) async throws -> NavigationResult
let result = try await page.goto(url: "https://example.com", waitUntil: .load_, timeoutMs: nil)

// func click(button: MouseButton, clickCount: Int32, delayMs: Int64, position: Position?, force: Bool, timeoutMs: KotlinLong?) async throws
try await page.locator(selector: "#save").click(
    button: .left, clickCount: 1, delayMs: 0, position: nil, force: false, timeoutMs: KotlinLong(value: 5_000))

// func toHaveText(text: String, timeoutMs: KotlinLong?, useInnerText: Bool) async throws
try await ExpectKt.expect(locator: page.locator(selector: "#status"))
    .toHaveText(text: "Saved", timeoutMs: nil, useInnerText: false)

Errors

Public functions declare @Throws(KmpwrightException::class, IllegalArgumentException::class, IllegalStateException::class, CancellationException::class); web-first assertions (expect(...).toHaveText(...) and the rest) also declare KmpwrightAssertionError, which extends AssertionError rather than KmpwrightException. Those exceptions, and their subclasses such as KmpwrightTimeoutException, arrive in Swift as an NSError in the KotlinException domain. Its localizedDescription is the Kotlin message and its kotlinException property holds the Kotlin exception object:

do {
    try await page.locator(selector: "#missing").click(
        button: .left, clickCount: 1, delayMs: 0, position: nil, force: false, timeoutMs: KotlinLong(value: 200))
} catch {
    let nsError = error as NSError
    switch nsError.kotlinException {
    case let timeout as KmpwrightTimeoutException:
        print("timed out:", timeout.message ?? "")  // "Element #missing was not found after 200ms while trying to click"
    case let failed as KmpwrightAssertionError:
        print("assertion failed:", failed.message ?? "")
    default:
        print(nsError.localizedDescription)
    }
}

A goto that fails to load does not throw: it returns a NavigationResult with ok == false. Kotlin exceptions outside the declared types (a bug, not an expected failure) still terminate the app, as Kotlin/Native does for any undeclared exception.

The repository's Swift consumer, tests/consumers/swift, is built against the packaged XCFramework and run on a simulator by scripts/verify-ios-xcframework.sh; it catches a locator timeout, a failed assertion and a constructor require failure this way.

Web view ownership and retention

  • KMPwright holds the WKWebView strongly while its page is open.
  • WebKit strongly retains URL-scheme handlers (through the configuration) and script-message handlers (through the user content controller). KMPwright registers small proxies that hold the page weakly, so a closed page can be collected even if your app keeps the web view alive.
  • When attached, KMPwright sets the web view's navigationDelegate and UIDelegate to its own and forwards to the delegates you had installed, which it holds until the page closes and then restores:
    • Policy decisions (decidePolicyFor navigation action and response): a KMPwright denial (allowedOriginRules, setAllowedDomains, a navigation guard) cancels and your delegate is not asked. Otherwise your delegate decides if it implements the method, and the default (allow) applies if it does not. A main-frame navigation your delegate cancels makes goto return ok = false with "Navigation was cancelled by the host navigation delegate: url".
    • JavaScript dialogs (alert, confirm, prompt): a page.onDialog handler or a pending waitForDialog answers first. Otherwise your runJavaScript…Panel method runs (page.events.dialog still reports the dialog); otherwise the dialog is dismissed.
    • Everything else your delegates implement is forwarded unchanged: didFinish and the failure callbacks after KMPwright's own bookkeeping, and didStartProvisionalNavigation, didCommit, redirects, authentication challenges, webViewWebContentProcessDidTerminate, createWebViewWith…, webViewDidClose, context menus and permission requests directly. WebKit sees the same set of optional methods your delegates implement, so its defaults for the rest do not change.
  • On page.close() (or dispose()), KMPwright stops loading, restores your delegates, removes its script-message handler, its content rule list and its own user scripts (your user scripts stay), and fails in-flight custom-scheme loads. It does not remove your web view from the view hierarchy.

JavaScript evaluation

WKWebView.callAsyncJavaScript awaits Promises natively. page.evaluate(expression) tries, in order, without running side effects twice:

  1. the source as an awaited expression (a leading return is tolerated),
  2. if that does not parse but the source is a classic program, the program's completion value (var x = 1; x + 1 returns 2),
  3. otherwise an async function body, so top-level return and await work.

Values are JSON-serialized in the page. When the page's Content Security Policy blocks eval, step 2 falls back to WebKit's native evaluation, which returns the completion value but reports Promises and non-serializable values as null.

Network interception

WKWebView has no request-interception hook like Chromium's Fetch domain, so page.route (capability RouteInterception = Partial) works through:

  • A fetch/XHR bridge. A document-start script patches fetch and XMLHttpRequest in the main frame and forwards each request to native code over a nonce-protected message channel. Handlers can fulfill, abort or resume. Requests the bridge does not see are not routed: images, scripts, stylesheets, iframes, the document itself, and fetch/XHR inside iframes. A route handler that throws aborts the request with failed; one that does not decide within TimeoutConfig.handler aborts it with timedout. Independently, the page side of the bridge fails a request that gets no native answer within 10 seconds ("KMPwright network bridge timed out"), so on iOS a fetch/XHR handler effectively has 10 seconds.
  • Custom-scheme interception (optional). With KmpwrightConfig(iosSchemeInterception = IosSchemeInterceptionConfig()), KMPwright registers a WKURLSchemeHandler for the kmpwright scheme on the web views it creates. Every request to kmpwright://host/path (including documents and subresources) goes through your routes with the URL mapped to https://host/path (fallbackScheme). This only works for web views KMPwright creates: WebKit registers scheme handlers at creation time, so for a host-provided web view the setting is ignored and a warning is logged.

Prop

Type

See Network.

Domain filter

page.setAllowedDomains(...) is fully supported (DomainFiltering = Supported):

  • main-frame navigations, including server redirects, are cancelled in the navigation policy; goto returns ok = false with "Blocked by KMPwright domain filter: host",
  • every subresource (fetch, XHR, images, iframes, WebSockets, redirects) is blocked by a WKContentRuleList that KMPwright compiles from your list and installs on the web view. setAllowedDomains returns once WebKit enforces it.

An entry that is not a host name or a *. wildcard throws "Domain filter entry '…' is not a host name or '*.' wildcard".

Color scheme emulation

page.emulateMedia(ColorScheme.Dark) sets overrideUserInterfaceStyle on the web view, which drives the CSS prefers-color-scheme media feature. WebKit only forwards the appearance of a view that is in a window, so KMPwright verifies the change with matchMedia for up to one second. If it did not apply (window-less web view) or for ColorScheme.NoPreference, it falls back to JavaScript: matchMedia and the root color-scheme property only, not your stylesheet's @media rules.

Storage and file protection

  • Saved auth profiles (AuthManager) live in the app's Application Support directory, under .kmpwright/auth.
  • On iOS, every state file KMPwright writes (auth profiles, StorageState.save, autosave) is written atomically with NSDataWritingFileProtectionComplete, and directories it creates get NSFileProtectionComplete. The files are unreadable while the device is locked, so autosave and loads fail during that time.

Screenshots and PDF

  • Captured with WKWebView.takeSnapshot. WebKit renders content outside the visible viewport for snapshot requests, so full-page screenshots need no scroll-and-stitch.
  • ScreenshotResult.width/height are the real pixel dimensions of the PNG: the logical size times the screenshot scale times the screen scale (the window's screen, or the main screen). The screenshot clip and annotation boxes are in CSS points.
  • PNG and JPEG. format = ScreenshotFormat.Webp throws KmpwrightUnsupportedException ("WEBP screenshots are not supported on iOS (ImageIO cannot encode WebP); use PNG or JPEG"), and so does omitBackground = true ("omitBackground is not supported on iOS WKWebView: …").
  • PDF (Pdf = Partial) uses createPDF, which produces one unpaginated canvas. page.pdf() with default options uses it (format = null means PdfFormat.Content on iOS). A paper format, landscape, width/height, non-zero margins, scale, pageRanges, header/footer templates or printBackground = false throw KmpwrightUnsupportedException: "WKWebView PDF export supports only the content canvas (format null or Content) with zero margins, …".

BrowserContext

browser.newContext() creates a context backed by a new WKWebsiteDataStore.nonPersistentDataStore() (BrowserContexts = Partial). Cookies and storage are isolated from other contexts, but all contexts share the app's WebKit processes. clearCookies() deletes the store's cookies; clearData() removes all website data; close() closes its pages and clears the store.

Other differences

FeatureiOS behavior
DialogsWithout a page.onDialog handler (or pending waitForDialog), a host WKUIDelegate that implements the panel method answers; otherwise dialogs are dismissed. A handler that throws or does not answer within TimeoutConfig.handler (30 s by default) dismisses. DialogType.BeforeUnload is never reported: WKUIDelegate has no beforeunload callback, so WebKit leaves the page without asking.
setExtraHTTPHeadersOnly an empty map is accepted; anything else throws "This backend cannot apply extra HTTP headers securely without exposing them to page JavaScript."
setHTTPCredentialsSent as a Basic Authorization header on goto requests to one origin: the page's origin when you set them, or the first origin you then navigate to. Subresource requests and other origins never receive them.
Inputpage.touchscreen, page.mouse and page.keyboard synthesize touch-first events with DOM text-input fallbacks (all Partial). touchscreen.pinch throws KmpwrightUnsupportedException: "WKWebView cannot synthesize a trusted native pinch gesture". Mouse and keyboard require InputMode.Hybrid.
emulateNetworkConditionsOnly offline, in JavaScript. Latency or throughput throw KmpwrightUnsupportedException.
setGeolocationJavaScript patch of navigator.geolocation.
setDeviceScaleFactorThrows KmpwrightUnsupportedException: "WKWebView device scale is determined by the host screen".
emulateDevice(device)Applies the viewport size and user agent. The scale factor, mobile layout and touch support come from the device.
frameLocatorSame-origin frames only (CrossOriginFrames = Unsupported).
Network historyCovers fetch/XHR requests (resourceType "fetch"/"xhr") and custom-scheme loads ("document" or "other"). Other page loads are not observed; response bodies are unavailable.
setViewportSizeSets the web view's frame (in points).
Downloads, file chooserUnsupported. setInputFiles with a non-empty list throws "setInputFiles is not supported in mobile WebView".
Tracing, profiler, video, screencast, CDP targetsUnsupported (CDP only).
launchBrowser, ChromeProfilesThrow KmpwrightUnsupportedException.
HttpReader / page.readUses the Darwin (URLSession) engine. caCertificatePath (PEM) adds trust anchors to the system roots, or replaces them with useSystemCa = false; useSystemCa = false without a certificate path throws.
browser.version()Returns a fixed BrowserVersion("Kmpwright", "1.3.0", …), not the WebKit version.

The full list is on Capabilities.

Sample app and Liquid Glass

The repository's example-ios is a SwiftUI host for the shared Compose Multiplatform sample. On iOS 26 and later it uses native SwiftUI TabView and NavigationStack, so the system draws the Liquid Glass tab and navigation bars while Compose renders each screen. On iOS 15 to 25 Compose draws everything. One WKWebView, Browser and page are shared by every tab; the visible screen adopts the web view. See its README to run it.

On this page