1use serde::{Deserialize, Serialize};
29
30use crate::error::{Error, Result};
31
32#[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#[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 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 Platform::Tv | Platform::Web => return None,
117 })
118 }
119
120 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
166#[serde(rename_all = "snake_case")]
167pub enum Requirement {
168 Required,
170 Conditional,
172 Recommended,
174 Optional,
176 RequiredIpad,
177 RequiredMac,
178 RequiredTv,
179 RequiredVision,
180 RequiredWatch,
181}
182
183impl Requirement {
184 pub fn is_mandatory(self) -> bool {
186 !matches!(self, Requirement::Optional | Requirement::Recommended)
187 }
188
189 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(self.as_str())
219 }
220}
221
222#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
224pub struct Device {
225 pub id: String,
227 pub label: String,
229 pub output_width: u32,
231 pub output_height: u32,
233 pub scale: u32,
235 #[serde(default)]
237 pub mobile: bool,
238 #[serde(default)]
242 pub platform: Platform,
243 #[serde(default)]
244 pub store: Store,
245 pub requirement: Requirement,
246 #[serde(default)]
248 pub verified: bool,
249 #[serde(default)]
251 pub source: String,
252}
253
254impl Device {
255 pub fn viewport(&self) -> (u32, u32) {
260 (
261 self.output_width / self.scale,
262 self.output_height / self.scale,
263 )
264 }
265
266 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#[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
303pub 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
325pub fn builtin() -> Vec<Device> {
327 parse_presets(include_str!("../presets/devices.json"))
328 .expect("built-in presets must parse; enforced by tests")
329}
330
331pub fn by_id(devices: &[Device], id: &str) -> Option<Device> {
333 devices.iter().find(|d| d.id == id).cloned()
334}
335
336pub 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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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}