bern-target-api 0.1.0

Interface between the Bern compiler and independent UI target modules
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
//! Application icons. Without `[app].icon` every target uses the Bern logo;
//! with it, the declared square PNG. `[app.icon]` can also give a platform
//! its own icon: another square PNG, or the icon in the platform's own form
//! (an `.icns` file, an `.ico` file, an `.appiconset` folder, an Icon
//! Composer `.icon` folder for macOS and iOS, a tvOS `.brandassets` folder,
//! an Android `res` folder, an icon theme folder, a folder of page icons),
//! which is shipped as it is. The platform
//! containers (`.ico` on Windows, `.icns` on macOS) hold PNG images as they
//! are, so nothing here decodes or scales pixels: the logo ships
//! pre-rendered at every size the platforms ask for, and a declared PNG is
//! used at its own size. Only tvOS's layered icon is drawn from the square
//! one, in [`crate::brand_assets`].

use bern_ir::Application;
use std::borrow::Cow;
use std::path::{Path, PathBuf};

/// Platforms that can have an icon of their own under `[app.icon]`, named
/// as `[targets]` names them.
pub const PLATFORMS: [&str; 7] = [
    "macos", "ios", "tvos", "windows", "linux", "android", "browser",
];

/// Where one platform's icon comes from.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Source {
    /// The Bern logo.
    Logo,
    /// A square PNG, used at its own size.
    Png(PathBuf),
    /// The icon in the platform's own form, shipped as it is; see
    /// [`check_native`].
    Native(PathBuf),
}

/// The icon written for `platform`: its own entry under `[app.icon]`, else
/// the one for every platform. Paths are relative to `Bern.toml`.
pub fn declared<'a>(app: &'a Application, platform: &str) -> Option<&'a str> {
    app.app
        .platform_icons
        .get(platform)
        .or(app.app.icon.as_ref())
        .map(String::as_str)
}

/// Where `platform`'s icon comes from.
pub fn source(app: &Application, platform: &str, project_dir: &Path) -> Source {
    match declared(app, platform) {
        None => Source::Logo,
        Some(name) if is_png_name(name) => Source::Png(project_dir.join(name)),
        Some(name) => Source::Native(project_dir.join(name)),
    }
}

/// The `[app.alternate_icons]` name a `WindowSet(icon:)` variant names:
/// `Skin` is `skin`, `DarkSkin` is `dark_skin`.
pub fn alternate_name(variant: &str) -> String {
    let mut name = String::new();
    for (index, c) in variant.chars().enumerate() {
        if c.is_ascii_uppercase() && index > 0 {
            name.push('_');
        }
        name.push(c.to_ascii_lowercase());
    }
    name
}

/// The icons `WindowSet(icon:)` chooses among, in its enum's order: each
/// variant's name, and the alternate icon it shows (`None`: `[app.icon]`,
/// for `Default`). Empty when the window set does not choose.
pub fn choices(app: &Application) -> Vec<(String, Option<String>)> {
    let Some(bern_protocol::Type::Enum { name }) = app.windows.icon.as_ref().map(|icon| &icon.ty)
    else {
        return Vec::new();
    };
    app.types
        .enum_def(name)
        .map(|def| {
            def.variants
                .iter()
                .map(|variant| {
                    let alternate =
                        (variant.name != "Default").then(|| alternate_name(&variant.name));
                    (variant.name.clone(), alternate)
                })
                .collect()
        })
        .unwrap_or_default()
}

/// Where the alternate icon `name` comes from on `platform`: its own entry
/// under `[app.alternate_icons.<name>]`, else its `default`; `None` when it
/// has neither.
pub fn alternate_source(
    app: &Application,
    name: &str,
    platform: &str,
    project_dir: &Path,
) -> Option<Source> {
    let entries = app.app.alternate_icons.get(name)?;
    let declared = entries.get(platform).or_else(|| entries.get("default"))?;
    Some(if is_png_name(declared) {
        Source::Png(project_dir.join(declared))
    } else {
        Source::Native(project_dir.join(declared))
    })
}

/// The alternate icons `WindowSet(icon:)` switches to on `platform`, by
/// name, with where each comes from; the error names one with nothing for
/// the platform.
pub fn alternates(
    app: &Application,
    platform: &str,
    project_dir: &Path,
) -> Result<Vec<(String, Source)>, String> {
    choices(app)
        .into_iter()
        .filter_map(|(_, alternate)| alternate)
        .map(|name| match alternate_source(app, &name, platform, project_dir) {
            Some(source) => Ok((name, source)),
            None => Err(format!(
                "[app.alternate_icons.{name}] has no `{platform}` or `default` entry; the {platform} app cannot switch to it"
            )),
        })
        .collect()
}

