如何防止 WKWebView 重复请求访问位置的权限?

How to prevent WKWebView to repeatedly ask for permission to access location?

我的应用程序中有一个 WKWebView,当我开始浏览 www.google.com 或任何其他需要定位服务的网站时,会出现一个弹出窗口 window,请求允许访问设备的位置,即使我已经同意共享我的位置。

我唯一做的就是在我的 info.plist.

中添加 NSLocationWhenInUseUsageDescription 属性来管理这个位置的东西

我在网上找不到任何答案,所以任何想法都将不胜感激。

事实证明这很难,但可以做到。您必须注入 JavaScript 代码拦截对 navigator.geolocation 的请求并将它们传输到您的应用程序,然后使用 CLLocationManager 获取位置,然后将位置注入回 JavaScript。

这是简要方案:

  1. WKUserScript 添加到您的 WKWebView 配置中,它会覆盖 navigator.geolocation 的方法。注入的 JavaScript 应该是这样的:

    navigator.geolocation.getCurrentPosition = function(success, error, options) { ... };
    navigator.geolocation.watchPosition = function(success, error, options) { ... };
    navigator.geolocation.clearWatch = function(id) { ... };
    
  2. 使用 WKUserContentController.add(_:name:) 将脚本消息处理程序添加到您的 WKWebView。注入的 JavaScript 应该调用您的处理程序,如下所示:

    window.webkit.messageHandlers.locationHandler.postMessage('getCurrentPosition');
    
  3. 当网页请求位置时,此方法将触发 userContentController(_:didReceive:) 因此您的应用会知道网页正在请求位置。像往常一样在 CLLocationManager 的帮助下找到您的位置。

  4. 现在是使用 webView.evaluateJavaScript("didUpdateLocation({coords: {latitude:55.0, longitude:0.0}, timestamp: 1494481126215.0})") 将位置注入回请求 JavaScript 的时候了。 当然,你注入的 JavaScript 应该有 didUpdateLocation 函数准备好启动保存的成功处理程序。

相当长的算法,但它有效!

所以按照@AlexanderVasenin 概述的步骤,我创建了一个完美运行的要点。

Code Sample Here

假设 index.html 是您要加载的页面。

  1. 覆盖 HTML 方法 navigator.geolocation.getCurrentPosition 用于使用此脚本请求位置信息
 let scriptSource = "navigator.geolocation.getCurrentPosition = function(success, error, options) {window.webkit.messageHandlers.locationHandler.postMessage('getCurrentPosition');};"
 let script = WKUserScript(source: scriptSource, injectionTime: .atDocumentEnd, forMainFrameOnly: true)
 contentController.addUserScript(script)

因此,每当网页尝试调用 navigator.geolocation.getCurrentPosition 时,我们都会通过调用 func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage)

来覆盖它
  1. userContentController 方法然后从 CLLocationManager 获取位置数据并调用网页中的方法来处理该响应。在我的例子中,方法是 getLocation(lat,lng)

这是完整代码。

ViewController.swift

import UIKit
import WebKit
import CoreLocation

class ViewController: UIViewController , CLLocationManagerDelegate, WKScriptMessageHandler{
    var webView: WKWebView?
    var manager: CLLocationManager!

    override func viewDidLoad() {
        super.viewDidLoad()

        manager = CLLocationManager()
        manager.delegate = self
        manager.desiredAccuracy = kCLLocationAccuracyBest
        manager.requestAlwaysAuthorization()
        manager.startUpdatingLocation()

        let contentController = WKUserContentController()
        contentController.add(self, name: "locationHandler")

        let config = WKWebViewConfiguration()
        config.userContentController = contentController

        let scriptSource = "navigator.geolocation.getCurrentPosition = function(success, error, options) {window.webkit.messageHandlers.locationHandler.postMessage('getCurrentPosition');};"
        let script = WKUserScript(source: scriptSource, injectionTime: .atDocumentEnd, forMainFrameOnly: true)
        contentController.addUserScript(script)

        self.webView = WKWebView(frame: self.view.bounds, configuration: config)
        view.addSubview(webView!)

        webView?.uiDelegate = self
        webView?.navigationDelegate = self
        webView?.scrollView.delegate = self
        webView?.scrollView.bounces = false
        webView?.scrollView.bouncesZoom = false

        let url = Bundle.main.url(forResource: "index", withExtension:"html")
        let request = URLRequest(url: url!)

        webView?.load(request)
    }

    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        if message.name == "locationHandler",let  messageBody = message.body as? String {
            if messageBody == "getCurrentPosition"{
                let script =
                    "getLocation(\(manager.location?.coordinate.latitude ?? 0) ,\(manager.location?.coordinate.longitude ?? 0))"
                webView?.evaluateJavaScript(script)
            }
        }
    }
}

