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>
This commit is contained in:
2026-11-07 00:57:31 +00:00
parent 742f76969e
commit af69b699a8
20 changed files with 425 additions and 8 deletions

View File

@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- `/api/art` also serves the pictures in the text of items iPX has sent, through its artwork cache, so the page can keep them to read offline (#389).
- The log has a line for each swipe in the page's reader, saying what came of it and where the reader stood, and a warning when a drag would not scroll the reader, with what lay over it (#384).
- `/metrics` says how long statements take and, for hand-written SQL, how long they waited for a connection (#367); counts feed failures by kind, new items, downloads saved and failed, and the silence DeadAir found (#368); and holds the page's own timings, a click to its list, play to sound, a stall (#369).
- Traces for what had none: each command the worker runs, from the CLI, the socket or the page, with its job inside (#373); the daemon's start, from opening the database to serving (#374); a list's sync and an OPML upload, step by step (#372); and the tap or key in the page that set a trace's requests off, as its root span, from the tap to its last answer (#376).

View File

@@ -1,14 +1,60 @@
//! Artwork kept on disk, so the page loads it from iPX: fast after the first time, nothing asked
//! of each publisher's server for every visit, and still there when a publisher is not.
use std::collections::HashMap;
use std::collections::hash_map::DefaultHasher;
use std::collections::{HashMap, HashSet, VecDeque};
use std::fs::File;
use std::path::{Path, PathBuf};
use std::sync::{LazyLock, Mutex};
use std::time::SystemTime;
use std::time::{Duration, Instant};
/// Pictures in item text ipx has sent, which `/api/art` may then fetch and keep as it does
/// artwork, so the page can save them to read offline (#389): only an address some feed's text
/// named, as artwork is only one some feed names. The oldest are forgotten past the limit, and
/// all of them at a restart, until a list sends them again.
static IN_TEXT: LazyLock<Mutex<(HashSet<String>, VecDeque<String>)>> = LazyLock::new(Mutex::default);
const IN_TEXT_MAX: usize = 20_000;
/// Remembers the pictures in a piece of cleaned item text.
pub fn name_in_text(html: &str) {
let srcs = img_srcs(html);
if srcs.is_empty() {
return;
}
let mut known = IN_TEXT.lock().unwrap_or_else(|e| e.into_inner());
for src in srcs {
if known.0.insert(src.clone()) {
known.1.push_back(src);
}
}
while known.1.len() > IN_TEXT_MAX {
if let Some(old) = known.1.pop_front() {
known.0.remove(&old);
}
}
}
pub fn named_in_text(url: &str) -> bool {
IN_TEXT.lock().unwrap_or_else(|e| e.into_inner()).0.contains(url)
}
/// The web addresses of the `<img src>`s in HTML as ammonia writes it: attributes in double
/// quotes, `&`, `"` and no-break spaces escaped, and no `srcset`, which it does not let through.
fn img_srcs(html: &str) -> Vec<String> {
let mut out = Vec::new();
for (at, _) in html.match_indices("<img ") {
let tag = &html[at..html[at..].find('>').map_or(html.len(), |end| at + end)];
let Some(start) = tag.find(" src=\"").map(|i| i + 6) else { continue };
let Some(len) = tag[start..].find('"') else { continue };
let src = tag[start..start + len].replace("&quot;", "\"").replace("&nbsp;", "\u{a0}").replace("&amp;", "&");
if src.starts_with("https://") || src.starts_with("http://") {
out.push(src);
}
}
out
}
pub fn dir() -> PathBuf {
crate::config::data_dir().join("art")
}
@@ -107,6 +153,15 @@ pub fn trim(dir: &Path, limit: u64) -> usize {
#[cfg(test)]
mod tests {
#[test]
fn the_pictures_in_cleaned_text_are_named_and_nothing_else() {
let html = r#"<p>a</p><img src="https://img.example/a.jpg?w=1&amp;h=2" alt="x" referrerpolicy="no-referrer"><img alt="no src"><img src="data:image/png;base64,xx"><a href="https://img.example/b.jpg">b</a>"#;
assert_eq!(super::img_srcs(html), ["https://img.example/a.jpg?w=1&h=2"]);
super::name_in_text(html);
assert!(super::named_in_text("https://img.example/a.jpg?w=1&h=2"));
assert!(!super::named_in_text("https://img.example/b.jpg"));
}
use super::*;
#[test]

View File

@@ -1697,7 +1697,9 @@ fn clean_description(sanitizer: &mut ammonia::Builder, html: &str, link: Option<
Some(b) => ammonia::UrlRelative::RewriteWithBase(b),
None => ammonia::UrlRelative::PassThrough,
});
sanitizer.clean(html).to_string()
let clean = sanitizer.clean(html).to_string();
crate::art::name_in_text(&clean);
clean
}
#[derive(Deserialize)]
@@ -2299,7 +2301,8 @@ struct ArtQuery {
u: String,
}
/// Artwork, from iPX: the page asks here for every image a feed or item names, and the first
/// Artwork, from iPX: the page asks here for every image a feed or item names (and, to keep for
/// offline, the pictures in item text, #389), and the first
/// time it is fetched from the publisher and kept on disk (`art`, up to `art_cache_mb`), so
/// later it is fast, the publisher is not asked on every visit, and it outlives the publisher's
/// server. Only an address some feed or item names as its artwork, and only an image up to
@@ -2322,6 +2325,7 @@ async fn art(State(state): State<WebState>, Query(q): Query<ArtQuery>) -> Respon
// Only what a feed here or the directory names: anything else would make ipx fetch any
// address it is given. The Directory shows podcasts nobody here subscribes to (#273).
if !crate::directory::listed_art(&q.u)
&& !crate::art::named_in_text(&q.u)
&& !state.ctx.db.names_image(&q.u).instrument(db::step("names_image")).await.unwrap_or(false)
{
return (StatusCode::NOT_FOUND, "no feed names that artwork").into_response();