Commit Graph

659 Commits

Author SHA1 Message Date
Ray Slakinski
5d0ff1ac57 ipx-play: metrics for Prometheus, a dashboard row and a down alert (#458)
ipx-play sent traces and logged JSON but served no /metrics, so nothing showed whether
shared links were opening or failing. It now serves them on metrics_bind (9189 in
production, off the tunnel's port), with the directory's histogram and /proc reading:
requests by method, route and status (by route, so each shared episode is not a row of its
own, and an unknown address is route=""), episodes shared, memory, open files, uptime and
version.

The iPX dashboard gets an ipx-play row, its warnings join the other containers' log panel,
and alerts.py fires when Prometheus cannot reach it. The prod-check skill and docs/play.md
name it, with the scrape job and the OTEL_RESOURCE_ATTRIBUTES tag the trace panels need.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 22:08:31 -05:00
47e093e313 Phone reader: back and the item's actions float over the text (#456)
On a phone they sat above the title and scrolled away with it, so
marking, pinning or sharing a long post meant scrolling back to its
top. Now back sits alone on the left and the actions in a capsule on
the right, sticky at the top of the reader, as Gmail's iOS reader does.
Each theme's own panel, frosted, so no theme changes colour; Glass gets
its sheen and a denser fill, since the text scrolls right under them,
and Classic its toolbar metal. Read and pin redraw the reader, which
now keeps its scroll position, since they are pressed mid-post. The
desktop reader is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVA8TdTxe556JB3sR1n
2026-11-13 17:43:45 +00:00
757ce5fbb3 Share never hands over a private feed's file (#455)
Share on a paid episode's file gave the file's own address, which for
Patreon is patreon.com/api/rss/u/<your id>/e/<n>.mp3?sig=..., signed for
the subscriber: whoever got it could download the episode as them. A
private feed's episode is now shared by its page alone, from the file
too, and with no page there is nothing to share. The access warning
matched any patreon.com address, so it warned on a post's page, which
asks for a sign-in and carries nothing of yours; it now matches
Patreon's feed and file addresses only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVA8TdTxe556JB3sR1n
2026-11-13 17:34:17 +00:00
45ef1e83f2 Share says why an episode gets no play link (#454)
A private feed's episode, or one ipx-play could not make a link for,
was offered its own address with no word of why, so someone expecting
a page anyone can play could not tell it was on purpose. The menu now
says the feed is private to them, or that the link could not be made
just now, beside the warning for an address carrying their access.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 17:26:01 +00:00
b3636e648f Share works in the iOS app on an episode of a private feed (#452)
With no ipx-play link (a private feed, or no ipx-play set up), Share fell
back to the share sheet after the request for the link, which WebKit
refuses once an await has come between the press and it, and a Patreon
address went through confirm() first, which the iOS app answers Cancel
without showing anything (#453). Taps sent the request and nothing
appeared. That address now goes through the same menu as an ipx-play
link, so the sheet opens from a press of its own, and the warning about
an address that carries your access is written in the menu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 16:57:25 +00:00
c133adf398 docs/play.md: the tunnel and traces reach ipx-play through the host's address
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 14:35:52 +00:00
ac246682f0 Dockerfile.play copies web/logo-dark.svg, which the directory crate embeds (#451)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 14:30:58 +00:00
Ray Slakinski
679a674fc9 Share an episode as a page, on ipx-play (#447)
Share handed over the raw file address or the item's link: no preview,
no time, nothing a person without iPX could do much with. Now, as
Overcast does, an episode shares as a short link to its own page,
play.ipxreader.app/e/<id>, which anyone can open: the show's art and
name, the episode's title, date, length and notes, a player on the
publisher's own file starting at ?t= if a time was shared, buttons to
subscribe in Apple Podcasts, Overcast, Pocket Casts or Castro, and Open
Graph tags for a preview where the link is pasted.

ipx-play is a service of its own (crates/play), built like the
directory: ipx sends it the episode with its token (POST /v1/shares)
and gets the link back. The id comes from the feed's address and the
guid, so the same episode is one link whoever shares it. Only episodes
of feeds the directory would be sent go there, by the directory's own
rule (snapshot_entries), so the two cannot disagree on what is private;
ipx-play also refuses an address that looks private. Anything else, and
any server without ipx-play set up, shares as before.

In the page, Share on an episode opens a menu once the link is back:
Share link, Share from 12:34 (the player's clock, or where you left
off), Copy link. A menu because the share sheet needs a press of its
own, and the request for the link used up the one that opened it.

The page runs no script: the start time is the media fragment #t=, and
its CSP loads nothing but its stylesheet, the art and the file.

Admin, Directory, Sharing episodes sets the address and token. The
browser suite starts its own ipx-play; it was not run here (no Caddy on
this Mac).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 09:24:18 -05:00
Ray Slakinski
171c77475a iOS: the app's settings in Settings, not behind a shake; iPhone upright (#445)
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>
2026-11-13 08:39:54 -05:00
Ray Slakinski
69f1a38e9f iOS tests: CarPlay's episodes need a downloaded file (#443)
6d6bfe6 added Enclosure.path, which the tests' enclosures lacked, so the
test target no longer built. They now have one, and a test checks that a
picture and a file not yet fetched are passed over.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 08:33:32 -05:00
6d6bfe6e1d iOS CarPlay: the phone's bar takes up the car's episode; only playable shows (#442, #443, #444)
#442: a car's pick told the page `replaced`, and the page closed its player bar, so leaving the
car with the episode playing left nothing on the phone to pause it. And when the car launched the
app, the page loaded later and was never told anything played. Now the app sends `nowPlaying`
(the item, its place, playing or not) on a car pick, at the page's `ready`, and on coming
forward, and the page's bar takes it up and drives the phone's player. `replaced` is still
handled for builds before this one.

#443: Shows listed every feed with items, blogs included. Now only feeds with a downloaded file,
and an episode only with a downloaded audio file: /media/{id} answers 404 before the download.

#444: tests/native-bridge.ts read its failure count before the CarPlay checks, so those could
fail and still exit 0. The count is read last now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVARreS9wxzH5o2rF7N
2026-11-11 23:08:14 +00:00
a55f08d4ff Tests for push passes, downloads, OPML round trips and directory refresh
Each against a local stand-in server: Apple's push service answering per token, a feed host
serving a login page and a torrent, a directory refresh of feeds that cannot be read. push.rs
takes the APNs address as a parameter so the test can point it at the stand-in. coverage.sh asks
for 8 GB free, not 9: its container is capped at 6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVARreS9wxzH5o2rF7N
2026-11-11 23:04:10 +00:00
75d099474c Terminal reader: play with mpv, mark read on move, folders and pins (#439, #440, #441)
p hands the item's downloaded file (/media/{id}) to mpv in its own window, with the token as a
header. Moving onto an item with j or k marks it read, as selecting one on the page does. The
feed list follows the page's order: pinned first, a subscribed OPML a folder that Space opens,
its unread the sum of its feeds', and P pins or unpins. scripts/build-tui.sh cross-builds the
binary for Linux and macOS under the same cap as the image builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVARreS9wxzH5o2rF7N
2026-11-11 23:04:02 +00:00
3a3900e959 Image builds keep compiled crates between builds (#438)
The ipx Dockerfile built its dependencies in a layer of stub crates,
which any change to Cargo.toml or Cargo.lock invalidated: all of them
compiled again in release, 405 crates, 170 s at the builder's cap. The
directory's had no such layer and compiled everything every time. Both
now build with cargo's registry and target/ as BuildKit cache mounts.
Measured on Tower: a Cargo.toml change rebuilds three crates in 52 s
instead of 405 in 170 s. The peak is about the same, since the ipx
crate itself is most of it; what goes is the minutes at the cap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 14:05:12 +00:00
f9e4c1d800 CLAUDE.md: the home page deploys with wrangler and an API token from /src/.envrc
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 13:38:12 +00:00
dccf9e1762 prod-check skill: the server's command in the container is ipxd (#437)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 13:32:55 +00:00
Ray Slakinski
2bb7ed2109 iOS: CarPlay (#420)
A CarPlay scene beside the phone's: In Progress, Unread and Shows as
CPListTemplates from ipx's API, read with the player's credentials, and
a pick played through the same Playback, so it resumes from the saved
place, saves as it goes and marks read at 90%. Now Playing is the
system's template over Playback's now-playing info.

When the car starts a different episode the page is sent
{t:"replaced"} and closes its player bar. Left open, the bar would have
gone on as the old episode while the host's clock belonged to the new
one, saving that time and marking the wrong item read.

The badge's fetch moves into API.get, which CarPlay shares. Build 13.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 08:10:34 -05:00
711b0395f1 A terminal reader, ipx; the server is ipxd (#437)
ipx-tui (crates/tui) is iPX's reader in ratatui: your feeds, a feed's
items, an item's text as plain text, read and flagged as on the page,
through the API with an API token. It installs as `ipx`, so the daemon's
binary is now `ipxd`: the Dockerfile, release script, tests and every
doc and message that names its commands follow.

Naming the binary ipxd alone renamed its crate, and with it every log
target (ipx::scan became ipxd::scan): the default filters are ipx=info,
so nearly every line would have been dropped and the dashboard's queries
on the targets gone blank. The daemon's code is now a library still
called ipx, under a five-line ipxd main.rs, and the targets are as they
were.

ratatui is taken without its default features: 32 new packages in place
of 78.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVDqdiQEMWAkNGQ2tYA
2026-11-09 11:52:10 +00:00
Ray Slakinski
f844cf349a iOS: Siri and Shortcuts, and a widget (#427, #428)
Siri: App Intents with phrases that need no setup: Resume, Pause,
Skip forward, Skip back, Set a sleep timer, How many unread. The
playback ones are AudioPlaybackIntents, run in the app and allowed to
start audio from the background. Playback is now one shared instance
(Playback.shared) so Siri and the widget reach it with no page loaded.
It remembers the last episode and its place (LastPlayed), so Resume
works with the app closed; the page, opened afterwards, does not know
about an episode started that way.

Widget: small and medium on the home screen, circular and rectangular
on the lock screen. It shows the unread count and the last episode,
with a Resume or Pause button (PlayPauseIntent, shared with Siri). It
draws what the app writes to the App Group group.net.sdf1.ipx. The app
asks for a redraw on play, pause, a new episode or a new count, not on
every position, since iOS rations redraws. Shared/ builds into both
targets, with WIDGET set in the widget's, where the player does not
exist.

ipx's push now carries content-available, which wakes the closed app
for a moment to give the widget the count the icon just got (#433).

Tested: unit tests for the snapshot and the remembered place, 19 in
all; in the simulator, playing an episode wrote the snapshot to the
App Group. Not testable there: Siri, and the widget drawn.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 19:33:04 -05:00
8f888ae10e Admin page: an error toast no longer throws for want of haptic (#436)
toast() calls haptic() on an error since cf0dad7 (#430), and haptic is
native.ts's, which admin.js is not built from: every error toast on the
admin page threw a ReferenceError and showed nothing. Never deployed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 00:04:12 +00:00
Ray Slakinski
da6cda28a5 iOS: the entitlements file is XcodeGen's output, not source (#433)
project.yml holds aps-environment; the plist it writes is ignored, as
Info.plist is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 17:47:41 -05:00
Ray Slakinski
bd535851b0 The iOS app's icon count, pushed by ipx (#433)
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>
2026-11-08 17:47:28 -05:00
Ray Slakinski
e6414941ab iOS: the icon's count follows the page's (#421)
Reported: the unread count on the icon only changed when the app was
opened. Two reasons:
- Items read in the page never reached it, since only the player's own
  mark-read refreshed the badge.
- The background refresh that should bring new items runs when iOS
  chooses, which for this app has been rarely.

The page now sends All Subscriptions' count ({t:"unread", n}) each time
its list is drawn with a new one, so reading, and new items arriving
over the event stream while the app is open, set it at once. While an
episode plays the app is awake with the screen locked, so the player's
tick also re-checks every quarter of an hour.

With the app closed and nothing playing, the count still waits on iOS's
refresh. Push (#433) is what makes that exact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 17:18:43 -05:00
Ray Slakinski
b455c1ec8c 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>
2026-11-08 17:08:27 -05:00
Ray Slakinski
cf0dad7380 iOS: presses are felt (#430)
The page asks the app for a haptic ({t:"haptic", kind}) and the app
plays the phone's own. A light tap for play, pause, flag and pull to
refresh; the system's success for marking read, one item or all; its
warning with an error toast. A browser has no host to ask, and an
older app ignores the message, so there is nothing to gate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 16:57:51 -05:00
Ray Slakinski
2e031763fa Player: a sleep timer, and AirPlay in the app (#429)
The sleep timer pauses after 5 minutes to an hour, fading out over the
last ten seconds. In the iOS app the phone's player keeps the time:
with the screen locked the web view is frozen, and a timer in the page
would not fire until the app was opened again. A browser plays on in a
background tab, so there the page keeps it.

The app says at ready what it does itself ({t:"host", can}), so a page
deployed ahead of the app falls back to its own timer and shows no
AirPlay button an older app would ignore. This deploy order problem
came up with #425.

AirPlay opens AVRoutePickerView by pressing its button from code; there
is no API to present it. On iOS 27 the picker opens on the button's
primary action, not touch-up, so that is what is sent, with touch-up
for versions that have nothing on it. The simulator has no AirPlay
devices and never showed the picker, so this is unverified until it
runs on a phone.

On a phone the player's one line left the title two letters wide once
the two controls were added, so the title takes the top line and the
controls flank play below it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 16:54:08 -05:00
Ray Slakinski
ecc248307e iOS: episodes kept on the phone play with no connection (#425)
A WKWebView runs no service worker, so the page's Keep button (#412)
was not shown in the app and everything played from the server. Now the
page asks the app to keep the file (keep, unkeep, clearKept), and the
app answers with what it holds (kept, keepFailed) whether or not
anything plays. The app fetches with a background URLSession, so a
download goes on with the app closed, into Application Support (not
backed up; the files can be fetched again), and Playback plays from the
file when there is one. The DeadAir copy is kept under its own path, as
the browser keeps it. Places and finishes reached offline wait in an
outbox, newest per address, and go when the network is back.

Checked in the simulator against a local server: kept /media/42 and
/media/42/deadair, then with the server refusing /media, a fresh launch
played each from the phone and asked the server for nothing. The page
itself still needs a connection to open (#435).

Deploy the app (build 6) before the page: a new page in an old app
shows a Keep button nothing answers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 14:40:01 -05:00
Ray Slakinski
05e522ac93 iOS: ask for the background refresh as the app leaves, too (#421)
Build 5. The badge's refresh was requested only when the app came
forward; iOS asks for it as the app goes to the background. The
request made on coming forward stays. The timing is still iOS's.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 14:31:21 -05:00
Ray Slakinski
9541f6c6f4 Phone: the bottom totals are centred, low on the screen (#424)
Centred, and the bottom padding is what the home indicator leaves
(safe-area minus 17px) rather than all of it, so the line sits just
above the indicator and the list gets back about 20px. A player open
still takes over the inset, as before. Checked in the simulator in the
glass theme.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 14:26:56 -05:00
Ray Slakinski
ca54e83f9c Phone: the bottom totals are text alone, set in from the sides (#424)
#status kept its own background and top rule to the bottom edge on a
narrow screen, where the rounded corners of the phone cut the band off
and the text sat against the curve; the glass theme's tinted, blurred
band made it plainest. Transparent, no rule, 24px in from each side.
Checked in the simulator against the built page in the glass theme.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 14:25:27 -05:00
Ray Slakinski
8d4c70a571 Admin page: the top bar clears the status bar in the iOS app (#423)
admin.html's viewport meta lacked viewport-fit=cover, which index.html
has. Without it env(safe-area-inset-top) is 0, so the top bar's padding
did not clear the clock in the app's full-screen web view, and the back
arrow sat under it and could not be tapped. Seen in the simulator, the
built page served locally: the arrow and the title level with the
clock before, below the status bar after.

Also a launch argument, -ipx.noBadgePrompt, so a simulator run can get
past the badge permission dialog.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 14:18:55 -05:00
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
Ray Slakinski
3626a30b52 iOS: the player goes to ipxapi.sdf1.net with one API token; unread badge (#419, #421)
The Access service token plus API token pair was three values to enter
and Access in front of both. The player, the badge and CarPlay now send
the ipx API token alone to https://ipxapi.sdf1.net: a route of the
rays-unraid tunnel to the same ipx-web, with no Access application, held
to the paths the app uses by a custom firewall rule on the sdf1.net zone
(403 for anything without a bearer header or off the list: the page, the
login form, /api/login, ?token= sign-in, the admin API). docs/sso.md
says so, since it also says not to bypass Access for /api/*; this is
that warning's narrow version, and the rule is on Cloudflare, not here.

The unread badge (#421) shows All Subscriptions' count, as the page's
badge does: set when the app comes forward, after an item is read, and
by a background refresh iOS runs now and then. The test target had no
Info.plist and could never have run; it does now.

Build 4. Not yet run on a phone with the token set.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 14:11:03 -05:00
Ray Slakinski
7d4456bb2f iOS: build 2, with the player tokens (#419)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 13:10:49 -05:00
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
a581e3fc88 An iOS app: the page in a web view, with the phone's own player (#418)
web/src/native.ts (#45) has handed playback to a host since September, and
there was no host in this repo: the earlier app, rays/ipodderx-app, grew native
lists beside the page and is left behind. This one is the page and a player:
a WKWebView of ipx-web, AVPlayer behind the bridge with the audio session to
play on locked, now playing and the remote commands (15 back, 30 forward,
scrubbing), and positions and read written from the player's own clock, with
DeadAir's copy named so the server reads the position on its clock.

Built with XcodeGen on a Mac; nothing here compiles the Swift. Its icon is
web/logo.svg at 1024.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVDqdiQEMWAkNGQ2tYA
2026-11-07 21:50:43 +00:00
6d64cba374 A kept episode's row says On this device (#413)
Only the keep button in the files pane said an episode was kept, so finding
what was on the device meant opening items one by one. The row's line says
it, and keeping or removing one redraws the rows whose state changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVDqdiQEMWAkNGQ2tYA
2026-11-07 17:36:12 +00:00
b209f55d2c Admin page: Save waits for a change, and the phone layout fits (#414)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVDqdiQEMWAkNGQ2tYA
2026-11-07 17:34:17 +00:00
c6dcb7f34e Admin page laid out as a feed's settings are (#414)
Prototype for approval on the issue; not to be merged until then.

The page had grown one captioned box at a time: at 390 px the tabs ran off
the edge and hid Log, Server was eleven full-width boxes (1150 px wide on a
desktop for a one-digit number) with units and the meaning of 0 in the
labels, and Directory's Save sat between its settings and the lists below.

Each tab is now named parts in .fsec, the layout #290 gave a feed's settings,
with its switches, numbers with their unit after the box, Server's Save
sticky and greyed until a change, the tabs in their own row on a phone, the
Proxy tag by the account's name, and the log's explanation moved into each
source tab's tooltip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVDqdiQEMWAkNGQ2tYA
2026-11-07 17:34:17 +00:00
88f4e4765b The search box says Search (#417)
On a phone it read "Search All S", the list's name cut off by the narrow box.
The placeholder is Search everywhere; the list it searches stays in the box's
accessible name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVDqdiQEMWAkNGQ2tYA
2026-11-07 17:28:56 +00:00
1b0b155226 One Check every feed button: the top bar's (#416)
All Subscriptions' header had its own, beside Mark everything read, doing
what the top bar's Refresh does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVDqdiQEMWAkNGQ2tYA
2026-11-07 17:26:53 +00:00
5a8a7fb0cc Subscribing wakes the Signals judge, so a known feed's items reach Signals at once (#415)
The judge woke for a scan's new items and for changes to Signals, and
otherwise every five minutes. A feed the server already had brings no new
items when someone subscribes, so its recent items waited for the next idle
pass before the new subscriber's Signals saw them. The browser test for a
Signal's new hits ran into this whenever an earlier test had subscribed to
the same fixture feed and left: it waited 20 s and failed in the full suite,
passing alone. scan_soon, which every subscribe from the API calls, wakes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVDqdiQEMWAkNGQ2tYA
2026-11-07 17:26:50 +00:00
2c4d03668f Episodes can be kept on the device to listen offline (#412)
Offline mode (#389) kept lists, text and pictures, but the worker let
every /media/ request pass, so no episode played without a connection.

A downloaded episode's files pane has a keep button beside Play. It
fetches the copy that person plays, the file or, when DeadAir cuts
something for them, the version without its dead air (/media/{id}/deadair,
the save button's), whole into a cache of its own, ipx-episodes, and
asks once for persistent storage. A kept episode plays as
/media/...?offline, online or not, and the worker answers that from the
cache with the byte ranges an audio element asks for (206, 416, 200);
not kept there, it goes to the server as before. Pressing it again takes
it off; signing out clears it with everything else. Only someone's
choice is kept, so there is no budget: a full device is the quota's
refusal, said in words. The button is data-a="offline", not "keep",
which the Directory's show page already uses for subscribing.

Places saved from DeadAir's version were read in its clock only if ipx
had handed out that file's playlist since it started (state.playing). A
kept copy plays without one, so the player now names the file in the
position it saves (deadair: <enclosure>) whenever it plays DeadAir's
clock, and ipx loads that file's plan when it has none, for that item's
file only. Checked the beacon is queued by the worker offline: it is.

Tests: playing a kept episode offline, its place queued and sent once
back, and taking it off (the offline show plays quiet.mp3, the only real
MP3 among the fixtures); the worker's ranges; which copy is kept and
played, in page-unit; the server reading a flagged place in DeadAir's
clock without a playlist, and ignoring another item's file. The files
pane's one-row test counts five buttons and checks a phone's width too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVDqdiQEMWAkNGQ2tYA
2026-11-07 15:31:04 +00:00
07923d665b Page coverage keeps what a document ran before it unloads, and every page (#407)
The fixture took Playwright's page.coverage once, at the end: a reload
or a sign-in's redirect had by then thrown the first document's script
away with its counts, so reloadKeepingPlace read as never run though the
new-version test runs it, and the second people's pages (piper's, opal's)
were not recorded at all.

Each page a test opens, its own and those of the contexts it makes, now
records through a CDP session of the fixture's own (precise coverage,
sources fetched as each script is parsed), and every document stops at a
breakpoint in a beforeunload listener the fixture adds, where it is taken
before the document goes. Taking it on the navigation's event came after
the document had gone, and holding the document's request (Fetch, as
page.route before) held the take up behind it, the renderer answering
nothing while the navigation waited. A page or context is taken again as
it closes. Page lines 80.0% to 84.5%.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVDqdiQEMWAkNGQ2tYA
2026-11-07 14:37:49 +00:00
f5a3847a21 Production's log lines name their trace again (#411, #408)
ba18b44 had the JSON layer find a line's span context through the
dispatch it was registered with, and production's logging wrapped that
layer in an Option, whose Layer impl does not pass on_register_dispatch
on: the layer never got its dispatch and every line went out without
trace_id and span_id. The log test built a subscriber of its own,
without the Option, and passed.

Both services now box their stderr layer instead of an Option of each
kind (a Box passes registration on), the formatter falls back to the
default dispatcher when it was never registered, which a global
subscriber's lines find, and the log test builds the subscriber with
the function init uses, writing to its buffer, so a wrapper like this
one is under test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVDqdiQEMWAkNGQ2tYA
2026-11-07 13:41:04 +00:00
ba18b445ec Log lines name their trace under a scoped subscriber too (#408)
WithTrace found a line's span with Span::current(), which asks the
thread's default dispatcher; inside an event under a scoped subscriber
(with_default), tracing's re-entrancy guard answers with none, so every
line went out without trace_id and span_id. Production, on the global
dispatcher, was not affected, but the log test had to go global for it.

The JSON layer now carries a small layer of its own that keeps the
subscriber it is registered with (on_register_dispatch, a weak handle),
and the formatter takes the registry's current span from its own
context and asks tracing-opentelemetry for its context through that
handle (get_otel_context), with no dispatcher lookup. The log test runs
scoped again; with the old formatter it fails for want of the ids.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVDqdiQEMWAkNGQ2tYA
2026-11-07 13:34:24 +00:00
8acf57961e Ten wrong passwords from one address hold it off sign-in for a quarter hour (#409)
POST /api/login answered every wrong password 401 for as long as someone
kept trying. Now ipx counts them by the client's address: the tenth in a
window holds that address off until the window, which starts at its first
wrong password, ends, answered 429 with how long is left. Counted by
address and not name, since by name anyone could lock anyone out; a right
password does not reset the count, or a guesser with an account of their
own could clear it between guesses.

Behind Caddy every peer is Caddy, so the address is the last one in
X-Forwarded-For, taken only from trusted_proxies. Caddy now sets that to
{client_ip}: Cf-Connecting-IP for the tunnel's requests, believed only
from TUNNEL_PEER (servers trusted_proxies), and the peer for everyone
else, replacing whatever the client sent. Checked with Caddy against an
echo upstream: a forged XFF and Cf-Connecting-IP from elsewhere reach ipx
as the peer, the tunnel's Cf-Connecting-IP as itself.

The sign-in load test sends each attempt from its own address, so it
still measures a flood from many, whose passwords are all checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVDqdiQEMWAkNGQ2tYA
2026-11-07 13:18:38 +00:00
46392c55e1 The daemon test stops its daemons when an assertion fails (#400)
A failed assertion skipped the terminate at the end, and the two runs
that failed while the test was written left their daemons running for
an hour on their scratch sockets and ports. Each daemon the test starts
is now killed when it is dropped, however the test ends.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 05:55:39 +00:00
f6ba08d506 Coverage measures the page's Node tests and runs the Rust on Postgres (#407)
page-unit.ts and native-bridge.ts run the page's script outside a
browser, so the page's coverage counted none of what they test, and
native.ts read as mostly untested. In a coverage run they now run the
script from a file of its own, named as the browser names it, under
NODE_V8_COVERAGE (scripts/coverage-node.sh, before the browser suite in
npm run coverage:page and coverage.sh api), and the report's teardown
takes their record in, mapped to web/src by the build's source map: the
page's lines went from 73.3% to 80.0% of 3,043, native.ts to 93 of 120.

The cargo tests in coverage.sh's container run on Postgres, as
production does, when IPX_TEST_DATABASE_URL names the _test database,
from the shell or else /src/.envrc (that variable only); on SQLite
without it. The integration tests' own files are left out of the
totals; a crate's inline mod tests cannot be on stable Rust.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 04:57:32 +00:00
7994f93f31 A download a restart stopped is taken up again at the start (#410)
A file download was never marked 'downloading', only torrents were, so
one under way when the daemon stopped stayed 'pending' and
requeue_interrupted had nothing to do. The first pass after a start
scans only the feeds that are due, and a pending file is fetched only
in a scan of its feed, so after a deploy a half-downloaded episode
waited for its feed's next check: an hour on the default schedule, a
day for a daily feed. Found by the daemon test of #400, where the
episode was not asked for again in a minute after a restart.

fetch_one now marks the file 'downloading' while it runs, as a
torrent's is; every way out of it already records 'done' or 'error',
and the scan's queue takes only 'pending', so a running download is
not queued twice. requeue_interrupted gives back the ids it put back,
and the daemon puts each on the worker's queue before it serves, where
download_one takes it whatever its feed's schedule says. The daemon
test now checks the restarted daemon asks for the episode again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 04:50:33 +00:00