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() } }