WebViewController is the web view's WKUIDelegate but had no confirm panel, so WebKit answered every confirm() false at once and showed nothing: the page's nine confirm() calls, deleting a file and sharing a Patreon address among them, could never go ahead in the app. It now shows an alert with Cancel and OK. With another alert already up it answers Cancel, as before, since one cannot be presented over the other. The page has no alert() or prompt(), so those panels are left out. Not compiled here: the Swift builds only on a Mac with Xcode. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
159 lines
7.6 KiB
Swift
159 lines
7.6 KiB
Swift
import UIKit
|
|
import WebKit
|
|
|
|
/// The app, very nearly: ipx's own page, full screen, with the phone's player behind it.
|
|
final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDelegate {
|
|
private var webView: WKWebView!
|
|
private var cookies: CookieBridge!
|
|
private var playback: Playback { .shared }
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
view.backgroundColor = .black
|
|
makeWebView()
|
|
}
|
|
|
|
/// Made again when the server changes, since whether it is app-bound is fixed when a web view
|
|
/// is made.
|
|
private func makeWebView() {
|
|
if let old = webView {
|
|
old.configuration.userContentController.removeScriptMessageHandler(forName: Bridge.name)
|
|
old.removeFromSuperview()
|
|
}
|
|
let cfg = WKWebViewConfiguration()
|
|
// A server in the Info.plist's WKAppBoundDomains gets the page's service worker, so iPX
|
|
// opens with no connection (#435); WebKit runs none in an app otherwise. The price is that
|
|
// the web view goes nowhere else, which decidePolicyFor sends to Safari. For any other
|
|
// server WebKit also cuts the bridge, so the page plays its own audio there.
|
|
cfg.limitsNavigationsToAppBoundDomains = Server.isAppBound(Server.url)
|
|
// The persistent store, so a sign-in through Cloudflare Access survives a relaunch, and
|
|
// there are cookies for the player to use at all.
|
|
cfg.websiteDataStore = .default()
|
|
cfg.allowsInlineMediaPlayback = true
|
|
// Video still plays in the page, and a tap on play is already the user's action.
|
|
cfg.mediaTypesRequiringUserActionForPlayback = []
|
|
|
|
webView = WKWebView(frame: view.bounds, configuration: cfg)
|
|
webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
webView.navigationDelegate = self
|
|
webView.uiDelegate = self
|
|
// The page is viewport-fit=cover and pads itself by the safe area, as it does on the
|
|
// home screen; letting the scroll view inset it too would pad it twice.
|
|
webView.scrollView.contentInsetAdjustmentBehavior = .never
|
|
webView.isOpaque = false
|
|
webView.backgroundColor = .black
|
|
view.addSubview(webView)
|
|
|
|
cookies = CookieBridge(store: cfg.websiteDataStore.httpCookieStore)
|
|
let bridge = Bridge(webView: webView, playback: playback)
|
|
bridge.onServer = { [weak self] url in self?.open(url) }
|
|
cfg.userContentController.add(bridge, name: Bridge.name)
|
|
}
|
|
|
|
override func viewDidAppear(_ animated: Bool) {
|
|
super.viewDidAppear(animated)
|
|
if webView.url == nil {
|
|
if let url = Server.url { webView.load(URLRequest(url: url)) } else { askForServer() }
|
|
}
|
|
}
|
|
|
|
// MARK: - which server
|
|
|
|
/// Asked on first launch, and when the server cannot be reached; otherwise changed in the
|
|
/// page's Settings (#445), which replaced shaking the phone for it.
|
|
private func askForServer(_ problem: String? = nil) {
|
|
guard presentedViewController == nil else { return }
|
|
let alert = UIAlertController(
|
|
title: "Your iPX server",
|
|
message: problem ?? "The address you open iPX at. You can change it later in Settings.",
|
|
preferredStyle: .alert)
|
|
alert.addTextField { f in
|
|
f.text = Server.url?.absoluteString ?? Server.suggested
|
|
f.keyboardType = .URL
|
|
f.autocapitalizationType = .none
|
|
f.autocorrectionType = .no
|
|
f.clearButtonMode = .whileEditing
|
|
}
|
|
if Server.url != nil { alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) }
|
|
alert.addAction(UIAlertAction(title: "Open", style: .default) { [weak self, weak alert] _ in
|
|
guard let self else { return }
|
|
let text = alert?.textFields?.first?.text ?? ""
|
|
guard let url = Server.normalise(text) else {
|
|
self.askForServer("That is not an address the app can open. Plain http only works "
|
|
+ "on your own network, such as 192.168.1.130:8099.")
|
|
return
|
|
}
|
|
self.open(url)
|
|
})
|
|
present(alert, animated: true)
|
|
}
|
|
|
|
private func open(_ url: URL) {
|
|
Server.url = url
|
|
playback.stop()
|
|
makeWebView()
|
|
webView.load(URLRequest(url: url))
|
|
}
|
|
|
|
// MARK: - the page
|
|
|
|
/// Sign-in leaves the server's address (Cloudflare Access, its identity provider) and must
|
|
/// stay in here to leave its cookies behind; only what the page opens in a new tab, an
|
|
/// item's own link, goes to Safari.
|
|
func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration,
|
|
for navigationAction: WKNavigationAction,
|
|
windowFeatures: WKWindowFeatures) -> WKWebView? {
|
|
if let url = navigationAction.request.url { UIApplication.shared.open(url) }
|
|
return nil
|
|
}
|
|
|
|
/// Bound to its domains, the web view cannot open a link to anywhere else, and failing to
|
|
/// would say the server cannot be reached. So such a link goes to Safari instead.
|
|
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction,
|
|
decisionHandler: @escaping @MainActor (WKNavigationActionPolicy) -> Void) {
|
|
guard webView.configuration.limitsNavigationsToAppBoundDomains,
|
|
navigationAction.targetFrame?.isMainFrame != false,
|
|
let url = navigationAction.request.url, !Server.isAppBound(url),
|
|
url.scheme == "http" || url.scheme == "https" else {
|
|
decisionHandler(.allow)
|
|
return
|
|
}
|
|
UIApplication.shared.open(url)
|
|
decisionHandler(.cancel)
|
|
}
|
|
|
|
/// The page's confirm(): without this WebKit answers Cancel at once and shows nothing, so
|
|
/// anything that asked first (deleting a file, sharing a Patreon address) could not go ahead
|
|
/// in the app (#453). The page uses no alert() or prompt().
|
|
func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String,
|
|
initiatedByFrame frame: WKFrameInfo,
|
|
completionHandler: @escaping @MainActor (Bool) -> Void) {
|
|
// Another alert already up cannot have this one over it: answered as before, Cancel.
|
|
guard presentedViewController == nil else { return completionHandler(false) }
|
|
let alert = UIAlertController(title: nil, message: message, preferredStyle: .alert)
|
|
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in completionHandler(false) })
|
|
alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in completionHandler(true) })
|
|
present(alert, animated: true)
|
|
}
|
|
|
|
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!,
|
|
withError error: Error) {
|
|
let host = Server.url?.host ?? "the server"
|
|
let alert = UIAlertController(title: "Cannot reach \(host)",
|
|
message: error.localizedDescription, preferredStyle: .alert)
|
|
alert.addAction(UIAlertAction(title: "Try Again", style: .default) { [weak self] _ in
|
|
if let url = Server.url { self?.webView.load(URLRequest(url: url)) }
|
|
})
|
|
alert.addAction(UIAlertAction(title: "Change Server", style: .default) { [weak self] _ in
|
|
self?.askForServer()
|
|
})
|
|
if presentedViewController == nil { present(alert, animated: true) }
|
|
}
|
|
|
|
/// WebKit's content process can be killed in the background to free memory, leaving a blank
|
|
/// page. The player is in this process and plays on; the page comes back where it was.
|
|
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
|
|
webView.reload()
|
|
}
|
|
}
|