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
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
#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>
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>
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>
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>
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>
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
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
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
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
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
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
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
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
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
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>
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>
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>
The Directory (#405): a search nothing matches says so and shows no
tiles; a show's page draws what the directory says of it, so with a
long description More opens it and Less closes it, a failing feed says
why, its latest items are listed, someone who is not the directory's
admin is offered neither Refresh nor Remove, and Subscribe there says
so and opens the feed. Search, Back, Subscribe from a tile and an
admin's Refresh and Remove were already tested.
The page (#406), on a fixture show of four linked episodes: a
schedule's bar tapped lights that many of the newest; Download latest
3 queues three and they download; v opens the item's own page;
Unsubscribe from the feed's dialog leaves no feed open. Shift J and K
move between feeds, Right and Left open and close a folder from the
keyboard, r takes the lists again. The player keeps its speed and
volume across a reload, reports a stall of over half a second to the
server's log, and marks an item played to its end read.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
events.ts's handlers for a download in progress, finished and failed,
and for Signals' new hits, ran in no test. The fixture server has a
show to download from by hand: one episode sent over two seconds and
one that always fails, added with a word nothing has so that nothing
downloads on its own. Downloading the slow one fills its bar, the count
line says how far, the toast names the file and the row shows it
downloaded; another person with a page open, not subscribed, hears of
none of it (#294). The broken one says which item failed and what to
do (#304), and its bar stops. A Signal open on its page shows its new
hits and its count as soon as a pass picks them out, without a reload;
its folder is opened from the keyboard.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The weekly Apple Podcasts sync runs unattended and ran in no test. Its
Apple address is now an argument (run passes Apple's), and its pause
between calls is none under test, so a test runs it against a stand-in
with one category: the top chart and the category's are taken each
show once in order, looked up, and filed as the apple-podcasts
snapshot with a feed that has a key in it refused as private; a feed an
admin blocked is left out of the next; and a sync whose lookup fails
is an error that leaves the last good snapshot as it was.
/metrics serves every directory_ metric the dashboard and alerts
query, read out of them. Dropping a server's snapshot through the API
is for admins only, and takes the feeds only it listed, keeping one
another server lists too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
follow_move, which rewrites a feed's address in the catalogue after a
permanent redirect, ran in no test; the fetch only said where a feed
went. It now: the feed keeps its id, so its subscribers and their read
state, takes the new address in memory and in the database, and is
left as it was when another feed already has that address, or when it
is an OPML's, whose list would only put the old one back.
WordPress.com's browser check is answered from its API's posts, and a
Patreon creator's shows are asked of Patreon by the campaign its feed
names, each show the creator feed with its own show and credentials;
an unknown one is an error. Both against a stand-in: the two APIs'
addresses go through api_base, which a test points at a server of its
own; production's are unchanged.
ipx add names a feed from its title, keeps its folder and keywords,
refuses it a second time, and asked again to list it or with a
category changes only those; ipx rm takes it out of the catalogue and
the database.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The daemon ran as a process in no test but the browser suite's, which
never stops it mid-job or starts a second. crates/ipx/tests/daemon.rs
runs the built binary on a scratch config and SQLite database, against
a feed served from the test whose episode takes minutes: ipx status
goes through the socket and with --local does the work itself; a first
start with no token writes one, keeps it out of the log, and it signs
in; a second daemon on the socket is refused and the first keeps
serving; SIGTERM mid-download stops it within 5 s, removes the socket,
and leaves the episode queued; it starts again cleanly; and with no
daemon, ipx status works on its own. A process of its own, since a
signal sent to the test binary would reach every test in it.
until_next_scan keeps its floor and ceiling: 10 minutes with nothing
to check, 30 s with a feed due now.
The abandoned download is taken again only at its feed's next check,
not at the next start: #410.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
torrent.rs's fetch, and scan.rs's spawn_torrent and torrent_one, ran in
no test. A test now makes a swarm on 127.0.0.1: a seeding librqbit
session and a web server that serves the .torrent and answers as its
tracker, naming the seed. A detached torrent then downloads through
ipx's own session into the enclosure's own folder, the row is recorded
done with that folder as its path, the file is byte for byte the seed's,
and the slot is free again. The .torrent is refused from an internal
address by the guarded client unless fetch_internal allows it. A scan
hands a .torrent enclosure to the torrent worker, counted as a torrent
and left 'downloading' while it waits for a slot, and skips it, still
counted, with torrents off.
A test's torrent session joins no public DHT and sends no LAN
multicast (cfg!(test) in Torrents::new); production's is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
signals.rs's pass, judge, pace, back_off, check_key and pick_icon ran
only against a live Jev. In cargo tests ask now goes to a stand-in for
it, served from a thread of its own so every test shares it, answering
by key as TypeSafe would: a pass judges each new item once, keeps its
verdicts and bills its owner's tokens on their own key; a refused key
stops its owner's judging, not anyone else's, and is not sent again; a
429 stops the pass and the next call to Jev waits out Retry-After; a
422 stops the pass rather than repeating on every item; a key check
costs tokens; an icon is one Jev was offered, on the server's key.
The API: a Signal made, its words trimmed and deduplicated, bad names,
words, icons and descriptions refused, renamed and re-worded, its icon
cleared, its picked items listed, deleted with its verdicts; someone
else's is 404 every way. In the browser, a Signal renamed and deleted
from its own page. The coverage run runs the browser Signals tests too
(IPX_SIGNALS_TESTS=1): Jev there is the fixture server's.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Who gets in had no test above the token check: the router now runs in
tests with a peer address, as the listener gives it. A name in the
proxy's header signs in only from the trusted proxy (not from
cloudflared's gateway or anyone else) and makes the first account an
admin; with Access set, the name is the signed token's, and a token for
another application, an expired one, garbage, a header without a token
or a token without the header are refused, as is a good token not via
Caddy. Wrong passwords and unknown names are 401 and the right one
after them signs in with a session cookie; a forged one is refused.
PATCH /api/me refuses an over-long block list and a malformed TypeSafe
key before asking TypeSafe. The signing keys are read from a local
stand-in for the team's certs, and a 503 or 404 is an error.
ipx user add, list, passwd, rename and rm run against a scratch
database: user_cmd takes its stdin as an argument, so a password can be
given without a terminal, and access's fetch takes the certs address.
There is no 429 for a flood of wrong passwords to test: that is #409.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Three tests for what the dashboard and the alert rules read and nothing
checked: every ipx_ metric named in grafana/dashboard.py and alerts.py
(read out of them at compile time) is served by render, with the ones
written only once known (counts, the last scan, a job under way) set
first; ipx's JSON log lines for a scan's events carry ev, feed, new,
bytes, msg and error.type, and inside a span trace_id and span_id; the
directory's snapshot line carries instance, accepted and the refused_
counts.
The JSON layer was built the same way in both services' setup; it is
otel::json_layer now, so the tests run the layer production does.
The log tests set the global subscriber, as production does: under a
scoped one WithTrace finds no current span (#408).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
retention::run's age and quota passes and remove ran in no test: only
the pure choosers did. Three tests now run them against a scratch
downloads folder and a database: an old file deleted, marked reaped,
its bytes counted and its entry pruned, a dry run deleting and pruning
nothing; over quota the oldest going first with the age pass's take
not counted again; a file already gone reaped without an error, and
one procfs will not unlink left claiming its file with nothing freed.
Held items, which have no path, are never candidates.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The pages anyone can browse at directory.ipxreader.app (site.rs) were
at 4% under the browser suite, which only called the directory's API.
directory-site.spec.ts opens them as a visitor would: the home page's
chart, a category and A to Z, sorted and as many as it says; the
podcasts and blogs tabs, which split the feeds between them; search
from the header, and a search that finds nothing; a feed's page, its
address, and Copy putting it on the clipboard (site.ts, the pages' one
script); an unknown feed's 404; and robots.txt and the sitemap, which
lists every feed's page. No script error, nothing the security policy
refuses, and no failed answer of the directory's own on any page; the
fixtures' covers are their publisher's and one is missing on purpose.
site.rs is at 87% under the browser suite now, the directory at 69%.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The cargo tests put web.rs at 17% and daemon.rs at 10%, because the
browser suite, which drives the API, ran a daemon nobody measured.
`sh scripts/coverage.sh api` builds an instrumented ipx and
ipx-directory in the capped coverage container and runs the browser
suite here against them (IPX_API_COVERAGE): web.rs is at 80% and
daemon.rs at 77%. What it does not reach: access.rs (Cloudflare Access,
0%; its unit tests 73%) and the directory's public pages (site.rs, 4%).
The coverage image is Ubuntu 24.04 now, as code-server is: built on
Debian trixie, the binaries needed glibc 2.41 and code-server has 2.39.
In that mode the suite starts those binaries rather than building its
own, its tests' `ipx` commands too (global-setup's IPX_BIN), passes
LLVM_PROFILE_FILE to both servers, and stops them with SIGTERM, since
an instrumented binary writes its profile as it exits and Playwright
otherwise kills them outright.
Reports go to target/coverage/cargo, api and page. Each run ends with
scripts/coverage-metrics.ts, which writes every suite's lines covered
and in all, by crate, to node-exporter's textfile directory; the
monitoring project's node-exporter now reads it (one flag, its compose
file backed up beside it), so Prometheus keeps them as
ipx_coverage_lines, and the iPX dashboard's Coverage row draws them:
each suite now, over time, and by part. The cargo tests' totals hold
their own code, which runs whole, so they read higher than the code
alone; the panels say so.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every browser test signed in with the token link, so the form was never
run: login.ts at 0% in the page coverage (#390). login.spec.ts, on the
suite's page before anything signs it in: signed out, the app sends you
to the form, its name field focused; a wrong password shows the
server's "wrong name or password" and leaves you signed out; the right
one, submitted with Enter, opens the app as admin. With the form
changed to show only its fallback message, the second fails, as it
should. login.ts is at 87% of lines now.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The page and the service worker were strict TypeScript; the tests, the
build, the home page's generator, the load tests, boot.js and the
directory's site.js were plain JavaScript that nothing checked. All of
it is TypeScript now, in five programs, each with its own globals, all
checked by npm run typecheck: the page (tsconfig.json, with boot.ts and
the directory's site.ts), the worker, Node's (tsconfig.tools.json: the
Node tests, the browser suite, the build, the site generator, the load
harness), and k6's (tsconfig.k6.json, with @types/k6).
Node 24 runs the .ts files by stripping their types, so they hold only
TypeScript that erases (erasableSyntaxOnly) and import each other by
.ts name; package.json says "type": "module" for it, and Dockerfile.web
moves to Node 24 to run build.mts. k6 2.3 strips its scripts' types
itself.
The browser suite's page.evaluate calls reach the page's globals; the
page's tsc run now also writes its declarations to target/page-types,
which the suite's program includes, so those calls are typed by the
page's own code rather than by hand-written copies.
boot.ts is compiled by build.mts with swc, as the worker is, and named
by the hash of what is served. The directory's build has no Node, so
site.js is compiled from site.ts and committed (node web/build.mts
--directory-script), and tests/directory-script.ts fails if they drift.
Checked: the site generator's 139 pages are byte for byte what the
JavaScript made; the page build's app.js is identical and only boot.js
(now minified) and its hash differ; contrast, page-smoke, page-unit,
native-bridge, the browser suite (96 passed, 2 skipped) and the
directory's and ipx's Rust tests pass; k6 parses each load script. The
load tests themselves did not run: they build a release binary outside
the capped builder. .agents/skills/security-audit is an installed
skill's, not ours, and is left as it is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Nothing measured what the tests reach. scripts/coverage.sh runs the
cargo tests under cargo-llvm-cov in the ipx-coverage image
(Dockerfile.coverage, built by build-image.sh coverage), in a container
held to the image builder's 6 GB and six threads, one crate at a time,
with the same room and load checks: an instrumented build rebuilds every
dependency, the kind of build that took Tower down on 2026-11-04. The
first run stayed under load 8 with 11 GB free; 69% of lines. web.rs and
daemon.rs read low because the browser suite drives them against a
daemon that is not instrumented.
npm run coverage:page runs the browser suite on a build of the page
(IPX_COVERAGE=1) whose script keeps a source map to each web/src file:
web/concat-map.mts maps the joined files line by line and swc takes it
as its input map. Each test's page records V8's coverage
(tests/ui/coverage.ts, which the specs now take test and expect from),
and monocart-coverage-reports writes it file by file: 73% of lines.
build.mjs requires the map helper only for that build, since
Dockerfile.web's Node does not run TypeScript, and not with a top-level
await, which stopped the CommonJS tests requiring build.mjs.
The offline tests move to offline.spec.ts, in TypeScript, and stand
alone (they add their feed and set the read state they need), with what
#391 found untested: another account signing in clears what was kept, a
position saved offline waits and is sent, and pictures past the budget
go oldest first (keepPictures takes the budget, and trims whether or
not anything new came). The fixture JPEGs were a few bytes no browser
could draw; they are real now, and the artwork test checks one draws.
tsconfig.tools.json type-checks the tests' and build's TypeScript, in
npm run typecheck.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With no network the home-screen app showed nothing: no service worker,
and the lists the page had fetched, full text included, lived only in
its memory.
A service worker (web/sw/sw.ts, TypeScript checked against a worker's
globals by tsconfig.sw.json, built by swc to /sw.js) goes to the network
first for the page and every API read and keeps the answers, so online
nothing is an old copy and #388's version check works as before;
offline the kept copy answers. Scripts named by hash come from what is
kept. Reads, flags and places in an episode that cannot reach the
server wait in an outbox, sent in order when the connection is back
(and new ones queue behind them meanwhile, so none lands out of turn);
a kept list served offline shows the reads waiting there.
Pictures: a worker cannot keep another site's pictures sensibly, since
browsers count each opaque answer as megabytes. ipx now remembers the
picture addresses in item text it sends (bounded, in memory) and
/api/art fetches those as it does artwork, through the guarded client;
the worker keeps them, for unread items, under a 200 MB budget, and
serves them offline in place of the publisher's. Online, pictures still
come from their publisher.
Sign-out, or another account signing in on the device, clears what was
kept. The browser suite blocks the worker except in the offline tests,
since it answers before page.route can.
The fixture JPEGs are placeholders a browser cannot draw; the offline
test uses a real PNG.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The live stream already came back after a deploy and the lists caught
up, but the page kept running the script it had loaded, in a tab or the
home-screen app, until reloaded by hand. Back after a drop, the page now
asks for / again without the cache; another app.js named there is a new
version. With nothing to lose it reloads, and the item that was open,
carried across in sessionStorage whole since a read one is off the
Unread list, is opened again. While an episode plays, a dialog is open,
a search is set or a field is typed in, it offers a reload in a toast.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>