index.html

<!DOCTYPE html>
<html>
    <body>

        <h1>Click the button to get your coordinates.</h1>

        <button style="font-size: 60px;" onclick="getUserLocation()">Try It</button>

        <p id="demo"></p>

        <script>
            var x = document.getElementById("demo");

            function getUserLocation() {
                if (navigator.geolocation) {
                    navigator.geolocation.getCurrentPosition(showPosition);
                } else {
                    x.innerHTML = "Geolocation is not supported by this browser.";
                }
            }

        function showPosition(position) {
            getLocation(position.coords.latitude,position.coords.longitude);
        }

        function getLocation(lat,lng) {
            x.innerHTML = "Lat: " +  lat+
            "<br>Lng: " + lng;
        }
        </script>

    </body>
</html>

因为我没有找到如何避免我创建的这个愚蠢的重复权限请求的解决方案 swift class NavigatorGeolocation。这个 class 的目的是用自定义的 JavaScript 的 navigator.geolocation API 覆盖原生的 navigator.geolocation API 具有 3 个好处:

  1. Frontend/JavaScript 开发者使用 navigator.geolocation API 标准方式而不注意它被覆盖并使用代码 调用 JS --> Swift on behind
  2. 尽可能将所有逻辑放在ViewController之外
  3. 不再有丑陋愚蠢的重复权限请求(应用程序第一个,网络视图第二个):

@AryeeteySolomonAryeetey 回答了一些解决方案,但它缺少我的第一个和第二个好处。在他的解决方案中,前端开发人员必须向 JavaScript 添加 iOS 的特定代码。我不喜欢这种丑陋的平台添加 - 我的意思是从 swift 调用的 JavaScript 函数 getLocation 从未被网络或 android 平台使用。我有一个在 ios/android 上使用 webview 的混合应用程序 (web/android/ios),我只想为所有平台使用一个相同的 HTML5 + JavaScript 代码,但我不想使用巨大的解决方案,如 Apache Cordova(以前称为 PhoneGap)。

您可以轻松地将 NavigatorGeolocation class 集成到您的项目中 - 只需创建新的 swift 文件 NavigatorGeolocation.swift,从我的回答中复制内容并在 ViewController.swift 中添加 4 行相关内容到 var navigatorGeolocation.

我认为 Google 的 Android 比 Apple 的 iOS 聪明得多,因为 Android 中的 webview 不会为重复的权限请求而烦恼,因为权限已经 granted/denied 由用户为应用程序。没有额外的安全要求,因为有些人捍卫苹果。

ViewController.swift:

import UIKit
import WebKit

class ViewController: UIViewController, WKNavigationDelegate {

    var webView: WKWebView!;
    var navigatorGeolocation = NavigatorGeolocation();

    override func loadView() {
        super.loadView();
        let webViewConfiguration = WKWebViewConfiguration();
        webView = WKWebView(frame:.zero , configuration: webViewConfiguration);
        webView.navigationDelegate = self;
        navigatorGeolocation.setWebView(webView: webView);
        view.addSubview(webView);
    }

    override func viewDidLoad() {
        super.viewDidLoad();
        let url = Bundle.main.url(forResource: "index", withExtension: "html", subdirectory: "webapp");
        let request = URLRequest(url: url!);
        webView.load(request);
    }

    func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
        webView.evaluateJavaScript(navigatorGeolocation.getJavaScripToEvaluate());
    }

}

NavigatorGeolocation.swift:

import WebKit
import CoreLocation

class NavigatorGeolocation: NSObject, WKScriptMessageHandler, CLLocationManagerDelegate {

    var locationManager = CLLocationManager();
    var listenersCount = 0;
    var webView: WKWebView!;

    override init() {
        super.init();
        locationManager.delegate = self;
    }

    func setWebView(webView: WKWebView) {
        webView.configuration.userContentController.add(self, name: "listenerAdded");
        webView.configuration.userContentController.add(self, name: "listenerRemoved");
        self.webView = webView;
    }

    func locationServicesIsEnabled() -> Bool {
        return (CLLocationManager.locationServicesEnabled()) ? true : false;
    }

    func authorizationStatusNeedRequest(status: CLAuthorizationStatus) -> Bool {
        return (status == .notDetermined) ? true : false;
    }

    func authorizationStatusIsGranted(status: CLAuthorizationStatus) -> Bool {
        return (status == .authorizedAlways || status == .authorizedWhenInUse) ? true : false;
    }

    func authorizationStatusIsDenied(status: CLAuthorizationStatus) -> Bool {
        return (status == .restricted || status == .denied) ? true : false;
    }

