A generic Swift Package for web scraping and interaction with websites through WKWebView. Provides a SwiftUI wrapper, type-safe JavaScript messaging, configurable cookie management, and async fetch strategies — all with Swift 6 concurrency support.
- iOS 16.0+
- Swift 6.0+
- Xcode 16+
Add WebViewAMC to your Package.swift:
dependencies: [
.package(url: "https://github.com/roncuevas/WebViewAMC.git", from: "1.0.0")
]Or in Xcode: File > Add Package Dependencies and paste the repository URL.
import WebViewAMC
// Use the shared singleton with default configuration
let manager = WebViewManager.shared
// Or create a custom instance
let config = WebViewConfiguration(
handlerName: "myApp",
timeoutDuration: 60,
isInspectable: true,
cookieDomain: URL(string: "https://example.com"),
verbose: true
)
let manager = WebViewManager(configuration: config)import SwiftUI
import WebViewAMC
struct BrowserView: View {
var body: some View {
WebView(webView: WebViewManager.shared.webView)
}
}Use WebViewReader for reactive state and programmatic control, following the ScrollViewReader pattern:
import SwiftUI
import WebViewAMC
struct BrowserView: View {
var body: some View {
WebViewReader { proxy in
VStack {
if proxy.isLoading {
ProgressView(value: proxy.estimatedProgress)
}
WebView(proxy: proxy)
HStack {
Button("Back") { proxy.goBack() }
.disabled(!proxy.canGoBack)
Button("Forward") { proxy.goForward() }
.disabled(!proxy.canGoForward)
Button("Reload") { proxy.reload() }
}
Text(proxy.title ?? "Untitled")
}
}
}
}WebViewConfiguration controls all behavior of the package:
let config = WebViewConfiguration(
handlerName: "myNativeApp", // JS message handler name (default)
timeoutDuration: 30.0, // Navigation timeout in seconds
isInspectable: false, // Safari Web Inspector (iOS 16.4+)
cookieDomain: nil, // Domain for cookie management
verbose: false, // Verbose JS error logging
logger: WebViewLogger() // Custom logger
)Use WebViewConfiguration.default for sensible defaults.
WebViewProxy is an ObservableObject that provides reactive KVO-backed properties and action methods for the underlying WKWebView.
| Property | Type | Description |
|---|---|---|
isLoading |
Bool |
Whether the web view is currently loading |
url |
URL? |
The current URL |
title |
String? |
The current page title |
canGoBack |
Bool |
Whether back navigation is available |
canGoForward |
Bool |
Whether forward navigation is available |
estimatedProgress |
Double |
Page load progress (0.0–1.0) |
WebViewReader { proxy in
// Load a URL
proxy.load("https://example.com")
proxy.load(URL(string: "https://example.com")!)
proxy.load("https://example.com", cookies: myCookies, forceRefresh: true)
// Navigation
proxy.goBack()
proxy.goForward()
proxy.reload()
proxy.stop()
// Convenience evaluation (delegates to fetcher)
let title: String = try await proxy.evaluate("document.title")
let html = try await proxy.getHTML()
let result = await proxy.fetch(.once(id: "test", javaScript: "run()"))
}The proxy provides pass-through access to all WebViewManager subsystems:
WebViewReader { proxy in
proxy.fetcher // WebViewDataFetcher
proxy.cookieManager // CookieManager
proxy.messageRouter // WebViewMessageRouter
proxy.coordinator // WebViewCoordinator
proxy.handler // WebViewMessageHandler
proxy.configuration // WebViewConfiguration
proxy.webView // WKWebView (direct access)
}By default, WebViewReader uses WebViewManager.shared. Pass a custom manager for isolated instances:
let manager = WebViewManager(configuration: myConfig)
WebViewReader(manager: manager) { proxy in
WebView(proxy: proxy)
}FetchAction provides an awaitable API with three strategies:
let manager = WebViewManager.shared
// One-shot fetch
let result = await manager.fetcher.fetch(
.once(
id: "getTitle",
url: "https://example.com",
javaScript: "postMessage({ title: document.title })"
)
)
// Check result using convenience properties
if result.isCompleted {
print("Fetch \(result.id) completed")
} else if result.isFailed {
print("Error: \(result.error?.localizedDescription ?? "unknown")")
}
// Or use pattern matching
switch result {
case .completed(let id):
print("Fetch \(id) completed")
case .cancelled(let id):
print("Fetch \(id) was cancelled")
case .failed(let id, let error):
print("Fetch \(id) failed: \(error.localizedDescription)")
}// Once: execute JS after a delay (default: 1 second)
.once(id: "title", javaScript: "postMessage({ title: document.title })")
// Once with custom delay
.once(id: "title", javaScript: "...", delay: .seconds(2))
// Poll: retry up to N times until a condition is met
.poll(
id: "grades",
url: "https://example.com/grades",
javaScript: "postMessage({ grades: getGrades() })",
maxAttempts: 5,
delay: .seconds(1),
until: { !grades.isEmpty }
)
// Continuous: keep executing while a condition holds
.continuous(
id: "captcha",
javaScript: "postMessage({ img: getCaptcha() })",
delay: .milliseconds(500),
while: { needsCaptcha }
)FetchResult is Equatable and provides convenience properties:
| Property | Type | Description |
|---|---|---|
id |
String |
The task identifier |
isCompleted |
Bool |
true if fetch completed |
isCancelled |
Bool |
true if fetch was cancelled |
isFailed |
Bool |
true if fetch failed |
error |
WebViewError? |
The error, if failed |
Replace fixed delays with intelligent wait conditions:
// Wait for a specific element to appear in the DOM
try await fetcher.waitForElement("#grades-table", timeout: .seconds(10))
// Wait for navigation to complete
try await fetcher.waitForNavigation(timeout: .seconds(15))
// Use with FetchAction — replaces fixed delay
let result = await fetcher.fetch(
.once(
id: "grades",
url: "https://example.com/grades",
javaScript: "postMessage(getGrades())",
waitFor: .element("#grades-table") // waits for element instead of fixed delay
)
)
// Wait for navigation before polling
let result = await fetcher.fetch(
.poll(
id: "data",
url: "https://example.com",
javaScript: "postMessage(getData())",
maxAttempts: 5,
waitFor: .navigation(timeout: .seconds(10)),
until: { !data.isEmpty }
)
)| WaitCondition | Default Timeout | Default Poll Interval | Description |
|---|---|---|---|
.element(_ selector:, timeout:, pollInterval:) |
10s | 250ms | Polls DOM until CSS selector matches |
.navigation(timeout:, pollInterval:) |
15s | 250ms | Polls until page finishes loading |
.none |
— | — | Uses the strategy's fixed delay (default) |
Evaluate JavaScript and get type-safe results:
// Primitive types
let title: String = try await fetcher.evaluate("document.title")
let count: Int = try await fetcher.evaluate("document.querySelectorAll('.item').length")
let ratio: Double = try await fetcher.evaluate("window.devicePixelRatio")
let loaded: Bool = try await fetcher.evaluate("document.readyState === 'complete'")
// Decodable types from JSON strings
struct Grade: Decodable { let subject: String; let score: Int }
let grades: [Grade] = try await fetcher.evaluate("JSON.stringify(getGrades())")
// Custom JSONDecoder
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
let user: User = try await fetcher.evaluate("JSON.stringify(getUserData())", using: decoder)if let html = try await manager.fetcher.getHTML() {
print(html)
}WebViewAMC provides two approaches for receiving JavaScript messages.
Type-safe message routing with automatic value decoding:
let router = manager.messageRouter
// Register handlers for specific keys
router.register(key: "grades") { message in
switch message.value {
case .json(let data):
let grades = try? JSONDecoder().decode([Grade].self, from: data)
case .string(let text):
print("Received: \(text)")
default:
break
}
}
router.register(key: "profileImage") { message in
if case .data(let imageData) = message.value {
let image = UIImage(data: imageData)
}
}
// Catch-all for unregistered keys
router.registerFallback { message in
print("Unhandled message: \(message.key)")
}
// Clean up
router.unregister(key: "grades")
router.unregisterAll()The decoder automatically detects the value type:
| JS Value | Decoded As |
|---|---|
"hello" |
.string("hello") |
"1" / "0" |
.bool(true) / .bool(false) |
'{"key":"val"}' |
.json(Data) |
'[1,2,3]' |
.json(Data) |
"data:image/jpeg;base64,..." |
.data(Data) |
{ key1: "val1", key2: "val2" } |
.dictionary([String: String]) |
class MyHandler: WebViewMessageHandlerDelegate {
func messageReceiver(message: [String: Any]) {
// Handle raw dictionary
}
}
manager.handler.delegate = myHandlerWhen a
routeris set onWebViewMessageHandler, messages are routed through it. The delegate is used as fallback only when no router is present.
let cookies = manager.cookieManager
// Inject cookies asynchronously
await cookies.injectCookies(myCookies)
// Inject cookies synchronously (fire-and-forget)
cookies.setCookiesSync(myCookies)
// Get all cookies
let all = await cookies.getAllCookies()
// Get cookies for the configured domain
let domainCookies = cookies.cookiesForDomain()
// Remove specific cookies by name
await cookies.removeCookies(named: ["session", "token"])
// Remove all cookies
await cookies.removeAllCookies()
// Format cookies for HTTP headers
let header = CookieManager.formatForHTTPHeader(myCookies)
// "session=abc123; token=xyz789"Monitor navigation lifecycle via delegate or AsyncStream:
let coordinator = manager.coordinator
Task {
for await event in coordinator.events {
switch event {
case .started:
print("Navigation started")
case .finished(let url):
print("Navigated to: \(url?.absoluteString ?? "unknown")")
case .failed(let error):
print("Navigation failed: \(error)")
case .timeout:
print("Navigation timed out")
}
}
}class MyCoordinator: WebViewCoordinatorDelegate {
func didNavigateTo(url: URL) { }
func cookiesReceiver(cookies: [HTTPCookie]) { }
func didFailLoading(error: Error) { }
func didTimeout() { }
}
manager.coordinator.delegate = myCoordinatorChange the navigation timeout duration at runtime:
manager.coordinator.setTimeout(60) // 60 secondsThe package injects common DOM helper functions automatically:
// Available in every injected script:
byID("elementId") // document.getElementById
byClass("className") // document.getElementsByClassName
byTag("tagName") // document.getElementsByTagName
byName("name") // document.getElementsByName
bySelector("css > selector") // document.querySelector
bySelectorAll(".items") // document.querySelectorAll
imageToData(element, scale) // Convert img to base64 data URI
postMessage({ key: "value" }) // Send message to native app// Generate a script with common helpers + your own
let script = Scripts.custom(
handlerName: "myApp",
additionalHelpers: [
"function getGrades() { return bySelector('.grades-table').innerHTML; }"
]
)try await manager.webView.injectJavaScriptAsync(
handlerName: "myApp",
defaultJS: ["var config = { debug: true };"],
javaScript: "postMessage({ title: document.title })",
verbose: true
)let logger = WebViewLogger(
subsystem: "com.myapp",
category: "WebScraping",
minimumLevel: .debug // .debug | .info | .warning | .error
)
let config = WebViewConfiguration(logger: logger, verbose: true)Conform to WebViewLoggerProtocol:
struct MyLogger: WebViewLoggerProtocol {
func log(_ level: WebViewLogLevel, _ message: String, source: String) {
// Send to your analytics, file, etc.
}
}
let config = WebViewConfiguration(logger: MyLogger())Control running fetch tasks:
let fetcher = manager.fetcher
// Check if a task is running
if fetcher.isRunning("grades") { ... }
// Cancel specific tasks
fetcher.cancelTasks(["grades", "schedule"])
// Cancel all tasks
fetcher.cancelAllTasks()
// Monitor running tasks via AsyncStream
Task {
for await runningKeys in fetcher.tasksRunning {
print("Active tasks: \(runningKeys)")
}
}If
fetch()is called with an ID that's already running, the previous task is automatically cancelled and a warning is logged.
All errors are typed via WebViewError:
do {
let html = try await manager.fetcher.getHTML()
} catch let error as WebViewError {
switch error {
case .invalidURL(let url):
print("Bad URL: \(url)")
case .javaScriptEvaluation(let detail):
print("JS error: \(detail)")
case .timeout:
print("Request timed out")
case .navigationFailed(let detail):
print("Nav failed: \(detail)")
case .taskCancelled(let id):
print("Task \(id) cancelled")
case .fetchFailed(let detail):
print("Fetch failed: \(detail)")
case .messageDecodingFailed(let detail):
print("Decode failed: \(detail)")
case .typeCastFailed(let expected, let actual):
print("Type cast failed: expected \(expected), got \(actual)")
}
}Use WebViewContextGroup to create multiple isolated web view instances that share cookies via a common WKProcessPool:
let group = WebViewContextGroup()
let loginContext = group.createContext(id: "login")
let gradesContext = group.createContext(id: "grades")
let scheduleContext = group.createContext(id: "schedule")
// All contexts share cookies — login once, scrape from all
await loginContext.fetcher.fetch(.once(id: "login", javaScript: "submitForm()"))
await gradesContext.fetcher.fetch(.poll(
id: "grades", javaScript: "getGrades()", maxAttempts: 5, until: { !grades.isEmpty }
))For finer control, pass a shared WKProcessPool directly:
let pool = WKProcessPool()
let mgr1 = WebViewManager(processPool: pool)
let mgr2 = WebViewManager(processPool: pool)
// mgr1 and mgr2 share cookie storage| Method | Description |
|---|---|
createContext(id:configuration:) |
Creates a new named context with shared pool |
context(for:) |
Retrieves a context by ID |
removeContext(_:) |
Removes a context |
removeAll() |
Removes all contexts |
count |
Number of active contexts |
ids |
Sorted list of context identifiers |
hasContext(_:) |
Whether a context exists |
Use HeadlessWebView to keep a WKWebView alive in the view hierarchy without it being visible. This prevents iOS from suspending JavaScript execution:
var body: some View {
MyContent()
.background { HeadlessWebView() }
}With a specific context:
var body: some View {
MyContent()
.background { HeadlessWebView(manager: scrapingManager) }
}The view renders at 1x1 pixels with near-zero opacity, invisible to users but active for scraping operations.
WebViewManager (singleton or custom instance)
├── webView: WKWebView — The web view
├── coordinator: WebViewCoordinator — Navigation delegate + events stream
├── fetcher: WebViewDataFetcher — Fetch orchestration + task tracking
│ ├── evaluate<T>(_:) — Typed JavaScript evaluation
│ ├── waitForElement(_:) — Smart element waiting
│ ├── waitForNavigation() — Smart navigation waiting
│ └── tasksRunning: AsyncStream — Stream of active task IDs
├── handler: WebViewMessageHandler — JS↔Swift bridge
├── messageRouter: WebViewMessageRouter — Type-safe message routing
├── cookieManager: CookieManager — Cookie operations
└── configuration: WebViewConfiguration — All settings
WebViewContextGroup (optional, manages multiple managers)
└── processPool: WKProcessPool — Shared cookie storage across contexts
SwiftUI Layer
├── WebViewReader<Content> — Container view (owns proxy as @StateObject)
├── WebViewProxy — ObservableObject with KVO-backed reactive state
├── WebView — UIViewRepresentable (accepts proxy or raw WKWebView)
└── HeadlessWebView — Invisible view for background scraping
| Protocol | Purpose |
|---|---|
WebViewManaging |
Abstracts the manager for testing |
JavaScriptEvaluating |
Abstracts JS evaluation (WKWebView conforms) |
WebViewCoordinatorDelegate |
Navigation lifecycle callbacks |
WebViewMessageHandlerDelegate |
Raw message reception |
WebViewLoggerProtocol |
Custom logging backends |
The package is iOS-only, so tests must run on a simulator:
xcodebuild test -scheme WebViewAMC \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro'The package includes 195 unit tests across 20 suites covering:
| Suite | Tests | Covers |
|---|---|---|
| WebViewMessageDecoder | 21 | Value type detection, edge cases |
| WebViewDataFetcher | 22 | Fetch strategies, task tracking, wait primitives |
| WebViewProxy | 20 | KVO state, pass-throughs, actions, fetch delegation |
| WebViewTaskManager | 18 | Insert, remove, cancel, await |
| FetchAction | 17 | Strategies, factories, defaults, WaitCondition |
| WebViewContextGroup | 16 | Context creation, removal, process pool sharing, isolation |
| JavaScriptResultMapper | 12 | Type casting, JSON decoding, error cases |
| WebViewMessageRouter | 12 | Routing, fallback, priority, replacement |
| WebViewManager | 10 | Initialization, component wiring, process pool |
| WebViewCoordinator | 10 | Navigation events, timeout, delegation |
| FetchResult | 7 | Convenience properties, Equatable |
| WebViewLogger | 6 | Capture, filtering, levels |
| CookieManager | 5 | Domain cookies, HTTP header formatting |
| Scripts | 5 | Handler interpolation, helpers |
| NavigationEvent | 5 | All event cases |
| HeadlessWebView | 4 | Init, custom manager, body render, context group integration |
| WebViewError | 3 | Equatable, localized descriptions, typeCastFailed |
| WebViewConfiguration | 3 | Defaults, custom values |
| WebViewReader | 2 | Custom manager, proxy init |
| WebViewMessage | 2 | Property storage, value cases |
See LICENSE for details.