Skip to content

Give Coil a descriptive User-Agent so remote card images load - #223

Merged
jvsena42 merged 2 commits into
mainfrom
fix/remote-card-image-user-agent
Sep 4, 2026
Merged

Give Coil a descriptive User-Agent so remote card images load#223
jvsena42 merged 2 commits into
mainfrom
fix/remote-card-image-user-agent

Conversation

@jvsena42

@jvsena42 jvsena42 commented Sep 4, 2026

Copy link
Copy Markdown
Owner

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 --json read 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)

CardMediaImage hands a remote ref straight to AsyncImage, and nothing in composeApp installed an ImageLoader — so requests went out as okhttp/4.12.0. Wikimedia refuses that outright:

HTTP/2 403   server: HAProxy   content-type: text/plain
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. Varying only that header:

User-Agent Result
okhttp/4.12.0 403, every URL tried
(empty) 403
Loopky/0.7.1 (+https://github.com/jvsena42/loopky) 200 image/jpeg
iosApp/1 CFNetwork/… Darwin/… 200 image/jpeg

Fixed with loopkyImageLoader, a singleton Coil ImageLoader whose only job is that header, installed via SingletonImageLoader.setSafe in LoopkyApp.

Why this is invisible. loopky stores a URL and never fetches it, so --json reports a card with an image while both the write and the read work exactly as designed. And AsyncImage has 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 through URLSession, 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.org now serves only a fixed set of thumbnail widths and answers everything else 400, 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-… or 800px-… as readily as 250px-, 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:

Card Host / shape Before After
01–02 upload.wikimedia.org, 320px / 280px thumb ❌ blank ❌ blank (width, not UA)
03 dummyimage.com png
04 picsum.photos, 302 redirect
05 fastly.picsum.photos, direct
06 text and picture on the front
07 both sides pictured
08 upload.wikimedia.org, original file ❌ blank
09 upload.wikimedia.org, 250px thumb ❌ blank
10 upload.wikimedia.org, 320px thumb ❌ blank ❌ blank (control)

Redirects, 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 detektAll green. Run recorded in journeys/RESULTS.md.

Still open (not in this PR)

  • The CLI accepts http:// as readily as https:// (looksLikeImageUrl), while Android at targetSdk 36 blocks cleartext and iOS ATS does the same — so an http:// ref is unloadable on both clients by construction, written without complaint. Found by reading CardFile.kt, not exercised in this run.
  • More broadly, nothing on the write path tells an agent that the picture it just attached will not render. --json says 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

jvsena42 and others added 2 commits September 4, 2026 07:05
…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
@jvsena42
jvsena42 merged commit 7f25773 into main Sep 4, 2026
4 checks passed
@jvsena42
jvsena42 deleted the fix/remote-card-image-user-agent branch September 4, 2026 10:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant