Background refresh runs when iOS chooses, and for this app that has been rarely, so the icon's unread count lagged until the app was opened (#421). Now ipx sends the count: - ipx: a push_devices table and POST/DELETE /api/push/devices. With [push] set to an APNs key, a task compares each phone's All Subscriptions unread count with the last sent, once a minute, and sends a changed one to Apple as a badge-only push: no alert, no sound. A token Apple says is gone is forgotten. The signing token is ES256 over the team and time, made again every forty minutes, inside Apple's limits. - iOS: the aps-environment entitlement. The app registers for push once badge permission is given, each launch, and hands its token to the page. - Page: registers the token with ipx as the person signed in, since the player's address (ipxapi) lets through only the player's paths, and removes it on signing out. jsonwebtoken here is built without PEM support, so the .p8 key's base64 is decoded in push.rs rather than turning on a feature and changing Cargo.lock for one file. cargo test -p ipx on macOS: 182 pass; the 3 that fail read /proc and fail there without this change too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6.9 KiB
iPX for iOS
ipx's page in an app, with the audio played by the phone rather than the page: it plays on with the screen locked, and the lock screen, Control Center, headphones and a car over Bluetooth or USB drive it. Everything you see is the page, as on the home screen; the app adds the player behind it.
Build it
On a Mac with Xcode:
brew install xcodegen
cd ios && xcodegen generate && open iPX.xcodeproj
Set your team under Signing & Capabilities, pick your phone, and run. The project is generated
from project.yml, so .xcodeproj is not in git: edit the yml. Nothing on Tower can build it.
A free personal team works, but a build on it expires after seven days and is reinstalled from
Xcode. A paid account ($99 a year) makes that a year and adds TestFlight. Playing with the screen
locked is a plist key (UIBackgroundModes: audio), not an entitlement, so it works on either.
CarPlay's own dashboard app does not: Apple grants com.apple.developer.carplay-audio on request.
Which server
The first launch asks, with https://ipodderx.sdf1.net filled in; a bare host gets https://.
Shake the phone to change it. Plain http:// only on your own network
(http://192.168.1.130:8099), which is all NSAllowsLocalNetworking allows. Sign in on the page
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.
To make it outlast that, shake the phone, choose Player Token and enter an ipx API token (the
page's Settings, API tokens) and the address to send it to, https://ipxapi.sdf1.net. The token is
kept in the keychain (Credentials.swift). The player's audio, positions, read flags, artwork and
the unread badge go to that address with Authorization: Bearer; the page still signs in as it
did, at the server address. Without a token the player sends only the cookie, as before.
ipxapi.sdf1.net is the tunnel's route to the same ipx-web as ipodderx.sdf1.net, with no Access
application in front, which would turn a bearer header away. Cloudflare's firewall (a custom rule
on the sdf1.net zone) blocks every request to it except a bearer-token one for /api/feeds,
/api/me, /api/art, /api/entries/* and /media/*; ipx still checks the token.
How it hangs together
Sources/App.swift |
the app and its one scene |
Sources/WebViewController.swift |
the WKWebView, the server question, a page that will not load |
Sources/Bridge.swift |
the messages to and from web/src/native.ts |
Sources/Playback.swift |
AVPlayer, the audio session, now playing, the remote commands, saving positions |
Sources/CookieBridge.swift |
the web view's cookies into HTTPCookieStorage.shared, for the player |
Sources/Server.swift |
which server |
The messages
The page's half is web/src/native.ts, and node tests/native-bridge.ts holds it to these. A
change to them is a change to both halves in one commit, and the page has to be deployed before
an app that needs it.
Page to app, on webkit.messageHandlers.ipx:
{t:"ready", version, rate, volume} |
the bridge is in |
{t:"load", url, enc, feedId, guid, title, feedTitle, artwork, position, duration, rate, volume} |
the element's src was set to an audio file |
{t:"play"} {t:"pause"} {t:"stop"} |
|
{t:"seek", to} {t:"rate", v} {t:"volume", v} |
|
{t:"position", url} |
save where we are; a path, not a time, since a page frozen in the background holds an old one |
{t:"keep", url} {t:"unkeep", url} |
keep a file on the phone, or let it go: the page's path for it, /media/42 or /media/42/deadair (#425) |
{t:"clearKept"} |
signing out: everything kept goes |
{t:"sleep", secs} |
pause after secs, fading out over the last ten; 0 turns the timer off (#429) |
{t:"unread", n} |
All Subscriptions' unread count, whenever the page's list is drawn with a new one: the icon's badge (#421) |
{t:"haptic", kind} |
a press felt: tap, done (marked read) or warn (an error) (#430) |
{t:"route", x, y, w, h} |
open the AirPlay picker, pointing at the page's button, in the web view's points (#429) |
App to page, as window.ipxNative.on(...):
{t:"time", cur, dur} |
timeupdate |
{t:"meta", dur} |
loadedmetadata: the page seeks to where you left off |
{t:"state", playing} |
play or pause |
{t:"ended"} |
|
{t:"error", message} |
the page says so |
{t:"kept", urls, keeping} |
what the phone holds and is fetching, at ready and after each change; sent whether or not anything plays |
{t:"keepFailed", url, message} |
one that could not be kept, and why |
{t:"host", can} |
at ready: what this build does itself (sleep, route), so a page newer than the app offers only those |
{t:"push", token, sandbox} |
at ready and when Apple gives a new one: the phone's push token, for the page to register with ipx (POST /api/push/devices) (#433) |
{t:"sleep", left} |
at ready, while the timer runs: seconds left, for a page that reloaded |
{t:"slept"} |
the timer ran out and playback paused |
The app writes the position (every ten seconds and on pause) and marks an item read at 90%, from its own clock, so both happen while the page is frozen. Video stays in the page.
Not yet
- 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
Playback.