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>
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>
#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>
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>
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>
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>
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>
The item list is fetched 50 at a time, and only the list's Load more
button asked for more. A swipe or j stops at the last item loaded, so
after 50 items the reader bounced on every swipe until a reload brought
a fresh page of unread ones.
Turning to within five of the end now fetches the next page behind. Its
offset counts what is still unread on the Unread tab: the items read
here have left the server's unread list, and Load more's flat 50 a page
skipped as many unread items as had been read. A refresh (a return from
the background, a live update) always asks for the first page, as the
kept list it fills is one, where it used to ask for whatever page Load
more had last reached. A page appended after new items arrived no longer
repeats ones already loaded.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A plain `docker buildx build` of ipx on 2026-11-04, its dependency layer
invalidated by a Cargo.toml change (#382), rebuilt everything in release
on all twelve threads with 5 GB free and no swap. Tower thrashed for
three hours with every core pegged and had to be rebooted.
scripts/build-image.sh now builds every image: it refuses under 9 GB
available or above load 12, and builds in the docker-container builder
ipx-capped, held to 6 GB and six threads by its cgroup, so a build that
outgrows that is killed instead of stalling production. The builder
pulls base images through mirror.gcr.io, which replaces tagging local
copies to dodge Docker Hub's 429s. CLAUDE.md deploys only through it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
YouTube answers some requests for a channel's feed with a 404 or 500
in tens of milliseconds, while the next request for the same feed, or
another channel's 200 ms later, reads. Checked once a day, the 39
YouTube channels in the catalogue missed most days: 178 failures in
the week to 2026-11-04, against 74 in the ten days before, and all 39
read from Tower when asked by hand.
feed::fetch now asks a youtube.com/feeds/videos.xml address again, up
to twice, a second apart, when it answers 404 or 5xx. Other hosts'
404s are real and are not retried. tokio moves from ipx-feed's dev
dependencies to its own, for the sleep; both services build it already.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>