Give Coil a descriptive User-Agent so remote card images load - #223
Merged
Conversation
…s load A card picture set from an address (#167) is fetched from whatever host the author named, and nothing in composeApp installed an ImageLoader — so every request went out as `okhttp/4.12.0`. Wikimedia refuses that outright: HTTP/2 403 server: HAProxy Please set a user-agent and respect our robot policy https://w.wiki/4wJS. The same URL returns 200 image/jpeg to a descriptive agent, so it is the header and nothing else. This is the failure behind "an agent created a deck with images through the CLI and the images are not rendering": `loopky` stores a URL and never fetches it, so `--json` reports a card with an image while both the write and the read are working exactly as designed. There is nothing to see when it happens. AsyncImage has no error slot here, so the card renders with a blank half where the picture should be and no log line says why — the deck looks broken rather than unfetched. The header follows Wikimedia's policy: an identifying name, a version, and a link that reaches a human. It is a contact string, not a disguise — pretending to be a browser would also work today and is the thing the policy is written against. Verified on emulator-5554 against a staging deck built for it: a 250px Wikimedia thumb and the original file both went from a blank half-card to the photo. iOS is not affected — URLSession's default agent carries the bundle name, which the same host answers with 200. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GJNMFxwtrBouskkFbQuA2u
…ch the CLI Wikimedia's rules Two gaps, both of which end in the same place: a card whose picture cannot render, written without complaint, with `--json` reporting success. **http:// is refused where the ref is minted.** Android blocks cleartext at targetSdk 28+ and iOS ATS does the same, so an http:// ref is unloadable on both clients by construction — the write succeeds, the read returns the URL intact, and the card renders with a blank half. `isRenderableImageUrl` and `remoteImageRef` put the rule in `domain/model/Media.kt`, where every client mints one, rather than in the CLI copy that had it. That factory also replaces the shape written out longhand in eight places across the CLI, the shared ViewModels and Swift (`path`/`sha256` empty, `image/jpeg`). The duplication is how http:// got in: a validation added to one copy is a validation the other seven do not have. Not upgraded to https silently. A host answering on one scheme and not the other would turn an honest refusal into the same blank card, one layer further from the person who could fix it. Detection stays deliberately looser than storage. `looksLikeImageUrl` answers "is this column pictures or prose", which decides how a whole file is read; an http:// address is a perfectly clear answer to that question, and refusing it there would send the import through the text parser instead, silently turning every picture into a third card side. **And the CLI now says what it knows.** It never fetches a URL, so it cannot tell you the picture loads — but Wikimedia serves thumbnails at 120, 250, 330, 500, 960 and 1280 px only and answers 400 for every other width, which an agent writing `320px-` or `800px-` hits constantly. Warned about on stderr with the fix named (drop /thumb/ for the original, which is always served), never fatal, because the list is Wikimedia's to change and a stale check must not fail somebody's import. `--help` gains a CARD IMAGES section so the rules are readable before they are hit. Verified against staging with the real binary: http:// exits 9 naming the scheme, an 800px Wikimedia thumb writes and warns. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GJNMFxwtrBouskkFbQuA2u
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Reported as "an agent created a deck with images through the CLI in production and the images are not rendering". Reproduced on staging with a deliberately mixed deck, and it turned out to be two independent failures — one fixable here, one not.
loopky card list --jsonread every URL back byte-identical, so nothing is wrong on the write side. The refs are correct; they are unfetchable.1. Coil's user-agent is refused (fixed here, Android only)
CardMediaImagehands a remote ref straight toAsyncImage, and nothing incomposeAppinstalled anImageLoader— so requests went out asokhttp/4.12.0. Wikimedia refuses that outright:The same URL returns
200 image/jpegto a descriptive agent, so it is the header and nothing else. Varying only that header:okhttp/4.12.0Loopky/0.7.1 (+https://github.com/jvsena42/loopky)iosApp/1 CFNetwork/… Darwin/…Fixed with
loopkyImageLoader, a singleton CoilImageLoaderwhose only job is that header, installed viaSingletonImageLoader.setSafeinLoopkyApp.Why this is invisible.
loopkystores a URL and never fetches it, so--jsonreports a card with an image while both the write and the read work exactly as designed. AndAsyncImagehas no error slot here, so the card renders with a blank half and no log line says why — the deck looks broken rather than unfetched.The header follows Wikimedia's policy — an identifying name, a version, and a link that reaches a human. It is a contact string, not a disguise: impersonating a browser would also work today and is the thing the policy is written against.
iOS is not affected by this one.
AsyncImage(url:)goes throughURLSession, whose default agent carries the bundle name, and the same host answers that with 200. Not verified on a simulator (this machine is Linux, no Xcode) — a code-and-protocol reading, not a device run. What iOS does share is the silent failure, and it offers no way to set a header if a host ever demands one.2. Wikimedia rejects arbitrary thumbnail widths (not fixable from the client)
Independent of the agent:
upload.wikimedia.orgnow serves only a fixed set of thumbnail widths and answers everything else400, Use thumbnail sizes listed on https://w.wiki/GHai. With a known-good user-agent, the allowed set is 120 / 250 / 330 / 500 / 1280, plus the original un-thumbed file.This matters more than it looks: an agent asked for pictures writes
320px-…or800px-…as readily as250px-, and those are a blank card on both platforms even with the UA fix in.Verified on device
emulator-5554, staging, against a 10-card matrix built for this:upload.wikimedia.org, 320px / 280px thumbdummyimage.compngpicsum.photos, 302 redirectfastly.picsum.photos, directupload.wikimedia.org, original fileupload.wikimedia.org, 250px thumbupload.wikimedia.org, 320px thumbRedirects, query strings and the text-plus-picture shape were never the problem — 03–07 passed throughout, which is what made the host the only variable left. Card 10 is the control that stays blank on purpose.
./gradlew detektAllgreen. Run recorded injourneys/RESULTS.md.Still open (not in this PR)
http://as readily ashttps://(looksLikeImageUrl), while Android at targetSdk 36 blocks cleartext and iOS ATS does the same — so anhttp://ref is unloadable on both clients by construction, written without complaint. Found by readingCardFile.kt, not exercised in this run.--jsonsays success either way, which is the gap that let a whole production deck get built on 403s.🤖 Generated with Claude Code
https://claude.ai/code/session_01GJNMFxwtrBouskkFbQuA2u