Files
ipx/ios/Sources/WebViewController.swift
Ray Slakinski 171c77475a iOS: the app's settings in Settings, not behind a shake; iPhone upright (#445)
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>
2026-11-13 08:39:54 -05:00

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