/// Whether a declared icon is a PNG by its name.
pub fn is_png_name(name: &str) -> bool {
    name.to_ascii_lowercase().ends_with(".png")
}

/// Whether a declared icon is an Icon Composer document, the layered
/// `.icon` folder that macOS 26 and iOS 26 draw in Liquid Glass. `actool`
/// compiles it, and draws flat images from it for earlier systems.
pub fn is_icon_document(path: &Path) -> bool {
    path.extension()
        .is_some_and(|extension| extension.eq_ignore_ascii_case("icon"))
}

/// How a platform shows application icons.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Shape {
    /// As drawn, transparent around the shape: Windows, Linux and the web.
    AsDrawn,
    /// A full square that the system cuts to its own shape: macOS shows
    /// application icons as rounded squares and puts any other shape on a
    /// grey plate.
    Masked,
}

/// One PNG image of an icon.
#[derive(Clone, Debug)]
pub struct Image {
    /// Width and height in pixels.
    pub size: u32,
    pub png: Cow<'static, [u8]>,
}

/// The Bern logo, the default icon, as SVG.
pub const DEFAULT_SVG: &str = include_str!("../assets/icon/bern.svg");

/// The logo rendered from `Docs/Assets/logo/bern-logo.svg` (round) and
/// `bern-logo-square.svg` (square) with `rsvg-convert -w N -h N`.
const ROUND: &[(u32, &[u8])] = &[
    (16, include_bytes!("../assets/icon/round-16.png")),
    (20, include_bytes!("../assets/icon/round-20.png")),
    (24, include_bytes!("../assets/icon/round-24.png")),
    (32, include_bytes!("../assets/icon/round-32.png")),
    (40, include_bytes!("../assets/icon/round-40.png")),
    (48, include_bytes!("../assets/icon/round-48.png")),
    (64, include_bytes!("../assets/icon/round-64.png")),
    (128, include_bytes!("../assets/icon/round-128.png")),
    (256, include_bytes!("../assets/icon/round-256.png")),
    (512, include_bytes!("../assets/icon/round-512.png")),
];
const SQUARE: &[(u32, &[u8])] = &[
    (16, include_bytes!("../assets/icon/square-16.png")),
    (32, include_bytes!("../assets/icon/square-32.png")),
    (64, include_bytes!("../assets/icon/square-64.png")),
    (128, include_bytes!("../assets/icon/square-128.png")),
    (256, include_bytes!("../assets/icon/square-256.png")),
    (512, include_bytes!("../assets/icon/square-512.png")),
    (1024, include_bytes!("../assets/icon/square-1024.png")),
];

/// Sizes a declared icon may have: the large sizes every platform scales
/// down from.
pub const DECLARED_SIZES: [u32; 3] = [256, 512, 1024];

/// Whether `platform` uses the Bern logo.
pub fn is_default(app: &Application, platform: &str) -> bool {
    declared(app, platform).is_none()
}

/// The logo in `shape`, smallest first.
pub fn default_images(shape: Shape) -> Vec<Image> {
    let set = match shape {
        Shape::AsDrawn => ROUND,
        Shape::Masked => SQUARE,
    };
    set.iter()
        .map(|(size, png)| Image {
            size: *size,
            png: Cow::Borrowed(*png),
        })
        .collect()
}

/// `platform`'s icon as PNG images, smallest first: the logo in `shape`, or
/// the declared PNG (relative to the project directory) at its own size. A
/// platform whose icon is in its own form has no images here; read it with
/// [`source`].
pub fn images(
    app: &Application,
    platform: &str,
    project_dir: &Path,
    shape: Shape,
) -> Result<Vec<Image>, String> {
    match source(app, platform, project_dir) {
        Source::Logo => Ok(default_images(shape)),
        Source::Png(path) => {
            let png = std::fs::read(&path)
                .map_err(|error| format!("cannot read the icon {}: {error}", path.display()))?;
            let size =
                check(&png).map_err(|message| format!("icon {}: {message}", path.display()))?;
            Ok(vec![Image {
                size,
                png: Cow::Owned(png),
            }])
        }
        Source::Native(path) => Err(format!(
            "the {platform} icon {} is not a PNG",
            path.display()
        )),
    }
}

/// The largest image.
pub fn largest(images: &[Image]) -> &Image {
    images
        .iter()
        .max_by_key(|image| image.size)
        .expect("an icon has at least one image")
}

