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:
@@ -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).
|
- 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).
|
- 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).
|
- 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).
|
- 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).
|
||||||
|
|||||||
@@ -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
|
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.
|
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
|
## Player token
|
||||||
|
|
||||||
The player normally rides on the web view's cookie, which ends with Cloudflare Access's session.
|
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
|
## 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
|
* 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.
|
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
|
* CarPlay's dashboard app needs Apple's entitlement first; then it is a second scene over the same
|
||||||
|
|||||||
@@ -35,6 +35,14 @@ enum Server {
|
|||||||
|| (p[0] == 172 && (16...31).contains(p[1]))
|
|| (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.
|
/// A path the page sent (`/media/42`), against the server.
|
||||||
static func resolve(_ path: String) -> URL? {
|
static func resolve(_ path: String) -> URL? {
|
||||||
guard let base = url else { return nil }
|
guard let base = url else { return nil }
|
||||||
|
|||||||
@@ -11,8 +11,22 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg
|
|||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
view.backgroundColor = .black
|
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()
|
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
|
// The persistent store, so a sign-in through Cloudflare Access survives a relaunch, and
|
||||||
// there are cookies for the player to use at all.
|
// there are cookies for the player to use at all.
|
||||||
cfg.websiteDataStore = .default()
|
cfg.websiteDataStore = .default()
|
||||||
@@ -76,6 +90,7 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg
|
|||||||
}
|
}
|
||||||
Server.url = url
|
Server.url = url
|
||||||
self.playback.stop()
|
self.playback.stop()
|
||||||
|
self.makeWebView()
|
||||||
self.webView.load(URLRequest(url: url))
|
self.webView.load(URLRequest(url: url))
|
||||||
})
|
})
|
||||||
present(alert, animated: true)
|
present(alert, animated: true)
|
||||||
@@ -132,6 +147,21 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg
|
|||||||
return nil
|
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!,
|
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!,
|
||||||
withError error: Error) {
|
withError error: Error) {
|
||||||
let host = Server.url?.host ?? "the server"
|
let host = Server.url?.host ?? "the server"
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ targets:
|
|||||||
properties:
|
properties:
|
||||||
CFBundleDisplayName: iPX
|
CFBundleDisplayName: iPX
|
||||||
CFBundleShortVersionString: $(MARKETING_VERSION)
|
CFBundleShortVersionString: $(MARKETING_VERSION)
|
||||||
CFBundleVersion: "8"
|
CFBundleVersion: "9"
|
||||||
# Playing on with the screen locked. A plist key, not a signed entitlement, so it works on
|
# 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.
|
# a free personal team; CarPlay's own dashboard app is an entitlement Apple has to grant.
|
||||||
UIBackgroundModes: [audio, fetch]
|
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.
|
# Plain HTTP only for a server on your own network (192.168.1.130:8099); the tunnel is HTTPS.
|
||||||
NSAppTransportSecurity:
|
NSAppTransportSecurity:
|
||||||
NSAllowsLocalNetworking: true
|
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:
|
settings:
|
||||||
base:
|
base:
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: net.sdf1.ipx
|
PRODUCT_BUNDLE_IDENTIFIER: net.sdf1.ipx
|
||||||
MARKETING_VERSION: "0.1.0"
|
MARKETING_VERSION: "0.1.0"
|
||||||
DEVELOPMENT_TEAM: 8743L8D729
|
DEVELOPMENT_TEAM: 8743L8D729
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
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:
|
iPXTests:
|
||||||
type: bundle.unit-test
|
type: bundle.unit-test
|
||||||
|
|||||||
Reference in New Issue
Block a user