I recently stumbled upon an interesting issue while developing an app that included a WKWebView
attempting to use location services. Initially, when this web view was displayed, a normal iOS location permission pop-up appeared—nothing unusual there. However, I was surprised to see that after allowing the app to access location services, a second pop-up immediately appeared, requesting permission to access location services on a specific webpage. Even worse, this second alert would persist even after terminating and reopening the app, resulting in a poor user experience.
As I couldn’t find a way to handle this through any of the WKWebView
delegates, it became clear that I needed to intercept the location service requests from the browser, handle them natively within iOS, and then pass control back to the browser once the location-related work was complete. Initially, this idea seemed daunting, but it turned out to be more straightforward than I anticipated.
Changelog
- October 23, 2024: Added a Swift 6 upgrade section at the end of the article
Understanding the Challenge
Setup: macOS Sonoma 14.6.1 | Xcode 15.4 | Swift 5.10
Let’s recreate the issue by setting up a WKWebView
that points to the Geolocation API from browserleaks.com—a set of tools that offers a range of tests to evaluate the security and privacy of a web browser:
struct ContentView: View {
var body: some View {
WebView()
}
}
import SwiftUI
import WebKit
struct WebView: UIViewRepresentable {
func makeUIView(context: Context) -> some UIView {
let webpage = URL(string: "https://browserleaks.com/geo")!
let view = WKWebView(frame: .zero, configuration: .init())
view.load(.init(url: webpage))
return view
}
func updateUIView(_ uiView: UIViewType, context: Context) { }
func makeCoordinator() -> Coordinator {
return Coordinator(self)
}
}
extension WebView {
class Coordinator {
let parent: WebView
init(_ parent: WebView) {
self.parent = parent
}
}
}
We’ll also need to add NSLocationWhenInUseUsageDescription
to our app’s Info.plist
to allow user location access:
NSLocationWhenInUseUsageDescription = "This app requires access to your location to enhance your experience by providing location-based features while you are using the app.";
Now, if we use the app as is, we quickly see the issue. Two permission pop-ups are shown: first, the one for the app in general, and then the one for the webpage. Even after granting all permissions, restarting the app triggers the webpage location permission request again:
Crafting the Solution
Let’s begin by understanding how JavaScript typically handles user location access. JavaScript accesses the user’s location using the Geolocation API, with the most important methods being:
getCurrentPosition(success, error, options)
for obtaining a single, last known location,watchPosition(success, error, options)
for continuously updating the location,clearWatch(id)
for stopping location updates
We’ll need to override these methods. Begin by creating a new empty file in your project named JavaScriptInjection.js
that will contain our JavaScript code. For now, leave it blank. Next, in XCode, select your project from the left sidebar, then choose your app name under the project. Go to the “Build Phases” tab, and ensure that this file is added as a resource in the “Copy Bundle Resources” section. This is necessary because we’ll be reading its contents later on.
Now, let’s set up the models. We’ll start by creating a Message
struct, which we’ll use to decode the raw JavaScript messages (in JSON format) that will be sent to the Swift side.
import Foundation
struct Message: Codable, Sendable {
let id: Double
let command: Command
let options: Options?
}
extension Message {
enum Command: String, Codable {
case getCurrentPosition, watchPosition, clearWatch
}
}
extension Message {
struct Options: Codable {
/// The maximum age in milliseconds of a cached position that is acceptable to return.
/// If the cached position is older than the specified `maximumAge`, the location will be re-fetched.
/// A value of 0 indicates that no cached position should be used and a new position should be obtained.
/// If not provided, there is no limit on the maximum age of cached position.
let maximumAge: Double?
/// The maximum time in milliseconds allowed for a location fetch operation to complete.
/// If the timeout is reached before a position is found, the operation fails.
/// If not provided, the location fetch can take an indefinite amount of time.
let timeout: Double?
/// A Boolean value indicating whether the location service should use the most accurate method
/// available to determine the position of the device.
/// Enabling high accuracy may use more battery power and take longer, but provides more precise results.
/// Defaults to `false` if not provided.
let enableHighAccuracy: Bool?
}
}
- The
id
is an integer because the Geolocation API operates this way:watchPosition
is expected to return an integer, which is then used inclearWatch
to stop updating the location,
command
represents the function being called, which in our case will be one of the three mentioned aboveoptions
is a direct mapping of the parameters used in the JS functions. These may or may not be present, depending on the situation
Let’s also add an error enum here, to handle any potential issues. This enum will be a direct mapping from the GeolocationPositionError:
import Foundation
enum LocationServicesError: Int, Error {
/// The acquisition of the geolocation information failed because the page didn't have the necessary permissions, for example because it is blocked by a Permissions Policy
case denied = 1
/// The acquisition of the geolocation failed because at least one internal source of position returned an internal error.
case unavailable
/// The time allowed to acquire the geolocation was reached before the information was obtained.
case timeout
}
Based on our review of the Geolocation API documentation and our understanding of how location services are managed in iOS, let’s create an interface for dependency injection into the WebView. This interface will enable us to invoke functions from the WKWebView
after receiving and decoding the messages we’ve defined. Here’s what we need:
import CoreLocation
protocol LocationServicesBridge: Actor {
/// Before calling `getCurrentPosition` or `watchPosition`,
/// we must first check if the necessary permissions are granted.
/// If permissions are not granted, we'll need to notify the JavaScript side.
/// If permissions are in place, we can proceed by either retrieving a single
/// location or starting continuous location updates.
func checkPermission() async throws
/// Get a single, last known location
/// - Parameters:
/// - options: An instance of `Message.Options` that specifies options such as maximum age, timeout, and accuracy for the location updates.
/// - Returns: The most recent location available, or `nil` if no location data is available.
func location(
options: Message.Options?
) -> CLLocation?
/// Begin continuous location updates using the specified ID
/// - Parameters:
/// - id: The unique identifier for the location update session.
/// - options: An instance of `Message.Options` that specifies options such as maximum age, timeout, and accuracy for the location updates.
/// - Returns: An asynchronous stream of `CLLocation` objects representing the device’s location over time.
func startUpdatingLocation(
id: Double,
options: Message.Options?
) -> AsyncStream<CLLocation>
/// Stop continuous location updates associated with the specified ID
/// - Parameter id: The unique identifier for the location update session to be stopped.
func stopUpdatingLocation(id: Double)
}
In your app, this is an opportunity to implement an interface and extend your own location service provider by adding the necessary methods. However, for demo purposes, I’ve provided a default implementation below. It’s a straightforward example that uses CLLocationManager under the hood to manage location services, handling permissions, starting and stopping location updates, and responding to location-related events as required.
You can ignore the following code; it’s just here so you can quickly copy and paste it to try it out. There’s nothing special going on, even though it seems like a lot! 😅
import CoreLocation
import Foundation
// Define an actor class that handles location services and acts as a bridge to your application's logic.
final actor LocationServicesManager: NSObject,
LocationServicesBridge,
Observable,
CLLocationManagerDelegate {
// MARK: - Properties
// The CLLocationManager instance responsible for managing location services.
private let locationManager: CLLocationManager
// Continuation for asynchronously streaming location updates.
private var continuations: [Double: AsyncStream<CLLocation>.Continuation?] = [:]
// Continuation for handling permission requests asynchronously.
private var authorizationContinuation: CheckedContinuation<Void, Error>?
// Keep track of requests
private var requests = Set<Double>()
// MARK: - Initializer
/// Initializes a new instance of DefaultLocationServicesBridge.
/// - Parameter locationManager: A custom CLLocationManager instance, defaulting to a new instance if not provided.
public init(
locationManager: CLLocationManager = .init()
) {
self.locationManager = locationManager
super.init()
// Set the location manager's delegate to self.
self.locationManager.delegate = self
}
// MARK: - LocationServicesBridge Protocol Conformance
/// Checks and requests location permissions as needed.
/// Throws an error if the user denies or restricts access to location services.
public func checkPermission() async throws {
switch locationManager.authorizationStatus {
case .notDetermined:
// If permission status is not determined, request permission asynchronously.
try await withCheckedThrowingContinuation { continuation in
Task {
askForPermission(authorizationContinuation: continuation)
}
}
case .restricted, .denied:
// If permission is restricted or denied, throw an error.
throw LocationServicesError.denied
case .authorizedAlways, .authorizedWhenInUse:
// If permission is granted, do nothing and proceed.
break
@unknown default:
// Handle any future cases that might be added to the authorization status enum.
break
}
}
/// Retrieves the most recent known location.
/// - Parameters:
/// - options: An instance of `Message.Options` that specifies options such as maximum age, timeout, and accuracy for the location updates.
/// - Returns: The last known location, or `nil` if no location is available.
func location(options _: Message.Options?) -> CLLocation? {
// TODO: Start the service and return the most recent location. For now, this should suffice:
return locationManager.location
}
/// Begins streaming continuous location updates.
/// - Parameters:
/// - id: A unique identifier for the location update session
/// - options: An instance of `Message.Options` that specifies options such as maximum age, timeout, and accuracy for the location updates.
/// - Returns: An `AsyncStream` that yields `CLLocation` objects as the device's location updates.
func startUpdatingLocation(id: Double, options _: Message.Options?) -> AsyncStream<CLLocation> {
let stream = AsyncStream<CLLocation>.makeStream()
continuations[id] = stream.continuation
locationManager.startUpdatingLocation()
requests.insert(id)
return stream.stream
}
/// Stops the continuous location updates associated with the specified session ID.
/// - Parameter id: The unique identifier for the location update session to stop.
func stopUpdatingLocation(id: Double) {
continuations[id]??.finish()
continuations[id] = nil
requests.remove(id)
if requests.isEmpty {
locationManager.stopUpdatingLocation()
}
}
// MARK: - Permission Request Helper
/// Requests location permission from the user.
/// - Parameter authorizationContinuation: A continuation that will be resumed once the user responds to the permission request.
func askForPermission(authorizationContinuation: CheckedContinuation<Void, Error>) {
self.authorizationContinuation = authorizationContinuation
Task { @MainActor in
locationManager.requestWhenInUseAuthorization()
}
}
// MARK: - Location Handling
/// Sends the most recent location update to the continuation.
/// - Parameter location: The new location to send.
func send(location: CLLocation) {
print("Location did update", location)
continuations.forEach { $0.value?.yield(location) }
}
/// Handles the result of a location permission request.
/// - Parameter status: The authorization status returned by the system.
func proceed(withAuthorization status: CLAuthorizationStatus) {
defer { authorizationContinuation = nil }
switch status {
case .authorizedWhenInUse, .authorizedAlways:
// If permission is granted, resume the continuation without error.
authorizationContinuation?.resume()
return
case .notDetermined, .denied, .restricted:
// If permission is denied or restricted, throw an error.
authorizationContinuation?.resume(throwing: LocationServicesError.denied)
return
@unknown default:
// Handle any future cases that might be added to the authorization status enum.
return
}
}
// MARK: - CLLocationManagerDelegate Conformance
/// Delegate method that is called when the location manager receives new location data.
/// - Parameter manager: The CLLocationManager object that generated the event.
/// - Parameter locations: An array of CLLocation objects containing the location data.
nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let location = locations.last else { return }
Task { await send(location: location) }
}
/// Delegate method that is called when the authorization status for the app changes.
/// - Parameter manager: The CLLocationManager object that generated the event.
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
let status = manager.authorizationStatus
guard status != .notDetermined else { return }
Task { await proceed(withAuthorization: status) }
}
}
Next, we’ll need to create an object that conforms to WKScriptMessageHandler. Its primary responsibility is to implement the func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage)
method. This method is called by the browser whenever a message is sent from the JavaScript side to Swift. We’ll explore the triggering mechanism shortly, but the main focus here is to decode the incoming JSON message into our model and then respond accordingly:
import Foundation
import CoreLocation
import WebKit
actor JavaScriptLocationAdapter: NSObject, WKScriptMessageHandler {
private weak var webView: WKWebView?
private weak var bridge: LocationServicesBridge?
func bind(webView: WKWebView, to bridge: LocationServicesBridge?) {
self.webView = webView
self.bridge = bridge
}
// MARK: - Confirming to `WKScriptMessageHandler`
nonisolated func userContentController(
_ userContentController: WKUserContentController,
didReceive message: WKScriptMessage
) {
print(message.body)
guard
let payload = message.body as? String,
let data = payload.data(using: .utf8),
let message = try? JSONDecoder().decode(Message.self, from: data)
else {
assertionFailure("Failed to decode a message")
return
}
Task { await _handle(message: message) }
}
// MARK: - Helpers
private func _handle(message: Message) async {
// More to come...
}
}
Note that we use a weak reference to the WKWebView
to enable calling back to the JavaScript side when needed. Similarly, we maintain a weak reference to the LocationServicesBridge
to handle requests from JavaScript for location data.
First, let’s add some private helper methods to send data back to the JavaScript side. These methods use the evaluateJavaScript(_)
function on the WKWebView
instance to execute JavaScript code (as you’ll notice, we’re calling some JavaScript functions that haven’t been defined yet. We’ll define these functions in the final step when we implement the JavaScriptInjection.js
file):
// ...
// MARK: - Making calls back to JS
@MainActor
private func _update(location: CLLocation, forId id: Double) async {
let coordinate = location.coordinate
let lat = coordinate.latitude
let lon = coordinate.longitude
let alt = location.altitude
let ha = location.horizontalAccuracy
let va = location.verticalAccuracy
let hd = location.course
let spd = location.speed
_ = try? await webView?.evaluateJavaScript(
"navigator.geolocation.iosWatchLocationDidUpdate(\(id),\(lat),\(lon),\(alt),\(ha),\(va),\(hd),\(spd))"
)
}
@MainActor
private func _updateFailed(id: Double, error: LocationServicesError) async {
_ = try? await webView?.evaluateJavaScript(
"navigator.geolocation.iosWatchLocationDidFailed(\(id),\(error.rawValue))"
)
}
@MainActor
private func _send(id: Double, location: CLLocation) async {
let coordinate = location.coordinate
let lat = coordinate.latitude
let lon = coordinate.longitude
let alt = location.altitude
let ha = location.horizontalAccuracy
let va = location.verticalAccuracy
let hd = location.course
let spd = location.speed
_ = try? await webView?.evaluateJavaScript(
"navigator.geolocation.iosLastLocation(\(id),\(lat),\(lon),\(alt),\(ha),\(va),\(hd),\(spd))"
)
}
@MainActor
private func _sendFailed(id: Double, error: LocationServicesError) async {
_ = try? await webView?.evaluateJavaScript(
"navigator.geolocation.iosLastLocationFailed(\(id),\(error.rawValue))"
)
}
// ...
Now let’s extend the function where we handle a received message:
// ...
// MARK: - Helpers
private func _handle(message: Message) async {
switch message.command {
case .getCurrentPosition:
do {
try await bridge?.checkPermission()
} catch let error as LocationServicesError {
await _sendFailed(id: message.id, error: error)
return
} catch {
await _sendFailed(id: message.id, error: .denied)
return
}
guard let location = await bridge?.location(options: message.options) else {
await _sendFailed(id: message.id, error: .unavailable)
return
}
await _send(id: message.id, location: location)
case .watchPosition:
do {
try await bridge?.checkPermission()
} catch let error as LocationServicesError {
await _updateFailed(id: message.id, error: error)
return
} catch {
await _updateFailed(id: message.id, error: .denied)
return
}
guard let stream = await bridge?.startUpdatingLocation(
id: message.id,
options: message.options
) else {
await _updateFailed(id: message.id, error: .unavailable);
return
}
for await location in stream {
await _update(location: location, forId: message.id)
}
case .clearWatch:
await bridge?.stopUpdatingLocation(id: message.id)
}
}
// ...
This method processes incoming commands from JavaScript related to geolocation services. It checks for the necessary location permissions and, based on the command received, either retrieves the current location, starts continuous location updates, or stops ongoing updates. The method ensures proper error handling and sends the appropriate responses or updates back to the JavaScript side (by using evaluateJavaScript(_)
).
Let’s bring everything together in our WKWebView
. Our goal is to enable sending and receiving messages between the web view and the app. This is accomplished using the WKUserContentController, an object that manages interactions between JavaScript and the web view. It is used both to inject JavaScript code into webpages and to install custom functions that can be called from within the app. Let’s inject our JavaScript code to override the necessary functions, and also set up listeners for when specific functions are called. The key point here is to remember the name you assign when adding a WKScriptMessageHandler
. We’ll need to reference that later. Here’s how the updated WebView
looks now:
import WebKit
struct WebView: UIViewRepresentable {
// 1
let locationServicesBridge: LocationServicesBridge
func makeUIView(context: Context) -> some UIView {
let webpage = URL(string: "https://browserleaks.com/geo")!
let userController = WKUserContentController()
// 2
let jsURL = Bundle.main.url(forResource: "JavaScriptInjection", withExtension: "js")!
let jsContent = try! String(contentsOf: jsURL, encoding: .utf8)
userController.addUserScript(.init(source: jsContent, injectionTime: .atDocumentStart, forMainFrameOnly: true))
// 3
let jsLocationServices = JavaScriptLocationAdapter()
userController.add(
jsLocationServices,
name: "locationHandler"
)
// 4
let config = WKWebViewConfiguration()
config.userContentController = userController
let view = WKWebView(frame: .zero, configuration: config)
view.load(.init(url: webpage))
Task {
// 5
await jsLocationServices.bind(
webView: view,
to: locationServicesBridge
)
}
return view
}
func updateUIView(_ uiView: UIViewType, context: Context) { }
func makeCoordinator() -> Coordinator {
return Coordinator(self)
}
}
extension WebView {
...
}
- The
locationServicesBridge
is injected into theWebView
struct to provide access to location services, - The JavaScript file
JavaScriptInjection.js
is loaded and injected into the webpage at the start of document loading. In the final step, we’ll override the Geolocation methods and add our custom implementations here. For now, the file remains empty, - The
JavaScriptLocationAdapter
is set up as a message handler to allow communication between JavaScript in the WebView and Swift. This allows the JavaScript running in the WebView to communicate with Swift by sending messages tolocationHandler
(the name we chose), which will be handled by theJavaScriptLocationAdapter
, - A
WKWebViewConfiguration
is created and customized with the user content controller, then used to initialize the WebView, - The
WebView
is bound to the location services bridge, enabling interaction between the WebView and native location services
Update the main app entry point, where a default implementation of LocationServicesBridge
is injected.
struct ContentView: View {
@State private var locationServicesBridge = LocationServicesManager()
var body: some View {
WebView(
locationServicesBridge: locationServicesBridge
)
}
}
The final step is implementing the JavaScript. To effectively communicate between the web view and the iOS environment, follow these steps:
- from WebView to iOS: use
window.webkit.messageHandlers.<messageHandlerName>.postMessage(...)
in your JavaScript. Ensure<messageHandlerName>
matches the one specified in the WebView (e.g., “locationHandler”). This sends a message from the WebView to the native iOS code, - from iOS to WebView: use the
evaluateJavaScript
method on theWKWebView
instance in your iOS code to execute JavaScript within the WebView
//
// Section 1.: Helper variables
//
let idCounter = 0; // ID counter for unique identification ~ JavaScript is single-threaded
const locationWatchCallbacks = new Map();
const locationCallbacks = new Map();
//
// Section 2.: Helpers
//
function createPayload(name, id, options) {
return JSON.stringify({
id: id,
command: name,
options: options
});
}
//
// Section 3.: Overrides
//
// https://developer.mozilla.org/en-US/docs/Web/API/Geolocation/getCurrentPosition
navigator.geolocation.getCurrentPosition = function(success, error, options) {
const id = idCounter++; // Generate a unique ID using the counter
locationCallbacks.set(id, { success, error }); // Store the callbacks using the id
const payload = createPayload("getCurrentPosition", id, options); // Payload dispatched to iOS
window.webkit.messageHandlers.locationHandler.postMessage(payload); // Send command
};
// https://developer.mozilla.org/en-US/docs/Web/API/Geolocation/watchPosition
navigator.geolocation.watchPosition = function(success, error, options) {
const id = idCounter++; // Generate a unique ID using the counter
locationWatchCallbacks.set(id, { success, error }); // Store the callbacks using the id
const payload = createPayload("watchPosition", id, options); // Payload dispatched to iOS
window.webkit.messageHandlers.locationHandler.postMessage(payload); // Send command
return id;
};
// https://developer.mozilla.org/en-US/docs/Web/API/Geolocation/clearWatch
navigator.geolocation.clearWatch = function(id) {
locationWatchCallbacks.delete(id); // Remove the callbacks using the id
const payload = createPayload("clearWatch", id, null); // Payload dispatched to iOS
window.webkit.messageHandlers.locationHandler.postMessage(payload); // Send command
};
This code modifies the default behavior of the navigator.geolocation
methods in JavaScript to integrate with a native iOS app via WKWebView
. It starts by defining helper variables:
idCounter
is a number we’ll use to generate unique IDs. Using an ID counter for callbacks is safe in our case because JavaScript is single-threaded, ensuring that operations like incrementing the counter and handling IDs happen sequentially without the risk of concurrent modifications. However, you could also use timestamps in milliseconds or other more advanced generators if needed,locationWatchCallbacks
is a map that stores thesuccess
anderror
callbacks for eachwatchPosition
call,locationCallbacks
is a map that holds the callbacks forgetCurrentPosition
calls, and- A helper function,
createPayload
, which formats the command, ID, and options into a JSON string, which is then sent as the message body to the iOS app.
The code then overrides the standard getCurrentPosition
, watchPosition
, and clearWatch
methods from the Geolocation API. In the getCurrentPosition
override, the success
and error
callbacks are stored, and a message is then sent to the iOS app using the locationHandler
in window.webkit.messageHandlers
to request the location. The watchPosition
override works similarly. The generated ID is used to store the callbacks in locationWatchCallbacks
, allowing the app to manage continuous location tracking.
Finally, the clearWatch
method stops a specific watch session by deleting the corresponding callbacks and notifying the iOS app to stop location updates.
As the final step, add the JavaScript functions that will be invoked from the JavaScriptLocationAdapter.swift
file. These functions will be called using the evaluateJavaScript(_)
method to pass control back to the JavaScript side and trigger the completion blocks. They will handle the results of location requests, updating the corresponding success or error callbacks stored earlier:
...
//
// Section 4.: Call from Swift
//
// Section 4.1: Callbacks used when `watchPosition` request is processed in iOS world
navigator.geolocation.iosWatchLocationDidUpdate = function(id, lat, lon, alt, ha, va, hd, spd) {
const callbacks = locationWatchCallbacks.get(id);
if (callbacks) {
const { success } = callbacks;
success({
coords: {
latitude: lat,
longitude: lon,
altitude: alt,
accuracy: ha,
altitudeAccuracy: va,
heading: hd,
speed: spd
}
});
}
return null;
}
navigator.geolocation.iosWatchLocationDidFailed = function(id, code) {
const callbacks = locationWatchCallbacks.get(id);
if (callbacks) {
const { error } = callbacks;
error({
code: code,
message: null
});
}
locationWatchCallbacks.delete(id); // Remove the callback after it's used
return null;
}
// Section 4.2: Callbacks used when `getCurrentPosition` request is processed in iOS world
navigator.geolocation.iosLastLocation = function(id, lat, lon, alt, ha, va, hd, spd) {
const callbacks = locationCallbacks.get(id);
if (callbacks) {
const { success } = callbacks;
success({
coords: {
latitude: lat,
longitude: lon,
altitude: alt,
accuracy: ha,
altitudeAccuracy: va,
heading: hd,
speed: spd
}
});
locationCallbacks.delete(id); // Remove the callback after it's used
}
return null;
}
navigator.geolocation.iosLastLocationFailed = function(id, code) {
const callbacks = locationCallbacks.get(id);
if (callbacks) {
const { error } = callbacks;
error({
code: code,
message: null
});
}
locationCallbacks.delete(id); // Remove the callback after it's used
return null;
}
iosWatchLocationDidUpdate
: This function is called whenever the location updates during a watch session. It triggers the success callback with the new location data, identified by the session ID,iosWatchLocationDidFailed
: This function is called when there is an error during a watch session. It triggers the error callback with an error message, using the session ID to identify the correct callback,iosLastLocation
: This function is called when a single location request is successfully completed. It triggers the success callback with the location data and then clears the stored callbacks,iosLastLocationFailed
: This function is called when a single location request fails. It triggers the error callback with an error message and clears the stored callbacks
Edit #01: Migrating to Swift 6
With the exciting release of Swift 6, a few adjustments are required to ensure the project compiles. Diving into the specifics of these changes is out of this article’s scope.
Use @preconcurrency
import when importing CoreLocation
and capture the locationManager
when requesting the location permission.
@preconcurrency import CoreLocation
...
func askForPermission(authorizationContinuation: CheckedContinuation<Void, Error>) {
self.authorizationContinuation = authorizationContinuation
Task { @MainActor [locationManager] in // capture here
locationManager.requestWhenInUseAuthorization()
}
}
Create a new LocationScriptMessageHandler
class that functions as a script message handler, wrapping our implementation of JavaScriptLocationAdapter
.
Since JavaScriptLocationAdapter
is no longer responsible for directly handling these messages, we remove its NSObject
and WKScriptMessageHandler
conformances, and move the corresponding method implementation (receiving messages) to the new class.
...
class LocationScriptMessageHandler: NSObject, WKScriptMessageHandler {
private let adapter: JavaScriptLocationAdapter
init(adapter: JavaScriptLocationAdapter) {
self.adapter = adapter
}
// MARK: - Confirming to `WKScriptMessageHandler`
// Important
// This function has been removed from `JavaScriptLocationAdapter`.
// Messages are now simply forwarded to it.
@MainActor
func userContentController(
_ userContentController: WKUserContentController,
didReceive message: WKScriptMessage
) {
print(message.body)
guard
let payload = message.body as? String,
let data = payload.data(using: .utf8),
let message = try? JSONDecoder().decode(Message.self, from: data)
else {
assertionFailure("Failed to decode a message")
return
}
Task { await adapter._handle(message: message) }
}
}
actor JavaScriptLocationAdapter { // Note #01: conformances removed
...
// Note #02: Remove the `didReceive message` function
...
// Note #03: Increase the access level to fileprivate to allow message forwarding
fileprivate func _handle(message: Message) async {
...
}
}
The final step is to use our new class when setting up the JavaScript message handler, which leverages our previous implementation and forwards the messages to it.
let jsLocationServices = JavaScriptLocationAdapter()
userController.add(
LocationScriptMessageHandler(adapter: jsLocationServices),
name: "locationHandler"
)
Wrapping Up
That’s it! Uninstall the app to reset the permissions, and then reinstall it. When you try again, you’ll notice the result—no more double pop-ups!”
By intercepting and handling the location requests within the native iOS environment, we’ve successfully streamlined the user experience. This approach ensures that the app only requests location permission once, providing a smoother and more professional experience for the end-user.
This method not only resolves the issue but also opens up possibilities for handling other JavaScript-native interactions within your iOS apps. Experiment with this setup and see how it can be extended to other use cases.
Leave a Reply