diff --git a/ios/CHANGELOG.md b/ios/CHANGELOG.md index 1569feb..2c4d1a9 100644 --- a/ios/CHANGELOG.md +++ b/ios/CHANGELOG.md @@ -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). diff --git a/ios/README.md b/ios/README.md index 76e05d9..22e7cef 100644 --- a/ios/README.md +++ b/ios/README.md @@ -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 ` 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 diff --git a/ios/Sources/Server.swift b/ios/Sources/Server.swift index 6c31d43..90373ff 100644 --- a/ios/Sources/Server.swift +++ b/ios/Sources/Server.swift @@ -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 } diff --git a/ios/Sources/WebViewController.swift b/ios/Sources/WebViewController.swift index 66defcf..513460d 100644 --- a/ios/Sources/WebViewController.swift +++ b/ios/Sources/WebViewController.swift @@ -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" diff --git a/ios/project.yml b/ios/project.yml index e7adac8..cc54dc5 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -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