Skip to main content

bern_target_api/
icon.rs

1//! Application icons. Without `[app].icon` every target uses the Bern logo;
2//! with it, the declared square PNG. `[app.icon]` can also give a platform
3//! its own icon: another square PNG, or the icon in the platform's own form
4//! (an `.icns` file, an `.ico` file, an `.appiconset` folder, an Icon
5//! Composer `.icon` folder for macOS and iOS, a tvOS `.brandassets` folder,
6//! an Android `res` folder, an icon theme folder, a folder of page icons),
7//! which is shipped as it is. The platform
8//! containers (`.ico` on Windows, `.icns` on macOS) hold PNG images as they
9//! are, so nothing here decodes or scales pixels: the logo ships
10//! pre-rendered at every size the platforms ask for, and a declared PNG is
11//! used at its own size. Only tvOS's layered icon is drawn from the square
12//! one, in [`crate::brand_assets`].
13
14use bern_ir::Application;
15use std::borrow::Cow;
16use std::path::{Path, PathBuf};
17
18/// Platforms that can have an icon of their own under `[app.icon]`, named
19/// as `[targets]` names them.
20pub const PLATFORMS: [&str; 7] = [
21    "macos", "ios", "tvos", "windows", "linux", "android", "browser",
22];
23
24/// Where one platform's icon comes from.
25#[derive(Clone, Debug, PartialEq, Eq)]
26pub enum Source {
27    /// The Bern logo.
28    Logo,
29    /// A square PNG, used at its own size.
30    Png(PathBuf),
31    /// The icon in the platform's own form, shipped as it is; see
32    /// [`check_native`].
33    Native(PathBuf),
34}
35
36/// The icon written for `platform`: its own entry under `[app.icon]`, else
37/// the one for every platform. Paths are relative to `Bern.toml`.
38pub 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
46/// Where `platform`'s icon comes from.
47pub 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
55/// The `[app.alternate_icons]` name a `WindowSet(icon:)` variant names:
56/// `Skin` is `skin`, `DarkSkin` is `dark_skin`.
57pub 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
68/// The icons `WindowSet(icon:)` chooses among, in its enum's order: each
69/// variant's name, and the alternate icon it shows (`None`: `[app.icon]`,
70/// for `Default`). Empty when the window set does not choose.
71pub 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
91/// Where the alternate icon `name` comes from on `platform`: its own entry
92/// under `[app.alternate_icons.<name>]`, else its `default`; `None` when it
93/// has neither.
94pub 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
109/// The alternate icons `WindowSet(icon:)` switches to on `platform`, by
110/// name, with where each comes from; the error names one with nothing for
111/// the platform.
112pub 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
129/// Whether a declared icon is a PNG by its name.
130pub fn is_png_name(name: &str) -> bool {
131    name.to_ascii_lowercase().ends_with(".png")
132}
133
134/// Whether a declared icon is an Icon Composer document, the layered
135/// `.icon` folder that macOS 26 and iOS 26 draw in Liquid Glass. `actool`
136/// compiles it, and draws flat images from it for earlier systems.
137pub fn is_icon_document(path: &Path) -> bool {
138    path.extension()
139        .is_some_and(|extension| extension.eq_ignore_ascii_case("icon"))
140}
141
142/// How a platform shows application icons.
143#[derive(Clone, Copy, Debug, PartialEq, Eq)]
144pub enum Shape {
145    /// As drawn, transparent around the shape: Windows, Linux and the web.
146    AsDrawn,
147    /// A full square that the system cuts to its own shape: macOS shows
148    /// application icons as rounded squares and puts any other shape on a
149    /// grey plate.
150    Masked,
151}
152
153/// One PNG image of an icon.
154#[derive(Clone, Debug)]
155pub struct Image {
156    /// Width and height in pixels.
157    pub size: u32,
158    pub png: Cow<'static, [u8]>,
159}
160
161/// The Bern logo, the default icon, as SVG.
162pub const DEFAULT_SVG: &str = include_str!("../assets/icon/bern.svg");
163
164/// The logo rendered from `Docs/Assets/logo/bern-logo.svg` (round) and
165/// `bern-logo-square.svg` (square) with `rsvg-convert -w N -h N`.
166const 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
188/// Sizes a declared icon may have: the large sizes every platform scales
189/// down from.
190pub const DECLARED_SIZES: [u32; 3] = [256, 512, 1024];
191
192/// Whether `platform` uses the Bern logo.
193pub fn is_default(app: &Application, platform: &str) -> bool {
194    declared(app, platform).is_none()
195}
196
197/// The logo in `shape`, smallest first.
198pub 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
211/// `platform`'s icon as PNG images, smallest first: the logo in `shape`, or
212/// the declared PNG (relative to the project directory) at its own size. A
213/// platform whose icon is in its own form has no images here; read it with
214/// [`source`].
215pub 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
240/// The largest image.
241pub 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
248/// Width and height of a PNG, from its header.
249pub 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
259/// Check a declared icon: a square PNG of 256, 512 or 1024 pixels. Returns
260/// its size.
261pub 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
274/// The side of an Android adaptive icon's layers in pixels: 108 dp at the
275/// density of `mipmap-xxxhdpi`.
276pub const ADAPTIVE_SIDE: u32 = 432;
277
278/// An Android adaptive icon made from a square icon image: the foreground
279/// layer (PNG, [`ADAPTIVE_SIDE`] square) holds the image over the 72 dp in
280/// the middle that the launcher's shape shows, and the background is the
281/// colour of the image's corner, or white where the corner is clear. A
282/// full-bleed icon on a plate thus fills the launcher's shape, as other
283/// apps' icons do, instead of being shrunk inside a plate of the
284/// launcher's own.
285pub 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
317/// The one-colour SVG of `StatusItem(image:)`, a declared resource name.
318pub fn status_image(app: &Application) -> Option<&str> {
319    app.windows.status_item.as_ref()?.resource("image")
320}
321
322/// The colour a status area's own pictures are in on a light bar.
323pub const ON_LIGHT: [u8; 3] = [0, 0, 0];
324/// The colour a status area's own pictures are in on a dark bar.
325pub const ON_DARK: [u8; 3] = [255, 255, 255];
326
327/// The status item's picture, `side` pixels square: the SVG at `path`
328/// fitted and centred, its shape (what it covers, whatever its colours) in
329/// `color`. PNG.
330pub 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    // Premultiplied: each channel is the colour times the coverage.
351    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
360/// The status item's picture at each of `sides`, smallest first.
361pub 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
373/// A Windows icon (`.ico`) holding the images as PNG. Images larger than
374/// 256 pixels are kept only when there is nothing smaller, as Windows reads
375/// icons up to 256 pixels.
376pub 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()); // reserved
384    out.extend_from_slice(&1u16.to_le_bytes()); // icon
385    out.extend_from_slice(&count.to_le_bytes());
386    let mut offset = 6 + 16 * chosen.len() as u32;
387    for image in &chosen {
388        // 0 stands for 256 pixels or more.
389        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); // no palette
397        out.push(0); // reserved
398        out.extend_from_slice(&1u16.to_le_bytes()); // colour planes
399        out.extend_from_slice(&32u16.to_le_bytes()); // bits per pixel
400        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
410/// A macOS icon (`.icns`) holding the images as PNG, each under the entry
411/// types for its size (the plain and the Retina slot).
412pub 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
440/// What `platform` takes under `[app.icon]`, for messages.
441pub 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
454/// Check an icon in `platform`'s own form at `path`, a file or a folder:
455/// an `.icns` file (macOS), an `.ico` file (Windows), an `.appiconset`
456/// folder (iOS), an Icon Composer `.icon` folder (macOS and iOS), a
457/// `.brandassets` folder (tvOS), a `res` folder (Android), an icon theme folder (Linux), or
458/// an SVG or a folder of page icons (browser).
459pub 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
486/// Check a macOS icon file: entries that add up to the length it states.
487pub 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
515/// Check a Windows icon file: images inside the file.
516pub 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
537/// The files below `dir`, relative to it and sorted. A symbolic link is an
538/// error: an icon names the files it ships.
539pub 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            // Hidden files, and Explorer's own.
550            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
576/// The `/`-separated form of a relative path, for messages and matching.
577fn slashed(path: &Path) -> String {
578    path.components()
579        .map(|part| part.as_os_str().to_string_lossy())
580        .collect::<Vec<_>>()
581        .join("/")
582}
583
584/// The files of an iOS `.appiconset` folder: `Contents.json` and the images
585/// it names, all at the top of the folder.
586pub 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
618/// The files of an Icon Composer document (an `.icon` folder):
619/// `icon.json` and the layer images it names, which sit in `Assets`.
620pub 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        // A layer names its image, and may name others per appearance.
637        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/// The files of an Android `res` folder with a launcher icon.
661#[derive(Clone, Debug, PartialEq, Eq)]
662pub struct AndroidRes {
663    /// Relative to the folder, as `mipmap-hdpi/ic_launcher.png`.
664    pub files: Vec<PathBuf>,
665    /// Whether it has a round icon, `ic_launcher_round`.
666    pub round: bool,
667}
668
669/// Read an Android `res` folder: resource folders (`mipmap*`, `drawable*`,
670/// `values*`) whose files make the launcher icon `@mipmap/ic_launcher`.
671/// Files Bern writes itself (`values*/strings.xml`, `values*/themes.xml`)
672/// cannot be in it.
673pub 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/// One application icon in an icon theme folder.
716#[derive(Clone, Debug, PartialEq, Eq)]
717pub struct ThemeIcon {
718    /// Relative to the folder, as `48x48/apps/name.png`.
719    pub path: PathBuf,
720    /// The folder it goes in, as `48x48/apps`.
721    pub folder: PathBuf,
722    /// Width and height for a PNG; `None` for SVG.
723    pub size: Option<u32>,
724    /// Whether it is the symbolic (single-colour) form, `name-symbolic.svg`.
725    pub symbolic: bool,
726    /// `png` or `svg`.
727    pub extension: String,
728}
729
730/// Read an icon theme folder laid out like `hicolor`: `<size>/apps/<name>`
731/// files, with `<size>` such as `48x48`, `48x48@2`, `scalable` or
732/// `symbolic`, all of one name (the symbolic one ends in `-symbolic.svg`).
733/// At least one PNG of a size. The name is replaced by the application's.
734pub 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
829/// Read a folder of page icons: files at its top, of the kinds a page
830/// links (`.svg`, `.ico`, `.png`, and a `.webmanifest` or `manifest.json`
831/// naming the icons beside it). At least one image.
832pub 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        // The page links them by name.
843        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        // The last entry is 256 pixels, written as 0, and points at a PNG.
902        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        // A red bar across a wide picture: fitted and centred in the
941        // square, white whatever its colour.
942        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}