/// Width and height of a PNG, from its header.
pub fn png_size(bytes: &[u8]) -> Option<(u32, u32)> {
    const SIGNATURE: &[u8] = b"\x89PNG\r\n\x1a\n";
    if bytes.len() < 24 || &bytes[..8] != SIGNATURE || &bytes[12..16] != b"IHDR" {
        return None;
    }
    let read =
        |at: usize| u32::from_be_bytes([bytes[at], bytes[at + 1], bytes[at + 2], bytes[at + 3]]);
    Some((read(16), read(20)))
}

/// Check a declared icon: a square PNG of 256, 512 or 1024 pixels. Returns
/// its size.
pub fn check(bytes: &[u8]) -> Result<u32, String> {
    let (width, height) = png_size(bytes).ok_or_else(|| "is not a PNG image".to_string())?;
    if width != height {
        return Err(format!("is {width} × {height} pixels; an icon is square"));
    }
    if !DECLARED_SIZES.contains(&width) {
        return Err(format!(
            "is {width} × {width} pixels; use 256, 512 or 1024 (1024 is best)"
        ));
    }
    Ok(width)
}

/// The side of an Android adaptive icon's layers in pixels: 108 dp at the
/// density of `mipmap-xxxhdpi`.
pub const ADAPTIVE_SIDE: u32 = 432;

/// An Android adaptive icon made from a square icon image: the foreground
/// layer (PNG, [`ADAPTIVE_SIDE`] square) holds the image over the 72 dp in
/// the middle that the launcher's shape shows, and the background is the
/// colour of the image's corner, or white where the corner is clear. A
/// full-bleed icon on a plate thus fills the launcher's shape, as other
/// apps' icons do, instead of being shrunk inside a plate of the
/// launcher's own.
pub fn adaptive_layers(png: &[u8]) -> Result<(Vec<u8>, [u8; 3]), String> {
    let source = resvg::tiny_skia::Pixmap::decode_png(png).map_err(|error| error.to_string())?;
    let corner = source
        .pixel(0, 0)
        .map(|pixel| pixel.demultiply())
        .filter(|pixel| pixel.alpha() == 255)
        .map_or([255, 255, 255], |pixel| {
            [pixel.red(), pixel.green(), pixel.blue()]
        });
    let side = ADAPTIVE_SIDE as f32;
    let shown = side * 72.0 / 108.0;
    let scale = shown / source.width().max(1) as f32;
    let offset = (side - shown) / 2.0;
    let mut layer = resvg::tiny_skia::Pixmap::new(ADAPTIVE_SIDE, ADAPTIVE_SIDE)
        .ok_or("cannot allocate the icon's layer")?;
    layer.draw_pixmap(
        0,
        0,
        source.as_ref(),
        &resvg::tiny_skia::PixmapPaint {
            quality: resvg::tiny_skia::FilterQuality::Bicubic,
            ..Default::default()
        },
        resvg::tiny_skia::Transform::from_row(scale, 0.0, 0.0, scale, offset, offset),
        None,
    );
    Ok((
        layer.encode_png().map_err(|error| error.to_string())?,
        corner,
    ))
}

/// The one-colour SVG of `StatusItem(image:)`, a declared resource name.
pub fn status_image(app: &Application) -> Option<&str> {
    app.windows.status_item.as_ref()?.resource("image")
}

/// The colour a status area's own pictures are in on a light bar.
pub const ON_LIGHT: [u8; 3] = [0, 0, 0];
/// The colour a status area's own pictures are in on a dark bar.
pub const ON_DARK: [u8; 3] = [255, 255, 255];

/// The status item's picture, `side` pixels square: the SVG at `path`
/// fitted and centred, its shape (what it covers, whatever its colours) in
/// `color`. PNG.
pub fn status_picture(path: &Path, side: u32, color: [u8; 3]) -> Result<Vec<u8>, String> {
    let data =
        std::fs::read(path).map_err(|error| format!("cannot read {}: {error}", path.display()))?;
    let options = resvg::usvg::Options {
        resources_dir: path.parent().map(|parent| parent.to_owned()),
        ..Default::default()
    };
    let tree = resvg::usvg::Tree::from_data(&data, &options)
        .map_err(|error| format!("{}: {error}", path.display()))?;
    let size = tree.size();
    let scale = side as f32 / size.width().max(size.height());
    let dx = (side as f32 - size.width() * scale) / 2.0;
    let dy = (side as f32 - size.height() * scale) / 2.0;
    let mut pixmap =
        resvg::tiny_skia::Pixmap::new(side, side).ok_or("cannot allocate the picture")?;
    resvg::render(
        &tree,
        resvg::tiny_skia::Transform::from_row(scale, 0.0, 0.0, scale, dx, dy),
        &mut pixmap.as_mut(),
    );
    // Premultiplied: each channel is the colour times the coverage.
    for pixel in pixmap.data_mut().as_chunks_mut::<4>().0 {
        let alpha = pixel[3] as u16;
        for (channel, value) in pixel[..3].iter_mut().zip(color) {
            *channel = ((value as u16 * alpha + 127) / 255) as u8;
        }
    }
    pixmap.encode_png().map_err(|error| error.to_string())
}