    func onLocationServicesIsDisabled() {
        webView.evaluateJavaScript("navigator.geolocation.helper.error(2, 'Location services disabled');");
    }

    func onAuthorizationStatusNeedRequest() {
        locationManager.requestWhenInUseAuthorization();
    }

    func onAuthorizationStatusIsGranted() {
        locationManager.startUpdatingLocation();
    }

    func onAuthorizationStatusIsDenied() {
        webView.evaluateJavaScript("navigator.geolocation.helper.error(1, 'App does not have location permission');");
    }

    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        if (message.name == "listenerAdded") {
            listenersCount += 1;

            if (!locationServicesIsEnabled()) {
                onLocationServicesIsDisabled();
            }
            else if (authorizationStatusIsDenied(status: CLLocationManager.authorizationStatus())) {
                onAuthorizationStatusIsDenied();
            }
            else if (authorizationStatusNeedRequest(status: CLLocationManager.authorizationStatus())) {
                onAuthorizationStatusNeedRequest();
            }
            else if (authorizationStatusIsGranted(status: CLLocationManager.authorizationStatus())) {
                onAuthorizationStatusIsGranted();
            }
        }
        else if (message.name == "listenerRemoved") {
            listenersCount -= 1;

            // no listener left in web view to wait for position
            if (listenersCount == 0) {
                locationManager.stopUpdatingLocation();
            }
        }
    }

    func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
        // didChangeAuthorization is also called at app startup, so this condition checks listeners
        // count before doing anything otherwise app will start location service without reason
        if (listenersCount > 0) {
            if (authorizationStatusIsDenied(status: status)) {
                onAuthorizationStatusIsDenied();
            }
            else if (authorizationStatusIsGranted(status: status)) {
                onAuthorizationStatusIsGranted();
            }
        }
    }

    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        if let location = locations.last {
            webView.evaluateJavaScript("navigator.geolocation.helper.success('\(location.timestamp)', \(location.coordinate.latitude), \(location.coordinate.longitude), \(location.altitude), \(location.horizontalAccuracy), \(location.verticalAccuracy), \(location.course), \(location.speed));");
        }
    }

    func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
        webView.evaluateJavaScript("navigator.geolocation.helper.error(2, 'Failed to get position (\(error.localizedDescription))');");
    }

    func getJavaScripToEvaluate() -> String {
        let javaScripToEvaluate = """
            // management for success and error listeners and its calling
            navigator.geolocation.helper = {
                listeners: {},
                noop: function() {},
                id: function() {
                    var min = 1, max = 1000;
                    return Math.floor(Math.random() * (max - min + 1)) + min;
                },
                clear: function(isError) {
                    for (var id in this.listeners) {
                        if (isError || this.listeners[id].onetime) {
                            navigator.geolocation.clearWatch(id);
                        }
                    }
                },
                success: function(timestamp, latitude, longitude, altitude, accuracy, altitudeAccuracy, heading, speed) {
                    var position = {
                        timestamp: new Date(timestamp).getTime() || new Date().getTime(), // safari can not parse date format returned by swift e.g. 2019-12-27 15:46:59 +0000 (fallback used because we trust that safari will learn it in future because chrome knows that format)
                        coords: {
                            latitude: latitude,
                            longitude: longitude,
                            altitude: altitude,
                            accuracy: accuracy,
                            altitudeAccuracy: altitudeAccuracy,
                            heading: (heading > 0) ? heading : null,
                            speed: (speed > 0) ? speed : null
                        }
                    };
                    for (var id in this.listeners) {
                        this.listeners[id].success(position);
                    }
                    this.clear(false);
                },
                error: function(code, message) {
                    var error = {
                        PERMISSION_DENIED: 1,
                        POSITION_UNAVAILABLE: 2,
                        TIMEOUT: 3,
                        code: code,
                        message: message
                    };
                    for (var id in this.listeners) {
                        this.listeners[id].error(error);
                    }
                    this.clear(true);
                }
            };

            // @override getCurrentPosition()
            navigator.geolocation.getCurrentPosition = function(success, error, options) {
                var id = this.helper.id();
                this.helper.listeners[id] = { onetime: true, success: success || this.noop, error: error || this.noop };
                window.webkit.messageHandlers.listenerAdded.postMessage("");
            };

            // @override watchPosition()
            navigator.geolocation.watchPosition = function(success, error, options) {
                var id = this.helper.id();
                this.helper.listeners[id] = { onetime: false, success: success || this.noop, error: error || this.noop };
                window.webkit.messageHandlers.listenerAdded.postMessage("");
                return id;
            };

            // @override clearWatch()
            navigator.geolocation.clearWatch = function(id) {
                var idExists = (this.helper.listeners[id]) ? true : false;
                if (idExists) {
                    this.helper.listeners[id] = null;
                    delete this.helper.listeners[id];
                    window.webkit.messageHandlers.listenerRemoved.postMessage("");
                }
            };
        """;

        return javaScripToEvaluate;
    }

}

