1use bern_ir::{LayoutConfig, ResourceDef, SourceLocation, Srgb, SvgPicture, ThemeMode};
5use bern_target_api::{Diagnostic, TargetConfig, WINDOWS_FRONTENDS};
6use serde::Deserialize;
7use std::collections::BTreeMap;
8use std::path::{Path, PathBuf};
9
10#[derive(Debug, Deserialize)]
11#[serde(deny_unknown_fields)]
12struct RawConfig {
13 app: RawApp,
14 languages: RawLanguages,
15 #[serde(default)]
16 theme: RawTheme,
17 protocol: RawProtocol,
18 engine: RawEngine,
19 ui: RawUi,
20 #[serde(default)]
21 targets: BTreeMap<String, RawTarget>,
22 #[serde(default)]
23 libraries: Vec<RawLibraryRef>,
24 #[serde(default)]
25 modules: Vec<RawModuleRef>,
26 resources: Option<RawResources>,
27 layouts: Option<RawLayouts>,
28 #[serde(default)]
31 contributes: BTreeMap<String, Vec<String>>,
32}
33
34#[derive(Debug, Deserialize)]
36#[serde(deny_unknown_fields)]
37struct RawModuleRef {
38 path: String,
39}
40
41#[derive(Debug, Deserialize)]
44#[serde(deny_unknown_fields)]
45struct RawModuleConfig {
46 module: RawModule,
47 languages: RawLanguages,
48 #[serde(default)]
49 pages: Vec<RawPage>,
50 #[serde(default)]
52 contributes: BTreeMap<String, Vec<String>>,
53}
54
55#[derive(Debug, Deserialize)]
57#[serde(deny_unknown_fields)]
58struct RawPage {
59 slot: String,
60 id: String,
61 label: BTreeMap<String, String>,
62 icon: Option<String>,
63 badge: Option<String>,
64 when: Option<String>,
67 #[serde(default)]
69 start: bool,
70 tab: Option<bool>,
72 component: String,
73}
74
75#[derive(Debug, Deserialize)]
76#[serde(deny_unknown_fields)]
77struct RawModule {
78 id: String,
79 name: BTreeMap<String, String>,
80 #[serde(default)]
81 depends: Vec<String>,
82 #[serde(default)]
83 optional: bool,
84 enabled: Option<bool>,
87 protocol: RawProtocol,
88 engine: RawEngine,
89 ui: Option<RawModuleUi>,
90}
91
92#[derive(Debug, Deserialize)]
93#[serde(deny_unknown_fields)]
94struct RawModuleUi {
95 package: String,
96 sources: RawSources,
97}
98
99#[derive(Debug, Deserialize)]
102#[serde(untagged)]
103enum RawSources {
104 Directory(String),
105 Files(Vec<String>),
106}
107
108#[derive(Clone, Debug)]
110pub struct ModuleConfig {
111 pub id: String,
112 pub dir: PathBuf,
113 pub manifest: PathBuf,
114 pub name: BTreeMap<String, String>,
115 pub depends: Vec<String>,
116 pub optional: bool,
117 pub enabled: bool,
119 pub protocol_package: Package,
120 pub protocol_source: SourceFile,
121 pub engine_package: Package,
122 pub engine_type: String,
124 pub ui_package: Option<Package>,
125 pub ui_sources: Vec<SourceFile>,
126 pub languages: Vec<String>,
127 pub pages: Vec<PageConfig>,
129 pub contributes: BTreeMap<String, Vec<String>>,
131}
132
133#[derive(Clone, Debug)]
135pub struct PageConfig {
136 pub slot: String,
137 pub id: String,
138 pub label: BTreeMap<String, String>,
139 pub icon: Option<String>,
140 pub badge: Option<String>,
142 pub when: Option<String>,
145 pub start: bool,
148 pub tab: bool,
151 pub component: String,
153}
154
155#[derive(Debug, Deserialize)]
157#[serde(deny_unknown_fields)]
158struct RawLayouts {
159 compact_below: f64,
161 medium_below: Option<f64>,
163 expanded_below: Option<f64>,
166}
167
168#[derive(Debug, Deserialize)]
171#[serde(deny_unknown_fields)]
172struct RawResources {
173 paths: Vec<toml::Spanned<String>>,
174}
175
176#[derive(Debug, Deserialize)]
177#[serde(deny_unknown_fields)]
178struct RawLibraryRef {
179 manifest: String,
180}
181
182#[derive(Debug, Deserialize)]
183#[serde(deny_unknown_fields)]
184struct RawLibraryConfig {
185 library: RawLibrary,
186 languages: RawLanguages,
187}
188
189#[derive(Debug, Deserialize)]
190#[serde(deny_unknown_fields)]
191struct RawLibrary {
192 package: String,
193 sources: Vec<String>,
194}
195
196#[derive(Clone, Debug)]
198pub struct LibraryConfig {
199 pub dir: PathBuf,
200 pub manifest: PathBuf,
201 pub package: Package,
202 pub version: String,
203 pub sources: Vec<SourceFile>,
204 pub languages: Vec<String>,
205 pub default_language: String,
206 pub strict: bool,
207}
208
209#[derive(Debug, Deserialize)]
210#[serde(deny_unknown_fields)]
211struct RawApp {
212 id: String,
213 bundle_id: String,
214 version: String,
215 name: BTreeMap<String, String>,
216 icon: Option<toml::Spanned<RawIcon>>,
219 alternate_icons: Option<BTreeMap<String, toml::Spanned<RawIcon>>>,
222 publisher: Option<String>,
224 #[serde(default)]
226 files_folder: bool,
227 #[serde(default)]
230 background_audio: bool,
231 #[serde(default)]
233 overlays: bool,
234 #[serde(default)]
236 photos: bool,
237}
238
239#[derive(Debug, Deserialize)]
242#[serde(untagged)]
243enum RawIcon {
244 One(String),
245 Platforms(BTreeMap<String, String>),
246}
247
248#[derive(Debug, Deserialize)]
249#[serde(deny_unknown_fields)]
250struct RawLanguages {
251 supported: Vec<String>,
252 default: String,
253 #[serde(default = "yes")]
254 strict: bool,
255}
256
257fn yes() -> bool {
258 true
259}
260
261#[derive(Debug, Default, Deserialize)]
262#[serde(deny_unknown_fields)]
263struct RawTheme {
264 mode: Option<String>,
265 brand_seed: Option<String>,
266}
267
268#[derive(Debug, Deserialize)]
269#[serde(deny_unknown_fields)]
270struct RawProtocol {
271 package: String,
272 source: String,
273}
274
275#[derive(Debug, Deserialize)]
276#[serde(deny_unknown_fields)]
277struct RawEngine {
278 package: String,
279 #[serde(rename = "type")]
280 type_path: String,
281 path: Option<String>,
284}
285
286#[derive(Debug, Deserialize)]
287#[serde(deny_unknown_fields)]
288struct RawUi {
289 package: String,
290 sources: RawSources,
291 root: String,
292}
293
294#[derive(Debug, Deserialize)]
295#[serde(deny_unknown_fields)]
296struct RawTarget {
297 platform: String,
298 ui: Option<String>,
299 web_mode: Option<String>,
300 web_extension: Option<bern_target_api::WebExtension>,
301 application_transport: Option<String>,
302 application_codec: Option<String>,
303 #[serde(default)]
304 channels: Vec<String>,
305 frontends: Option<Vec<String>>,
306 webview: Option<bool>,
307 #[serde(default)]
308 javascript: bool,
309 android: Option<bern_target_api::AndroidOptions>,
310 abis: Option<Vec<String>>,
311}
312
313#[derive(Clone, Debug)]
315pub struct SourceFile {
316 pub display: String,
318 pub path: PathBuf,
319 pub module: Vec<String>,
321}
322
323#[derive(Clone, Debug)]
324pub struct Package {
325 pub name: String,
326 pub dir: PathBuf,
327}
328
329impl Package {
330 pub fn crate_name(&self) -> String {
332 self.name.replace('-', "_")
333 }
334}
335
336#[derive(Clone, Debug)]
337pub struct Config {
338 pub dir: PathBuf,
339 pub app_id: String,
340 pub bundle_id: String,
341 pub version: String,
342 pub name: BTreeMap<String, String>,
343 pub icon: Option<String>,
346 pub platform_icons: BTreeMap<String, String>,
349 pub alternate_icons: BTreeMap<String, BTreeMap<String, String>>,
352 pub publisher: Option<String>,
354 pub files_folder: bool,
356 pub background_audio: bool,
358 pub overlays: bool,
360 pub photos: bool,
362 pub languages: Vec<String>,
363 pub default_language: String,
364 pub strict: bool,
365 pub theme_mode: ThemeMode,
366 pub brand_seed: Option<Srgb>,
367 pub protocol_package: Package,
368 pub protocol_source: SourceFile,
369 pub engine_package: Package,
370 pub engine_type: String,
371 pub ui_package: Package,
372 pub ui_sources: Vec<SourceFile>,
373 pub root: String,
374 pub targets: Vec<TargetConfig>,
375 pub libraries: Vec<PathBuf>,
377 pub modules: Vec<ModuleConfig>,
379 pub build_modules: bern_syntax::build::Modules,
381 pub contributes: BTreeMap<String, Vec<String>>,
384 pub resources: Vec<ResourceDef>,
387 pub layouts: Option<LayoutConfig>,
389}
390
391fn valid_language(tag: &str) -> bool {
392 let mut parts = tag.split('-');
393 let Some(language) = parts.next() else {
394 return false;
395 };
396 (2..=3).contains(&language.len())
397 && language.chars().all(|c| c.is_ascii_lowercase())
398 && parts.all(|part| {
399 (2..=8).contains(&part.len()) && part.chars().all(|c| c.is_ascii_alphanumeric())
400 })
401}
402
403fn expand_sources(dir: &Path, base: &str, sources: &RawSources) -> Result<Vec<String>, Diagnostic> {
407 let join = |written: &str| {
408 if base.is_empty() {
409 written.to_string()
410 } else {
411 format!("{base}/{written}")
412 }
413 };
414 match sources {
415 RawSources::Files(files) => Ok(files.iter().map(|file| join(file)).collect()),
416 RawSources::Directory(directory) if directory.ends_with(".rs") => Ok(vec![join(directory)]),
417 RawSources::Directory(directory) => {
418 let relative = join(directory.trim_end_matches('/'));
419 let mut found = Vec::new();
420 rust_files(&dir.join(&relative), &relative, &mut found)?;
421 found.sort();
422 if found.is_empty() {
423 return Err(Diagnostic::error(format!(
424 "source directory `{relative}` holds no .rs files"
425 )));
426 }
427 Ok(found)
428 }
429 }
430}
431
432fn rust_files(path: &Path, written: &str, found: &mut Vec<String>) -> Result<(), Diagnostic> {
433 let entries = std::fs::read_dir(path).map_err(|error| {
434 Diagnostic::error(format!(
435 "cannot read source directory {}: {error}",
436 path.display()
437 ))
438 })?;
439 for entry in entries.flatten() {
440 let name = entry.file_name().to_string_lossy().into_owned();
441 if name.starts_with('.') {
442 continue;
443 }
444 let child = format!("{written}/{name}");
445 match entry.file_type() {
446 Ok(kind) if kind.is_dir() => rust_files(&entry.path(), &child, found)?,
447 Ok(kind) if kind.is_file() && name.ends_with(".rs") => found.push(child),
448 _ => {}
449 }
450 }
451 Ok(())
452}
453
454fn source_file(dir: &Path, written: &str) -> Result<(PathBuf, Vec<String>), Diagnostic> {
456 let relative = Path::new(written);
457 let components: Vec<_> = relative
458 .components()
459 .map(|component| component.as_os_str().to_string_lossy().to_string())
460 .collect();
461 let src = components
462 .iter()
463 .position(|part| part == "src")
464 .ok_or_else(|| {
465 Diagnostic::error(format!(
466 "source `{written}` must be inside a package's `src` directory"
467 ))
468 })?;
469 let package_dir = dir.join(components[..src].iter().collect::<PathBuf>());
470 let mut module: Vec<String> = components[src + 1..].to_vec();
471 let file = module
472 .pop()
473 .ok_or_else(|| Diagnostic::error(format!("source `{written}` does not name a file")))?;
474 let stem = file
475 .strip_suffix(".rs")
476 .ok_or_else(|| Diagnostic::error(format!("source `{written}` must be a `.rs` file")))?;
477 if !matches!(stem, "lib" | "main" | "mod") {
478 module.push(stem.to_string());
479 }
480 if matches!(stem, "lib" | "main") && !module.is_empty() {
481 return Err(Diagnostic::error(format!(
482 "`{written}`: only the crate root may be named lib.rs or main.rs"
483 )));
484 }
485 Ok((package_dir, module))
486}
487
488fn read_package_field(dir: &Path, field: &str) -> Result<String, Diagnostic> {
489 let manifest = dir.join("Cargo.toml");
490 let text = std::fs::read_to_string(&manifest).map_err(|error| {
491 Diagnostic::error(format!("cannot read {}: {error}", manifest.display()))
492 })?;
493 let value: toml::Value = text
494 .parse()
495 .map_err(|error| Diagnostic::error(format!("{}: {error}", manifest.display())))?;
496 value
497 .get("package")
498 .and_then(|package| package.get(field))
499 .and_then(|value| match value {
500 toml::Value::String(text) => Some(text.clone()),
501 toml::Value::Table(table) if table.get("workspace").is_some() => {
503 workspace_package_field(dir, field)
504 }
505 _ => None,
506 })
507 .ok_or_else(|| Diagnostic::error(format!("{} has no package {field}", manifest.display())))
508}
509
510fn workspace_package_field(dir: &Path, field: &str) -> Option<String> {
511 let mut directory = dir.parent();
512 while let Some(current) = directory {
513 if let Ok(text) = std::fs::read_to_string(current.join("Cargo.toml"))
514 && let Ok(value) = text.parse::<toml::Value>()
515 && let Some(found) = value
516 .get("workspace")
517 .and_then(|workspace| workspace.get("package"))
518 .and_then(|package| package.get(field))
519 .and_then(toml::Value::as_str)
520 {
521 return Some(found.to_string());
522 }
523 directory = current.parent();
524 }
525 None
526}
527
528fn read_package_name(dir: &Path) -> Result<String, Diagnostic> {
529 let manifest = dir.join("Cargo.toml");
530 let text = std::fs::read_to_string(&manifest).map_err(|error| {
531 Diagnostic::error(format!("cannot read {}: {error}", manifest.display()))
532 })?;
533 let value: toml::Value = text
534 .parse()
535 .map_err(|error| Diagnostic::error(format!("{}: {error}", manifest.display())))?;
536 value
537 .get("package")
538 .and_then(|package| package.get("name"))
539 .and_then(toml::Value::as_str)
540 .map(str::to_string)
541 .ok_or_else(|| Diagnostic::error(format!("{} has no package name", manifest.display())))
542}
543
544fn package(
545 dir: &Path,
546 expected: &str,
547 sources: &[(String, PathBuf)],
548 what: &str,
549) -> Result<Package, Diagnostic> {
550 let package_dir = sources
551 .first()
552 .map(|(_, path)| path.clone())
553 .unwrap_or_else(|| dir.join(expected));
554 if sources.iter().any(|(_, path)| *path != package_dir) {
555 return Err(Diagnostic::error(format!(
556 "all {what} sources must belong to the package `{expected}`"
557 )));
558 }
559 let name = read_package_name(&package_dir)?;
560 if name != expected {
561 return Err(Diagnostic::error(format!(
562 "{what} sources are in package `{name}`, but Bern.toml names `{expected}`"
563 )));
564 }
565 Ok(Package {
566 name,
567 dir: package_dir,
568 })
569}
570
571fn manifest_location(file: &str, text: &str, offset: usize) -> SourceLocation {
573 let before = &text[..offset.min(text.len())];
574 let line_start = before.rfind('\n').map_or(0, |index| index + 1);
575 SourceLocation {
576 file: file.to_string(),
577 line: before.matches('\n').count() as u32 + 1,
578 column: before[line_start..].chars().count() as u32 + 1,
579 }
580}
581
582fn app_icons(
586 dir: &Path,
587 manifest: &str,
588 text: &str,
589 (parent, leaf): (&str, &str),
590 entry: &toml::Spanned<RawIcon>,
591 errors: &mut Vec<Diagnostic>,
592) -> (Option<String>, BTreeMap<String, String>) {
593 let declared = manifest_location(manifest, text, entry.span().start);
594 let mut platforms = BTreeMap::new();
595 match entry.get_ref() {
596 RawIcon::One(written) => (
597 app_icon(
598 dir,
599 &format!("[{parent}].{leaf}"),
600 None,
601 written,
602 &declared,
603 errors,
604 ),
605 platforms,
606 ),
607 RawIcon::Platforms(table) => {
608 let mut every = None;
609 for (key, written) in table {
610 let name = format!("[{parent}.{leaf}].{key}");
611 if key == "default" {
612 every = app_icon(dir, &name, None, written, &declared, errors);
613 } else if bern_target_api::icon::PLATFORMS.contains(&key.as_str()) {
614 if let Some(icon) = app_icon(dir, &name, Some(key), written, &declared, errors)
615 {
616 platforms.insert(key.clone(), icon);
617 }
618 } else {
619 errors.push(
620 Diagnostic::error(format!(
621 "{name}: `{key}` is not a platform; use `default` or one of {}",
622 bern_target_api::icon::PLATFORMS.join(", ")
623 ))
624 .at(declared.clone()),
625 );
626 }
627 }
628 (every, platforms)
629 }
630 }
631}
632
633fn app_icon(
638 dir: &Path,
639 name: &str,
640 platform: Option<&str>,
641 written: &str,
642 declared: &SourceLocation,
643 errors: &mut Vec<Diagnostic>,
644) -> Option<String> {
645 let error = |message: String| Diagnostic::error(message).at(declared.clone());
646 if let Err(message) = bern_protocol::check_resource_name(written) {
647 errors.push(error(format!("{name}: {message}")));
648 return None;
649 }
650 let mut path = dir.to_path_buf();
651 for segment in written.split('/') {
652 path.push(segment);
653 match std::fs::symlink_metadata(&path) {
654 Ok(metadata) if metadata.file_type().is_symlink() => {
655 errors.push(error(format!(
656 "{name} `{written}`: {} is a symbolic link; name the file it points to",
657 path.display()
658 )));
659 return None;
660 }
661 Ok(_) => {}
662 Err(_) => {
663 errors.push(error(format!(
664 "{name} `{written}` does not exist: {}",
665 dir.join(written).display()
666 )));
667 return None;
668 }
669 }
670 }
671 let checked = match platform {
672 Some(platform) if !bern_target_api::icon::is_png_name(written) => {
673 bern_target_api::icon::check_native(platform, &path)
674 }
675 _ if path.is_dir() => Err(format!(
676 "is a folder; {}",
677 match platform {
678 Some(platform) => format!("give {}", bern_target_api::icon::accepted(platform)),
679 None => "give a square PNG".to_string(),
680 }
681 )),
682 _ => std::fs::read(&path)
683 .map_err(|failure| format!("cannot be read: {failure}"))
684 .and_then(|bytes| bern_target_api::icon::check(&bytes).map(drop)),
685 };
686 if let Err(message) = checked {
687 errors.push(error(format!("{name} `{written}` {message}")));
688 return None;
689 }
690 Some(written.to_string())
691}
692
693fn resources(
696 dir: &Path,
697 manifest: &str,
698 text: &str,
699 raw: &RawResources,
700 errors: &mut Vec<Diagnostic>,
701) -> Vec<ResourceDef> {
702 let mut found: Vec<ResourceDef> = Vec::new();
703 for entry in &raw.paths {
704 let written = entry.get_ref().as_str();
705 let declared = manifest_location(manifest, text, entry.span().start);
706 let error = |message: String| Diagnostic::error(message).at(declared.clone());
707 if let Err(message) = bern_protocol::check_resource_name(written) {
708 errors.push(error(message));
709 continue;
710 }
711 let mut path = dir.to_path_buf();
714 let mut kind = None;
715 for segment in written.split('/') {
716 path.push(segment);
717 match std::fs::symlink_metadata(&path) {
718 Ok(metadata) if metadata.file_type().is_symlink() => {
719 kind = Some(Err(format!(
720 "resource `{written}`: {} is a symbolic link; list the file it points to",
721 path.display()
722 )));
723 break;
724 }
725 Ok(metadata) => kind = Some(Ok(metadata.file_type())),
726 Err(_) => {
727 kind = Some(Err(format!(
728 "resource `{written}` does not exist: {}",
729 dir.join(written).display()
730 )));
731 break;
732 }
733 }
734 }
735 match kind {
736 Some(Ok(file_type)) if file_type.is_file() => found.push(ResourceDef {
737 name: written.to_string(),
738 declared,
739 picture: svg_picture(&path),
740 }),
741 Some(Ok(file_type)) if file_type.is_dir() => {
742 let before = found.len();
743 if let Err(message) = directory_files(&path, written, &declared, &mut found) {
744 errors.push(error(message));
745 } else if found.len() == before {
746 errors.push(error(format!(
747 "resource directory `{written}` has no files to ship"
748 )));
749 }
750 }
751 Some(Ok(_)) => errors.push(error(format!(
752 "resource `{written}` is neither a file nor a directory"
753 ))),
754 Some(Err(message)) => errors.push(error(message)),
755 None => {}
756 }
757 }
758 let mut by_name: std::collections::BTreeMap<String, &ResourceDef> = Default::default();
761 for resource in &found {
762 match by_name.get(&resource.name.to_lowercase()) {
763 Some(first) if first.name == resource.name => errors.push(
764 Diagnostic::error(format!(
765 "resource `{}` is listed twice; it is already declared at line {}",
766 resource.name, first.declared.line
767 ))
768 .at(resource.declared.clone()),
769 ),
770 Some(first) => errors.push(
771 Diagnostic::error(format!(
772 "resources `{}` and `{}` differ only in case",
773 first.name, resource.name
774 ))
775 .at(resource.declared.clone()),
776 ),
777 None => {
778 by_name.insert(resource.name.to_lowercase(), resource);
779 }
780 }
781 }
782 found.sort_by(|a, b| a.name.cmp(&b.name));
783 found.dedup_by(|a, b| a.name == b.name);
784 found
785}
786
787fn directory_files(
789 path: &Path,
790 prefix: &str,
791 declared: &SourceLocation,
792 found: &mut Vec<ResourceDef>,
793) -> Result<(), String> {
794 let mut entries: Vec<_> = std::fs::read_dir(path)
795 .and_then(|entries| entries.collect::<Result<Vec<_>, _>>())
796 .map_err(|error| format!("cannot read resource directory {}: {error}", path.display()))?;
797 entries.sort_by_key(|entry| entry.file_name());
798 for entry in entries {
799 let file_name = entry.file_name().to_string_lossy().into_owned();
800 if file_name.starts_with('.') {
801 continue;
802 }
803 let name = format!("{prefix}/{file_name}");
804 bern_protocol::check_resource_name(&name)?;
805 let file_type = entry
806 .file_type()
807 .map_err(|error| format!("cannot read {}: {error}", entry.path().display()))?;
808 if file_type.is_symlink() {
809 return Err(format!(
810 "resource `{name}`: {} is a symbolic link; list the file it points to",
811 entry.path().display()
812 ));
813 } else if file_type.is_dir() {
814 directory_files(&entry.path(), &name, declared, found)?;
815 } else if file_type.is_file() {
816 found.push(ResourceDef {
817 picture: svg_picture(&entry.path()),
818 name,
819 declared: declared.clone(),
820 });
821 } else {
822 return Err(format!(
823 "resource `{name}` is neither a file nor a directory"
824 ));
825 }
826 }
827 Ok(())
828}
829
830fn svg_picture(path: &Path) -> Option<SvgPicture> {
832 if !path
833 .extension()
834 .is_some_and(|extension| extension.eq_ignore_ascii_case("svg"))
835 {
836 return None;
837 }
838 let source = std::fs::read_to_string(path).ok()?;
839 Some(match bern_svg::picture(&source)? {
840 bern_svg::Picture::Still => SvgPicture::Still,
841 bern_svg::Picture::Tinted => SvgPicture::Tinted,
842 bern_svg::Picture::Themed => SvgPicture::Themed,
843 })
844}
845
846fn check_app(app: &RawApp, errors: &mut Vec<Diagnostic>) {
848 if !bern_protocol::valid_name(&app.id) {
849 errors.push(Diagnostic::error(format!(
850 "[app].id `{}` must be lower snake case",
851 app.id
852 )));
853 }
854 let bundle_ok = app.bundle_id.split('.').count() >= 2
855 && app.bundle_id.split('.').all(|part| {
856 !part.is_empty() && part.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
857 });
858 if !bundle_ok {
859 errors.push(Diagnostic::error(format!(
860 "[app].bundle_id `{}` must be reverse-DNS, e.g. `com.example.app`",
861 app.bundle_id
862 )));
863 }
864}
865
866fn check_languages(
869 languages: &RawLanguages,
870 name: &BTreeMap<String, String>,
871 errors: &mut Vec<Diagnostic>,
872) {
873 let mut seen = std::collections::BTreeSet::new();
874 for tag in &languages.supported {
875 if !valid_language(tag) {
876 errors.push(Diagnostic::error(format!(
877 "language tag `{tag}` is not a standard tag such as `zh-Hans` or `en`"
878 )));
879 }
880 if !seen.insert(tag) {
881 errors.push(Diagnostic::error(format!(
882 "language `{tag}` is listed twice"
883 )));
884 }
885 }
886 if languages.supported.is_empty() {
887 errors.push(Diagnostic::error("[languages].supported is empty"));
888 }
889 if !languages.supported.contains(&languages.default) {
890 errors.push(Diagnostic::error(format!(
891 "default language `{}` is not in [languages].supported",
892 languages.default
893 )));
894 }
895 for language in name.keys() {
896 if !languages.supported.contains(language) {
897 errors.push(Diagnostic::error(format!(
898 "[app].name has language `{language}`, which is not supported"
899 )));
900 }
901 }
902 if languages.strict {
903 for language in &languages.supported {
904 if !name.contains_key(language) {
905 errors.push(
906 Diagnostic::error(format!(
907 "Missing translation\nProperty: [app].name\nMissing language: {language}"
908 ))
909 .code("B102"),
910 );
911 }
912 }
913 }
914}
915
916fn theme_mode(theme: &RawTheme, errors: &mut Vec<Diagnostic>) -> ThemeMode {
918 match theme.mode.as_deref().unwrap_or("system") {
919 "system" => ThemeMode::System,
920 "light" => ThemeMode::Light,
921 "dark" => ThemeMode::Dark,
922 other => {
923 errors.push(Diagnostic::error(format!(
924 "[theme].mode `{other}` must be system, light or dark"
925 )));
926 ThemeMode::System
927 }
928 }
929}
930
931fn layout_config(
933 layouts: Option<&RawLayouts>,
934 errors: &mut Vec<Diagnostic>,
935) -> Option<LayoutConfig> {
936 match layouts {
937 None => None,
938 Some(layouts) if layouts.compact_below.is_finite() && layouts.compact_below > 0.0 => {
939 let mut previous = ("compact_below", layouts.compact_below);
941 let mut ordered = true;
942 for (name, value) in [
943 ("medium_below", layouts.medium_below),
944 ("expanded_below", layouts.expanded_below),
945 ] {
946 let Some(value) = value else { continue };
947 if !value.is_finite() || value <= previous.1 {
948 errors.push(Diagnostic::error(format!(
949 "[layouts].{name} must be wider than {} ({}), not {value}",
950 previous.0, previous.1
951 )));
952 ordered = false;
953 }
954 previous = (name, value);
955 }
956 ordered.then_some(LayoutConfig {
957 compact_below: layouts.compact_below,
958 medium_below: layouts.medium_below,
959 expanded_below: layouts.expanded_below,
960 })
961 }
962 Some(layouts) => {
963 errors.push(Diagnostic::error(format!(
964 "[layouts].compact_below must be a positive width, not {}",
965 layouts.compact_below
966 )));
967 None
968 }
969 }
970}
971
972fn brand_seed(theme: &RawTheme, errors: &mut Vec<Diagnostic>) -> Option<Srgb> {
974 let seed = theme.brand_seed.as_ref()?;
975 let parsed = Srgb::parse(seed);
976 if parsed.is_none() {
977 errors.push(Diagnostic::error(format!(
978 "[theme].brand_seed `{seed}` must be a `#RRGGBB` color"
979 )));
980 }
981 parsed
982}
983
984struct AppSources {
987 protocol_source: Option<SourceFile>,
988 protocol_package: Option<Package>,
989 ui_sources: Vec<SourceFile>,
990 ui_package: Option<Package>,
991 engine_package: Option<Package>,
992}
993
994fn app_sources(
997 dir: &Path,
998 protocol: &RawProtocol,
999 ui: &RawUi,
1000 engine: &RawEngine,
1001 errors: &mut Vec<Diagnostic>,
1002) -> AppSources {
1003 let ui_written = expand_sources(dir, "", &ui.sources).unwrap_or_else(|error| {
1004 errors.push(error);
1005 Vec::new()
1006 });
1007 let mut resolve = |written: &str| -> Option<SourceFile> {
1008 match source_file(dir, written) {
1009 Ok((_, module)) => Some(SourceFile {
1010 display: written.to_string(),
1011 path: dir.join(written),
1012 module,
1013 }),
1014 Err(error) => {
1015 errors.push(error);
1016 None
1017 }
1018 }
1019 };
1020 let protocol_source = resolve(&protocol.source);
1021 let ui_sources: Vec<SourceFile> = ui_written.iter().filter_map(|s| resolve(s)).collect();
1022
1023 let protocol_package = protocol_source.as_ref().and_then(|source| {
1024 let package_dir = source_file(dir, &source.display).ok()?.0;
1025 package(
1026 dir,
1027 &protocol.package,
1028 &[(source.display.clone(), package_dir)],
1029 "protocol",
1030 )
1031 .map_err(|error| errors.push(error))
1032 .ok()
1033 });
1034 let ui_dirs: Vec<(String, PathBuf)> = ui_sources
1035 .iter()
1036 .filter_map(|source| {
1037 source_file(dir, &source.display)
1038 .ok()
1039 .map(|(package_dir, _)| (source.display.clone(), package_dir))
1040 })
1041 .collect();
1042 let ui_package = package(dir, &ui.package, &ui_dirs, "UI")
1043 .map_err(|error| errors.push(error))
1044 .ok();
1045 let engine_dir = dir.join(engine.path.as_deref().unwrap_or(&engine.package));
1046 let engine_package = package(
1047 dir,
1048 &engine.package,
1049 &[(engine.package.clone(), engine_dir)],
1050 "engine",
1051 )
1052 .map_err(|error| errors.push(error))
1053 .ok();
1054 if ui_written.is_empty() {
1055 errors.push(Diagnostic::error("[ui].sources lists no files"));
1056 }
1057 AppSources {
1058 protocol_source,
1059 protocol_package,
1060 ui_sources,
1061 ui_package,
1062 engine_package,
1063 }
1064}
1065
1066fn target_config(name: String, target: RawTarget, errors: &mut Vec<Diagnostic>) -> TargetConfig {
1068 check_web_mode(&name, &target, errors);
1069 if target.platform == "browser" && target.webview == Some(false) {
1070 errors.push(Diagnostic::error(format!(
1071 "target `{name}`: the browser provides JavaScript; webview cannot be disabled"
1072 )));
1073 }
1074 if target.platform == "tvos" && target.webview == Some(true) {
1075 errors.push(Diagnostic::error(format!(
1076 "target `{name}`: tvOS has no WebView; omit webview or set it to false"
1077 )));
1078 }
1079 if target.javascript && target.web_mode.as_deref() == Some("ui") {
1080 errors.push(Diagnostic::error(format!(
1081 "target `{name}`: JavaScript hosting currently requires web_mode = \"app\""
1082 )));
1083 }
1084 if target.web_extension.is_some() && target.platform != "browser" {
1085 errors.push(Diagnostic::error(format!(
1086 "target `{name}`: web_extension requires a browser target"
1087 )));
1088 }
1089 if target.android.is_some() && target.platform != "android" {
1090 errors.push(Diagnostic::error(format!(
1091 "target `{name}`: android host options require an Android target"
1092 )));
1093 }
1094
1095 let frontends = windows_frontends(&name, &target.platform, target.frontends, errors);
1096 let abis = android_abis(&name, &target.platform, target.abis, errors);
1097 for channel in &target.channels {
1098 if channel != "ipc" {
1099 errors.push(Diagnostic::error(format!(
1100 "target `{name}`: unknown channel `{channel}`; supported: ipc"
1101 )));
1102 }
1103 }
1104 TargetConfig {
1105 name,
1106 platform: target.platform,
1107 ui: target.ui,
1108 web_mode: target.web_mode,
1109 web_extension: target.web_extension,
1110 channels: target.channels,
1111 frontends,
1112 webview: target.webview,
1113 javascript: target.javascript,
1114 android: target.android,
1115 abis,
1116 }
1117}
1118
1119fn check_web_mode(name: &str, target: &RawTarget, errors: &mut Vec<Diagnostic>) {
1123 if target.platform == "browser" {
1124 match target.web_mode.as_deref() {
1125 None => errors.push(Diagnostic::error(format!(
1126 "target `{name}`: browser targets must set `web_mode = \"app\"` or `\"ui\"`"
1127 ))),
1128 Some("app") | Some("ui") => {}
1129 Some(other) => errors.push(Diagnostic::error(format!(
1130 "target `{name}`: `web_mode = \"{other}\"` is unknown; use \"app\" or \"ui\""
1131 ))),
1132 }
1133 let (transport, codec) = match target.web_mode.as_deref() {
1137 Some("ui") => ("websocket", Some("json")),
1138 _ => ("in-process", None),
1139 };
1140 if let Some(declared) = &target.application_transport
1141 && declared != transport
1142 {
1143 errors.push(Diagnostic::error(format!(
1144 "target `{name}`: `web_mode = \"{}\"` uses `application_transport = \"{transport}\"`, not `\"{declared}\"`",
1145 target.web_mode.as_deref().unwrap_or("app")
1146 )));
1147 }
1148 if let Some(declared) = &target.application_codec
1149 && Some(declared.as_str()) != codec
1150 {
1151 errors.push(Diagnostic::error(format!(
1152 "target `{name}`: `application_codec = \"{declared}\"` does not apply; {}",
1153 match codec {
1154 Some(codec) => format!("the WebUI channel uses `{codec}`"),
1155 None => "the in-page engine is called directly".into(),
1156 }
1157 )));
1158 }
1159 } else if target.web_mode.is_some() {
1160 errors.push(Diagnostic::error(format!(
1161 "target `{name}`: `web_mode` applies to browser targets only"
1162 )));
1163 }
1164}
1165
1166fn android_abis(
1170 name: &str,
1171 platform: &str,
1172 abis: Option<Vec<String>>,
1173 errors: &mut Vec<Diagnostic>,
1174) -> Vec<String> {
1175 let Some(abis) = abis else {
1176 return Vec::new();
1177 };
1178 if platform != "android" {
1179 errors.push(Diagnostic::error(format!(
1180 "target `{name}`: `abis` applies to Android targets only"
1181 )));
1182 return Vec::new();
1183 }
1184 if abis.is_empty() {
1185 errors.push(Diagnostic::error(format!(
1186 "target `{name}`: `abis` lists none; name \"arm64-v8a\", \"armeabi-v7a\" or \"x86_64\""
1187 )));
1188 }
1189 for (index, abi) in abis.iter().enumerate() {
1190 if !bern_target_api::ANDROID_ABIS.contains(&abi.as_str()) {
1191 errors.push(Diagnostic::error(format!(
1192 "target `{name}`: `abis` lists `{abi}`; name \"arm64-v8a\", \"armeabi-v7a\" or \"x86_64\""
1193 )));
1194 } else if abis[..index].contains(abi) {
1195 errors.push(Diagnostic::error(format!(
1196 "target `{name}`: `abis` lists `{abi}` twice"
1197 )));
1198 }
1199 }
1200 abis
1201}
1202
1203fn windows_frontends(
1204 name: &str,
1205 platform: &str,
1206 frontends: Option<Vec<String>>,
1207 errors: &mut Vec<Diagnostic>,
1208) -> Vec<String> {
1209 match (platform, frontends) {
1210 ("windows", None) => WINDOWS_FRONTENDS.map(str::to_string).to_vec(),
1211 ("windows", Some(frontends)) => {
1212 if frontends.is_empty() {
1213 errors.push(Diagnostic::error(format!(
1214 "target `{name}`: `frontends` lists none; name \"winui3\", \"xaml-islands\" or both"
1215 )));
1216 }
1217 for (index, frontend) in frontends.iter().enumerate() {
1218 if !WINDOWS_FRONTENDS.contains(&frontend.as_str()) {
1219 errors.push(Diagnostic::error(format!(
1220 "target `{name}`: unknown front end `{frontend}`; use \"winui3\" or \"xaml-islands\""
1221 )));
1222 } else if frontends[..index].contains(frontend) {
1223 errors.push(Diagnostic::error(format!(
1224 "target `{name}`: `frontends` lists `{frontend}` twice"
1225 )));
1226 }
1227 }
1228 frontends
1229 }
1230 (_, Some(_)) => {
1231 errors.push(Diagnostic::error(format!(
1232 "target `{name}`: `frontends` applies to Windows targets only"
1233 )));
1234 Vec::new()
1235 }
1236 (_, None) => Vec::new(),
1237 }
1238}
1239
1240fn library_manifests(
1242 dir: &Path,
1243 libraries: &[RawLibraryRef],
1244 errors: &mut Vec<Diagnostic>,
1245) -> Vec<PathBuf> {
1246 let libraries: Vec<PathBuf> = libraries
1247 .iter()
1248 .map(|library| dir.join(&library.manifest))
1249 .collect();
1250 for library in &libraries {
1251 if !library.is_file() {
1252 errors.push(Diagnostic::error(format!(
1253 "library manifest {} does not exist",
1254 library.display()
1255 )));
1256 }
1257 }
1258 libraries
1259}
1260
1261fn load_modules(
1264 dir: &Path,
1265 entries: &[RawModuleRef],
1266 languages: &[String],
1267 selection: &bern_syntax::build::Modules,
1268 errors: &mut Vec<Diagnostic>,
1269) -> Vec<ModuleConfig> {
1270 let mut modules: Vec<ModuleConfig> = Vec::new();
1271 for entry in entries {
1272 let manifest = dir.join(&entry.path).join("Bern.toml");
1273 let id = std::fs::read_to_string(&manifest)
1274 .ok()
1275 .and_then(|text| text.parse::<toml::Value>().ok())
1276 .and_then(|value| value.get("module")?.get("id")?.as_str().map(str::to_string));
1277 if id
1278 .as_ref()
1279 .is_some_and(|id| selection.0.get(id) == Some(&false))
1280 {
1281 continue;
1282 }
1283 match load_module(dir, &entry.path) {
1284 Ok(module) => {
1285 if modules.iter().any(|other| other.id == module.id) {
1286 errors.push(Diagnostic::error(format!(
1287 "module `{}` is listed twice",
1288 module.id
1289 )));
1290 }
1291 for language in languages {
1292 if !module.languages.contains(language) {
1293 errors.push(Diagnostic::error(format!(
1294 "module `{}` does not translate `{language}`, which the application supports; add it to {}",
1295 module.id,
1296 module.manifest.display()
1297 )));
1298 }
1299 }
1300 modules.push(module);
1301 }
1302 Err(module_errors) => errors.extend(module_errors),
1303 }
1304 }
1305 check_module_order(&modules, errors);
1306 modules
1307}
1308
1309fn check_module_order(modules: &[ModuleConfig], errors: &mut Vec<Diagnostic>) {
1312 for (index, module) in modules.iter().enumerate() {
1313 for dependency in &module.depends {
1314 match modules.iter().position(|other| &other.id == dependency) {
1315 Some(position) if position < index && modules[position].optional && !module.optional => {
1316 errors.push(Diagnostic::error(format!(
1317 "module `{}` always runs but depends on the optional module `{dependency}`; make it optional too",
1318 module.id
1319 )))
1320 }
1321 Some(position) if position < index => {}
1322 Some(_) => errors.push(Diagnostic::error(format!(
1323 "module `{}` depends on `{dependency}`, which Bern.toml lists after it; list modules after the ones they depend on",
1324 module.id
1325 ))),
1326 None => errors.push(Diagnostic::error(format!(
1327 "module `{}` depends on `{dependency}`, which is not a module of this application",
1328 module.id
1329 ))),
1330 }
1331 }
1332 }
1333}
1334
1335fn check_contributes(
1337 contributes: &BTreeMap<String, Vec<String>>,
1338 manifest: &str,
1339 errors: &mut Vec<Diagnostic>,
1340) {
1341 for slot in contributes.keys() {
1342 if !bern_protocol::valid_name(slot) {
1343 errors.push(Diagnostic::error(format!(
1344 "{manifest}: slot `{slot}` must be lower snake case"
1345 )));
1346 }
1347 }
1348}
1349
1350fn module_selection(
1352 dir: &Path,
1353 entries: &[RawModuleRef],
1354 excluded: &[String],
1355) -> Result<bern_syntax::build::Modules, Vec<Diagnostic>> {
1356 let mut catalog = BTreeMap::<String, Vec<String>>::new();
1357 let mut errors = Vec::new();
1358 for entry in entries {
1359 let path = dir.join(&entry.path).join("Bern.toml");
1360 let result = std::fs::read_to_string(&path)
1361 .map_err(|e| e.to_string())
1362 .and_then(|text| text.parse::<toml::Value>().map_err(|e| e.to_string()));
1363 match result {
1364 Ok(value) => {
1365 let module = value.get("module");
1366 if let Some(id) = module
1367 .and_then(|m| m.get("id"))
1368 .and_then(toml::Value::as_str)
1369 {
1370 let depends = module
1371 .and_then(|m| m.get("depends"))
1372 .and_then(toml::Value::as_array)
1373 .into_iter()
1374 .flatten()
1375 .filter_map(toml::Value::as_str)
1376 .map(str::to_string)
1377 .collect();
1378 if !bern_protocol::valid_name(id) {
1379 errors.push(Diagnostic::error(format!(
1380 "invalid module id `{id}` in {}",
1381 path.display()
1382 )));
1383 }
1384 if catalog.insert(id.to_string(), depends).is_some() {
1385 errors.push(Diagnostic::error(format!("module `{id}` is listed twice")));
1386 }
1387 } else {
1388 errors.push(Diagnostic::error(format!(
1389 "{} needs [module].id",
1390 path.display()
1391 )));
1392 }
1393 }
1394 Err(e) => errors.push(Diagnostic::error(format!("{}: {e}", path.display()))),
1395 }
1396 }
1397 for id in excluded {
1398 if !catalog.contains_key(id) {
1399 errors.push(Diagnostic::error(format!(
1400 "unknown module `{id}` in --exclude-module; declared modules: {}",
1401 catalog.keys().cloned().collect::<Vec<_>>().join(", ")
1402 )));
1403 }
1404 }
1405 fn chain(
1406 id: &str,
1407 catalog: &BTreeMap<String, Vec<String>>,
1408 excluded: &[String],
1409 path: &mut Vec<String>,
1410 ) -> Option<Vec<String>> {
1411 if path.iter().any(|p| p == id) {
1412 return None;
1413 }
1414 path.push(id.to_string());
1415 if excluded.iter().any(|e| e == id) {
1416 return Some(path.clone());
1417 }
1418 for dep in catalog.get(id).into_iter().flatten() {
1419 if let Some(found) = chain(dep, catalog, excluded, path) {
1420 return Some(found);
1421 }
1422 }
1423 path.pop();
1424 None
1425 }
1426 for id in catalog.keys().filter(|id| !excluded.contains(id)) {
1427 if let Some(path) = chain(id, &catalog, excluded, &mut Vec::new()) {
1428 errors.push(Diagnostic::error(format!("cannot exclude module `{}`: retained dependency chain {}; also exclude the dependent module or keep its dependency", path.last().unwrap(), path.join(" -> "))));
1429 }
1430 }
1431 if !errors.is_empty() {
1432 return Err(errors);
1433 }
1434 Ok(bern_syntax::build::Modules(
1435 catalog
1436 .keys()
1437 .map(|id| (id.clone(), !excluded.contains(id)))
1438 .collect(),
1439 ))
1440}
1441
1442pub fn load(path: &Path) -> Result<Config, Vec<Diagnostic>> {
1444 load_excluding(path, &[])
1445}
1446
1447pub fn load_excluding(path: &Path, excluded: &[String]) -> Result<Config, Vec<Diagnostic>> {
1449 let text = std::fs::read_to_string(path).map_err(|error| {
1450 vec![Diagnostic::error(format!(
1451 "cannot read {}: {error}",
1452 path.display()
1453 ))]
1454 })?;
1455 let raw: RawConfig = toml::from_str(&text)
1456 .map_err(|error| vec![Diagnostic::error(format!("{}: {error}", path.display()))])?;
1457 let dir = path
1458 .parent()
1459 .map(Path::to_path_buf)
1460 .unwrap_or_else(|| PathBuf::from("."));
1461 let dir = canonical(dir);
1462 let mut errors = Vec::new();
1463
1464 check_app(&raw.app, &mut errors);
1465 check_languages(&raw.languages, &raw.app.name, &mut errors);
1466 let theme_mode = theme_mode(&raw.theme, &mut errors);
1467 let layouts = layout_config(raw.layouts.as_ref(), &mut errors);
1468 let brand_seed = brand_seed(&raw.theme, &mut errors);
1469 let sources = app_sources(&dir, &raw.protocol, &raw.ui, &raw.engine, &mut errors);
1470 let targets: Vec<TargetConfig> = raw
1471 .targets
1472 .into_iter()
1473 .map(|(name, target)| target_config(name, target, &mut errors))
1474 .collect();
1475 let libraries = library_manifests(&dir, &raw.libraries, &mut errors);
1476 let build_modules = module_selection(&dir, &raw.modules, excluded)?;
1477 let modules = load_modules(
1478 &dir,
1479 &raw.modules,
1480 &raw.languages.supported,
1481 &build_modules,
1482 &mut errors,
1483 );
1484 let manifest = path.display().to_string();
1485 check_contributes(&raw.contributes, &manifest, &mut errors);
1486 let resources = raw
1487 .resources
1488 .as_ref()
1489 .map(|raw| resources(&dir, &manifest, &text, raw, &mut errors))
1490 .unwrap_or_default();
1491 let (icon, platform_icons) = raw
1492 .app
1493 .icon
1494 .as_ref()
1495 .map(|entry| app_icons(&dir, &manifest, &text, ("app", "icon"), entry, &mut errors))
1496 .unwrap_or_default();
1497 let mut alternate_icons = BTreeMap::new();
1498 for (name, entry) in raw.app.alternate_icons.iter().flatten() {
1499 let named = name.starts_with(|c: char| c.is_ascii_lowercase())
1502 && name
1503 .chars()
1504 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
1505 && name != "default";
1506 if !named {
1507 errors.push(
1508 Diagnostic::error(format!(
1509 "[app.alternate_icons].{name}: name an alternate icon with lowercase letters, digits and `_`, starting with a letter; `default` is `[app.icon]`"
1510 ))
1511 .at(manifest_location(&manifest, &text, entry.span().start)),
1512 );
1513 continue;
1514 }
1515 let (every, mut entries) = app_icons(
1516 &dir,
1517 &manifest,
1518 &text,
1519 ("app.alternate_icons", name),
1520 entry,
1521 &mut errors,
1522 );
1523 if let Some(every) = every {
1524 entries.insert("default".to_string(), every);
1525 }
1526 if !entries.is_empty() {
1527 alternate_icons.insert(name.clone(), entries);
1528 }
1529 }
1530
1531 if !errors.is_empty() {
1532 return Err(errors);
1533 }
1534 Ok(Config {
1535 dir,
1536 app_id: raw.app.id,
1537 bundle_id: raw.app.bundle_id,
1538 version: raw.app.version,
1539 name: raw.app.name,
1540 icon,
1541 platform_icons,
1542 alternate_icons,
1543 publisher: raw
1544 .app
1545 .publisher
1546 .map(|publisher| publisher.trim().to_string())
1547 .filter(|publisher| !publisher.is_empty()),
1548 files_folder: raw.app.files_folder,
1549 background_audio: raw.app.background_audio,
1550 overlays: raw.app.overlays,
1551 photos: raw.app.photos,
1552 languages: raw.languages.supported,
1553 default_language: raw.languages.default,
1554 strict: raw.languages.strict,
1555 theme_mode,
1556 brand_seed,
1557 protocol_package: sources.protocol_package.unwrap(),
1558 protocol_source: sources.protocol_source.unwrap(),
1559 engine_package: sources.engine_package.unwrap(),
1560 engine_type: raw.engine.type_path,
1561 ui_package: sources.ui_package.unwrap(),
1562 ui_sources: sources.ui_sources,
1563 root: raw.ui.root,
1564 targets,
1565 libraries,
1566 modules,
1567 build_modules,
1568 contributes: raw.contributes,
1569 resources,
1570 layouts,
1571 })
1572}
1573
1574fn load_module(dir: &Path, written: &str) -> Result<ModuleConfig, Vec<Diagnostic>> {
1577 let base = written.trim_end_matches('/');
1578 let manifest = dir.join(base).join("Bern.toml");
1579 let text = std::fs::read_to_string(&manifest).map_err(|error| {
1580 vec![Diagnostic::error(format!(
1581 "cannot read module manifest {}: {error}",
1582 manifest.display()
1583 ))]
1584 })?;
1585 let raw: RawModuleConfig = toml::from_str(&text).map_err(|error| {
1586 vec![Diagnostic::error(format!(
1587 "{}: {error}",
1588 manifest.display()
1589 ))]
1590 })?;
1591 let mut errors = Vec::new();
1592 let id = raw.module.id;
1593 if !bern_protocol::valid_name(&id) {
1594 errors.push(Diagnostic::error(format!(
1595 "{}: module id `{id}` must be lower snake case",
1596 manifest.display()
1597 )));
1598 }
1599 let mut resolve = |written: &str| -> Option<SourceFile> {
1600 match source_file(dir, written) {
1601 Ok((_, module)) => Some(SourceFile {
1602 display: written.to_string(),
1603 path: dir.join(written),
1604 module,
1605 }),
1606 Err(error) => {
1607 errors.push(error);
1608 None
1609 }
1610 }
1611 };
1612 let protocol_source = resolve(&format!("{base}/{}", raw.module.protocol.source));
1613 let mut ui_sources = Vec::new();
1614 let mut ui_dirs = Vec::new();
1615 if let Some(ui) = &raw.module.ui {
1616 match expand_sources(dir, base, &ui.sources) {
1617 Ok(files) => {
1618 for file in files {
1619 if let Ok((package_dir, _)) = source_file(dir, &file) {
1620 ui_dirs.push((file.clone(), package_dir));
1621 }
1622 ui_sources.extend(resolve(&file));
1623 }
1624 }
1625 Err(error) => errors.push(error),
1626 }
1627 }
1628 let protocol_package = protocol_source.as_ref().and_then(|source| {
1629 let package_dir = source_file(dir, &source.display).ok()?.0;
1630 package(
1631 dir,
1632 &raw.module.protocol.package,
1633 &[(source.display.clone(), package_dir)],
1634 "protocol",
1635 )
1636 .map_err(|error| errors.push(error))
1637 .ok()
1638 });
1639 let ui_package = raw.module.ui.as_ref().and_then(|ui| {
1640 package(dir, &ui.package, &ui_dirs, "UI")
1641 .map_err(|error| errors.push(error))
1642 .ok()
1643 });
1644 let engine_dir = dir.join(base).join(
1645 raw.module
1646 .engine
1647 .path
1648 .as_deref()
1649 .unwrap_or(&raw.module.engine.package),
1650 );
1651 let engine_package = package(
1652 dir,
1653 &raw.module.engine.package,
1654 &[(raw.module.engine.package.clone(), engine_dir)],
1655 "engine",
1656 )
1657 .map_err(|error| errors.push(error))
1658 .ok();
1659 for page in &raw.pages {
1660 for (what, field) in [("badge", &page.badge), ("when", &page.when)] {
1661 if let Some(field) = field
1662 && !bern_protocol::valid_name(field)
1663 {
1664 errors.push(Diagnostic::error(format!(
1665 "{}: page {what} `{field}` must name a lower snake case state field",
1666 manifest.display()
1667 )));
1668 }
1669 }
1670 for (what, name) in [("slot", &page.slot), ("page id", &page.id)] {
1671 if !bern_protocol::valid_name(name) {
1672 errors.push(Diagnostic::error(format!(
1673 "{}: {what} `{name}` must be lower snake case",
1674 manifest.display()
1675 )));
1676 }
1677 }
1678 if raw.module.ui.is_none() {
1679 errors.push(Diagnostic::error(format!(
1680 "{}: page `{}` needs the module's [module.ui] to show it",
1681 manifest.display(),
1682 page.id
1683 )));
1684 }
1685 }
1686 for slot in raw.contributes.keys() {
1687 if !bern_protocol::valid_name(slot) {
1688 errors.push(Diagnostic::error(format!(
1689 "{}: slot `{slot}` must be lower snake case",
1690 manifest.display()
1691 )));
1692 }
1693 }
1694 if raw.module.enabled.is_some() && !raw.module.optional {
1695 errors.push(Diagnostic::error(format!(
1696 "{}: `enabled` applies to optional modules; add `optional = true` or remove it",
1697 manifest.display()
1698 )));
1699 }
1700 if !errors.is_empty() {
1701 return Err(errors);
1702 }
1703 Ok(ModuleConfig {
1704 id,
1705 dir: canonical(dir.join(base)),
1706 manifest,
1707 name: raw.module.name,
1708 depends: raw.module.depends,
1709 optional: raw.module.optional,
1710 enabled: raw.module.enabled.unwrap_or(true),
1711 protocol_package: protocol_package.unwrap(),
1712 protocol_source: protocol_source.unwrap(),
1713 engine_package: engine_package.unwrap(),
1714 engine_type: raw.module.engine.type_path,
1715 ui_package,
1716 ui_sources,
1717 languages: raw.languages.supported,
1718 pages: raw
1719 .pages
1720 .into_iter()
1721 .map(|page| PageConfig {
1722 slot: page.slot,
1723 id: page.id,
1724 label: page.label,
1725 icon: page.icon,
1726 badge: page.badge,
1727 when: page.when,
1728 start: page.start,
1729 tab: page.tab.unwrap_or(true),
1730 component: page.component,
1731 })
1732 .collect(),
1733 contributes: raw.contributes,
1734 })
1735}
1736
1737pub fn load_library(path: &Path) -> Result<LibraryConfig, Vec<Diagnostic>> {
1739 let text = std::fs::read_to_string(path).map_err(|error| {
1740 vec![Diagnostic::error(format!(
1741 "cannot read {}: {error}",
1742 path.display()
1743 ))]
1744 })?;
1745 let raw: RawLibraryConfig = toml::from_str(&text)
1746 .map_err(|error| vec![Diagnostic::error(format!("{}: {error}", path.display()))])?;
1747 let dir = path
1748 .parent()
1749 .map(Path::to_path_buf)
1750 .unwrap_or_else(|| PathBuf::from("."));
1751 let dir = canonical(dir);
1752 let mut errors = Vec::new();
1753 let mut sources = Vec::new();
1754 let mut dirs = Vec::new();
1755 for written in &raw.library.sources {
1756 match source_file(&dir, written) {
1757 Ok((package_dir, module)) => {
1758 dirs.push((written.clone(), package_dir));
1759 sources.push(SourceFile {
1760 display: format!("{}/{written}", raw.library.package),
1761 path: dir.join(written),
1762 module,
1763 });
1764 }
1765 Err(error) => errors.push(error),
1766 }
1767 }
1768 for tag in &raw.languages.supported {
1769 if !valid_language(tag) {
1770 errors.push(Diagnostic::error(format!(
1771 "language tag `{tag}` is not a standard tag"
1772 )));
1773 }
1774 }
1775 let package = package(&dir, &raw.library.package, &dirs, "library")
1776 .map_err(|error| errors.push(error))
1777 .ok();
1778 let version = package.as_ref().and_then(|package| {
1779 read_package_field(&package.dir, "version")
1780 .map_err(|error| errors.push(error))
1781 .ok()
1782 });
1783 if !errors.is_empty() {
1784 return Err(errors);
1785 }
1786 Ok(LibraryConfig {
1787 dir,
1788 manifest: path.to_path_buf(),
1789 package: package.unwrap(),
1790 version: version.unwrap(),
1791 sources,
1792 languages: raw.languages.supported,
1793 default_language: raw.languages.default,
1794 strict: raw.languages.strict,
1795 })
1796}
1797
1798fn canonical(dir: PathBuf) -> PathBuf {
1801 let Ok(path) = dir.canonicalize() else {
1802 return dir;
1803 };
1804 if cfg!(windows)
1805 && let Some(rest) = path.to_string_lossy().strip_prefix(r"\\?\")
1806 && !rest.starts_with(r"UNC\")
1807 {
1808 return PathBuf::from(rest);
1809 }
1810 path
1811}
1812
1813#[cfg(test)]
1814mod tests {
1815 use super::*;
1816
1817 #[test]
1818 fn android_host_options_are_explicit_and_platform_scoped() {
1819 let parse = |text: &str| toml::from_str::<RawTarget>(text).unwrap();
1820 let mut errors = Vec::new();
1821 let target = target_config(
1822 "phone".into(),
1823 parse("platform = 'android'\nandroid = { network = true, multicast = true }"),
1824 &mut errors,
1825 );
1826 assert!(errors.is_empty(), "{errors:?}");
1827 let options = target.android.unwrap();
1828 assert!(options.network && options.multicast);
1829
1830 let default = target_config("phone".into(), parse("platform = 'android'"), &mut errors);
1831 assert!(default.android.is_none());
1832 target_config(
1833 "desktop".into(),
1834 parse("platform = 'macos'\nandroid = { network = true }"),
1835 &mut errors,
1836 );
1837 assert_eq!(errors.len(), 1);
1838 assert!(errors[0].message.contains("require an Android target"));
1839 assert!(
1840 toml::from_str::<RawTarget>("platform = 'android'\nandroid = { netwrok = true }")
1841 .is_err()
1842 );
1843 }
1844
1845 #[test]
1846 fn android_abis_are_known_listed_once_and_for_android() {
1847 let target = |text: &str| {
1848 let mut errors = Vec::new();
1849 let raw = toml::from_str::<RawTarget>(text).unwrap();
1850 let target = target_config("phone".into(), raw, &mut errors);
1851 (target.abis, errors.len())
1852 };
1853 let (abis, errors) = target("platform = 'android'\nabis = ['arm64-v8a', 'x86_64']");
1854 assert_eq!(
1855 (abis, errors),
1856 (vec!["arm64-v8a".into(), "x86_64".into()], 0)
1857 );
1858 assert_eq!(target("platform = 'android'").0, Vec::<String>::new());
1859 assert_eq!(target("platform = 'android'\nabis = []").1, 1);
1860 assert_eq!(target("platform = 'android'\nabis = ['mips']").1, 1);
1861 assert_eq!(
1862 target("platform = 'android'\nabis = ['x86_64', 'x86_64']").1,
1863 1
1864 );
1865 assert_eq!(target("platform = 'macos'\nabis = ['x86_64']").1, 1);
1866 }
1867
1868 #[test]
1869 fn language_tags_are_validated() {
1870 for tag in ["zh-Hans", "en", "ja", "pt-BR", "zh-Hant-TW"] {
1871 assert!(valid_language(tag), "{tag}");
1872 }
1873 for tag in ["", "EN", "english", "zh_Hans", "zh-"] {
1874 assert!(!valid_language(tag), "{tag}");
1875 }
1876 }
1877
1878 fn check_resources(files: &[&str], entries: &str) -> (Vec<ResourceDef>, Vec<Diagnostic>) {
1882 static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
1883 let dir = std::env::temp_dir().join(format!(
1884 "bern-resources-test-{}-{}",
1885 std::process::id(),
1886 NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
1887 ));
1888 let _ = std::fs::remove_dir_all(&dir);
1889 std::fs::create_dir_all(&dir).unwrap();
1890 for file in files {
1891 let path = dir.join(file);
1892 if file.ends_with('/') {
1893 std::fs::create_dir_all(&path).unwrap();
1894 } else {
1895 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1896 std::fs::write(&path, b"x").unwrap();
1897 }
1898 }
1899 #[derive(Deserialize)]
1900 struct Manifest {
1901 resources: RawResources,
1902 }
1903 let text = format!("# Bern.toml\n[resources]\npaths = [\n{entries}\n]\n");
1904 let raw: Manifest = toml::from_str(&text).unwrap();
1905 let mut errors = Vec::new();
1906 let found = resources(&dir, "Bern.toml", &text, &raw.resources, &mut errors);
1907 let _ = std::fs::remove_dir_all(&dir);
1908 (found, errors)
1909 }
1910
1911 fn names(found: &[ResourceDef]) -> Vec<&str> {
1912 found
1913 .iter()
1914 .map(|resource| resource.name.as_str())
1915 .collect()
1916 }
1917
1918 #[test]
1919 fn resources_expand_directories_and_point_at_their_entries() {
1920 let (found, errors) = check_resources(
1921 &[
1922 "media/b.mp4",
1923 "data/seed.json",
1924 "data/nested/more.json",
1925 "data/.DS_Store",
1926 "data/.git/config",
1927 ],
1928 " \"media/b.mp4\",\n \"data\",",
1929 );
1930 assert!(errors.is_empty(), "{errors:?}");
1931 assert_eq!(
1932 names(&found),
1933 ["data/nested/more.json", "data/seed.json", "media/b.mp4"]
1934 );
1935 let media = &found[2].declared;
1938 assert_eq!(
1939 (media.file.as_str(), media.line, media.column),
1940 ("Bern.toml", 4, 3)
1941 );
1942 assert_eq!(found[0].declared.line, 5);
1943 }
1944
1945 #[test]
1946 fn resource_errors_are_strict_and_located() {
1947 let cases: &[(&[&str], &str, &str)] = &[
1948 (&[], "\"media/missing.mp4\"", "does not exist"),
1949 (&["a.mp4"], "\"/a.mp4\"", "is absolute"),
1950 (&["a.mp4"], "\"../a.mp4\"", "starts with `.`"),
1951 (&["a b.mp4"], "\"a b.mp4\"", "contains ` `"),
1952 (&["empty/"], "\"empty\"", "has no files to ship"),
1953 (&["only/.hidden"], "\"only\"", "has no files to ship"),
1954 (&["d/a b.txt"], "\"d\"", "contains ` `"),
1955 (&["a.mp4"], "\"a.mp4\", \"a.mp4\"", "listed twice"),
1956 (&["d/a.mp4"], "\"d\", \"d/a.mp4\"", "listed twice"),
1957 (&["d/A.mp4", "e/a.mp4"], "\"d/A.mp4\", \"e/a.mp4\"", ""),
1958 ];
1959 for (files, entries, expected) in cases {
1960 let (_, errors) = check_resources(files, entries);
1961 if expected.is_empty() {
1962 assert!(errors.is_empty(), "{entries}: {errors:?}");
1963 continue;
1964 }
1965 assert_eq!(errors.len(), 1, "{entries}: {errors:?}");
1966 assert!(
1967 errors[0].message.contains(expected),
1968 "{entries}: {}",
1969 errors[0].message
1970 );
1971 let location = errors[0].location.as_ref().expect("located");
1972 assert_eq!((location.file.as_str(), location.line), ("Bern.toml", 4));
1973 }
1974 }
1975
1976 #[cfg(unix)]
1977 #[test]
1978 fn symbolic_links_and_case_collisions_are_rejected() {
1979 let (_, errors) = check_resources(&["a.mp4", "A.MP4"], "\"a.mp4\", \"A.MP4\"");
1980 assert_eq!(errors.len(), 1, "{errors:?}");
1983 assert!(
1984 errors[0].message.contains("differ only in case")
1985 || errors[0].message.contains("listed twice"),
1986 "{}",
1987 errors[0].message
1988 );
1989
1990 let dir = std::env::temp_dir().join(format!("bern-resources-link-{}", std::process::id()));
1991 let _ = std::fs::remove_dir_all(&dir);
1992 std::fs::create_dir_all(dir.join("real")).unwrap();
1993 std::fs::write(dir.join("real/a.mp4"), b"x").unwrap();
1994 std::os::unix::fs::symlink(dir.join("real"), dir.join("linked")).unwrap();
1995 std::os::unix::fs::symlink(dir.join("real/a.mp4"), dir.join("real/b.mp4")).unwrap();
1996 #[derive(Deserialize)]
1997 struct Manifest {
1998 resources: RawResources,
1999 }
2000 for entry in ["linked/a.mp4", "real"] {
2001 let text = format!("[resources]\npaths = [\"{entry}\"]\n");
2002 let raw: Manifest = toml::from_str(&text).unwrap();
2003 let mut errors = Vec::new();
2004 resources(&dir, "Bern.toml", &text, &raw.resources, &mut errors);
2005 assert_eq!(errors.len(), 1, "{entry}: {errors:?}");
2006 assert!(
2007 errors[0].message.contains("symbolic link"),
2008 "{}",
2009 errors[0].message
2010 );
2011 }
2012 let _ = std::fs::remove_dir_all(&dir);
2013 }
2014
2015 #[test]
2016 fn module_paths_follow_cargo_layout() {
2017 let dir = Path::new("/p");
2018 assert_eq!(
2019 source_file(dir, "ui/src/lib.rs").unwrap().1,
2020 Vec::<String>::new()
2021 );
2022 assert_eq!(
2023 source_file(dir, "ui/src/pages.rs").unwrap().1,
2024 vec!["pages"]
2025 );
2026 assert_eq!(
2027 source_file(dir, "ui/src/a/b/mod.rs").unwrap().1,
2028 vec!["a", "b"]
2029 );
2030 assert_eq!(
2031 source_file(dir, "ui/src/pages.rs").unwrap().0,
2032 PathBuf::from("/p/ui")
2033 );
2034 assert!(source_file(dir, "ui/pages.rs").is_err());
2035 }
2036
2037 fn check_icons(
2039 files: &[(&str, Vec<u8>)],
2040 value: &str,
2041 ) -> (Option<String>, BTreeMap<String, String>, Vec<Diagnostic>) {
2042 static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
2043 let dir = std::env::temp_dir().join(format!(
2044 "bern-icon-test-{}-{}",
2045 std::process::id(),
2046 NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
2047 ));
2048 let _ = std::fs::remove_dir_all(&dir);
2049 std::fs::create_dir_all(&dir).unwrap();
2050 for (file, bytes) in files {
2051 let path = dir.join(file);
2052 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2053 std::fs::write(&path, bytes).unwrap();
2054 }
2055 #[derive(Deserialize)]
2056 struct Manifest {
2057 icon: toml::Spanned<RawIcon>,
2058 }
2059 let text = format!("# Bern.toml\nicon = {value}\n");
2060 let raw: Manifest = toml::from_str(&text).unwrap();
2061 let mut errors = Vec::new();
2062 let (every, platforms) = app_icons(
2063 &dir,
2064 "Bern.toml",
2065 &text,
2066 ("app", "icon"),
2067 &raw.icon,
2068 &mut errors,
2069 );
2070 let _ = std::fs::remove_dir_all(&dir);
2071 (every, platforms, errors)
2072 }
2073
2074 fn check_icon(files: &[(&str, Vec<u8>)], entry: &str) -> (Option<String>, Vec<Diagnostic>) {
2076 let (every, _, errors) = check_icons(files, &format!("\"{entry}\""));
2077 (every, errors)
2078 }
2079
2080 fn png(width: u32, height: u32) -> Vec<u8> {
2082 let mut bytes = b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDR".to_vec();
2083 bytes.extend_from_slice(&width.to_be_bytes());
2084 bytes.extend_from_slice(&height.to_be_bytes());
2085 bytes
2086 }
2087
2088 #[test]
2089 fn app_icons_are_square_pngs_in_the_project() {
2090 let (found, errors) = check_icon(&[("art/icon.png", png(1024, 1024))], "art/icon.png");
2091 assert!(errors.is_empty(), "{errors:?}");
2092 assert_eq!(found.as_deref(), Some("art/icon.png"));
2093 for (files, entry, expected) in [
2094 (vec![], "icon.png", "does not exist"),
2095 (vec![("icon.png", png(300, 300))], "icon.png", "1024"),
2096 (vec![("icon.png", png(512, 256))], "icon.png", "square"),
2097 (
2098 vec![("icon.png", b"GIF89a".to_vec())],
2099 "icon.png",
2100 "not a PNG",
2101 ),
2102 (vec![], "../icon.png", "[app].icon"),
2103 ] {
2104 let (found, errors) = check_icon(&files, entry);
2105 assert!(found.is_none(), "{entry}");
2106 assert_eq!(errors.len(), 1, "{entry}: {errors:?}");
2107 assert!(
2108 errors[0].message.contains(expected),
2109 "{entry}: {}",
2110 errors[0].message
2111 );
2112 assert_eq!(errors[0].location.as_ref().map(|at| at.line), Some(2));
2113 }
2114 }
2115
2116 #[test]
2117 fn platforms_take_their_own_icons() {
2118 let icns = {
2119 let mut bytes = b"icns\0\0\0\x18ic10\0\0\0\x10".to_vec();
2120 bytes.extend_from_slice(&png(1024, 1024)[..8]);
2121 bytes
2122 };
2123 let ico = {
2124 let mut bytes = vec![0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 1, 0, 32, 0];
2125 bytes.extend_from_slice(&24u32.to_le_bytes());
2126 bytes.extend_from_slice(&22u32.to_le_bytes());
2127 bytes.extend_from_slice(&png(256, 256));
2128 bytes
2129 };
2130 let files = vec![
2131 ("icon.png", png(1024, 1024)),
2132 ("mac/App.icns", icns),
2133 ("win/app.ico", ico),
2134 (
2135 "ios/AppIcon.appiconset/Contents.json",
2136 br#"{"images":[{"filename":"AppIcon.png","idiom":"universal","platform":"ios","size":"1024x1024"}]}"#.to_vec(),
2137 ),
2138 ("ios/AppIcon.appiconset/AppIcon.png", png(1024, 1024)),
2139 (
2140 "apple/App.icon/icon.json",
2141 br#"{"fill":"automatic","groups":[{"layers":[{"image-name":"Shape.svg","name":"Shape","glass":true}]}]}"#.to_vec(),
2142 ),
2143 ("apple/App.icon/Assets/Shape.svg", b"<svg/>".to_vec()),
2144 ("android/res/mipmap-anydpi-v26/ic_launcher.xml", b"<adaptive-icon/>".to_vec()),
2145 ("android/res/mipmap-hdpi/ic_launcher_foreground.png", png(162, 162)),
2146 ("android/res/mipmap-hdpi/ic_launcher_round.png", png(72, 72)),
2147 ("android/res/values/ic_launcher_background.xml", b"<resources/>".to_vec()),
2148 ("linux/hicolor/48x48/apps/app.png", png(48, 48)),
2149 ("linux/hicolor/256x256/apps/app.png", png(256, 256)),
2150 ("linux/hicolor/scalable/apps/app.svg", b"<svg/>".to_vec()),
2151 ("linux/hicolor/symbolic/apps/app-symbolic.svg", b"<svg/>".to_vec()),
2152 ("web/favicon.svg", b"<svg/>".to_vec()),
2153 ("web/apple-touch-icon.png", png(180, 180)),
2154 ("web/site.webmanifest", b"{}".to_vec()),
2155 (
2156 "tv/App.brandassets/Contents.json",
2157 br#"{"assets":[{"filename":"App Icon.imagestack","idiom":"tv","role":"primary-app-icon","size":"400x240"}]}"#.to_vec(),
2158 ),
2159 (
2160 "tv/App.brandassets/App Icon.imagestack/Contents.json",
2161 br#"{"layers":[{"filename":"Front.imagestacklayer"},{"filename":"Back.imagestacklayer"}]}"#.to_vec(),
2162 ),
2163 (
2164 "tv/App.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Contents.json",
2165 br#"{"images":[{"filename":"front.png","idiom":"tv"}]}"#.to_vec(),
2166 ),
2167 (
2168 "tv/App.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/front.png",
2169 png(400, 240),
2170 ),
2171 (
2172 "tv/App.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Contents.json",
2173 br#"{"images":[{"filename":"back.png","idiom":"tv"}]}"#.to_vec(),
2174 ),
2175 (
2176 "tv/App.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/back.png",
2177 png(400, 240),
2178 ),
2179 ];
2180 let (every, platforms, errors) = check_icons(
2181 &files,
2182 r#"{ default = "icon.png", macos = "mac/App.icns", windows = "win/app.ico", ios = "ios/AppIcon.appiconset", tvos = "tv/App.brandassets", android = "android/res", linux = "linux/hicolor", browser = "web" }"#,
2183 );
2184 assert!(errors.is_empty(), "{errors:?}");
2185 assert_eq!(every.as_deref(), Some("icon.png"));
2186 assert_eq!(platforms.len(), 7);
2187 assert_eq!(platforms["linux"], "linux/hicolor");
2188 let (every, platforms, errors) = check_icons(&files, r#"{ android = "icon.png" }"#);
2190 assert!(errors.is_empty(), "{errors:?}");
2191 assert_eq!(every, None);
2192 assert_eq!(platforms["android"], "icon.png");
2193 let (_, platforms, errors) = check_icons(
2195 &files,
2196 r#"{ macos = "apple/App.icon", ios = "apple/App.icon" }"#,
2197 );
2198 assert!(errors.is_empty(), "{errors:?}");
2199 assert_eq!(platforms["macos"], "apple/App.icon");
2200 assert_eq!(platforms["ios"], "apple/App.icon");
2201
2202 for (extra, value, expected) in [
2203 (vec![], r#"{ watchos = "icon.png" }"#, "not a platform"),
2204 (vec![], r#"{ tvos = "win/app.ico" }"#, "`.brandassets`"),
2205 (
2206 vec![(
2207 "tv/App.brandassets/App Icon.imagestack/Contents.json",
2208 br#"{"layers":[{"filename":"Front.imagestacklayer"}]}"#.to_vec(),
2209 )],
2210 r#"{ tvos = "tv/App.brandassets" }"#,
2211 "two to five",
2212 ),
2213 (vec![], r#"{ macos = "win/app.ico" }"#, "`.icns`"),
2214 (vec![], r#"{ default = "mac/App.icns" }"#, "not a PNG"),
2215 (vec![], r#"{ windows = "web" }"#, "`.ico`"),
2216 (
2217 vec![("bad.icns", b"icns\0\0\0\x40".to_vec())],
2218 r#"{ macos = "bad.icns" }"#,
2219 "bytes long",
2220 ),
2221 (
2222 vec![("android/res/values/strings.xml", b"<resources/>".to_vec())],
2223 r#"{ android = "android/res" }"#,
2224 "Bern writes itself",
2225 ),
2226 (
2227 vec![("other/res/mipmap-hdpi/ic_foo.png", png(72, 72))],
2228 r#"{ android = "other/res" }"#,
2229 "ic_launcher",
2230 ),
2231 (
2232 vec![("linux/hicolor/64x64/apps/app.png", png(48, 48))],
2233 r#"{ linux = "linux/hicolor" }"#,
2234 "48 × 48 pixels, not 64 × 64",
2235 ),
2236 (
2237 vec![("linux/hicolor/32x32/apps/other.png", png(32, 32))],
2238 r#"{ linux = "linux/hicolor" }"#,
2239 "one icon",
2240 ),
2241 (
2242 vec![(
2243 "ios/AppIcon.appiconset/Contents.json",
2244 br#"{"images":[{"filename":"Gone.png"}]}"#.to_vec(),
2245 )],
2246 r#"{ ios = "ios/AppIcon.appiconset" }"#,
2247 "Gone.png",
2248 ),
2249 (
2250 vec![("web/notes.txt", b"x".to_vec())],
2251 r#"{ browser = "web" }"#,
2252 "notes.txt",
2253 ),
2254 (
2255 vec![(
2256 "apple/App.icon/icon.json",
2257 br#"{"groups":[{"layers":[{"image-name":"Gone.svg"}]}]}"#.to_vec(),
2258 )],
2259 r#"{ macos = "apple/App.icon" }"#,
2260 "Gone.svg",
2261 ),
2262 (vec![], r#"{ windows = "apple/App.icon" }"#, "`.ico`"),
2263 ] {
2264 let mut all = files.clone();
2265 all.retain(|(path, _)| !extra.iter().any(|(other, _)| other == path));
2266 all.extend(extra);
2267 let (_, platforms, errors) = check_icons(&all, value);
2268 assert_eq!(errors.len(), 1, "{value}: {errors:?}");
2269 assert!(platforms.is_empty(), "{value}");
2270 assert!(
2271 errors[0].message.contains(expected),
2272 "{value}: {}",
2273 errors[0].message
2274 );
2275 }
2276 }
2277}