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

@@ -11,4 +11,5 @@ The iOS app, `ios/`. Format: [Keep a Changelog](https://keepachangelog.com/en/1.
- The icon shows All Subscriptions' unread count, as the page's badge does: set when the app comes forward, after an item is marked read, and by a refresh iOS runs now and then while the app is closed, so it can be hours behind (#421).
- Episodes kept on the phone, with the page's Keep button, play with no connection: the app fetches the file in the background, even with the app closed, and plays it from the phone; where you got to and what you finished offline is sent once the connection is back. Signing out removes them (#425).
- A sleep timer in the player, kept by the phone, so it pauses on time with the screen locked, fading out over the last ten seconds; and an AirPlay button beside it, for another speaker or a TV (#429).
- iPX opens with no connection, showing the lists it last had, as it does in a browser: the page's service worker now runs in the app. This is for `ipodderx.sdf1.net` only, and links to other sites open in Safari (#435).
- Presses are felt: a light tap on play, pause, flag and pull to refresh, a success on marking read, a warning with an error (#430).

View File

@@ -29,6 +29,24 @@ Shake the phone to change it. Plain `http://` only on your own network
that follows, through Cloudflare Access or ipx's own form; the cookies that leaves are what the
player uses (`CookieBridge`). `-ipx.server <url>` as a launch argument overrides it for a run. `-ipx.noBadgePrompt YES` skips the badge permission dialog, which covers the page in a simulator run nobody is there to tap.
### Opening with no connection
The page's service worker, which keeps iPX readable offline in a browser (#389), runs in the app
only for a server in `WKAppBoundDomains` in `project.yml` (#435): `ipodderx.sdf1.net`, with
Cloudflare Access's and Authentik's hosts for signing in. For those the web view is app-bound, and
iPX opens without a connection from the second launch on (the first installs the worker). What
that costs:
* The web view goes nowhere outside the list. A link to anywhere else opens in Safari; an
embedded player from another site in an item's text may not load.
* A server not in the list still opens, but WebKit then cuts the bridge, so the page plays its own
audio: no lock screen, no CarPlay, nothing kept on the phone. That includes the LAN address.
Another server means adding it to the list (ten at most) and building again.
* Signing in that leaves those hosts, through another identity provider, opens in Safari and does
not come back. Add its host too.
A debug build adds `localhost`, so a simulator can test the worker against a page served on the Mac.
## Player token
The player normally rides on the web view's cookie, which ends with Cloudflare Access's session.
@@ -95,11 +113,6 @@ its own clock, so both happen while the page is frozen. Video stays in the page.
## Not yet
* Episodes kept on the phone (#425) play with no connection, from files the app fetches with a
background `URLSession` (`Offline.swift`, `EpisodeStore.swift`); a place or a finish reached
offline waits in `Outbox.swift` until the connection is back. But the page itself does not open
without a connection: a `WKWebView` runs no service worker without App-Bound Domains (#435). So
keep what you want, open the app while online, and it plays on with the connection gone.
* Without the player token the cookie is the only credential: when Cloudflare Access's expires
mid-drive, playback stops with "Sign in again", and opening the app signs in.
* CarPlay's dashboard app needs Apple's entitlement first; then it is a second scene over the same

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"

View File

@@ -25,7 +25,7 @@ targets:
properties:
CFBundleDisplayName: iPX
CFBundleShortVersionString: $(MARKETING_VERSION)
CFBundleVersion: "8"
CFBundleVersion: "9"
# Playing on with the screen locked. A plist key, not a signed entitlement, so it works on
# a free personal team; CarPlay's own dashboard app is an entitlement Apple has to grant.
UIBackgroundModes: [audio, fetch]
@@ -48,12 +48,30 @@ targets:
# Plain HTTP only for a server on your own network (192.168.1.130:8099); the tunnel is HTTPS.
NSAppTransportSecurity:
NSAllowsLocalNetworking: true
# The page's service worker runs only for these, so iPX opens offline only at them
# (#435): the server, and Cloudflare Access and Authentik, where its sign-in goes. Ten at
# most, and a server not here loses the bridge (WebViewController.makeWebView).
WKAppBoundDomains:
- ipodderx.sdf1.net
- rays-sdf1.cloudflareaccess.com
- authentik.sdf1.net
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: net.sdf1.ipx
MARKETING_VERSION: "0.1.0"
DEVELOPMENT_TEAM: 8743L8D729
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
# The script below writes into the built Info.plist, which the sandbox would refuse.
ENABLE_USER_SCRIPT_SANDBOXING: NO
postBuildScripts:
# A debug build is app-bound to localhost too, so the simulator can test the service
# worker against a page served on the Mac (#435). Never in a release.
- name: App-bound localhost (debug)
basedOnDependencyAnalysis: false
script: |
if [ "$CONFIGURATION" = Debug ]; then
/usr/libexec/PlistBuddy -c "Add :WKAppBoundDomains: string localhost" "$TARGET_BUILD_DIR/$INFOPLIST_PATH"
fi
iPXTests:
type: bundle.unit-test