/// The status item's picture at each of `sides`, smallest first.
pub fn status_pictures(path: &Path, sides: &[u32], color: [u8; 3]) -> Result<Vec<Image>, String> {
    sides
        .iter()
        .map(|&side| {
            Ok(Image {
                size: side,
                png: Cow::Owned(status_picture(path, side, color)?),
            })
        })
        .collect()
}

/// A Windows icon (`.ico`) holding the images as PNG. Images larger than
/// 256 pixels are kept only when there is nothing smaller, as Windows reads
/// icons up to 256 pixels.
pub fn ico(images: &[Image]) -> Vec<u8> {
    let mut chosen: Vec<&Image> = images.iter().filter(|image| image.size <= 256).collect();
    if chosen.is_empty() {
        chosen.push(largest(images));
    }
    let count = chosen.len() as u16;
    let mut out = Vec::new();
    out.extend_from_slice(&0u16.to_le_bytes()); // reserved
    out.extend_from_slice(&1u16.to_le_bytes()); // icon
    out.extend_from_slice(&count.to_le_bytes());
    let mut offset = 6 + 16 * chosen.len() as u32;
    for image in &chosen {
        // 0 stands for 256 pixels or more.
        let side = if image.size >= 256 {
            0
        } else {
            image.size as u8
        };
        out.push(side);
        out.push(side);
        out.push(0); // no palette
        out.push(0); // reserved
        out.extend_from_slice(&1u16.to_le_bytes()); // colour planes
        out.extend_from_slice(&32u16.to_le_bytes()); // bits per pixel
        out.extend_from_slice(&(image.png.len() as u32).to_le_bytes());
        out.extend_from_slice(&offset.to_le_bytes());
        offset += image.png.len() as u32;
    }
    for image in &chosen {
        out.extend_from_slice(&image.png);
    }
    out
}

/// A macOS icon (`.icns`) holding the images as PNG, each under the entry
/// types for its size (the plain and the Retina slot).
pub fn icns(images: &[Image]) -> Vec<u8> {
    let types = |size: u32| -> &'static [&'static [u8; 4]] {
        match size {
            16 => &[b"icp4"],
            32 => &[b"icp5", b"ic11"],
            64 => &[b"icp6", b"ic12"],
            128 => &[b"ic07"],
            256 => &[b"ic08", b"ic13"],
            512 => &[b"ic09", b"ic14"],
            1024 => &[b"ic10"],
            _ => &[],
        }
    };
    let mut body = Vec::new();
    for image in images {
        for kind in types(image.size) {
            body.extend_from_slice(*kind);
            body.extend_from_slice(&(8 + image.png.len() as u32).to_be_bytes());
            body.extend_from_slice(&image.png);
        }
    }
    let mut out = Vec::with_capacity(8 + body.len());
    out.extend_from_slice(b"icns");
    out.extend_from_slice(&(8 + body.len() as u32).to_be_bytes());
    out.extend_from_slice(&body);
    out
}

/// What `platform` takes under `[app.icon]`, for messages.
pub fn accepted(platform: &str) -> &'static str {
    match platform {
        "macos" => "a square PNG, an `.icns` file or an Icon Composer `.icon` folder",
        "ios" => "a square PNG, an `.appiconset` folder or an Icon Composer `.icon` folder",
        "tvos" => "a square PNG or a `.brandassets` folder",
        "windows" => "a square PNG or an `.ico` file",
        "linux" => "a square PNG or an icon theme folder laid out like `hicolor`",
        "android" => "a square PNG or a `res` folder with `mipmap-*/ic_launcher`",
        "browser" => "a square PNG, an SVG or a folder of page icons",
        _ => "a square PNG",
    }
}

