WKWebView Hybrid App: Bridge JavaScript and Swift in WKWebView
Last updated
A WKWebView hybrid app needs two bridges. JavaScript calls Swift with window.webkit.messageHandlers.<name>.postMessage, and WKScriptMessageHandlerWithReply makes that call return a Promise when you need a result. Swift calls JavaScript with evaluateJavaScript, or callAsyncJavaScript when it has to wait for an async result. Below you create a project from the KXBuild WKWebView template, bundle your pages or load a remote URL, debug on an iPhone and build an .ipa. You need an Apple Developer account, an iPhone with a USB cable, and KXBuild installed. APIs are as of October 2026.
Should you bundle the pages or load a remote URL?
WKWebView can show pages bundled inside the app or open a URL on your server. You can mix both, for example bundling the home page and core flows while loading campaign pages remotely.
| Bundled pages (in the app) | Remote URL | |
|---|---|---|
| First load | Fast, no network needed | Depends on the network and server |
| Offline | Works | Doesn’t work, you need your own offline message |
| Updating pages | Rebuild and submit for review | Live as soon as you change the server |
| How to load | loadFileURL or a custom scheme |
load(URLRequest) |
| App Review | Same as any app | Reviewers are more likely to see it as a repackaged website, see App Review and guideline 4.2 |
| Good for | Utilities, offline forms, core screens that need a stable experience | News and campaign pages that change often |
App Review Guideline 2.5.2 doesn’t allow an app to download code that adds or changes its features. Remote pages follow the same rule, so don’t use them to ship features that weren’t in the version Apple reviewed.
Step 1: How do you create a project from the WKWebView template?
KXBuild includes an iOS App (WKWebView shell) template. The whole UI is one WKWebView that loads the bundled www folder by default, with a progress bar, the back-swipe gesture and a JS-to-native bridge example. Use either approach.
- VS Code extension. Run KXBuild: New Project from the Command Palette and pick iOS App (WKWebView shell).
- Command line. Run the command below. The project name can only contain letters, digits and underscores.
1kxbuild project create -t app_webview -n H5Shell -b com.example.h5shell -o D:\work
The generated project looks like this. The deployment target is iOS 15.
1H5Shell/
2 H5Shell.xcodeproj/
3 H5Shell/
4 AppDelegate.swift
5 SceneDelegate.swift Sets WebViewController as the root view controller
6 WebViewController.swift The WKWebView and the JS bridge
7 Info.plist Already includes NSAllowsArbitraryLoadsInWebContent
8 Assets.xcassets/
9 www/ Web pages: index.html, app.js, style.css
www is a folder reference. The whole folder is copied into the app as is, subfolders included. You can add files and subfolders without touching the project file, and the next build picks them up.
If you already have a UIKit project, you can write a WebViewController by hand as shown below instead of using the template. See built-in templates for details on each template.
Step 2: How do you load pages bundled in the app?
Copy your frontend build into www
For Vue, React and similar projects, build the static files first, then copy the contents of the dist folder into www. Use relative asset paths. The default /assets/... absolute paths point to the root of the phone’s file system under file://, and the page comes up blank. In Vite add base: './' to the config, in Vue CLI set publicPath: './', and use hash mode for the router.
1npm run build
2Remove-Item -Recurse -Force D:\work\H5Shell\H5Shell\www\*
3Copy-Item -Recurse D:\work\myweb\dist\* D:\work\H5Shell\H5Shell\www\
Open it with loadFileURL
This is what the template does. The second argument gives WKWebView read access to the whole www folder, which the page needs to load its CSS, JS and images. If you only grant access to index.html, every other resource returns 404.
1guard let index = Bundle.main.url(forResource: "index", withExtension: "html", subdirectory: "www") else { return }
2webView.loadFileURL(index, allowingReadAccessTo: index.deletingLastPathComponent())
Use a custom scheme for fetch or history routing
A page loaded from file:// has no regular origin. Reading local JSON with fetch or XHR, or loading type="module" scripts, can fail because of cross-origin rules, and history-mode routes can’t find their file after a reload. In that case, register your own scheme with WKURLSchemeHandler, for example app://local/, and let native code serve the files in www as if they were a website.
1import Foundation
2import WebKit
3import UniformTypeIdentifiers
4
5/// Serves the files in the www folder through a custom scheme (app://local/),
6/// so fetch, XHR, ES modules and history routing work the same as on a website.
7final class AppSchemeHandler: NSObject, WKURLSchemeHandler {
8 private let root: URL
9
10 init(root: URL) {
11 self.root = root.standardizedFileURL
12 }
13
14 func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
15 guard let url = urlSchemeTask.request.url else {
16 urlSchemeTask.didFailWithError(URLError(.badURL))
17 return
18 }
19 var path = url.path
20 if path.isEmpty || path == "/" { path = "/index.html" }
21 var file = root.appendingPathComponent(String(path.dropFirst())).standardizedFileURL
22
23 // Frontend routes (no extension, no matching file) fall back to index.html.
24 if file.pathExtension.isEmpty && !FileManager.default.fileExists(atPath: file.path) {
25 file = root.appendingPathComponent("index.html")
26 }
27 // Don't allow ../ to read files outside www.
28 guard file.path.hasPrefix(root.path), let data = try? Data(contentsOf: file) else {
29 urlSchemeTask.didFailWithError(URLError(.fileDoesNotExist))
30 return
31 }
32
33 let mime = UTType(filenameExtension: file.pathExtension)?.preferredMIMEType ?? "application/octet-stream"
34 let headers = ["Content-Type": mime, "Content-Length": String(data.count)]
35 guard let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/1.1",
36 headerFields: headers) else {
37 urlSchemeTask.didFailWithError(URLError(.cannotParseResponse))
38 return
39 }
40 urlSchemeTask.didReceive(response)
41 urlSchemeTask.didReceive(data)
42 urlSchemeTask.didFinish()
43 }
44
45 func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {}
46}
Register the handler before you create the WKWebView. It has no effect on a web view that already exists. The scheme can’t be one WebKit handles itself, such as http, https or file.
1let www = Bundle.main.url(forResource: "www", withExtension: nil)!
2let config = WKWebViewConfiguration()
3config.setURLSchemeHandler(AppSchemeHandler(root: www), forURLScheme: "app")
4let webView = WKWebView(frame: .zero, configuration: config)
5webView.load(URLRequest(url: URL(string: "app://local/index.html")!))
With a custom scheme, your frontend build can keep absolute /assets/... paths, and history routes survive a reload.
How do you load a remote URL in WKWebView?
Change startURL in WebViewController to your URL.
1let startURL: URL? = URL(string: "https://m.example.com/")
During development you can point the app at the frontend dev server on your computer. Save a change to a page and refresh it on the phone, with no rebuild.
- Make the dev server listen on your local network. For Vite, run
npm run dev -- --hostand note thehttp://192.168.x.x:5173address it prints. - Temporarily set
startURLto that address and press F5 to install the app on your phone. - Connect the phone and the computer to the same Wi-Fi network. Allow inbound connections on that port in Windows Firewall. The first time the app reaches the local network, iOS asks whether it may find devices on your local network. Tap Allow.
1New-NetFirewallRule -DisplayName "Vite 5173" -Direction Inbound -Protocol TCP -LocalPort 5173 -Action Allow
The dev server uses http, which only works because the template’s Info.plist includes an ATS exception, see ATS and http resources. Before release, set startURL back to nil or to your production https URL.
What does the complete WebViewController look like?
The code below replaces WebViewController.swift from the template. It adds what most real projects run into.
- Two message channels.
nativeis fire-and-forget,nativeAsyncreturns a value. - Messages are forwarded through a weak reference. WKUserContentController holds its handlers strongly, so calling
add(self, name:)directly means the view controller is never released. - Message origins are checked, so only bundled pages and your own domains can call native code.
- Debug builds forward the page’s console to the native log and turn on isInspectable.
target="_blank"links, phone and email links, and the blank page after the web content process is terminated are all handled.
1import UIKit
2import WebKit
3import os
4
5private let webLog = Logger(subsystem: Bundle.main.bundleIdentifier ?? "H5Shell", category: "web")
6
7/// Weak forwarding. WKUserContentController holds handlers strongly, so add(self) directly would keep the controller alive forever.
8final class WeakMessageHandler: NSObject, WKScriptMessageHandler {
9 weak var target: WKScriptMessageHandler?
10
11 init(_ target: WKScriptMessageHandler) {
12 self.target = target
13 }
14
15 func userContentController(_ userContentController: WKUserContentController,
16 didReceive message: WKScriptMessage) {
17 target?.userContentController(userContentController, didReceive: message)
18 }
19}
20
21/// The version with a reply. postMessage on the JS side returns a Promise.
22final class WeakReplyHandler: NSObject, WKScriptMessageHandlerWithReply {
23 weak var target: WKScriptMessageHandlerWithReply?
24
25 init(_ target: WKScriptMessageHandlerWithReply) {
26 self.target = target
27 }
28
29 func userContentController(_ userContentController: WKUserContentController,
30 didReceive message: WKScriptMessage,
31 replyHandler: @escaping @MainActor @Sendable (Any?, String?) -> Void) {
32 guard let target else {
33 replyHandler(nil, "The web view container has been released")
34 return
35 }
36 target.userContentController(userContentController, didReceive: message, replyHandler: replyHandler)
37 }
38}
39
40final class WebViewController: UIViewController, WKNavigationDelegate,
41 WKScriptMessageHandler, WKScriptMessageHandlerWithReply {
42
43 /// Remote start page. When nil, the app loads the bundled www/index.html.
44 let startURL: URL? = nil
45 /// Remote domains allowed to call native code.
46 private let trustedHosts: Set<String> = ["m.example.com"]
47
48 private var webView: WKWebView!
49
50 override func viewDidLoad() {
51 super.viewDidLoad()
52 view.backgroundColor = .systemBackground
53
54 let contentController = WKUserContentController()
55 contentController.add(WeakMessageHandler(self), name: "native")
56 contentController.addScriptMessageHandler(WeakReplyHandler(self), contentWorld: .page, name: "nativeAsync")
57 #if DEBUG
58 contentController.add(WeakMessageHandler(self), name: "console")
59 contentController.addUserScript(WKUserScript(source: Self.consoleBridge,
60 injectionTime: .atDocumentStart,
61 forMainFrameOnly: false))
62 #endif
63
64 let config = WKWebViewConfiguration()
65 config.userContentController = contentController
66 config.allowsInlineMediaPlayback = true
67
68 webView = WKWebView(frame: view.bounds, configuration: config)
69 webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
70 webView.navigationDelegate = self
71 webView.allowsBackForwardNavigationGestures = true
72 #if DEBUG
73 if #available(iOS 16.4, *) {
74 webView.isInspectable = true
75 }
76 #endif
77 view.addSubview(webView)
78
79 NotificationCenter.default.addObserver(self, selector: #selector(appDidBecomeActive),
80 name: UIApplication.didBecomeActiveNotification, object: nil)
81 loadStartPage()
82 }
83
84 private func loadStartPage() {
85 if let url = startURL {
86 webView.load(URLRequest(url: url))
87 return
88 }
89 guard let index = Bundle.main.url(forResource: "index", withExtension: "html", subdirectory: "www") else {
90 webLog.error("www/index.html is not bundled in the app")
91 return
92 }
93 // Grant read access to the whole www folder so relative CSS, JS and image paths load.
94 webView.loadFileURL(index, allowingReadAccessTo: index.deletingLastPathComponent())
95 }
96
97 /// Accept messages only from bundled pages and trusted domains.
98 private func isTrusted(_ message: WKScriptMessage) -> Bool {
99 let origin = message.frameInfo.securityOrigin
100 // Pages bundled in the app: file:// or the custom app:// scheme from above
101 if origin.protocol == "file" || origin.protocol == "app" { return true }
102 return origin.protocol == "https" && trustedHosts.contains(origin.host)
103 }
104
105 // MARK: - JS to native, no return value
106
107 func userContentController(_ userContentController: WKUserContentController,
108 didReceive message: WKScriptMessage) {
109 if message.name == "console" {
110 webLog.debug("[H5] \(String(describing: message.body), privacy: .public)")
111 return
112 }
113 guard message.name == "native", isTrusted(message),
114 let body = message.body as? [String: Any],
115 let action = body["action"] as? String else { return }
116
117 switch action {
118 case "share":
119 let text = body["text"] as? String ?? ""
120 let sheet = UIActivityViewController(activityItems: [text], applicationActivities: nil)
121 sheet.popoverPresentationController?.sourceView = view
122 present(sheet, animated: true)
123 case "haptic":
124 UIImpactFeedbackGenerator(style: .medium).impactOccurred()
125 default:
126 webLog.error("Unknown action: \(action, privacy: .public)")
127 }
128 }
129
130 // MARK: - JS to native, with a return value
131
132 func userContentController(_ userContentController: WKUserContentController,
133 didReceive message: WKScriptMessage,
134 replyHandler: @escaping @MainActor @Sendable (Any?, String?) -> Void) {
135 guard isTrusted(message),
136 let body = message.body as? [String: Any],
137 let action = body["action"] as? String else {
138 replyHandler(nil, "Invalid call")
139 return
140 }
141
142 switch action {
143 case "deviceInfo":
144 let device = UIDevice.current
145 let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? ""
146 replyHandler(["model": device.model,
147 "system": "\(device.systemName) \(device.systemVersion)",
148 "appVersion": version], nil)
149 case "getItem":
150 let key = body["key"] as? String ?? ""
151 replyHandler(UserDefaults.standard.string(forKey: "h5." + key), nil)
152 case "setItem":
153 guard let key = body["key"] as? String, let value = body["value"] as? String else {
154 replyHandler(nil, "Missing key or value")
155 return
156 }
157 UserDefaults.standard.set(value, forKey: "h5." + key)
158 replyHandler(true, nil)
159 default:
160 replyHandler(nil, "Unknown action: \(action)")
161 }
162 }
163
164 // MARK: - Native to JS
165
166 /// Calls window.onNativeEvent({ event, data }) in the page.
167 func sendEvent(_ event: String, data: [String: Any] = [:]) {
168 let payload: [String: Any] = ["event": event, "data": data]
169 guard JSONSerialization.isValidJSONObject(payload),
170 let json = try? JSONSerialization.data(withJSONObject: payload),
171 let text = String(data: json, encoding: .utf8) else { return }
172 webView.evaluateJavaScript("window.onNativeEvent && window.onNativeEvent(\(text))") { _, error in
173 if let error {
174 webLog.error("evaluateJavaScript failed: \(error.localizedDescription, privacy: .public)")
175 }
176 }
177 }
178
179 /// Calls an async function in the page and gets its result. WebKit converts the arguments, so you don't build strings by hand.
180 func readCartCount() async -> Int {
181 do {
182 let result = try await webView.callAsyncJavaScript(
183 "return await window.app.getCartCount(userId)",
184 arguments: ["userId": "u_1001"],
185 contentWorld: .page)
186 return (result as? NSNumber)?.intValue ?? 0
187 } catch {
188 webLog.error("callAsyncJavaScript failed: \(error.localizedDescription, privacy: .public)")
189 return 0
190 }
191 }
192
193 @objc private func appDidBecomeActive() {
194 sendEvent("resume")
195 Task {
196 let count = await readCartCount()
197 webLog.info("Cart count: \(count)")
198 }
199 }
200
201 // MARK: - WKNavigationDelegate
202
203 func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction,
204 decisionHandler: @escaping @MainActor @Sendable (WKNavigationActionPolicy) -> Void) {
205 guard let url = navigationAction.request.url, let scheme = url.scheme?.lowercased() else {
206 decisionHandler(.cancel)
207 return
208 }
209 // Hand phone, email and App Store links to the system.
210 if ["tel", "mailto", "sms", "itms-apps"].contains(scheme) {
211 UIApplication.shared.open(url)
212 decisionHandler(.cancel)
213 return
214 }
215 // target="_blank" links have no target frame and do nothing by default, so open them in the current page.
216 if navigationAction.targetFrame == nil {
217 webView.load(navigationAction.request)
218 decisionHandler(.cancel)
219 return
220 }
221 decisionHandler(.allow)
222 }
223
224 func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!,
225 withError error: Error) {
226 webLog.error("Page failed to load: \(error.localizedDescription, privacy: .public)")
227 sendEvent("loadError", data: ["message": error.localizedDescription])
228 }
229
230 /// The page goes blank after the system terminates the web content process. Reload it.
231 func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
232 webView.reload()
233 }
234
235 // MARK: - Debugging: forward console output to the native log
236
237 private static let consoleBridge = """
238 (function () {
239 var h = window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.console;
240 if (!h) return;
241 function send(level, args) {
242 var text = Array.prototype.map.call(args, function (a) {
243 if (typeof a === 'string') return a;
244 try { return JSON.stringify(a); } catch (e) { return String(a); }
245 }).join(' ');
246 h.postMessage(level + ': ' + text);
247 }
248 ['log', 'info', 'warn', 'error'].forEach(function (level) {
249 var original = console[level];
250 console[level] = function () {
251 send(level, arguments);
252 original.apply(console, arguments);
253 };
254 });
255 window.addEventListener('error', function (e) {
256 send('error', [e.message + ' @ ' + e.filename + ':' + e.lineno]);
257 });
258 window.addEventListener('unhandledrejection', function (e) {
259 send('error', ['unhandled rejection: ' + String(e.reason)]);
260 });
261 })();
262 """
263}
This code and the AppSchemeHandler from the previous section compile against the iOS SDK with an iOS 15 deployment target, with no warnings in either Swift 5 or Swift 6 language mode. Replace m.example.com in trustedHosts with your own domain.
How does JavaScript call Swift in a WKWebView?
How it works
When you register a name with add(_:name:), WebKit creates window.webkit.messageHandlers.<name>.postMessage(message) in the page. The message can be a string, number, boolean, array or plain object, which arrive in Swift as String, NSNumber, Array and Dictionary. Functions, DOM nodes and undefined fields don’t come through.
| Approach | Register with | JavaScript gets | Good for |
|---|---|---|---|
| WKScriptMessageHandler | add(_:name:) |
No return value | Sharing, haptics, analytics events, closing a page |
| WKScriptMessageHandlerWithReply (iOS 14 and later) | addScriptMessageHandler(_:contentWorld:name:) |
A Promise | Reading device info, reading and writing local storage, native features you have to wait for |
On the reply channel, replyHandler(value, nil) resolves the Promise with that value, and a nil value reaches JavaScript as undefined. replyHandler(nil, "error text") rejects the Promise, and the message of the Error caught in JavaScript is that text. Call replyHandler at most once. If you never call it, the Promise also ends with an error. Return values can only be numbers, strings, dates, arrays, dictionaries and NSNull.
Register each name only once. According to the WebKit API docs, a name maps to a single handler within a content world, and the two handler types conflict with each other too. Registering a name twice throws an exception and crashes the app. That’s why the example uses two different names, native and nativeAsync.
Wrap the bridge in JavaScript
Keep every call in one bridge.js so the rest of your code never touches window.webkit directly. When you debug the page in a desktop browser, window.webkit doesn’t exist, so the wrapper checks for it first and the page doesn’t throw.
1// www/bridge.js: the single entry point between the page and native code
2(function () {
3 function handler(name) {
4 return window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers[name];
5 }
6
7 window.bridge = {
8 // Whether the page runs inside the app. False when debugging in a browser
9 get inApp() {
10 return !!handler('native');
11 },
12
13 // No return value: sharing, haptics, analytics events
14 post(action, data = {}) {
15 const h = handler('native');
16 if (!h) {
17 console.warn('Not running in the app, ignoring', action);
18 return;
19 }
20 h.postMessage({ action, ...data });
21 },
22
23 // With a return value: returns a Promise that rejects when native code reports an error
24 async call(action, data = {}) {
25 const h = handler('nativeAsync');
26 if (!h) throw new Error('Not running in the app: ' + action);
27 return h.postMessage({ action, ...data });
28 },
29 };
30
31 // Events pushed from native code through evaluateJavaScript
32 const listeners = {};
33 window.bridge.on = function (event, fn) {
34 (listeners[event] = listeners[event] || []).push(fn);
35 };
36 window.onNativeEvent = function (payload) {
37 (listeners[payload.event] || []).forEach((fn) => fn(payload.data));
38 };
39})();
Use it in your pages like this. In index.html, include bridge.js before app.js.
1// www/app.js: using the bridge in a page
2document.getElementById('share').addEventListener('click', () => {
3 window.bridge.post('share', { text: 'Check out this app' });
4});
5
6document.getElementById('info').addEventListener('click', async () => {
7 try {
8 const info = await window.bridge.call('deviceInfo');
9 console.log('Device info', info);
10 } catch (e) {
11 console.error('Call failed', e.message);
12 }
13});
14
15window.bridge.on('resume', () => {
16 console.log('App is back in the foreground, refreshing data');
17});
18
19// Called from native callAsyncJavaScript. It can be async
20window.app = {
21 async getCartCount(userId) {
22 const saved = await window.bridge.call('getItem', { key: 'cart.' + userId });
23 return saved ? JSON.parse(saved).length : 0;
24 },
25};
Security
Registered channels are visible to every page the web view opens, including third-party sites the user reaches through a link and iframes embedded in your pages. The example’s isTrusted checks message.frameInfo.securityOrigin and only accepts bundled pages and https domains in trustedHosts. For sensitive actions such as reading files or user data, validate the parameters as well. Don’t build a generic reflection bridge that runs whatever method name JavaScript sends.
How does Swift call JavaScript in the page?
| Method | Minimum iOS | Behavior | Good for |
|---|---|---|---|
evaluateJavaScript(_:completionHandler:) |
iOS 8 | Runs a script string, and you build the arguments into it yourself | Pushing events, calling synchronous functions |
callAsyncJavaScript(_:arguments:in:contentWorld:) (async version) |
iOS 15 | Takes a function body and an arguments dictionary, and waits for the Promise before returning the result | Calling async functions in the page and getting the result |
With evaluateJavaScript, always convert arguments to JSON with JSONSerialization before you put them into the script, as sendEvent does in the example. If you paste a string straight into single quotes, any quote or line break in it causes a syntax error, and user input could inject script.
The first argument of callAsyncJavaScript is a function body, not a function. The example passes return await window.app.getCartCount(userId), with userId supplied through the arguments dictionary, and WebKit converts the types. If the Promise returned by the function body is rejected, the Swift side throws.
Wait for the page to finish loading before you call JavaScript. Call it after webView(_:didFinish:), or have the page call native code once it’s ready and start pushing events from there.
What if ATS blocks http resources?
App Transport Security (ATS) only allows https by default. The template’s Info.plist includes the key below. It only relaxes requests made by web content in WKWebView. Native networking such as URLSession is still subject to ATS.
1<key>NSAppTransportSecurity</key>
2<dict>
3 <key>NSAllowsArbitraryLoadsInWebContent</key>
4 <true/>
5</dict>
According to Apple’s documentation, if you set NSAllowsArbitraryLoadsInWebContent to YES, you need to give a justification when you submit for review. If your pages and APIs all use https, remove the key before release. If only a few domains still use http, add exceptions for just those domains with NSExceptionDomains, which is easier to justify than opening everything.
Double-click Info.plist in VS Code to edit it in the Property List Editor.
How do you debug the web pages in a WKWebView on iPhone?
Get the page working in a desktop browser first
Layout and business logic are fastest to debug with the developer tools in a desktop browser. In the browser, bridge.js takes the “not running in the app” branch and the page keeps working. Use the methods below only for problems that show up on the iPhone.
Forward console output to the native log
In the complete code, Debug builds inject a user script that forwards console.log, warn, error, uncaught exceptions and unhandled Promise rejections to the native Logger. After you press F5, the page’s output appears next to the native log in Output > KXBuild Console in VS Code, prefixed with [H5]. When you aren’t debugging, you can also see it with kxdevice ostrace --subsystem <your bundle ID>. See Read iPhone device logs with Logger and os_log, then filter and export them.
You can set breakpoints in the native message handlers. Put one in userContentController(_:didReceive:), tap a button in the page, and inspect the message body JavaScript sent. See Run and debug.
Use an in-page debug panel
To inspect network requests, the DOM and local storage on the phone, temporarily add an in-page debug panel such as vConsole or Eruda to the pages in your Debug build, and remove it before release.
Safari Web Inspector and isInspectable
If you have a Mac at hand, you can also connect Safari Web Inspector on the Mac to the WKWebView on your phone. Starting with iOS 16.4, WKWebView’s isInspectable defaults to false and you have to set it to true explicitly. The example only turns it on in Debug builds. On the phone, also turn on Web Inspector on the Advanced page of Safari’s settings in the Settings app.
Step 3: How do you run on your iPhone and build the .ipa?
- Put an Apple Development certificate and a provisioning profile that includes this iPhone in the
keychainfolder, see Certificates and signing. - Connect the iPhone with a USB cable. The first time, follow Device setup to install drivers, trust the computer and turn on Developer Mode.
- Select the iPhone in the KXBuild view and press F5. KXBuild builds, signs, installs and launches the app, then attaches the debugger.
After you change pages in www, stop debugging and press F5 again so the new files get bundled. The Restart button on the debug toolbar doesn’t rebuild.
For the App Store, build with the Release configuration. The keychain folder needs an Apple Distribution certificate and an App Store provisioning profile. Use either approach.
- VS Code extension. Run KXBuild: Build for App Store (.ipa) from the Command Palette or the project’s context menu.
- Command line. Run the command below in the project folder.
1kxbuild build .\H5Shell.xcodeproj -c Release --export app-store
When the build finishes, build\H5Shell.ipa is ready to upload to App Store Connect. For uploading and submitting, see Publishing.
How does a hybrid app pass guideline 4.2 minimum functionality?
App Review Guideline 4.2 Minimum Functionality asks apps to offer features, content and UI beyond a repackaged website. An app that just puts a URL in a WKWebView is easily rejected under 4.2. For typical rejection wording and how to reply, see Common App Store rejection reasons.
Before you submit, go through the list below and add native value that a website can’t offer.
| Area | What to add |
|---|---|
| Native features | Push notifications, camera scanning, the photo library, location, Sign in with Apple, Home Screen widgets |
| Native UI | Build the tab bar, navigation bar and settings screens with native controls, and keep the web view for content |
| Offline use | Bundle the core pages, and show a clear message and cached content when offline |
| Polish | Pull to refresh, the back gesture, loading progress, retry on failure, and external links in the system browser |
| Accounts | If users can create an account, they must be able to delete it in the app |
Two more guidelines apply to hybrid apps. Remote pages can’t add new features after review, see 2.5.2. If your app is a platform that runs third-party HTML5 mini apps, follow 4.7 on content indexing, reporting and age restrictions, and don’t expose native APIs to those third-party pages without Apple’s permission.
How do you troubleshoot common WKWebView problems?
| Symptom | Cause | Fix |
|---|---|---|
| Blank page on launch | No index.html in www, or the build output uses absolute /assets/ paths |
Check the www folder and switch the frontend build to relative paths, or use a custom scheme |
| Page shows but images and styles are missing | loadFileURL only granted access to one file |
Pass the www folder to allowingReadAccessTo |
| fetch of local JSON fails | file:// pages are subject to cross-origin rules | Use a custom scheme, or let native code read the data and return it through the bridge |
| http APIs or images don’t load | Blocked by ATS | Use NSAllowsArbitraryLoadsInWebContent for web content or per-domain exceptions, and preferably move to https |
window.webkit is undefined |
The page runs in a desktop browser, or the channel name is misspelled | Check bridge.inApp, and compare with the name registered in Swift |
| App crashes when registering a channel | The same name was registered twice | Register each name once, and use a different name for the reply channel |
| View controller isn’t released after closing | add(self, name:) holds it strongly |
Forward through the weak WeakMessageHandler |
| evaluateJavaScript reports SyntaxError | Arguments were pasted into the string and contain quotes or line breaks | Convert to JSON with JSONSerialization first, or use callAsyncJavaScript |
target="_blank" links do nothing |
WKWebView doesn’t handle new windows by default | Open them in the current page in decidePolicyFor |
| Blank page after the app sat in the background | The system terminated the web content process | Implement webViewWebContentProcessDidTerminate and reload |
| Content hidden by the notch or home indicator | The page doesn’t handle safe areas | Add viewport-fit=cover to the viewport and pad with env(safe-area-inset-top) and related CSS |
| Changed pages don’t show up on the phone | You used Restart on the debug toolbar | Stop debugging and press F5 again |
For anything not in the table, forward the console to the native log as described above, then set a breakpoint in userContentController(_:didReceive:) to see whether the message reaches native code. Most WKWebView hybrid app problems come down to paths, ATS or channel names, so check those three first.
FAQ
How do I call Swift from JavaScript in WKWebView and get a return value?
Make your handler conform to WKScriptMessageHandlerWithReply and register it with addScriptMessageHandler(_:contentWorld:name:). Then window.webkit.messageHandlers.name.postMessage returns a Promise in JavaScript. In Swift, call replyHandler(value, nil) to resolve it, or pass error text to reject it. This requires iOS 14 or later.
How do I call an async function in the page from Swift?
Use callAsyncJavaScript, available from iOS 15. Pass a function body as the first argument, such as return await window.app.getData(id), and put the arguments in the arguments dictionary. It waits for the page’s Promise and hands the result to Swift, and throws if the Promise is rejected.
How do I debug the web pages in a WKWebView on Windows?
Get the page logic working in a desktop browser first. For problems that only happen on the phone, forward the page’s console to the native log and read it in Output > KXBuild Console in VS Code, or temporarily add an in-page debug panel such as vConsole or Eruda to your Debug build.
Why can’t Safari inspect my WKWebView on iOS 16.4 and later?
Starting with iOS 16.4, WKWebView’s isInspectable defaults to false. Set it to true in Debug builds inside if #available(iOS 16.4, *), then turn on Web Inspector on the Advanced page of Safari’s settings on the phone.
Can an app that only wraps a website get on the App Store?
An app that only puts a website in a WKWebView with no native features is easily rejected under guideline 4.2 Minimum Functionality. Add things a website can’t do, such as push notifications, scanning, native navigation and offline use. Whether the app is approved is up to Apple’s App Review.