Skip to main content

proofsheet_core/
device.rs

1//! Device presets.
2//!
3//! # Why output pixels come first
4//!
5//! Apple and Google state their requirements in **output pixels** — "1320 x
6//! 2868", "1024 x 500". A browser, however, is driven in **CSS pixels** plus a
7//! device pixel ratio. Storing the CSS size and multiplying is the obvious
8//! design and it is the wrong one: it lets a preset exist that cannot produce
9//! a required size, and the failure only shows up as a rejected upload.
10//!
11//! So a [`Device`] stores the required output size and *derives* the viewport
12//! as `output / scale`. A preset whose output does not divide evenly by its
13//! scale is rejected at parse time, which makes "the size we emit is a size
14//! the store accepts" a structural property rather than arithmetic somebody
15//! has to get right by hand.
16//!
17//! # Why this is data
18//!
19//! Stores change these numbers without warning. The table lives in
20//! `crates/proofsheet-core/presets/devices.json` so it can be corrected
21//! without cutting a release, and `--presets` overrides it at runtime.
22//!
23//! It lives INSIDE the crate deliberately. `include_str!` reaching outside
24//! the crate directory compiles locally and then fails for everyone who
25//! installs from crates.io, because `cargo package` only ships files under
26//! the crate root. That shipped once as a crate that could not compile.
27
28use serde::{Deserialize, Serialize};
29
30use crate::error::{Error, Result};
31
32/// Which store a preset targets.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
34#[serde(rename_all = "lowercase")]
35pub enum Store {
36    Apple,
37    Play,
38    #[default]
39    Web,
40}
41
42/// What kind of device is being emulated.
43///
44/// # Why this exists
45///
46/// Setting the viewport is **not** device emulation. A page served to a
47/// desktop User-Agent can declare `<meta name="viewport" content="width=1120">`,
48/// and Chrome honours that meta tag whenever `mobile` is set — so the layout
49/// viewport becomes 1120 CSS px and the desktop layout is merely *scaled down*
50/// into a phone-sized frame. The image is the right number of pixels and shows
51/// entirely the wrong thing.
52///
53/// Measured against a real site with identical metrics, changing only the
54/// User-Agent and touch points:
55///
56/// | | metrics only | + UA + touch |
57/// |---|---|---|
58/// | `innerWidth` | 1120 | 440 |
59/// | `maxTouchPoints` | 0 | 5 |
60/// | meta viewport | `width=1120` | `width=device-width` |
61///
62/// The server returned different HTML. Emulating the platform is therefore
63/// part of producing a correct screenshot, not a nicety.
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
65#[serde(rename_all = "snake_case")]
66pub enum Platform {
67    IosPhone,
68    IosTablet,
69    IosWatch,
70    AndroidPhone,
71    AndroidTablet,
72    Macos,
73    Tv,
74    #[default]
75    Web,
76}
77
78impl Platform {
79    /// A representative User-Agent for this platform.
80    ///
81    /// These are deliberately generic-but-plausible rather than pinned to one
82    /// handset: the goal is for content negotiation to pick the right layout,
83    /// not to impersonate a specific device.
84    pub fn user_agent(self) -> Option<&'static str> {
85        Some(match self {
86            Platform::IosPhone => {
87                "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) \
88                 AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 \
89                 Mobile/15E148 Safari/604.1"
90            }
91            Platform::IosTablet => {
92                "Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) \
93                 AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 \
94                 Mobile/15E148 Safari/604.1"
95            }
96            Platform::IosWatch => {
97                "Mozilla/5.0 (Apple Watch; CPU WatchOS 10_0 like Mac OS X) \
98                 AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148"
99            }
100            Platform::AndroidPhone => {
101                "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 \
102                 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36"
103            }
104            Platform::AndroidTablet => {
105                "Mozilla/5.0 (Linux; Android 14; Pixel Tablet) \
106                 AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 \
107                 Safari/537.36"
108            }
109            Platform::Macos => {
110                "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \
111                 AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 \
112                 Safari/605.1.15"
113            }
114            // Leave the browser's own UA alone for TV and generic web: there
115            // is no single credible string, and a wrong one is worse than none.
116            Platform::Tv | Platform::Web => return None,
117        })
118    }
119
120    /// Platform name for User-Agent Client Hints (`Sec-CH-UA-Platform`).
121    ///
122    /// Modern sites increasingly branch on Client Hints rather than the UA
123    /// string, so overriding one without the other produces a page that is
124    /// half-convinced it is on a phone.
125    pub fn ch_platform(self) -> &'static str {
126        match self {
127            Platform::IosPhone | Platform::IosTablet | Platform::IosWatch => "iOS",
128            Platform::AndroidPhone | Platform::AndroidTablet => "Android",
129            Platform::Macos => "macOS",
130            Platform::Tv | Platform::Web => "Linux",
131        }
132    }
133
134    /// Whether Client Hints should report a mobile device.
135    pub fn ch_mobile(self) -> bool {
136        matches!(
137            self,
138            Platform::IosPhone
139                | Platform::IosWatch
140                | Platform::AndroidPhone
141                | Platform::IosTablet
142                | Platform::AndroidTablet
143        )
144    }
145
146    /// Simultaneous touch points to report, or 0 for a pointer device.
147    pub fn touch_points(self) -> u32 {
148        match self {
149            Platform::IosPhone
150            | Platform::IosTablet
151            | Platform::AndroidPhone
152            | Platform::AndroidTablet => 5,
153            Platform::IosWatch => 1,
154            Platform::Macos | Platform::Tv | Platform::Web => 0,
155        }
156    }
157}
158
159/// How strongly the store asks for this size.
160///
161/// Free text from the documentation is deliberately narrowed to an enum: an
162/// unknown value fails the parse rather than being silently treated as
163/// optional, because quietly downgrading a required asset is the expensive
164/// failure.
165#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
166#[serde(rename_all = "snake_case")]
167pub enum Requirement {
168    /// Must be supplied.
169    Required,
170    /// Required only in a stated situation (e.g. 6.5" when 6.9" is absent).
171    Conditional,
172    /// Explicitly recommended by the store, not mandatory.
173    Recommended,
174    /// Accepted, not asked for.
175    Optional,
176    RequiredIpad,
177    RequiredMac,
178    RequiredTv,
179    RequiredVision,
180    RequiredWatch,
181}
182
183impl Requirement {
184    /// Whether omitting this asset can block or degrade a submission.
185    pub fn is_mandatory(self) -> bool {
186        !matches!(self, Requirement::Optional | Requirement::Recommended)
187    }
188
189    /// Stable snake_case name, matching the JSON representation.
190    ///
191    /// Written out rather than derived from `Debug`, because lowercasing
192    /// `RequiredIpad` silently yields `requiredipad`.
193    pub fn as_str(self) -> &'static str {
194        match self {
195            Requirement::Required => "required",
196            Requirement::Conditional => "conditional",
197            Requirement::Recommended => "recommended",
198            Requirement::Optional => "optional",
199            Requirement::RequiredIpad => "required_ipad",
200            Requirement::RequiredMac => "required_mac",
201            Requirement::RequiredTv => "required_tv",
202            Requirement::RequiredVision => "required_vision",
203            Requirement::RequiredWatch => "required_watch",
204        }
205    }
206}
207
208impl std::fmt::Display for Requirement {
209    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
210        // f.pad, NOT f.write_str. write_str goes straight to the underlying
211        // buffer and silently discards the format spec, so `{:<16}` on a
212        // Requirement produced no padding at all and the CLI's table ran its
213        // REQUIREMENT and VERIFIED columns together as "requiredyes".
214        //
215        // This is a library bug, not a CLI one: it affected every caller who
216        // formatted a Requirement with a width. pad() honours width, fill,
217        // alignment and precision.
218        f.pad(self.as_str())
219    }
220}
221
222/// One capture target.
223#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
224pub struct Device {
225    /// Stable identifier, used in filenames and receipts.
226    pub id: String,
227    /// Human label for reports.
228    pub label: String,
229    /// Required output width in real pixels.
230    pub output_width: u32,
231    /// Required output height in real pixels.
232    pub output_height: u32,
233    /// Device pixel ratio the page renders at.
234    pub scale: u32,
235    /// Emulate a mobile viewport.
236    #[serde(default)]
237    pub mobile: bool,
238    /// What platform to emulate: User-Agent, Client Hints and touch points.
239    /// Without this, a desktop UA is sent and UA-sniffing sites return their
240    /// desktop layout regardless of the viewport size.
241    #[serde(default)]
242    pub platform: Platform,
243    #[serde(default)]
244    pub store: Store,
245    pub requirement: Requirement,
246    /// True only when the numbers were read from official documentation.
247    #[serde(default)]
248    pub verified: bool,
249    /// The documentation URL the numbers came from.
250    #[serde(default)]
251    pub source: String,
252}
253
254impl Device {
255    /// The CSS-pixel viewport to drive the browser with.
256    ///
257    /// Exact by construction: [`parse_presets`] rejects any preset where this
258    /// division would not be exact.
259    pub fn viewport(&self) -> (u32, u32) {
260        (
261            self.output_width / self.scale,
262            self.output_height / self.scale,
263        )
264    }
265
266    /// The pixel dimensions the captured image must have.
267    pub fn output_size(&self) -> (u32, u32) {
268        (self.output_width, self.output_height)
269    }
270
271    fn validate(&self) -> Result<()> {
272        if self.scale == 0 {
273            return Err(Error::Shape(format!("device {}: scale is zero", self.id)));
274        }
275        if self.output_width == 0 || self.output_height == 0 {
276            return Err(Error::Shape(format!(
277                "device {}: zero output dimension",
278                self.id
279            )));
280        }
281        if self.output_width % self.scale != 0 || self.output_height % self.scale != 0 {
282            return Err(Error::Shape(format!(
283                "device {}: output {}x{} does not divide by scale {}, so no \
284                 integer viewport can produce it",
285                self.id, self.output_width, self.output_height, self.scale
286            )));
287        }
288        Ok(())
289    }
290}
291
292/// The on-disk preset file.
293#[derive(Debug, Clone, Serialize, Deserialize)]
294pub struct PresetFile {
295    pub version: u32,
296    #[serde(default)]
297    pub note: String,
298    #[serde(default)]
299    pub verified_on: String,
300    pub devices: Vec<Device>,
301}
302
303/// Parse and validate a preset file.
304pub fn parse_presets(json: &str) -> Result<Vec<Device>> {
305    let f: PresetFile = serde_json::from_str(json)?;
306    if f.version != 1 {
307        return Err(Error::Shape(format!(
308            "preset schema version {} is not supported by this build",
309            f.version
310        )));
311    }
312    for d in &f.devices {
313        d.validate()?;
314    }
315    let mut ids: Vec<&str> = f.devices.iter().map(|d| d.id.as_str()).collect();
316    ids.sort_unstable();
317    let before = ids.len();
318    ids.dedup();
319    if ids.len() != before {
320        return Err(Error::Shape("duplicate device id in preset file".into()));
321    }
322    Ok(f.devices)
323}
324
325/// The compiled-in fallback table.
326pub fn builtin() -> Vec<Device> {
327    parse_presets(include_str!("../presets/devices.json"))
328        .expect("built-in presets must parse; enforced by tests")
329}
330
331/// Look a device up by id.
332pub fn by_id(devices: &[Device], id: &str) -> Option<Device> {
333    devices.iter().find(|d| d.id == id).cloned()
334}
335
336/// Every device targeting a given store.
337pub fn for_store(devices: &[Device], store: Store) -> Vec<Device> {
338    devices
339        .iter()
340        .filter(|d| d.store == store)
341        .cloned()
342        .collect()
343}
344
345#[cfg(test)]
346mod tests {
347    /// Display must honour the format spec. Written because it did not:
348    /// `f.write_str` bypasses padding entirely, which is invisible until a
349    /// column-aligned table collides.
350    #[test]
351    fn requirement_display_honours_width_and_alignment() {
352        let r = Requirement::Required;
353        assert_eq!(format!("{r}"), "required");
354        assert_eq!(format!("{r:<16}"), "required        ");
355        assert_eq!(format!("{r:>10}"), "  required");
356        assert_eq!(format!("{r:*^12}"), "**required**");
357    }
358
359    /// The CLI table's real failure mode, reproduced at the library level:
360    /// a padded requirement followed immediately by another column.
361    #[test]
362    fn padded_requirement_does_not_collide_with_next_column() {
363        let line = format!("{:<16}{}", Requirement::Required, "yes");
364        assert!(!line.contains("requiredyes"), "columns collided: {line:?}");
365        assert_eq!(line, "required        yes");
366    }
367
368    use super::*;
369
370    #[test]
371    fn builtin_presets_parse() {
372        assert!(!builtin().is_empty());
373    }
374
375    /// The core invariant. If this fails, some preset cannot produce the
376    /// pixel count the store demands, and the harness would emit a
377    /// silently-wrong asset.
378    #[test]
379    fn every_builtin_output_divides_evenly_by_scale() {
380        for d in builtin() {
381            assert_eq!(
382                d.output_width % d.scale,
383                0,
384                "{}: width {} not divisible by {}",
385                d.id,
386                d.output_width,
387                d.scale
388            );
389            assert_eq!(
390                d.output_height % d.scale,
391                0,
392                "{}: height {} not divisible by {}",
393                d.id,
394                d.output_height,
395                d.scale
396            );
397            let (vw, vh) = d.viewport();
398            assert_eq!((vw * d.scale, vh * d.scale), d.output_size());
399        }
400    }
401
402    /// Google Play rejects screenshots outside these bounds. Encoding the
403    /// rule as a test means a future edit to the JSON cannot quietly
404    /// introduce an unusable Play preset.
405    #[test]
406    fn play_screenshots_respect_documented_bounds() {
407        for d in builtin() {
408            if d.store != Store::Play || d.id == "play-feature-graphic" {
409                continue;
410            }
411            let lo = d.output_width.min(d.output_height);
412            let hi = d.output_width.max(d.output_height);
413            assert!(lo >= 320, "{}: min dimension {lo} below Play's 320", d.id);
414            assert!(hi <= 3840, "{}: max dimension {hi} above Play's 3840", d.id);
415            assert!(
416                hi <= 2 * lo,
417                "{}: {hi} exceeds twice {lo}; Play forbids this ratio",
418                d.id
419            );
420        }
421    }
422
423    /// Anything claiming a store requirement must cite where the number came
424    /// from. This is the guard against inventing authority.
425    #[test]
426    fn store_presets_are_verified_and_sourced() {
427        for d in builtin() {
428            if d.store == Store::Web {
429                continue;
430            }
431            assert!(d.verified, "{} targets a store but is not verified", d.id);
432            assert!(
433                d.source.starts_with("https://"),
434                "{} has no source URL",
435                d.id
436            );
437        }
438    }
439
440    #[test]
441    fn known_apple_sizes_are_present() {
442        let b = builtin();
443        for id in [
444            "apple-iphone-6-9-1320",
445            "apple-iphone-6-9-1290",
446            "apple-ipad-13-2064",
447            "apple-ipad-13-2048",
448        ] {
449            assert!(by_id(&b, id).is_some(), "missing required preset {id}");
450        }
451        // 6.9" at 1320x2868 is the current-generation flagship size.
452        let d = by_id(&b, "apple-iphone-6-9-1320").unwrap();
453        assert_eq!(d.output_size(), (1320, 2868));
454        assert_eq!(d.viewport(), (440, 956));
455    }
456
457    #[test]
458    fn play_feature_graphic_is_exact() {
459        let d = by_id(&builtin(), "play-feature-graphic").unwrap();
460        assert_eq!(d.output_size(), (1024, 500));
461        assert_eq!(d.scale, 1);
462    }
463
464    #[test]
465    fn indivisible_preset_is_rejected_with_a_useful_message() {
466        let js = r#"{"version":1,"devices":[{"id":"bad","label":"bad",
467            "output_width":1001,"output_height":2000,"scale":3,
468            "requirement":"optional"}]}"#;
469        let e = parse_presets(js).unwrap_err().to_string();
470        assert!(e.contains("does not divide"), "unhelpful message: {e}");
471    }
472
473    #[test]
474    fn duplicate_ids_are_rejected() {
475        let js = r#"{"version":1,"devices":[
476            {"id":"a","label":"a","output_width":2,"output_height":2,"scale":1,
477             "requirement":"optional"},
478            {"id":"a","label":"b","output_width":4,"output_height":4,"scale":1,
479             "requirement":"optional"}]}"#;
480        assert!(parse_presets(js).is_err());
481    }
482
483    #[test]
484    fn unknown_requirement_fails_rather_than_defaulting() {
485        let js = r#"{"version":1,"devices":[{"id":"a","label":"a",
486            "output_width":2,"output_height":2,"scale":1,
487            "requirement":"whenever_you_feel_like_it"}]}"#;
488        assert!(parse_presets(js).is_err());
489    }
490
491    #[test]
492    fn future_schema_version_is_rejected_clearly() {
493        let e = parse_presets(r#"{"version":99,"devices":[]}"#)
494            .unwrap_err()
495            .to_string();
496        assert!(e.contains("99"));
497    }
498
499    /// `as_str` and the serde name must agree, or the JSON a user edits will
500    /// not match the label the CLI prints back at them.
501    #[test]
502    fn requirement_labels_match_their_serde_names() {
503        for r in [
504            Requirement::Required,
505            Requirement::Conditional,
506            Requirement::Recommended,
507            Requirement::Optional,
508            Requirement::RequiredIpad,
509            Requirement::RequiredMac,
510            Requirement::RequiredTv,
511            Requirement::RequiredVision,
512            Requirement::RequiredWatch,
513        ] {
514            let serde_name = serde_json::to_string(&r).unwrap();
515            assert_eq!(serde_name, format!("\"{}\"", r.as_str()));
516        }
517    }
518
519    /// The bug this guards: phones were emulated with a desktop User-Agent,
520    /// so UA-sniffing sites served desktop layouts at phone pixel counts.
521    /// Every dimension assertion passed.
522    #[test]
523    fn phone_and_tablet_presets_emulate_a_touch_platform() {
524        for d in builtin() {
525            let p = d.platform;
526            if d.id.contains("iphone") || d.id.starts_with("play-phone") {
527                assert!(
528                    matches!(p, Platform::IosPhone | Platform::AndroidPhone),
529                    "{}: platform is {:?}, not a phone",
530                    d.id,
531                    p
532                );
533                assert!(p.user_agent().is_some(), "{}: no UA override", d.id);
534                assert!(p.touch_points() > 0, "{}: reports no touch", d.id);
535                assert!(p.ch_mobile(), "{}: Client Hints say not mobile", d.id);
536            }
537            if d.id.contains("ipad") {
538                assert_eq!(p, Platform::IosTablet, "{}: not a tablet", d.id);
539                assert!(p.user_agent().unwrap().contains("iPad"), "{}", d.id);
540            }
541        }
542    }
543
544    #[test]
545    fn desktop_platforms_report_no_touch() {
546        assert_eq!(Platform::Macos.touch_points(), 0);
547        assert_eq!(Platform::Web.touch_points(), 0);
548        assert!(!Platform::Macos.ch_mobile());
549    }
550
551    /// A wrong User-Agent is worse than none, so platforms without a credible
552    /// string must return None rather than guessing.
553    #[test]
554    fn platforms_without_a_credible_ua_return_none() {
555        assert!(Platform::Web.user_agent().is_none());
556        assert!(Platform::Tv.user_agent().is_none());
557    }
558
559    #[test]
560    fn every_ua_names_its_platform() {
561        for (p, needle) in [
562            (Platform::IosPhone, "iPhone"),
563            (Platform::IosTablet, "iPad"),
564            (Platform::AndroidPhone, "Android"),
565            (Platform::AndroidTablet, "Android"),
566            (Platform::Macos, "Macintosh"),
567        ] {
568            assert!(
569                p.user_agent().unwrap().contains(needle),
570                "{p:?} UA does not mention {needle}"
571            );
572        }
573    }
574
575    #[test]
576    fn store_filter_partitions_the_table() {
577        let b = builtin();
578        let n = for_store(&b, Store::Apple).len()
579            + for_store(&b, Store::Play).len()
580            + for_store(&b, Store::Web).len();
581        assert_eq!(n, b.len());
582    }
583}