/// Check an icon in `platform`'s own form at `path`, a file or a folder:
/// an `.icns` file (macOS), an `.ico` file (Windows), an `.appiconset`
/// folder (iOS), an Icon Composer `.icon` folder (macOS and iOS), a
/// `.brandassets` folder (tvOS), a `res` folder (Android), an icon theme folder (Linux), or
/// an SVG or a folder of page icons (browser).
pub fn check_native(platform: &str, path: &Path) -> Result<(), String> {
    let extension = path
        .extension()
        .map(|extension| extension.to_string_lossy().to_ascii_lowercase())
        .unwrap_or_default();
    let folder = path.is_dir();
    let read = || std::fs::read(path).map_err(|error| format!("cannot be read: {error}"));
    match (platform, folder, extension.as_str()) {
        ("macos", false, "icns") => check_icns(&read()?),
        ("windows", false, "ico") => check_ico(&read()?),
        ("browser", false, "svg") => {
            if String::from_utf8_lossy(&read()?).contains("<svg") {
                Ok(())
            } else {
                Err("is not an SVG image".into())
            }
        }
        ("macos" | "ios", true, "icon") => icon_document(path).map(drop),
        ("ios", true, _) => app_icon_set(path).map(drop),
        ("tvos", true, _) => crate::brand_assets::check(path).map(drop),
        ("android", true, _) => android_res(path).map(drop),
        ("linux", true, _) => theme_icons(path).map(drop),
        ("browser", true, _) => page_icons(path).map(drop),
        _ => Err(format!("is not {}", accepted(platform))),
    }
}

/// Check a macOS icon file: entries that add up to the length it states.
pub fn check_icns(bytes: &[u8]) -> Result<(), String> {
    if bytes.len() < 8 || &bytes[..4] != b"icns" {
        return Err("is not an .icns file".into());
    }
    let length = u32::from_be_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]) as usize;
    if length != bytes.len() {
        return Err(format!(
            "says it is {length} bytes long but is {}",
            bytes.len()
        ));
    }
    let mut at = 8;
    let mut entries = 0;
    while at < bytes.len() {
        let entry = bytes
            .get(at + 4..at + 8)
            .map(|size| u32::from_be_bytes([size[0], size[1], size[2], size[3]]) as usize)
            .filter(|size| *size >= 8 && at + size <= bytes.len())
            .ok_or_else(|| format!("has a broken entry at byte {at}"))?;
        at += entry;
        entries += 1;
    }
    if entries == 0 {
        return Err("holds no images".into());
    }
    Ok(())
}

/// Check a Windows icon file: images inside the file.
pub fn check_ico(bytes: &[u8]) -> Result<(), String> {
    if bytes.len() < 6 || bytes[..4] != [0, 0, 1, 0] {
        return Err("is not an .ico file".into());
    }
    let count = u16::from_le_bytes([bytes[4], bytes[5]]) as usize;
    if count == 0 {
        return Err("holds no images".into());
    }
    for index in 0..count {
        let entry = bytes
            .get(6 + 16 * index..6 + 16 * (index + 1))
            .ok_or_else(|| "is cut short".to_string())?;
        let size = u32::from_le_bytes([entry[8], entry[9], entry[10], entry[11]]) as usize;
        let offset = u32::from_le_bytes([entry[12], entry[13], entry[14], entry[15]]) as usize;
        if size == 0 || offset + size > bytes.len() {
            return Err(format!("has a broken image {}", index + 1));
        }
    }
    Ok(())
}

/// The files below `dir`, relative to it and sorted. A symbolic link is an
/// error: an icon names the files it ships.
pub fn files_in(dir: &Path) -> Result<Vec<PathBuf>, String> {
    fn walk(dir: &Path, relative: &Path, out: &mut Vec<PathBuf>) -> Result<(), String> {
        let entries = std::fs::read_dir(dir)
            .map_err(|error| format!("cannot read {}: {error}", dir.display()))?;
        for entry in entries {
            let entry = entry.map_err(|error| format!("cannot read {}: {error}", dir.display()))?;
            let kind = entry
                .file_type()
                .map_err(|error| format!("cannot read {}: {error}", entry.path().display()))?;
            let name = entry.file_name();
            // Hidden files, and Explorer's own.
            if name.to_string_lossy().starts_with('.')
                || name == "Thumbs.db"
                || name == "desktop.ini"
            {
                continue;
            }
            let inside = relative.join(&name);
            if kind.is_symlink() {
                return Err(format!(
                    "{} is a symbolic link; put the file itself there",
                    entry.path().display()
                ));
            } else if kind.is_dir() {
                walk(&entry.path(), &inside, out)?;
            } else {
                out.push(inside);
            }
        }
        Ok(())
    }
    let mut out = Vec::new();
    walk(dir, Path::new(""), &mut out)?;
    out.sort();
    Ok(out)
}

/// The `/`-separated form of a relative path, for messages and matching.
fn slashed(path: &Path) -> String {
    path.components()
        .map(|part| part.as_os_str().to_string_lossy())
        .collect::<Vec<_>>()
        .join("/")
}