2021/02 更新:我删除了无用的方法 NavigatorGeolocation.setUserContentController() 因为 WKWebViewConfiguration.userContentController 可以通过以下方式添加到 NavigatorGeolocation.setWebView() 中webView.configuration.userContentController.add() 所以 NavigatorGeolocation 在 ViewController 中的实现更简单(减去一行)

基于 我能够让 WKWebView 访问用户位置(如果你有权限)在网站上使用,例如带有地图的网站,在 macOS 上(以前 OSX );尽管这在 macOS iOS 上开箱即用,但完全不同。

使用Swift 5

创建一个实现 WKScriptMessageHandler 协议的 class。最好这需要是一个单独的对象,因为它将由 WKUserContentController 保留。

从JavaScript

发送消息时将调用该方法
final class Handler: NSObject, WKScriptMessageHandler {
    weak var web: Web?
        
    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        switch message.body as? String {
            case "getCurrentPosition":

                let location = /* get user location using CoreLocation, as a CLLocation object */
                web?.evaluateJavaScript(
                            "locationReceived(\(location.coordinate.latitude), \(location.coordinate.longitude), \(location.horizontalAccuracy));")

            default: break
            }
    }
}

JavaScript 需要添加到用户控制器

let script = """
var locationSuccess = null;

function locationReceived(latitude, longitude, accuracy) {
    var position = {
        coords: {
            latitude: latitude,
            longitude: longitude,
            accuracy: accuracy
        }
    };

    if (locationSuccess != null) {
        locationSuccess(position);
    }

    locationSuccess = null;
}

navigator.geolocation.getCurrentPosition = function(success, error, options) {
    locationSuccess = success;
    window.webkit.messageHandlers.handler.postMessage('getCurrentPosition');
};

"""

使用 WKWebViewConfiguration 上的处理程序实例化 WKWebView,并将处理程序的弱引用分配给 webview

同时将 JavaScript 作为用户脚本添加到 WKUserContentController


let handler = Handler()
let configuration = WKWebViewConfiguration()
configuration.userContentController.add(handler, name: "handler")
configuration.userContentController.addUserScript(.init(source: script, injectionTime: .atDocumentEnd, forMainFrameOnly: true))

let webView = WKWebView(frame: .zero, configuration: configuration)
handler.web = webView

为简单起见,这里有一个 SwiftUI 版本

import SwiftUI
import WebKit

struct ContentView: View {
    var body: some View {
        WebView()
    }
}

struct WebView: UIViewRepresentable {
    func makeUIView(context: Context) -> WKWebView {
        let webView = WKWebView()
        WebScriptManager.shared.config(webView)
        return webView
    }
    
    func updateUIView(_ webView: WKWebView, context: Context) {
        webView.load(URLRequest(url: URL(string: "https://developer.mozilla.org/en-US/docs/Web/API/Geolocation_API/Using_the_Geolocation_API#result")!))
    }
}
struct ScriptMessageCall {
    let name: String
    let body: String
    let callback: String
}

let GEOGetCurrentPosition = ScriptMessageCall(name: "geolocation", body: "getCurrentPosition", callback: "getCurrentPositionCallback")

class WebScriptManager: NSObject, WKScriptMessageHandler {
    static let shared = WebScriptManager()
    
    private override init() {}
    
    let injectScript = """
        navigator.geolocation.getCurrentPosition = function(success, error, options) {
          webkit.messageHandlers.\(GEOGetCurrentPosition.name).postMessage("\(GEOGetCurrentPosition.body)");
        };

        function \(GEOGetCurrentPosition.callback)(latitude, longitude) {
          console.log(`position: ${latitude}, ${longitude}`);
        };
    """

    var webView: WKWebView!
    
    func config(_ webView: WKWebView) {
        self.webView = webView
        let controller = self.webView.configuration.userContentController
        controller.addUserScript(WKUserScript(source: injectScript, injectionTime: .atDocumentEnd, forMainFrameOnly: false))
        controller.add(self, name: GEOGetCurrentPosition.name)
    }

    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        if message.name == GEOGetCurrentPosition.name, (message.body as? String) == GEOGetCurrentPosition.body {
            webView.evaluateJavaScript("\(GEOGetCurrentPosition.callback)(0, 0)", completionHandler: nil)
        }
    }
}

您可以通过 Enabling Web Inspector

查看 console.log