The player rode only on the web view's Cloudflare Access cookie, so it stopped with "Sign in again" when that session ended, and CarPlay (#420), which has no web view, would have had nothing at all. Credentials.swift keeps an ipx API token and an Access service token in the keychain, and the player sends them (Authorization: Bearer, CF-Access-Client-Id and -Secret) on its audio, position, read-flag and artwork requests. Without all three it uses the cookie as before. Shake, Player Tokens enters them. project.yml gets what TestFlight needs: the team, a numeric version (App Store Connect refuses 0.1.0-dev), upside-down portrait (an iPad build fails validation without every orientation) and ITSAppUsesNonExemptEncryption. Resources/Info.plist is generated by XcodeGen and now ignored. Not run on a phone: the header option on AVURLAsset is read by name, as the SDK has no constant for it. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
150 lines
6.9 KiB
Swift
150 lines
6.9 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!
|
|
// 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
|
|
|
|
let cfg = WKWebViewConfiguration()
|
|
// 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 Tokens…", 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.webView.load(URLRequest(url: url))
|
|
})
|
|
present(alert, animated: true)
|
|
}
|
|
|
|
/// The tokens the player and CarPlay use in place of the page's cookie (#419). Left as they
|
|
/// are when a field is empty; all three emptied clears them.
|
|
private func askForCredentials() {
|
|
guard presentedViewController == nil else { return }
|
|
let alert = UIAlertController(
|
|
title: "Player tokens",
|
|
message: "An ipx API token (Settings, API tokens) and a Cloudflare Access service token, "
|
|
+ "so playback and CarPlay keep working when the page's sign-in ends. "
|
|
+ (Credentials.isSet ? "Set now; leave a field empty to keep it." : "Not set."),
|
|
preferredStyle: .alert)
|
|
let fields: [(String, Credentials.Field)] = [
|
|
("ipx API token", .apiToken), ("Access client ID", .accessId),
|
|
("Access client secret", .accessSecret),
|
|
]
|
|
for (placeholder, _) in fields {
|
|
alert.addTextField { f in
|
|
f.placeholder = placeholder
|
|
f.isSecureTextEntry = true
|
|
f.autocapitalizationType = .none
|
|
f.autocorrectionType = .no
|
|
}
|
|
}
|
|
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
|
|
alert.addAction(UIAlertAction(title: "Clear", style: .destructive) { _ in
|
|
Credentials.Field.allCases.forEach { Credentials[$0] = nil }
|
|
})
|
|
alert.addAction(UIAlertAction(title: "Save", style: .default) { [weak alert] _ in
|
|
for (i, (_, field)) in fields.enumerated() {
|
|
if let v = alert?.textFields?[i].text, !v.isEmpty { Credentials[field] = v }
|
|
}
|
|
})
|
|
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
|
|
}
|
|
|
|
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()
|
|
}
|
|
}
|