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! // Lazy, so it is made in viewDidLoad, on the main actor it belongs to. private lazy var playback = Playback() 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) cfg.userContentController.add(Bridge(webView: webView, playback: playback), 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 /// Shake to change it. A setting touched about once, so it takes no room on the screen. override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) { if motion == .motionShake { askForServer() } } 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. Shake the phone to change it later.", 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: "Player Token…", style: .default) { [weak self] _ in self?.askForCredentials() }) 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 } Server.url = url self.playback.stop() self.makeWebView() self.webView.load(URLRequest(url: url)) }) present(alert, animated: true) } /// The token the player and CarPlay use in place of the page's cookie, and the address they /// send it to (#419). An empty token field keeps the one there is; Clear removes it. private func askForCredentials(_ problem: String? = nil) { guard presentedViewController == nil else { return } let alert = UIAlertController( title: "Player token", message: problem ?? "An ipx API token (Settings, API tokens) so playback and CarPlay keep " + "working when the page's sign-in ends. " + (Credentials.isSet ? "Set now; leave the token empty to keep it." : "Not set."), preferredStyle: .alert) alert.addTextField { f in f.placeholder = "ipx API token" f.isSecureTextEntry = true f.autocapitalizationType = .none f.autocorrectionType = .no } alert.addTextField { f in f.text = Server.apiURL?.absoluteString ?? Server.suggestedAPI f.keyboardType = .URL f.autocapitalizationType = .none f.autocorrectionType = .no } alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) alert.addAction(UIAlertAction(title: "Clear", style: .destructive) { _ in Credentials.apiToken = nil }) alert.addAction(UIAlertAction(title: "Save", style: .default) { [weak self, weak alert] _ in let fields = alert?.textFields ?? [] guard fields.count == 2, let url = Server.normalise(fields[1].text ?? ""), url.scheme == "https" else { self?.askForCredentials("The player address has to be https, such as \(Server.suggestedAPI).") return } Server.apiURL = url if let token = fields[0].text, !token.isEmpty { Credentials.apiToken = token } }) present(alert, animated: true) } // 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) } 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() } }