Skip to content

Carry a link's icon in the encrypted push instead of asking Google for it - #21

Merged
cdeutsch merged 17 commits into
mainfrom
claude/pushthat-data-transmission-0f5e66
Nov 12, 2026
Merged

cdeutsch merged 17 commits into
mainfrom
claude/pushthat-data-transmission-0f5e66

Conversation

@cdeutsch

Copy link
Copy Markdown
Contributor

Why

Every client drew a link's icon from Google's favicon service (www.google.com/s2/favicons?domain=<host>). Each time a list was drawn, that handed Google the host of every pushed link, in plaintext and with the device's IP address, which is exactly what the envelope keeps from Google. File-type icons came from pushthat.link/files/<ext>, which put the type of every encrypted file a list showed into our hosting logs. Neither was in the privacy policy.

What changed

  • Spec (docs/e2ee.md, new "Icons" section): a URL push's envelope gains an optional icon, a PNG of at most 64 by 64 pixels and 4096 bytes in base64. The section covers the candidate order, the bounds, when a push is rewritten, how a row falls back, and why fetching from the site at display time and a drawn monogram were rejected. The vectors are unchanged: the field adds no cryptography, and icon is written only when present, so a push without one encrypts the same bytes as before.
  • Android, iOS, macOS: the device that scrapes the title also takes the page's icon in the same pass, shrinks it and seals it with the title. The write is the existing retitle (enc and searchTokens), so the rules do not change. It runs in a transaction that re-reads the push, so a concurrent title-only write cannot drop an icon another device saved. Each request is time-bounded. The row draws only what the push carries: its icon, a file-type icon shipped in the app, or the placeholder. Glide is gone from Android.
  • Web and extension: reads icon from the envelope as a data: URL, after checking it is base64 of a PNG within the bounds. The file-type SVGs are inlined into the bundle. The PNGs stay in public/files for app versions from before this change.
  • Privacy and security pages: a page's icon is now listed as encrypted Push Content.
  • Android release note: updated, including that links pushed before the update show a plain icon.

For the reviewer

  • Older links lose their icon. Pushes made before this change carry no icon and are not fetched again for one, so they show the placeholder. A web push gets its icon once a phone or Mac that receives it fetches the title.
  • Merging deploys the web change (apps/web).
  • Polished in two review rounds before opening this PR (12 fix commits on top of the original 5). One finding was declined: inlining the file-type SVGs adds about 19 KB gzipped to the single bundle. The real fix is splitting the list into its own code chunk, which is a separate change.

Verification

  • Web: npm run lint, npm run test:keys (72 passing, including new tests/pageIcon.mjs), npm run build and npm run build:chrome.
  • Android: ./gradlew test and ./gradlew assembleDebug.
  • iOS: the documented simulator build passes, and the unit tests (49) pass.
  • macOS: the documented build fails only on the existing deployment-target issue in docs/macos.md. With MACOSX_DEPLOYMENT_TARGET=14.0 it builds and its 49 tests pass.
  • Not run: test:rules and test:emulator (no rules or functions changed), and nothing has been tried on a real device against real pages.

🤖 Generated with Claude Code

cdeutsch and others added 17 commits November 12, 2026 15:00
Every client drew a link's icon from Google's favicon service, which handed Google the host of every pushed link, in plaintext and with the device's address, each time a list was drawn. That is exactly what the envelope exists to keep from Google.

The `icon` field is a small PNG that the device which scrapes the title captures from the page in the same pass and seals with the title, so no device asks anyone for an icon to draw a row. The section records the bounds, the candidate order, when a push is rewritten, how a row falls back, and why fetching from the site at display time and a drawn monogram were rejected. File-type icons ship inside each app so that pushthat.link's logs stop seeing encrypted files' types.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The list built `https://www.google.com/s2/favicons?domain=<host>` for every URL push, so Google learned the host of every pushed link each time the list was drawn, and file icons came from `/files/<ext>.svg`, which put the type of each encrypted file into the hosting logs.

A URL push now draws the `icon` from its decrypted envelope (docs/e2ee.md, Icons) as a `data:` URL, accepted only when it is plain base64 within the spec's bound. Without one it falls back to the file-type icon for its `ext`, then the placeholder. The file-type SVGs moved into `src/assets` and are inlined into the bundle, so drawing a row makes no request at all. The PNGs stay in `public/files` for app versions from before this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… too

URL pushes now carry their page's icon inside the encrypted envelope, so it belongs in what the policy calls Push Content and in the security page's list of what is encrypted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The list loaded every link's icon from Google's favicon service, which told Google the host of every pushed link each time the list was drawn, and loaded file-type icons from pushthat.link, which put encrypted files' types in its logs.

`TitleScrapingWorker` now also takes the page's icon from the page it already reads, shrinks it to the spec's bounds and seals it with the title (docs/e2ee.md, Icons). A push that has an icon keeps it, and when the icon would push the envelope past `enc.ct`'s bound the title is written alone. The row draws only what the push carries: its icon, the bundled icon for its type, or the placeholder, so Glide and every image request are gone. The candidate order and sizing live in `PageIcons` so the JVM tests cover them, and `PushDocument.retitle` holds the rewrite rules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…gle for it

`PushRowView` loaded every link's icon from Google's favicon service, which told Google the host of every pushed link each time the list was drawn, and loaded file-type icons from pushthat.link, which put encrypted files' types in its logs.