/// The files of an iOS `.appiconset` folder: `Contents.json` and the images
/// it names, all at the top of the folder.
pub fn app_icon_set(dir: &Path) -> Result<Vec<PathBuf>, String> {
    let files = files_in(dir)?;
    if let Some(nested) = files.iter().find(|file| file.components().count() > 1) {
        return Err(format!(
            "has {}; an .appiconset holds its files at its top",
            slashed(nested)
        ));
    }
    let contents = std::fs::read(dir.join("Contents.json"))
        .map_err(|_| "has no Contents.json; an .appiconset names its images there".to_string())?;
    let contents: serde_json::Value = serde_json::from_slice(&contents)
        .map_err(|error| format!("has a Contents.json that is not JSON: {error}"))?;
    let images = contents
        .get("images")
        .and_then(|images| images.as_array())
        .ok_or_else(|| "has a Contents.json without an `images` list".to_string())?;
    let mut named = 0;
    for image in images {
        let Some(name) = image.get("filename").and_then(|name| name.as_str()) else {
            continue;
        };
        if !files.iter().any(|file| file == Path::new(name)) {
            return Err(format!("names {name} in Contents.json, which is not there"));
        }
        named += 1;
    }
    if named == 0 {
        return Err("names no image in Contents.json".into());
    }
    Ok(files)
}

/// The files of an Icon Composer document (an `.icon` folder):
/// `icon.json` and the layer images it names, which sit in `Assets`.
pub fn icon_document(dir: &Path) -> Result<Vec<PathBuf>, String> {
    let files = files_in(dir)?;
    let contents = std::fs::read(dir.join("icon.json"))
        .map_err(|_| "has no icon.json; an .icon folder describes its layers there".to_string())?;
    let document: serde_json::Value = serde_json::from_slice(&contents)
        .map_err(|error| format!("has an icon.json that is not JSON: {error}"))?;
    let groups = document
        .get("groups")
        .and_then(|groups| groups.as_array())
        .ok_or_else(|| "has an icon.json without a `groups` list".to_string())?;
    let mut named = 0;
    let layers = groups
        .iter()
        .filter_map(|group| group.get("layers")?.as_array())
        .flatten();
    for layer in layers {
        // A layer names its image, and may name others per appearance.
        let specialized = layer
            .get("image-name-specializations")
            .and_then(|values| values.as_array())
            .into_iter()
            .flatten()
            .filter_map(|value| value.get("value"));
        for name in layer.get("image-name").into_iter().chain(specialized) {
            let Some(name) = name.as_str() else { continue };
            if !files
                .iter()
                .any(|file| *file == Path::new("Assets").join(name))
            {
                return Err(format!("names {name} in icon.json, which is not in Assets"));
            }
            named += 1;
        }
    }
    if named == 0 {
        return Err("names no layer image in icon.json".into());
    }
    Ok(files)
}

/// The files of an Android `res` folder with a launcher icon.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AndroidRes {
    /// Relative to the folder, as `mipmap-hdpi/ic_launcher.png`.
    pub files: Vec<PathBuf>,
    /// Whether it has a round icon, `ic_launcher_round`.
    pub round: bool,
}

/// Read an Android `res` folder: resource folders (`mipmap*`, `drawable*`,
/// `values*`) whose files make the launcher icon `@mipmap/ic_launcher`.
/// Files Bern writes itself (`values*/strings.xml`, `values*/themes.xml`)
/// cannot be in it.
pub fn android_res(dir: &Path) -> Result<AndroidRes, String> {
    let files = files_in(dir)?;
    let mut launcher = false;
    let mut round = false;
    for file in &files {
        let parts: Vec<String> = file
            .components()
            .map(|part| part.as_os_str().to_string_lossy().into_owned())
            .collect();
        let [folder, name] = parts.as_slice() else {
            return Err(format!(
                "has {}; a res folder holds resource folders such as mipmap-hdpi",
                slashed(file)
            ));
        };
        let kind = folder.split('-').next().unwrap_or_default();
        if !matches!(kind, "mipmap" | "drawable" | "values") {
            return Err(format!(
                "has {folder}; an icon's res folder holds mipmap, drawable and values folders"
            ));
        }
        if kind == "values" && matches!(name.as_str(), "strings.xml" | "themes.xml") {
            return Err(format!(
                "has {folder}/{name}, which Bern writes itself; keep only the icon's resources"
            ));
        }
        let stem = name
            .rsplit_once('.')
            .map_or(name.as_str(), |(stem, _)| stem);
        if kind == "mipmap" && stem == "ic_launcher" {
            launcher = true;
        }
        if kind == "mipmap" && stem == "ic_launcher_round" {
            round = true;
        }
    }
    if !launcher {
        return Err("has no mipmap-*/ic_launcher; the launcher icon is @mipmap/ic_launcher".into());
    }
    Ok(AndroidRes { files, round })
}

