Files
iOS/Sources/App/Frontend/WebView/WebViewController+WebKitDelegates.swift
nstefanelli 9ca16127c8 Fix kiosk screensaver idle timer not resetting on touch (#4434)
## Summary
- Kiosk screensaver idle timer was not resetting on user interaction —
`recordKioskActivity()` was defined but never called
- WKWebView consumes touch events before UIKit idle detection, so the
timer counted down from kiosk activation rather than last touch
- Added a tap gesture recognizer on the WebView (`cancelsTouchesInView =
false`) to detect taps without interfering with normal interaction
- Added `scrollViewWillBeginDragging` delegate hook to detect
scroll/drag interactions

## Test plan
- [ ] Enable kiosk mode with 30s screensaver timeout
- [ ] Tap on the WebView dashboard — idle timer should reset
- [ ] Scroll on the dashboard — idle timer should reset
- [ ] Stop interacting — screensaver should appear after configured
timeout
- [ ] Verify normal WebView interaction (taps, links, scrolling) still
works normally

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Nick Stefanelli <nstefanelli@users.noreply.github.com>
2026-03-23 09:28:44 +01:00

269 lines
9.3 KiB
Swift

import Shared
import SwiftUI
import UIKit
import WebKit
// MARK: - WebView
extension WebViewController {
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
updateFrontendConnectionState(state: FrontEndConnectionState.disconnected.rawValue)
webViewExternalMessageHandler.stopImprovScanIfNeeded()
}
func webView(
_ webView: WKWebView,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
let result = server.info.connection.evaluate(challenge)
completionHandler(result.0, result.1)
}
func webView(
_ webView: WKWebView,
createWebViewWith configuration: WKWebViewConfiguration,
for navigationAction: WKNavigationAction,
windowFeatures: WKWindowFeatures
) -> WKWebView? {
if navigationAction.targetFrame == nil {
guard let url = navigationAction.request.url else {
Current.Log.error("Received navigation action without URL for new window")
return nil
}
openURLInBrowser(url, self)
}
return nil
}
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
refreshControl.endRefreshing()
if let err = error as? URLError {
if err.code != .cancelled {
Current.Log.error("Failure during nav: \(err)")
}
if !error.isCancelled {
showEmptyState()
showSwiftMessage(error: error)
}
}
}
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
refreshControl.endRefreshing()
let nsError = error as NSError
let shouldShowError: Bool
// Handle URLError
if let urlError = error as? URLError {
shouldShowError = urlError.code != .cancelled
if shouldShowError {
Current.Log.error("Failure during content load: \(error)")
}
}
// Handle WebKitErrorDomain errors (e.g., Code 101 - invalid URL)
else if nsError.domain == "WebKitErrorDomain" {
shouldShowError = !nsError.isCancelled
Current.Log.error("WebKit error during content load: \(error)")
} else {
shouldShowError = !error.isCancelled
if shouldShowError {
Current.Log.error("Failure during content load: \(error)")
}
}
if shouldShowError {
showEmptyState()
showSwiftMessage(error: error)
}
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
refreshControl.endRefreshing()
// in case the view appears again, don't reload
initialURL = nil
updateWebViewSettings(reason: .load)
}
func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) {
if #available(iOS 17.0, *) {
let viewModel = DownloadManagerViewModel()
let downloadManager = DownloadManagerView(viewModel: viewModel)
let downloadController = UIHostingController(rootView: downloadManager)
presentOverlayController(controller: downloadController, animated: true)
download.delegate = viewModel
}
}
func webView(
_ webView: WKWebView,
decidePolicyFor navigationResponse: WKNavigationResponse,
decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void
) {
lastNavigationWasServerError = false
guard navigationResponse.isForMainFrame else {
// we don't need to modify the response if it's for a sub-frame
decisionHandler(.allow)
return
}
guard let httpResponse = navigationResponse.response as? HTTPURLResponse, httpResponse.statusCode >= 400 else {
// not an error response, we don't need to inspect at all
decisionHandler(.allow)
return
}
lastNavigationWasServerError = true
// error response, let's inspect if it's restoring a page or normal navigation
if navigationResponse.response.url != initialURL {
// just a normal loading error
decisionHandler(.allow)
} else {
// first: clear that saved url, it's bad
initialURL = nil
// it's for the restored page, let's load the default url
if let webviewURL = server.info.connection.webviewURL() {
decisionHandler(.cancel)
load(request: URLRequest(url: webviewURL))
} else {
// we don't have anything we can do about this
decisionHandler(.allow)
}
}
}
// WKUIDelegate
func webView(
_ webView: WKWebView,
runJavaScriptConfirmPanelWithMessage message: String,
initiatedByFrame frame: WKFrameInfo,
completionHandler: @escaping (Bool) -> Void
) {
let style: UIAlertController.Style = {
switch webView.traitCollection.userInterfaceIdiom {
case .carPlay, .phone, .tv:
return .actionSheet
case .mac:
return .alert
case .pad, .unspecified, .vision:
// without a touch to tell us where, an action sheet in the middle of the screen isn't great
return .alert
@unknown default:
return .alert
}
}()
let alertController = UIAlertController(title: nil, message: message, preferredStyle: style)
alertController.addAction(UIAlertAction(title: L10n.Alerts.Confirm.ok, style: .default, handler: { _ in
completionHandler(true)
}))
alertController.addAction(UIAlertAction(title: L10n.Alerts.Confirm.cancel, style: .cancel, handler: { _ in
completionHandler(false)
}))
if presentedViewController != nil {
Current.Log.error("attempted to present an alert when already presenting, bailing")
completionHandler(false)
} else {
present(alertController, animated: true, completion: nil)
}
}
func webView(
_ webView: WKWebView,
runJavaScriptTextInputPanelWithPrompt prompt: String,
defaultText: String?,
initiatedByFrame frame: WKFrameInfo,
completionHandler: @escaping (String?) -> Void
) {
let alertController = UIAlertController(title: nil, message: prompt, preferredStyle: .alert)
alertController.addTextField { textField in
textField.text = defaultText
}
alertController.addAction(UIAlertAction(title: L10n.Alerts.Prompt.ok, style: .default, handler: { _ in
if let text = alertController.textFields?.first?.text {
completionHandler(text)
} else {
completionHandler(defaultText)
}
}))
alertController.addAction(UIAlertAction(title: L10n.Alerts.Prompt.cancel, style: .cancel, handler: { _ in
completionHandler(nil)
}))
if presentedViewController != nil {
Current.Log.error("attempted to present an alert when already presenting, bailing")
completionHandler(nil)
} else {
present(alertController, animated: true, completion: nil)
}
}
func webView(
_ webView: WKWebView,
runJavaScriptAlertPanelWithMessage message: String,
initiatedByFrame frame: WKFrameInfo,
completionHandler: @escaping () -> Void
) {
let alertController = UIAlertController(title: nil, message: message, preferredStyle: .alert)
alertController.addAction(UIAlertAction(title: L10n.Alerts.Alert.ok, style: .default, handler: { _ in
completionHandler()
}))
alertController.popoverPresentationController?.sourceView = self.webView
if presentedViewController != nil {
Current.Log.error("attempted to present an alert when already presenting, bailing")
completionHandler()
} else {
present(alertController, animated: true, completion: nil)
}
}
func webView(
_ webView: WKWebView,
requestMediaCapturePermissionFor origin: WKSecurityOrigin,
initiatedByFrame frame: WKFrameInfo,
type: WKMediaCaptureType,
decisionHandler: @escaping (WKPermissionDecision) -> Void
) {
decisionHandler(.grant)
}
}
extension WebViewController: UIScrollViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
// Prevent scrollView from scrolling past the top or bottom
if scrollView.contentOffset.y > scrollView.contentSize.height - scrollView.bounds.height {
scrollView.contentOffset.y = scrollView.contentSize.height - scrollView.bounds.height
}
}
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
recordKioskActivity()
}
}
extension WebViewController: UIGestureRecognizerDelegate {
func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
) -> Bool {
true
}
}