The server address and the player token were set from alerts that opened on shaking the phone, which nobody finds. Settings now has a This device section in the app: the server, the player token and its address, and a Make button that creates an API token and hands it straight to the app's keychain. The app sends what it has at ready (appSettings) and takes setServer, setToken and clearToken; a new server rebuilds the web view as the alert did. The first-launch prompt and the Cannot reach alert stay native, since there is no page then. The iPhone is portrait only; the iPad keeps all four orientations, which Split View and Slide Over need (#434 checked the iPad's layout). Build 14. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
156 lines
9.9 KiB
Markdown
156 lines
9.9 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 does not: Apple grants `com.apple.developer.carplay-audio` on request (granted for team
|
|
8743L8D729 on 2026-11-09), and the App ID needs it ticked under Additional Capabilities.
|
|
|
|
## Which server
|
|
|
|
The first launch asks, with `https://ipodderx.sdf1.net` filled in; a bare host gets `https://`.
|
|
Change it in the page's Settings, under This device, or from the alert that comes up when the
|
|
server cannot be reached. 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, open Settings in the app and, under This device, press Make, or paste an
|
|
ipx API token (Settings, API tokens), with 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 |
|
|
| `Sources/Intents.swift`, `Shared/PlayPauseIntent.swift` | Siri and Shortcuts (#427) |
|
|
| `Sources/LastPlayed.swift` | the last episode and its place, so Resume works with the app closed |
|
|
| `Widgets/`, `Shared/Snapshot.swift`, `Sources/WidgetFeed.swift` | the widget (#428), and what the app writes for it |
|
|
|
|
### Siri and Shortcuts
|
|
|
|
With nothing to set up, Siri answers "Resume iPX" (or "Play iPX"), "Pause iPX", "Skip forward
|
|
in iPX", "Skip back in iPX", "Set a sleep timer in iPX" and "How many unread in iPX"; each is also
|
|
an action in the Shortcuts app. The playback ones are `AudioPlaybackIntent`s, which iOS runs in the
|
|
app and lets start audio with the phone locked. Resume carries on with what is loaded, or else the
|
|
last episode from where it got to (`LastPlayed`), which plays without the page: opening the app
|
|
afterwards shows the page with nothing in its player, while the lock screen has it.
|
|
|
|
### The widget
|
|
|
|
Small and medium on the home screen, circular and rectangular on the lock screen: All
|
|
Subscriptions' unread count and the last episode, with a Resume or Pause button (the same
|
|
`PlayPauseIntent`). The widget has no sign-in and no player, so it draws what the app last wrote
|
|
to the App Group `group.net.sdf1.ipx` (`Snapshot`), and the app asks WidgetKit to redraw on a play,
|
|
a pause, a new episode or a new count, not on every position saved, since iOS rations redraws.
|
|
With the app closed, ipx's push (#433) carries `content-available`, which wakes the app for a
|
|
moment to give the widget the new count; iOS rations those wakes too, so the widget can trail the
|
|
icon. `Shared/` is built into both targets, with `WIDGET` set in the widget's, where the player
|
|
does not exist.
|
|
|
|
## 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:"setServer", url}` | Settings' This device: open another server, which replaces the page (#445) |
|
|
| `{t:"setToken", api, token?}` `{t:"clearToken"}` | Settings' This device: the player address, and a token to keep in the keychain or none (#445) |
|
|
| `{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`, `settings`), so a page newer than the app offers only those |
|
|
| `{t:"appSettings", server, api, tokenSet, saved}` | at `ready` and after a change: the app's server, player address and whether a player token is set, for Settings' This device (#445) |
|
|
| `{t:"appSettingsError", message}` | a server or player address the app would not take |
|
|
| `{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 |
|
|
| `{t:"nowPlaying", feedId, guid, enc, title, feedTitle, artwork, playing, cur, dur}` | what the phone plays that the page did not choose: the car's pick, and at `ready` and on coming forward whatever is loaded; the page's player bar takes it up (#442) |
|
|
| `{t:"replaced"}` | builds before `nowPlaying`: the car chose another episode, and the page's player bar closes without saving |
|
|
|
|
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 (`CarScreen.swift`) reads ipx with the player's credentials and nothing else: launched
|
|
by the car with the phone's app never opened since a restart, it has only cookies saved to disk,
|
|
and without the player token a lapsed Access cookie shows "iPX can't reach your server". Its
|
|
lists need a connection; a kept episode plays without one once it is picked.
|