/// One application icon in an icon theme folder.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ThemeIcon {
    /// Relative to the folder, as `48x48/apps/name.png`.
    pub path: PathBuf,
    /// The folder it goes in, as `48x48/apps`.
    pub folder: PathBuf,
    /// Width and height for a PNG; `None` for SVG.
    pub size: Option<u32>,
    /// Whether it is the symbolic (single-colour) form, `name-symbolic.svg`.
    pub symbolic: bool,
    /// `png` or `svg`.
    pub extension: String,
}

/// Read an icon theme folder laid out like `hicolor`: `<size>/apps/<name>`
/// files, with `<size>` such as `48x48`, `48x48@2`, `scalable` or
/// `symbolic`, all of one name (the symbolic one ends in `-symbolic.svg`).
/// At least one PNG of a size. The name is replaced by the application's.
pub fn theme_icons(dir: &Path) -> Result<Vec<ThemeIcon>, String> {
    let mut icons = Vec::new();
    let mut name: Option<String> = None;
    for file in files_in(dir)? {
        let shown = slashed(&file);
        let parts: Vec<String> = file
            .components()
            .map(|part| part.as_os_str().to_string_lossy().into_owned())
            .collect();
        if parts.len() == 1 && parts[0] == "index.theme" {
            continue;
        }
        let [size, context, file_name] = parts.as_slice() else {
            return Err(format!(
                "has {shown}; an icon theme holds <size>/apps/<name>.png files"
            ));
        };
        if context != "apps" {
            return Err(format!(
                "has {shown}; an application icon goes in an apps folder"
            ));
        }
        let pixels = size
            .split('@')
            .next()
            .and_then(|side| side.split_once('x'))
            .and_then(|(width, height)| {
                let width: u32 = width.parse().ok()?;
                (height.parse::<u32>().ok()? == width).then_some(width)
            });
        let (stem, extension) = file_name
            .rsplit_once('.')
            .map(|(stem, extension)| (stem, extension.to_ascii_lowercase()))
            .unwrap_or((file_name.as_str(), String::new()));
        let symbolic = stem.ends_with("-symbolic");
        match (pixels, size.as_str(), extension.as_str()) {
            (Some(_), _, "png") | (None, "scalable", "svg") | (None, "symbolic", "svg") => {}
            _ => {
                return Err(format!(
                    "has {shown}; sized folders hold PNG files, scalable and symbolic hold SVG"
                ));
            }
        }
        if symbolic != (size == "symbolic") {
            return Err(format!(
                "has {shown}; symbolic/apps holds the one <name>-symbolic.svg"
            ));
        }
        let base = stem.strip_suffix("-symbolic").unwrap_or(stem);
        match &name {
            None => name = Some(base.to_string()),
            Some(name) if name != base => {
                return Err(format!(
                    "has icons named both {name} and {base}; an application has one icon"
                ));
            }
            Some(_) => {}
        }
        let size = match pixels {
            Some(pixels) => {
                let scale: u32 = size
                    .split_once('@')
                    .map_or(Some(1), |(_, scale)| scale.parse().ok())
                    .ok_or_else(|| format!("has {shown}; a scaled folder is named like 48x48@2"))?;
                let bytes = std::fs::read(dir.join(&file))
                    .map_err(|error| format!("cannot read {shown}: {error}"))?;
                match png_size(&bytes) {
                    Some((width, height)) if width == height && width == pixels * scale => {
                        Some(width)
                    }
                    Some((width, height)) => {
                        return Err(format!(
                            "has {shown}, which is {width} × {height} pixels, not {0} × {0}",
                            pixels * scale
                        ));
                    }
                    None => return Err(format!("has {shown}, which is not a PNG")),
                }
            }
            None => None,
        };
        icons.push(ThemeIcon {
            folder: file.parent().map(Path::to_path_buf).unwrap_or_default(),
            path: file,
            size,
            symbolic,
            extension,
        });
    }
    if !icons.iter().any(|icon| icon.size.is_some()) {
        return Err("has no PNG icon of a size, such as 256x256/apps/<name>.png".into());
    }
    Ok(icons)
}

