1use bern_ir::Application;
15use std::borrow::Cow;
16use std::path::{Path, PathBuf};
17
18pub const PLATFORMS: [&str; 7] = [
21 "macos", "ios", "tvos", "windows", "linux", "android", "browser",
22];
23
24#[derive(Clone, Debug, PartialEq, Eq)]
26pub enum Source {
27 Logo,
29 Png(PathBuf),
31 Native(PathBuf),
34}
35
36pub fn declared<'a>(app: &'a Application, platform: &str) -> Option<&'a str> {
39 app.app
40 .platform_icons
41 .get(platform)
42 .or(app.app.icon.as_ref())
43 .map(String::as_str)
44}
45
46pub fn source(app: &Application, platform: &str, project_dir: &Path) -> Source {
48 match declared(app, platform) {
49 None => Source::Logo,
50 Some(name) if is_png_name(name) => Source::Png(project_dir.join(name)),
51 Some(name) => Source::Native(project_dir.join(name)),
52 }
53}
54
55pub fn alternate_name(variant: &str) -> String {
58 let mut name = String::new();
59 for (index, c) in variant.chars().enumerate() {
60 if c.is_ascii_uppercase() && index > 0 {
61 name.push('_');
62 }
63 name.push(c.to_ascii_lowercase());
64 }
65 name
66}
67
68pub fn choices(app: &Application) -> Vec<(String, Option<String>)> {
72 let Some(bern_protocol::Type::Enum { name }) = app.windows.icon.as_ref().map(|icon| &icon.ty)
73 else {
74 return Vec::new();
75 };
76 app.types
77 .enum_def(name)
78 .map(|def| {
79 def.variants
80 .iter()
81 .map(|variant| {
82 let alternate =
83 (variant.name != "Default").then(|| alternate_name(&variant.name));
84 (variant.name.clone(), alternate)
85 })
86 .collect()
87 })
88 .unwrap_or_default()
89}
90
91pub fn alternate_source(
95 app: &Application,
96 name: &str,
97 platform: &str,
98 project_dir: &Path,
99) -> Option<Source> {
100 let entries = app.app.alternate_icons.get(name)?;
101 let declared = entries.get(platform).or_else(|| entries.get("default"))?;
102 Some(if is_png_name(declared) {
103 Source::Png(project_dir.join(declared))
104 } else {
105 Source::Native(project_dir.join(declared))
106 })
107}
108
109pub fn alternates(
113 app: &Application,
114 platform: &str,
115 project_dir: &Path,
116) -> Result<Vec<(String, Source)>, String> {
117 choices(app)
118 .into_iter()
119 .filter_map(|(_, alternate)| alternate)
120 .map(|name| match alternate_source(app, &name, platform, project_dir) {
121 Some(source) => Ok((name, source)),
122 None => Err(format!(
123 "[app.alternate_icons.{name}] has no `{platform}` or `default` entry; the {platform} app cannot switch to it"
124 )),
125 })
126 .collect()
127}
128
129pub fn is_png_name(name: &str) -> bool {
131 name.to_ascii_lowercase().ends_with(".png")
132}
133
134pub fn is_icon_document(path: &Path) -> bool {
138 path.extension()
139 .is_some_and(|extension| extension.eq_ignore_ascii_case("icon"))
140}
141
142#[derive(Clone, Copy, Debug, PartialEq, Eq)]
144pub enum Shape {
145 AsDrawn,
147 Masked,
151}
152
153#[derive(Clone, Debug)]
155pub struct Image {
156 pub size: u32,
158 pub png: Cow<'static, [u8]>,
159}
160
161pub const DEFAULT_SVG: &str = include_str!("../assets/icon/bern.svg");
163
164const ROUND: &[(u32, &[u8])] = &[
167 (16, include_bytes!("../assets/icon/round-16.png")),
168 (20, include_bytes!("../assets/icon/round-20.png")),
169 (24, include_bytes!("../assets/icon/round-24.png")),
170 (32, include_bytes!("../assets/icon/round-32.png")),
171 (40, include_bytes!("../assets/icon/round-40.png")),
172 (48, include_bytes!("../assets/icon/round-48.png")),
173 (64, include_bytes!("../assets/icon/round-64.png")),
174 (128, include_bytes!("../assets/icon/round-128.png")),
175 (256, include_bytes!("../assets/icon/round-256.png")),
176 (512, include_bytes!("../assets/icon/round-512.png")),
177];
178const SQUARE: &[(u32, &[u8])] = &[
179 (16, include_bytes!("../assets/icon/square-16.png")),
180 (32, include_bytes!("../assets/icon/square-32.png")),
181 (64, include_bytes!("../assets/icon/square-64.png")),
182 (128, include_bytes!("../assets/icon/square-128.png")),
183 (256, include_bytes!("../assets/icon/square-256.png")),
184 (512, include_bytes!("../assets/icon/square-512.png")),
185 (1024, include_bytes!("../assets/icon/square-1024.png")),
186];
187
188pub const DECLARED_SIZES: [u32; 3] = [256, 512, 1024];
191
192pub fn is_default(app: &Application, platform: &str) -> bool {
194 declared(app, platform).is_none()
195}
196
197pub fn default_images(shape: Shape) -> Vec<Image> {
199 let set = match shape {
200 Shape::AsDrawn => ROUND,
201 Shape::Masked => SQUARE,
202 };
203 set.iter()
204 .map(|(size, png)| Image {
205 size: *size,
206 png: Cow::Borrowed(*png),
207 })
208 .collect()
209}
210
211pub fn images(
216 app: &Application,
217 platform: &str,
218 project_dir: &Path,
219 shape: Shape,
220) -> Result<Vec<Image>, String> {
221 match source(app, platform, project_dir) {
222 Source::Logo => Ok(default_images(shape)),
223 Source::Png(path) => {
224 let png = std::fs::read(&path)
225 .map_err(|error| format!("cannot read the icon {}: {error}", path.display()))?;
226 let size =
227 check(&png).map_err(|message| format!("icon {}: {message}", path.display()))?;
228 Ok(vec![Image {
229 size,
230 png: Cow::Owned(png),
231 }])
232 }
233 Source::Native(path) => Err(format!(
234 "the {platform} icon {} is not a PNG",
235 path.display()
236 )),
237 }
238}
239
240pub fn largest(images: &[Image]) -> &Image {
242 images
243 .iter()
244 .max_by_key(|image| image.size)
245 .expect("an icon has at least one image")
246}
247
248pub fn png_size(bytes: &[u8]) -> Option<(u32, u32)> {
250 const SIGNATURE: &[u8] = b"\x89PNG\r\n\x1a\n";
251 if bytes.len() < 24 || &bytes[..8] != SIGNATURE || &bytes[12..16] != b"IHDR" {
252 return None;
253 }
254 let read =
255 |at: usize| u32::from_be_bytes([bytes[at], bytes[at + 1], bytes[at + 2], bytes[at + 3]]);
256 Some((read(16), read(20)))
257}
258
259pub fn check(bytes: &[u8]) -> Result<u32, String> {
262 let (width, height) = png_size(bytes).ok_or_else(|| "is not a PNG image".to_string())?;
263 if width != height {
264 return Err(format!("is {width} × {height} pixels; an icon is square"));
265 }
266 if !DECLARED_SIZES.contains(&width) {
267 return Err(format!(
268 "is {width} × {width} pixels; use 256, 512 or 1024 (1024 is best)"
269 ));
270 }
271 Ok(width)
272}
273
274pub const ADAPTIVE_SIDE: u32 = 432;
277
278pub fn adaptive_layers(png: &[u8]) -> Result<(Vec<u8>, [u8; 3]), String> {
286 let source = resvg::tiny_skia::Pixmap::decode_png(png).map_err(|error| error.to_string())?;
287 let corner = source
288 .pixel(0, 0)
289 .map(|pixel| pixel.demultiply())
290 .filter(|pixel| pixel.alpha() == 255)
291 .map_or([255, 255, 255], |pixel| {
292 [pixel.red(), pixel.green(), pixel.blue()]
293 });
294 let side = ADAPTIVE_SIDE as f32;
295 let shown = side * 72.0 / 108.0;
296 let scale = shown / source.width().max(1) as f32;
297 let offset = (side - shown) / 2.0;
298 let mut layer = resvg::tiny_skia::Pixmap::new(ADAPTIVE_SIDE, ADAPTIVE_SIDE)
299 .ok_or("cannot allocate the icon's layer")?;
300 layer.draw_pixmap(
301 0,
302 0,
303 source.as_ref(),
304 &resvg::tiny_skia::PixmapPaint {
305 quality: resvg::tiny_skia::FilterQuality::Bicubic,
306 ..Default::default()
307 },
308 resvg::tiny_skia::Transform::from_row(scale, 0.0, 0.0, scale, offset, offset),
309 None,
310 );
311 Ok((
312 layer.encode_png().map_err(|error| error.to_string())?,
313 corner,
314 ))
315}
316
317pub fn status_image(app: &Application) -> Option<&str> {
319 app.windows.status_item.as_ref()?.resource("image")
320}
321
322pub const ON_LIGHT: [u8; 3] = [0, 0, 0];
324pub const ON_DARK: [u8; 3] = [255, 255, 255];
326
327pub fn status_picture(path: &Path, side: u32, color: [u8; 3]) -> Result<Vec<u8>, String> {
331 let data =
332 std::fs::read(path).map_err(|error| format!("cannot read {}: {error}", path.display()))?;
333 let options = resvg::usvg::Options {
334 resources_dir: path.parent().map(|parent| parent.to_owned()),
335 ..Default::default()
336 };
337 let tree = resvg::usvg::Tree::from_data(&data, &options)
338 .map_err(|error| format!("{}: {error}", path.display()))?;
339 let size = tree.size();
340 let scale = side as f32 / size.width().max(size.height());
341 let dx = (side as f32 - size.width() * scale) / 2.0;
342 let dy = (side as f32 - size.height() * scale) / 2.0;
343 let mut pixmap =
344 resvg::tiny_skia::Pixmap::new(side, side).ok_or("cannot allocate the picture")?;
345 resvg::render(
346 &tree,
347 resvg::tiny_skia::Transform::from_row(scale, 0.0, 0.0, scale, dx, dy),
348 &mut pixmap.as_mut(),
349 );
350 for pixel in pixmap.data_mut().as_chunks_mut::<4>().0 {
352 let alpha = pixel[3] as u16;
353 for (channel, value) in pixel[..3].iter_mut().zip(color) {
354 *channel = ((value as u16 * alpha + 127) / 255) as u8;
355 }
356 }
357 pixmap.encode_png().map_err(|error| error.to_string())
358}
359
360pub fn status_pictures(path: &Path, sides: &[u32], color: [u8; 3]) -> Result<Vec<Image>, String> {
362 sides
363 .iter()
364 .map(|&side| {
365 Ok(Image {
366 size: side,
367 png: Cow::Owned(status_picture(path, side, color)?),
368 })
369 })
370 .collect()
371}
372
373pub fn ico(images: &[Image]) -> Vec<u8> {
377 let mut chosen: Vec<&Image> = images.iter().filter(|image| image.size <= 256).collect();
378 if chosen.is_empty() {
379 chosen.push(largest(images));
380 }
381 let count = chosen.len() as u16;
382 let mut out = Vec::new();
383 out.extend_from_slice(&0u16.to_le_bytes()); out.extend_from_slice(&1u16.to_le_bytes()); out.extend_from_slice(&count.to_le_bytes());
386 let mut offset = 6 + 16 * chosen.len() as u32;
387 for image in &chosen {
388 let side = if image.size >= 256 {
390 0
391 } else {
392 image.size as u8
393 };
394 out.push(side);
395 out.push(side);
396 out.push(0); out.push(0); out.extend_from_slice(&1u16.to_le_bytes()); out.extend_from_slice(&32u16.to_le_bytes()); out.extend_from_slice(&(image.png.len() as u32).to_le_bytes());
401 out.extend_from_slice(&offset.to_le_bytes());
402 offset += image.png.len() as u32;
403 }
404 for image in &chosen {
405 out.extend_from_slice(&image.png);
406 }
407 out
408}
409
410pub fn icns(images: &[Image]) -> Vec<u8> {
413 let types = |size: u32| -> &'static [&'static [u8; 4]] {
414 match size {
415 16 => &[b"icp4"],
416 32 => &[b"icp5", b"ic11"],
417 64 => &[b"icp6", b"ic12"],
418 128 => &[b"ic07"],
419 256 => &[b"ic08", b"ic13"],
420 512 => &[b"ic09", b"ic14"],
421 1024 => &[b"ic10"],
422 _ => &[],
423 }
424 };
425 let mut body = Vec::new();
426 for image in images {
427 for kind in types(image.size) {
428 body.extend_from_slice(*kind);
429 body.extend_from_slice(&(8 + image.png.len() as u32).to_be_bytes());
430 body.extend_from_slice(&image.png);
431 }
432 }
433 let mut out = Vec::with_capacity(8 + body.len());
434 out.extend_from_slice(b"icns");
435 out.extend_from_slice(&(8 + body.len() as u32).to_be_bytes());
436 out.extend_from_slice(&body);
437 out
438}
439
440pub fn accepted(platform: &str) -> &'static str {
442 match platform {
443 "macos" => "a square PNG, an `.icns` file or an Icon Composer `.icon` folder",
444 "ios" => "a square PNG, an `.appiconset` folder or an Icon Composer `.icon` folder",
445 "tvos" => "a square PNG or a `.brandassets` folder",
446 "windows" => "a square PNG or an `.ico` file",
447 "linux" => "a square PNG or an icon theme folder laid out like `hicolor`",
448 "android" => "a square PNG or a `res` folder with `mipmap-*/ic_launcher`",
449 "browser" => "a square PNG, an SVG or a folder of page icons",
450 _ => "a square PNG",
451 }
452}
453
454pub fn check_native(platform: &str, path: &Path) -> Result<(), String> {
460 let extension = path
461 .extension()
462 .map(|extension| extension.to_string_lossy().to_ascii_lowercase())
463 .unwrap_or_default();
464 let folder = path.is_dir();
465 let read = || std::fs::read(path).map_err(|error| format!("cannot be read: {error}"));
466 match (platform, folder, extension.as_str()) {
467 ("macos", false, "icns") => check_icns(&read()?),
468 ("windows", false, "ico") => check_ico(&read()?),
469 ("browser", false, "svg") => {
470 if String::from_utf8_lossy(&read()?).contains("<svg") {
471 Ok(())
472 } else {
473 Err("is not an SVG image".into())
474 }
475 }
476 ("macos" | "ios", true, "icon") => icon_document(path).map(drop),
477 ("ios", true, _) => app_icon_set(path).map(drop),
478 ("tvos", true, _) => crate::brand_assets::check(path).map(drop),
479 ("android", true, _) => android_res(path).map(drop),
480 ("linux", true, _) => theme_icons(path).map(drop),
481 ("browser", true, _) => page_icons(path).map(drop),
482 _ => Err(format!("is not {}", accepted(platform))),
483 }
484}
485
486pub fn check_icns(bytes: &[u8]) -> Result<(), String> {
488 if bytes.len() < 8 || &bytes[..4] != b"icns" {
489 return Err("is not an .icns file".into());
490 }
491 let length = u32::from_be_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]) as usize;
492 if length != bytes.len() {
493 return Err(format!(
494 "says it is {length} bytes long but is {}",
495 bytes.len()
496 ));
497 }
498 let mut at = 8;
499 let mut entries = 0;
500 while at < bytes.len() {
501 let entry = bytes
502 .get(at + 4..at + 8)
503 .map(|size| u32::from_be_bytes([size[0], size[1], size[2], size[3]]) as usize)
504 .filter(|size| *size >= 8 && at + size <= bytes.len())
505 .ok_or_else(|| format!("has a broken entry at byte {at}"))?;
506 at += entry;
507 entries += 1;
508 }
509 if entries == 0 {
510 return Err("holds no images".into());
511 }
512 Ok(())
513}
514
515pub fn check_ico(bytes: &[u8]) -> Result<(), String> {
517 if bytes.len() < 6 || bytes[..4] != [0, 0, 1, 0] {
518 return Err("is not an .ico file".into());
519 }
520 let count = u16::from_le_bytes([bytes[4], bytes[5]]) as usize;
521 if count == 0 {
522 return Err("holds no images".into());
523 }
524 for index in 0..count {
525 let entry = bytes
526 .get(6 + 16 * index..6 + 16 * (index + 1))
527 .ok_or_else(|| "is cut short".to_string())?;
528 let size = u32::from_le_bytes([entry[8], entry[9], entry[10], entry[11]]) as usize;
529 let offset = u32::from_le_bytes([entry[12], entry[13], entry[14], entry[15]]) as usize;
530 if size == 0 || offset + size > bytes.len() {
531 return Err(format!("has a broken image {}", index + 1));
532 }
533 }
534 Ok(())
535}
536
537pub fn files_in(dir: &Path) -> Result<Vec<PathBuf>, String> {
540 fn walk(dir: &Path, relative: &Path, out: &mut Vec<PathBuf>) -> Result<(), String> {
541 let entries = std::fs::read_dir(dir)
542 .map_err(|error| format!("cannot read {}: {error}", dir.display()))?;
543 for entry in entries {
544 let entry = entry.map_err(|error| format!("cannot read {}: {error}", dir.display()))?;
545 let kind = entry
546 .file_type()
547 .map_err(|error| format!("cannot read {}: {error}", entry.path().display()))?;
548 let name = entry.file_name();
549 if name.to_string_lossy().starts_with('.')
551 || name == "Thumbs.db"
552 || name == "desktop.ini"
553 {
554 continue;
555 }
556 let inside = relative.join(&name);
557 if kind.is_symlink() {
558 return Err(format!(
559 "{} is a symbolic link; put the file itself there",
560 entry.path().display()
561 ));
562 } else if kind.is_dir() {
563 walk(&entry.path(), &inside, out)?;
564 } else {
565 out.push(inside);
566 }
567 }
568 Ok(())
569 }
570 let mut out = Vec::new();
571 walk(dir, Path::new(""), &mut out)?;
572 out.sort();
573 Ok(out)
574}
575
576fn slashed(path: &Path) -> String {
578 path.components()
579 .map(|part| part.as_os_str().to_string_lossy())
580 .collect::<Vec<_>>()
581 .join("/")
582}
583
584pub fn app_icon_set(dir: &Path) -> Result<Vec<PathBuf>, String> {
587 let files = files_in(dir)?;
588 if let Some(nested) = files.iter().find(|file| file.components().count() > 1) {
589 return Err(format!(
590 "has {}; an .appiconset holds its files at its top",
591 slashed(nested)
592 ));
593 }
594 let contents = std::fs::read(dir.join("Contents.json"))
595 .map_err(|_| "has no Contents.json; an .appiconset names its images there".to_string())?;
596 let contents: serde_json::Value = serde_json::from_slice(&contents)
597 .map_err(|error| format!("has a Contents.json that is not JSON: {error}"))?;
598 let images = contents
599 .get("images")
600 .and_then(|images| images.as_array())
601 .ok_or_else(|| "has a Contents.json without an `images` list".to_string())?;
602 let mut named = 0;
603 for image in images {
604 let Some(name) = image.get("filename").and_then(|name| name.as_str()) else {
605 continue;
606 };
607 if !files.iter().any(|file| file == Path::new(name)) {
608 return Err(format!("names {name} in Contents.json, which is not there"));
609 }
610 named += 1;
611 }
612 if named == 0 {
613 return Err("names no image in Contents.json".into());
614 }
615 Ok(files)
616}
617
618pub fn icon_document(dir: &Path) -> Result<Vec<PathBuf>, String> {
621 let files = files_in(dir)?;
622 let contents = std::fs::read(dir.join("icon.json"))
623 .map_err(|_| "has no icon.json; an .icon folder describes its layers there".to_string())?;
624 let document: serde_json::Value = serde_json::from_slice(&contents)
625 .map_err(|error| format!("has an icon.json that is not JSON: {error}"))?;
626 let groups = document
627 .get("groups")
628 .and_then(|groups| groups.as_array())
629 .ok_or_else(|| "has an icon.json without a `groups` list".to_string())?;
630 let mut named = 0;
631 let layers = groups
632 .iter()
633 .filter_map(|group| group.get("layers")?.as_array())
634 .flatten();
635 for layer in layers {
636 let specialized = layer
638 .get("image-name-specializations")
639 .and_then(|values| values.as_array())
640 .into_iter()
641 .flatten()
642 .filter_map(|value| value.get("value"));
643 for name in layer.get("image-name").into_iter().chain(specialized) {
644 let Some(name) = name.as_str() else { continue };
645 if !files
646 .iter()
647 .any(|file| *file == Path::new("Assets").join(name))
648 {
649 return Err(format!("names {name} in icon.json, which is not in Assets"));
650 }
651 named += 1;
652 }
653 }
654 if named == 0 {
655 return Err("names no layer image in icon.json".into());
656 }
657 Ok(files)
658}
659
660#[derive(Clone, Debug, PartialEq, Eq)]
662pub struct AndroidRes {
663 pub files: Vec<PathBuf>,
665 pub round: bool,
667}
668
669pub fn android_res(dir: &Path) -> Result<AndroidRes, String> {
674 let files = files_in(dir)?;
675 let mut launcher = false;
676 let mut round = false;
677 for file in &files {
678 let parts: Vec<String> = file
679 .components()
680 .map(|part| part.as_os_str().to_string_lossy().into_owned())
681 .collect();
682 let [folder, name] = parts.as_slice() else {
683 return Err(format!(
684 "has {}; a res folder holds resource folders such as mipmap-hdpi",
685 slashed(file)
686 ));
687 };
688 let kind = folder.split('-').next().unwrap_or_default();
689 if !matches!(kind, "mipmap" | "drawable" | "values") {
690 return Err(format!(
691 "has {folder}; an icon's res folder holds mipmap, drawable and values folders"
692 ));
693 }
694 if kind == "values" && matches!(name.as_str(), "strings.xml" | "themes.xml") {
695 return Err(format!(
696 "has {folder}/{name}, which Bern writes itself; keep only the icon's resources"
697 ));
698 }
699 let stem = name
700 .rsplit_once('.')
701 .map_or(name.as_str(), |(stem, _)| stem);
702 if kind == "mipmap" && stem == "ic_launcher" {
703 launcher = true;
704 }
705 if kind == "mipmap" && stem == "ic_launcher_round" {
706 round = true;
707 }
708 }
709 if !launcher {
710 return Err("has no mipmap-*/ic_launcher; the launcher icon is @mipmap/ic_launcher".into());
711 }
712 Ok(AndroidRes { files, round })
713}
714
715#[derive(Clone, Debug, PartialEq, Eq)]
717pub struct ThemeIcon {
718 pub path: PathBuf,
720 pub folder: PathBuf,
722 pub size: Option<u32>,
724 pub symbolic: bool,
726 pub extension: String,
728}
729
730pub fn theme_icons(dir: &Path) -> Result<Vec<ThemeIcon>, String> {
735 let mut icons = Vec::new();
736 let mut name: Option<String> = None;
737 for file in files_in(dir)? {
738 let shown = slashed(&file);
739 let parts: Vec<String> = file
740 .components()
741 .map(|part| part.as_os_str().to_string_lossy().into_owned())
742 .collect();
743 if parts.len() == 1 && parts[0] == "index.theme" {
744 continue;
745 }
746 let [size, context, file_name] = parts.as_slice() else {
747 return Err(format!(
748 "has {shown}; an icon theme holds <size>/apps/<name>.png files"
749 ));
750 };
751 if context != "apps" {
752 return Err(format!(
753 "has {shown}; an application icon goes in an apps folder"
754 ));
755 }
756 let pixels = size
757 .split('@')
758 .next()
759 .and_then(|side| side.split_once('x'))
760 .and_then(|(width, height)| {
761 let width: u32 = width.parse().ok()?;
762 (height.parse::<u32>().ok()? == width).then_some(width)
763 });
764 let (stem, extension) = file_name
765 .rsplit_once('.')
766 .map(|(stem, extension)| (stem, extension.to_ascii_lowercase()))
767 .unwrap_or((file_name.as_str(), String::new()));
768 let symbolic = stem.ends_with("-symbolic");
769 match (pixels, size.as_str(), extension.as_str()) {
770 (Some(_), _, "png") | (None, "scalable", "svg") | (None, "symbolic", "svg") => {}
771 _ => {
772 return Err(format!(
773 "has {shown}; sized folders hold PNG files, scalable and symbolic hold SVG"
774 ));
775 }
776 }
777 if symbolic != (size == "symbolic") {
778 return Err(format!(
779 "has {shown}; symbolic/apps holds the one <name>-symbolic.svg"
780 ));
781 }
782 let base = stem.strip_suffix("-symbolic").unwrap_or(stem);
783 match &name {
784 None => name = Some(base.to_string()),
785 Some(name) if name != base => {
786 return Err(format!(
787 "has icons named both {name} and {base}; an application has one icon"
788 ));
789 }
790 Some(_) => {}
791 }
792 let size = match pixels {
793 Some(pixels) => {
794 let scale: u32 = size
795 .split_once('@')
796 .map_or(Some(1), |(_, scale)| scale.parse().ok())
797 .ok_or_else(|| format!("has {shown}; a scaled folder is named like 48x48@2"))?;
798 let bytes = std::fs::read(dir.join(&file))
799 .map_err(|error| format!("cannot read {shown}: {error}"))?;
800 match png_size(&bytes) {
801 Some((width, height)) if width == height && width == pixels * scale => {
802 Some(width)
803 }
804 Some((width, height)) => {
805 return Err(format!(
806 "has {shown}, which is {width} × {height} pixels, not {0} × {0}",
807 pixels * scale
808 ));
809 }
810 None => return Err(format!("has {shown}, which is not a PNG")),
811 }
812 }
813 None => None,
814 };
815 icons.push(ThemeIcon {
816 folder: file.parent().map(Path::to_path_buf).unwrap_or_default(),
817 path: file,
818 size,
819 symbolic,
820 extension,
821 });
822 }
823 if !icons.iter().any(|icon| icon.size.is_some()) {
824 return Err("has no PNG icon of a size, such as 256x256/apps/<name>.png".into());
825 }
826 Ok(icons)
827}
828
829pub fn page_icons(dir: &Path) -> Result<Vec<PathBuf>, String> {
833 let files = files_in(dir)?;
834 let mut images = 0;
835 for file in &files {
836 let shown = slashed(file);
837 if file.components().count() > 1 {
838 return Err(format!(
839 "has {shown}; page icons sit at the top of their folder"
840 ));
841 }
842 bern_protocol::check_resource_name(&shown)?;
844 let extension = file
845 .extension()
846 .map(|extension| extension.to_string_lossy().to_ascii_lowercase())
847 .unwrap_or_default();
848 match extension.as_str() {
849 "svg" | "ico" | "png" => images += 1,
850 "webmanifest" => {}
851 "json" if shown == "manifest.json" => {}
852 _ => {
853 return Err(format!(
854 "has {shown}; page icons are .svg, .ico and .png files and a .webmanifest"
855 ));
856 }
857 }
858 }
859 if images == 0 {
860 return Err("has no .svg, .ico or .png icon".into());
861 }
862 Ok(files)
863}
864
865#[cfg(test)]
866mod tests {
867 use super::*;
868
869 #[test]
870 fn the_logo_is_rendered_at_every_size() {
871 for shape in [Shape::AsDrawn, Shape::Masked] {
872 for image in default_images(shape) {
873 assert_eq!(png_size(&image.png), Some((image.size, image.size)));
874 }
875 }
876 assert!(DEFAULT_SVG.contains("<svg"));
877 }
878
879 #[test]
880 fn declared_icons_are_square_pngs_of_a_large_size() {
881 let png = |size: u32| {
882 let mut bytes = b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDR".to_vec();
883 bytes.extend_from_slice(&size.to_be_bytes());
884 bytes.extend_from_slice(&size.to_be_bytes());
885 bytes
886 };
887 assert_eq!(check(&png(1024)), Ok(1024));
888 assert!(check(&png(300)).unwrap_err().contains("1024"));
889 assert!(check(b"GIF89a").unwrap_err().contains("PNG"));
890 let mut wide = png(512);
891 wide[16..20].copy_from_slice(&1024u32.to_be_bytes());
892 assert!(check(&wide).unwrap_err().contains("square"));
893 }
894
895 #[test]
896 fn ico_holds_the_small_images_as_png() {
897 let ico = ico(&default_images(Shape::AsDrawn));
898 assert_eq!(&ico[..4], &[0, 0, 1, 0]);
899 let count = u16::from_le_bytes([ico[4], ico[5]]) as usize;
900 assert_eq!(count, 9, "16 to 256 pixels");
901 let entry = &ico[6 + 16 * (count - 1)..6 + 16 * count];
903 assert_eq!(entry[0], 0);
904 let offset = u32::from_le_bytes([entry[12], entry[13], entry[14], entry[15]]) as usize;
905 assert_eq!(png_size(&ico[offset..]), Some((256, 256)));
906 }
907
908 #[test]
909 fn icns_files_every_image_under_its_types() {
910 let icns = icns(&default_images(Shape::Masked));
911 assert_eq!(&icns[..4], b"icns");
912 assert_eq!(
913 u32::from_be_bytes([icns[4], icns[5], icns[6], icns[7]]) as usize,
914 icns.len()
915 );
916 let mut kinds = Vec::new();
917 let mut at = 8;
918 while at < icns.len() {
919 let length =
920 u32::from_be_bytes([icns[at + 4], icns[at + 5], icns[at + 6], icns[at + 7]])
921 as usize;
922 kinds.push(String::from_utf8_lossy(&icns[at..at + 4]).into_owned());
923 assert!(png_size(&icns[at + 8..]).is_some());
924 at += length;
925 }
926 assert_eq!(
927 kinds,
928 [
929 "icp4", "icp5", "ic11", "icp6", "ic12", "ic07", "ic08", "ic13", "ic09", "ic14",
930 "ic10"
931 ]
932 );
933 }
934
935 #[test]
936 fn a_status_picture_is_its_shape_in_one_colour() {
937 let dir = std::env::temp_dir().join(format!("bern-status-picture-{}", std::process::id()));
938 std::fs::create_dir_all(&dir).unwrap();
939 let path = dir.join("tray.svg");
940 std::fs::write(
943 &path,
944 r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 16"><rect width="32" height="16" fill="#e00"/></svg>"##,
945 )
946 .unwrap();
947 let png = super::status_picture(&path, 16, super::ON_DARK).unwrap();
948 assert_eq!(super::png_size(&png), Some((16, 16)));
949 let pixmap = resvg::tiny_skia::Pixmap::decode_png(&png).unwrap();
950 let at = |x: u32, y: u32| pixmap.pixel(x, y).unwrap();
951 assert_eq!(at(8, 1).alpha(), 0, "above the bar");
952 let middle = at(8, 8);
953 assert_eq!(
954 (middle.red(), middle.green(), middle.blue(), middle.alpha()),
955 (255, 255, 255, 255)
956 );
957 assert_eq!(at(8, 14).alpha(), 0, "below the bar");
958 let black = super::status_pictures(&path, &[16, 32], super::ON_LIGHT).unwrap();
959 assert_eq!(
960 black.iter().map(|image| image.size).collect::<Vec<_>>(),
961 [16, 32]
962 );
963 let _ = std::fs::remove_dir_all(dir);
964 }
965}