Files
ipx/ios
Ray Slakinski 4957c297b7 Stop tracking ios/.envrc
It was swept in by the previous commit and holds a Cloudflare Access
service token's secret. The file stays on disk and is ignored; the
secret is in 3626a30 and has to be treated as exposed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 14:11:22 -05:00
..
2026-11-08 14:11:22 -05:00

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.

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

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

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

  • Episodes kept on the device (#412) are the page's service worker's, which a WKWebView does not run without App-Bound Domains; the app plays them from the server.
  • 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.