/// Read a folder of page icons: files at its top, of the kinds a page
/// links (`.svg`, `.ico`, `.png`, and a `.webmanifest` or `manifest.json`
/// naming the icons beside it). At least one image.
pub fn page_icons(dir: &Path) -> Result<Vec<PathBuf>, String> {
    let files = files_in(dir)?;
    let mut images = 0;
    for file in &files {
        let shown = slashed(file);
        if file.components().count() > 1 {
            return Err(format!(
                "has {shown}; page icons sit at the top of their folder"
            ));
        }
        // The page links them by name.
        bern_protocol::check_resource_name(&shown)?;
        let extension = file
            .extension()
            .map(|extension| extension.to_string_lossy().to_ascii_lowercase())
            .unwrap_or_default();
        match extension.as_str() {
            "svg" | "ico" | "png" => images += 1,
            "webmanifest" => {}
            "json" if shown == "manifest.json" => {}
            _ => {
                return Err(format!(
                    "has {shown}; page icons are .svg, .ico and .png files and a .webmanifest"
                ));
            }
        }
    }
    if images == 0 {
        return Err("has no .svg, .ico or .png icon".into());
    }
    Ok(files)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn the_logo_is_rendered_at_every_size() {
        for shape in [Shape::AsDrawn, Shape::Masked] {
            for image in default_images(shape) {
                assert_eq!(png_size(&image.png), Some((image.size, image.size)));
            }
        }
        assert!(DEFAULT_SVG.contains("<svg"));
    }

    #[test]
    fn declared_icons_are_square_pngs_of_a_large_size() {
        let png = |size: u32| {
            let mut bytes = b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDR".to_vec();
            bytes.extend_from_slice(&size.to_be_bytes());
            bytes.extend_from_slice(&size.to_be_bytes());
            bytes
        };
        assert_eq!(check(&png(1024)), Ok(1024));
        assert!(check(&png(300)).unwrap_err().contains("1024"));
        assert!(check(b"GIF89a").unwrap_err().contains("PNG"));
        let mut wide = png(512);
        wide[16..20].copy_from_slice(&1024u32.to_be_bytes());
        assert!(check(&wide).unwrap_err().contains("square"));
    }

    #[test]
    fn ico_holds_the_small_images_as_png() {
        let ico = ico(&default_images(Shape::AsDrawn));
        assert_eq!(&ico[..4], &[0, 0, 1, 0]);
        let count = u16::from_le_bytes([ico[4], ico[5]]) as usize;
        assert_eq!(count, 9, "16 to 256 pixels");
        // The last entry is 256 pixels, written as 0, and points at a PNG.
        let entry = &ico[6 + 16 * (count - 1)..6 + 16 * count];
        assert_eq!(entry[0], 0);
        let offset = u32::from_le_bytes([entry[12], entry[13], entry[14], entry[15]]) as usize;
        assert_eq!(png_size(&ico[offset..]), Some((256, 256)));
    }

    #[test]
    fn icns_files_every_image_under_its_types() {
        let icns = icns(&default_images(Shape::Masked));
        assert_eq!(&icns[..4], b"icns");
        assert_eq!(
            u32::from_be_bytes([icns[4], icns[5], icns[6], icns[7]]) as usize,
            icns.len()
        );
        let mut kinds = Vec::new();
        let mut at = 8;
        while at < icns.len() {
            let length =
                u32::from_be_bytes([icns[at + 4], icns[at + 5], icns[at + 6], icns[at + 7]])
                    as usize;
            kinds.push(String::from_utf8_lossy(&icns[at..at + 4]).into_owned());
            assert!(png_size(&icns[at + 8..]).is_some());
            at += length;
        }
        assert_eq!(
            kinds,
            [
                "icp4", "icp5", "ic11", "icp6", "ic12", "ic07", "ic08", "ic13", "ic09", "ic14",
                "ic10"
            ]
        );
    }

    #[test]
    fn a_status_picture_is_its_shape_in_one_colour() {
        let dir = std::env::temp_dir().join(format!("bern-status-picture-{}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();
        let path = dir.join("tray.svg");
        // A red bar across a wide picture: fitted and centred in the
        // square, white whatever its colour.
        std::fs::write(
            &path,
            r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 16"><rect width="32" height="16" fill="#e00"/></svg>"##,
        )
        .unwrap();
        let png = super::status_picture(&path, 16, super::ON_DARK).unwrap();
        assert_eq!(super::png_size(&png), Some((16, 16)));
        let pixmap = resvg::tiny_skia::Pixmap::decode_png(&png).unwrap();
        let at = |x: u32, y: u32| pixmap.pixel(x, y).unwrap();
        assert_eq!(at(8, 1).alpha(), 0, "above the bar");
        let middle = at(8, 8);
        assert_eq!(
            (middle.red(), middle.green(), middle.blue(), middle.alpha()),
            (255, 255, 255, 255)
        );
        assert_eq!(at(8, 14).alpha(), 0, "below the bar");
        let black = super::status_pictures(&path, &[16, 32], super::ON_LIGHT).unwrap();
        assert_eq!(
            black.iter().map(|image| image.size).collect::<Vec<_>>(),
            [16, 32]
        );
        let _ = std::fs::remove_dir_all(dir);
    }
}