Files
ipx/ios/README.md
Ray Slakinski f6042e9b5f iOS: player tokens in the keychain, and a build that uploads (#419)
The player rode only on the web view's Cloudflare Access cookie, so it
stopped with "Sign in again" when that session ended, and CarPlay (#420),
which has no web view, would have had nothing at all. Credentials.swift
keeps an ipx API token and an Access service token in the keychain, and
the player sends them (Authorization: Bearer, CF-Access-Client-Id and
-Secret) on its audio, position, read-flag and artwork requests. Without
all three it uses the cookie as before. Shake, Player Tokens enters them.

project.yml gets what TestFlight needs: the team, a numeric version
(App Store Connect refuses 0.1.0-dev), upside-down portrait (an iPad
build fails validation without every orientation) and
ITSAppUsesNonExemptEncryption. Resources/Info.plist is generated by
XcodeGen and now ignored.

Not run on a phone: the header option on AVURLAsset is read by name, as
the SDK has no constant for it.

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

90 lines
4.2 KiB
Markdown

# 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:
```sh
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 tokens
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 Tokens and enter three things, all kept
in the keychain (`Credentials.swift`): an ipx API token (the page's Settings, API tokens), and the
Client ID and Client Secret of an Access service token (Zero Trust, Access, Service credentials),
which the `ipodderx` application needs a *Service Auth* policy for. Without all three the player
sends only the cookie, as before. Playback's own requests use them (the audio, positions, the
artwork); the page still signs in as it did.
## 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 tokens 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`.