`TitleScraperService` now also takes the page's icon in the pass that reads its title, shrinks it with ImageIO to the spec's bounds and seals it with the title (docs/e2ee.md, Icons). A push that has an icon keeps it, and when the icon would push the envelope past `enc.ct`'s bound the title is written alone. The row draws only what the push carries: its icon, the file-type icon from the asset catalog, or the placeholder, with no `AsyncImage` left. The candidate scanner and the image work live in `PushContent.swift`'s `PushIcon`, which the test target already compiles. The same change is made in both apps, since `apps/macos` is a fork of `apps/ios`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…write

The worker read the push, scraped for seconds, then wrote a blind update. Two devices scraping the same web push used to write the same title, so the last write did no harm, but their icons can differ (one device's decoder may open an icon another's refuses). A title-only write landing after a title-and-icon one dropped the icon for good, because a titled push is never scraped again.

The write now runs in a transaction that re-reads the push and hands that to `PushDocument.retitle`, which keeps an icon the push gained meanwhile and writes nothing when nothing is new. The too-large case is caught inside the transaction, so the worker's own catch for it is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…at the write

Two things were wrong with how iOS and macOS wrote a scrape back. The write was a blind update of what the list had read before a scrape that takes seconds, so when another device wrote the same push meanwhile, a title-only write could land after its title-and-icon one and drop the icon for good, since a titled push is never scraped again. And a scrape whose only find was an icon too large to fit sealed the same title again, a write every listening device received, repeated on every launch for a page with no title.

`PushContent.rescraped` now holds the rules Android's `PushDocument.retitle` follows: write for a new title or a first icon, keep the icon a push has, write the title alone when the icon does not fit, and nothing when nothing is new. `PushDocuments.retitle` applies it in a transaction to the push as it is at the write. The same change is made in both apps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…b stops

The scrape's timeouts bounded only the gap between bytes, so a server sending a byte every few seconds held a request open indefinitely. That was tolerable for one page, but the icon adds up to nine requests after it. On Android the candidate loop also blocked on an IO thread and ran on through every candidate after WorkManager had stopped the worker.

Android now gives every request of a scrape a 15-second `callTimeout` and checks between candidates both that the worker is still running and a 30-second budget for the icons. iOS and macOS check the same budget, and the task's cancellation, between candidates and while one downloads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s properties

`FILE_ICONS` was a plain object indexed by the extension, which comes from a file's name. A file named with an extension such as `constructor` or `__proto__` got what the object inherits: a function, which React drops so the row drew nothing, or an object, which became a request for `[object Object]`. A `Map` answers only for the icons it holds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Links pushed before this version carry no icon and are not fetched again for one, so on the phone they change from a site's logo to the placeholder. Unexplained, that reads as a bug. The note now says so, trimmed elsewhere to stay under Play's 500 characters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…c's bounds

The clients disagreed on which icons they would draw. iOS and macOS opened only a PNG within 64 by 64, Android drew anything `BitmapFactory` opened (sampled to up to 127 pixels a side, past what its cache's size comment allowed) and decoded a refused icon again on every bind, and the web drew whatever the browser decoded behind `data:image/png`. Every writer produces in-spec icons today, but one that did not would draw on two platforms and fall back on the third.

Now every client reads the header first and draws only a PNG of at most 64 by 64 and 4096 bytes, as the spec now says. Android remembers icons it refused, and the web's check moved to `pageIcon.ts` so `npm run test:keys` covers it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`brokenIcons` was keyed by push id and lives as long as the list. A push whose icon changed while the list was open (a later scrape, or an icon dropped for a title that no longer fit) kept drawing the fallback for an icon it no longer had. The phones key their caches by the icon string for this reason, and so does the web now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The "Showing it" paragraph said what a file push draws twice, two sentences apart. The two are now one sentence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e write

The clients now write a scrape in a transaction that re-reads the push, so that a title-only write cannot drop an icon another device wrote meanwhile. The Icons section described the rewrite without it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The icon budget was checked between candidates and only every 4096 bytes during a download, so a server sending one byte every nine seconds stayed under the 10-second idle timeout and held a download open for hours before the first check. The page request had no bound at all, and the cancellation check could never fire, since nothing cancels the detached scrape.

Every request of a scrape now goes through a session whose resource timeout bounds it to 15 seconds, as `callTimeout` does on Android, and the 30-second budget is checked between candidates. Same change in both apps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The web capped the icon at 5464 characters, which is 4096 bytes only when padded: without padding it holds 4098. Android, iOS and macOS refuse anything over 4096 bytes, so an icon of 4097 or 4098 bytes drew on the web and nowhere else, which is the disagreement the display check exists to remove. The check now counts the bytes the base64 decodes to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`PushDocument.retitle` compared the scraped title uncut with the stored one, which the envelope keeps cut to 1000 characters. A page whose title was over the limit therefore always counted as changed, and every scrape of it rewrote the same cut title under a fresh nonce. It now cuts first, as iOS and macOS do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions github-actions Bot added web The web app and the Chrome extension (apps/web) android The Android app (apps/android) ios The iOS app (apps/ios) macos The macOS app (apps/macos) labels Nov 12, 2026
@cdeutsch
cdeutsch merged commit d7eef90 into main Nov 12, 2026
10 checks passed
@cdeutsch
cdeutsch deleted the claude/pushthat-data-transmission-0f5e66 branch November 12, 2026 22:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

android The Android app (apps/android) ios The iOS app (apps/ios) macos The macOS app (apps/macos) web The web app and the Chrome extension (apps/web)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant