The server address and the player token were set from alerts that opened on shaking the phone, which nobody finds. Settings now has a This device section in the app: the server, the player token and its address, and a Make button that creates an API token and hands it straight to the app's keychain. The app sends what it has at ready (appSettings) and takes setServer, setToken and clearToken; a new server rebuilds the web view as the alert did. The first-launch prompt and the Cannot reach alert stay native, since there is no page then. The iPhone is portrait only; the iPad keeps all four orientations, which Split View and Slide Over need (#434 checked the iPad's layout). Build 14. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
145 lines
6.7 KiB
Swift
145 lines
6.7 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)
|
|
}
|
|
|
|
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()
|
|
}
|
|
}
|