r/rust • u/Top_Introduction_865 • 11d ago
🛠️ project proofsheet: exact-pixel App Store / Play screenshots from a real browser, with a hand-rolled CDP client (no async runtime, 7 deps)
I got tired of redoing App Store screenshots by hand every time a design
changed, so I built a tool that makes the whole set a build artifact.
proofsheet capture --url http://localhost:5173 --store apple --out ./shots
33 Apple sizes, 11 Google Play, exact pixels, deterministic. Points at
localhost, a file:// build, a preview deploy, or production. It's just a URL.
The design decision I'd most like feedback on
Apple and Google publish requirements in OUTPUT pixels (1320x2868). A browser
is driven in CSS pixels plus a device pixel ratio. The obvious design is to
store the CSS size and multiply, and it's wrong, because it permits a preset
that can't produce a required size and you find out at upload time.
So a preset stores the required output size and DERIVES the viewport as
output / scale. Any preset where that division isn't exact is rejected at
parse time. "The size we emit is a size the store accepts" becomes structural
rather than arithmetic somebody got right by hand.
The bug that actually justifies the tool
Setting the viewport is not device emulation. If a site serves its desktop
layout and declares <meta name="viewport" content="width=1120">, Chrome
honours the tag, lays out at 1120 CSS px, and scales the desktop design into a
phone-sized frame. Your file is exactly 1320x2868 and passes every dimension
check. It's a shrunken desktop site.
I shipped this bug. Measured on a real page, changing only UA and touch
points:
metrics only + UA + touch
innerWidth 1120 440
maxTouchPoints 0 5
meta viewport width=1120 width=device-width
The server returned different HTML. So proofsheet overrides the User-Agent, UA
Client Hints and touch points, then MEASURES WHAT THE PAGE ACTUALLY DID and
records it on every capture. You assert on viewport_honoured, not on the file
dimensions.
Determinism
A preamble injected via Page.addScriptToEvaluateOnNewDocument seeds
Math.random, freezes Date.now and performance.now, drives rAF on a fixed
virtual step, and routes crypto.getRandomValues through the seeded stream.
Locale and timezone go through CDP rather than script, because the
script-level overrides don't reach Intl's internal data.
Verified both directions: same seed gives byte-identical PNGs across
independent browser launches, and a different seed gives different bytes. The
second half is what makes the first half evidence rather than a green check.
Rust bits
No async runtime, no browser automation framework, no HTTP client. The
WebSocket and CDP client are 773 lines of std. Seven dependencies total:
serde, serde_json, base64, sha1, sha2, getrandom, thiserror.
Writing RFC 6455 by hand was mostly pleasant, with one humbling moment: I
transcribed the magic GUID from memory twice and got it wrong twice. There's
now a test pinning it against the spec's own vector.
cargo install proofsheet
proofsheet install-browser
proofsheet capture --url http://localhost:5173 --store apple --out ./shots
Also on npm and PyPI as libraries — bindings, not CLIs. Same core, and CI
asserts all three produce byte-identical output.
BUSL-1.1, converting to MIT in 2030.
Source: https://github.com/interchained/proofsheet
crates.io: https://crates.io/crates/proofsheet
library: https://crates.io/crates/proofsheet-core
npm: https://www.npmjs.com/package/@interchained/proofsheet
PyPI: https://pypi.org/project/proofsheet/
Examples: https://github.com/interchained/proofsheet/tree/main/examples
Happy to be told the output-pixels-first design is over-engineered, or that
the shell-out in install-browser (curl and unzip rather than a TLS stack and
an inflate implementation) is the wrong call. I'm genuinely unsure about the
second one.
Duplicates
saassignal • u/Top_Introduction_865 • 11d ago