Commit Graph

636 Commits

Author SHA1 Message Date
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
5c5dd91d21 Browser tests for the Directory's show page, dialogs, keys and player (#405, #406)
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>
2026-11-07 04:45:11 +00:00
ee5610ba4e Browser tests for the page's live updates (#404)
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>
2026-11-07 04:37:45 +00:00
8e8307b569 Tests for the directory's Apple sync, metrics and dropped snapshots (#402)
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>
2026-11-07 04:32:28 +00:00
c00d5383de Tests for feeds that move, WordPress.com, Patreon and ipx add/rm (#401)
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>
2026-11-07 04:29:03 +00:00
2db42b5fae Tests for the daemon's start, shutdown and CLI (#400)
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>
2026-11-07 04:24:54 +00:00
1253b470a5 Tests for torrents: a real swarm, the guard and the scan's deferral (#397)
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>
2026-11-07 04:19:59 +00:00
980fbc3f66 Tests for Signals' judging, pacing, icons and API (#398)
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>
2026-11-07 04:15:49 +00:00
3bb59c215a Tests for proxy sign-in, passwords, account settings and ipx user (#399)
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>
2026-11-07 04:10:29 +00:00
b62182e38e Tests for the log's fields and the metrics Grafana reads (#403)
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>
2026-11-07 04:02:15 +00:00
a92e81aba2 Tests for the reaper deleting files (#396)
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>
2026-11-07 03:55:52 +00:00
bda2ef07be Browser tests for the directory's public pages (#395)
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>
2026-11-07 03:46:49 +00:00
194a9250e7 The API's coverage is measured, and every suite's is in Grafana (#394)
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>
2026-11-07 03:33:49 +00:00
352e2b77a5 A test signs in with the sign-in form (#393)
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>
2026-11-07 03:16:42 +00:00
f8408a3d26 Tests, tooling and the served scripts are TypeScript (#392)
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>
2026-11-07 02:23:22 +00:00
b509ea4b35 Coverage for the Rust and the page, and tests for the offline gaps (#390, #391)
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>
2026-11-07 01:55:19 +00:00
af69b699a8 iPX opens and reads without a connection (#389)
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>
2026-11-07 00:57:31 +00:00
742f76969e An open page takes up a new version by itself after a deploy (#388)
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>
2026-11-07 00:04:55 +00:00
e5388b443f YouTube feeds are read one at a time, two seconds apart (#386)
A scan fetches six feeds ahead of its turn, so channels next to each
other alphabetically (android-authority-2, android-developers, apple,
architectural-digest-2) went to YouTube together and failed together,
while one asked alone a minute later read. feed::fetch now takes a turn
before each request for a YouTube feed: one at a time per process, two
seconds after the last, its wait a `youtube_turn` span. The retries
take their turn too, in place of their one-second sleep. The directory
builds the same fetch and paces its own checks the same way.

39 channels due together add up to 80 s to that scan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 23:54:32 +00:00
bbcf69062e A failing YouTube feed is asked again hourly for a day (#386)
#382's retries run (each failing fetch takes the two seconds) and still
end 404: YouTube fails about 70% of feed reads from Tower, in streaks
longer than seconds, for ?user= and ?channel_id= addresses alike (142
and 68 failures in 7 days across 26 and 13 feeds). The 39 YouTube
channels are catalogue feeds nobody subscribes to, read once a day, so
each missed about 5 days in 7.

due_at now waits at most an hour for a YouTube feed in the first day of
a run of failures. A channel gone for good costs 24 requests, then backs
off as any feed does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 23:44:51 +00:00
d3b8c4cd01 A short Unread list read through no longer says more to load (#385)
hasMore() compared nextOffset(), which on Unread counts only the loaded
items still unread, with S.total from the answer: once they were read it
was 0 against that total, so the page asked for a page that came back
empty and took its total, 0, with the items still on screen. Whether
more is left now comes from the last answer itself, its offset and items
against its total; reading takes an item off the server's list and off
nextOffset() alike, so what that answer said is left stays true.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 23:44:51 +00:00
8a66f9c7ee prod-check names each trace's service and an event's fields (#387)
Two 10 s error traces named `poll` read as unexplained in a check: they
are hnopml's, a Hacker News fetch timing out, which the event's own
`error` field said all along. `traces` now prints the root service,
`trace` marks where the service changes and prints an event's fields,
and the skill's error-trace search keeps to ipx's three services.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 23:44:51 +00:00
1b2343cf28 prod-check reads swipes and a reader that will not scroll (#384)
Step 7 counts swipes by outcome, lists the ones that bounced at the end,
and every scroll_stuck with its state, and says how to read each: an end
with more to load is #383 back, and a scroll_stuck is filed with its
state and the swipes before it, being the one record of that bug.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 20:56:15 +00:00
5c1970e8ee Swipes and a reader that will not scroll are reported (#384)
A swipe that only bounces sends no request and throws nothing, so
#383's stop at the 50th item left nothing in the log or the traces, and
the reader that would not scroll in the same report was never found.

The page now reports each swipe in the reader (next, prev, list, end,
short; a tap is not one) with where the reader stood, under the tap's
trace, and scroll_stuck when a drag up or down with room to move the
reader left it where it was. That one is watched by where the finger
was, not by what it landed on, so a layer lying over the reader is
caught, and its state names what is under the finger, what is inert,
the dialog, the reader's transform, a #dpeek left behind, and how long
since the app came back. ipx logs swipe as info and scroll_stuck as a
warning, in a new free-text state field, and the dashboard counts both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 20:46:31 +00:00