iOS: iPX opens with no connection (#435)

A WKWebView runs no service worker unless the app lists its domains
in WKAppBoundDomains and the web view limits itself to them. So the
Info.plist lists ipodderx.sdf1.net, with Cloudflare Access's
(rays-sdf1.cloudflareaccess.com) and Authentik's (authentik.sdf1.net)
hosts so signing in still works. For a server in that list the web
view is made app-bound and #389's worker runs: the page and its lists
open offline from the second launch, since the first only installs the
worker.

The costs:
- Navigation elsewhere is refused, and the failure looked like the
  server being down. decidePolicyFor sends such links to Safari.
- With the key present, WebKit cuts the bridge for any server not in
  the list. That server then gets the page with its own audio, not
  the phone's player.

The web view is made again when the server changes, since being
app-bound is fixed when it is made.

A debug build adds localhost through a build script, so the simulator
can test against a page served on the Mac; the release archive's plist
was checked to have only the three. Tested in the simulator: opened
twice online, the server stopped, relaunched, and the page came up from
the worker. With the bridge still up on the app-bound host, the
phone's player played.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Ray Slakinski
2026-11-08 17:08:27 -05:00
parent cf0dad7380
commit b455c1ec8c
5 changed files with 76 additions and 6 deletions

View File

@@ -35,6 +35,14 @@ enum Server {
|| (p[0] == 172 && (16...31).contains(p[1]))
}
/// The hosts in the Info.plist's WKAppBoundDomains (#435): the server and where its sign-in
/// goes. Fixed when the app is built, as Apple has it; a debug build adds localhost.
static let appBoundHosts = Set(Bundle.main.object(forInfoDictionaryKey: "WKAppBoundDomains") as? [String] ?? [])
static func isAppBound(_ url: URL?) -> Bool {
url?.host.map { appBoundHosts.contains($0.lowercased()) } ?? false
}
/// A path the page sent (`/media/42`), against the server.
static func resolve(_ path: String) -> URL? {
guard let base = url else { return nil }

View File

@@ -11,8 +11,22 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg
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()
@@ -76,6 +90,7 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg
}
Server.url = url
self.playback.stop()
self.makeWebView()
self.webView.load(URLRequest(url: url))
})
present(alert, animated: true)
@@ -132,6 +147,21 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg
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"