//! Build-script support for the make-family design system. //! //! //! //! Every consumer materialises the same generated files from a `build.rs`, and //! until now every consumer wrote that code itself. GoingsOn and Balanced //! Breakfast grew byte-identical copies of the theme materialiser during the //! makeover-geometry adoption, and the layout stylesheet would have been the //! third and fourth copies. This is that code, once. //! //! # What is deliberately not here //! //! The geometry emitter. GoingsOn and Balanced Breakfast do *not* agree on it: //! GO scopes the touch preset to a `ui-mode-mobile` class set by a bootstrap //! script, BB hangs it off `@media (hover: none)`, and audiofiles has no //! switch at all. Extracting it would mean picking one of those policies by //! accident, inside a shared crate, without anyone deciding. Density selection //! is unowned and has its own task; the geometry half lands after it. //! //! Taking the identical half now and leaving the contested half alone is the //! whole point: a helper crate should record agreement, not manufacture it. //! //! # Why these files are generated rather than checked in //! //! Tauri's resource globs are read by its CLI against the crate directory, so //! they cannot point into a registry checkout or `OUT_DIR`. Materialising into //! the crate keeps the source crate authoritative without vendoring a second //! copy that drifts. Every path written here is expected to be gitignored. #![forbid(unsafe_code)] use std::path::Path; /// Re-exported so a consumer's `build.rs` needs one dependency rather than /// three. Nothing here wraps it; the emitter's options are the emitter's. pub use makeover_webview::Emit; /// Write the themes `makeover` ships into `dir`, as `.toml`. /// /// Clears stale `.toml` files first, so a theme removed or renamed upstream /// does not linger in the bundle from an earlier build. That detail is the /// reason this is worth sharing rather than retyping: it is easy to omit and /// its absence shows up as a theme that will not go away. /// /// # Panics /// /// If the directory cannot be created, read, or written. A build script has /// nowhere useful to return an error to, and a half-materialised theme set is /// worse than a failed build. pub fn themes(dir: impl AsRef) { let dir = dir.as_ref(); std::fs::create_dir_all(dir).expect("create themes dir"); for entry in std::fs::read_dir(dir).expect("read themes dir").flatten() { let path = entry.path(); if path.extension().is_some_and(|e| e == "toml") { std::fs::remove_file(&path).expect("remove stale theme"); } } for (id, source) in makeover::embedded_themes() { std::fs::write(dir.join(format!("{id}.toml")), source).expect("write theme"); } } /// Write `makeover-webview`'s component stylesheet to `path`. /// /// Baked at build time rather than applied from JS the way the intent layer /// is, because composition never changes at runtime: no theme may reach it, so /// there is nothing to re-apply and no second pass over `:root` to pay for on /// load. /// /// # Panics /// /// If the file cannot be written. pub fn layout_css(path: impl AsRef, opts: &makeover_webview::Emit) { std::fs::write(path, makeover_webview::stylesheet(opts)).expect("write layout css"); } /// Write `makeover-geometry`'s spacing layer, with its canonical density /// selection, to `path`. /// /// The policy is the crate's, not this one's: touch hangs off /// `(hover: none), (pointer: coarse)` because density is a capability rather /// than a device or a width, and `explicit_touch` names a selector an app sets /// when the user has chosen. See [`makeover_geometry::density_css`]. All this /// adds is the generated-file banner and the write. /// /// # Panics /// /// If the file cannot be written. pub fn geometry_css(path: impl AsRef, explicit_touch: Option<&str>) { let mut css = String::from( "/* Generated by makeover-build from makeover-geometry. Do not edit.\n \ Spacing is named by relationship, not by size. Touch density is a\n \ capability question: a narrow desktop window still has a pointer, a\n \ full-width tablet still has a finger. */\n", ); css.push_str(&makeover_geometry::density_css(explicit_touch)); std::fs::write(path, css).expect("write geometry css"); } /// All three generated files at the layout every Tauri consumer already uses: /// `themes/` beside the manifest, and `frontend/css/{geometry,layout}.css` /// under it. /// /// Pass `env!("CARGO_MANIFEST_DIR")`. Consumers that want different paths call /// [`themes`] and [`layout_css`] directly. /// /// # Panics /// /// If either file cannot be written. pub fn tauri_frontend( manifest_dir: impl AsRef, opts: &makeover_webview::Emit, explicit_touch: Option<&str>, ) { let root = manifest_dir.as_ref(); let css = root.join("frontend").join("css"); themes(root.join("themes")); geometry_css(css.join("geometry.css"), explicit_touch); layout_css(css.join("layout.css"), opts); } #[cfg(test)] mod tests { use super::*; /// A scratch directory keyed by process id, so a parallel test run does /// not collide. No timestamp: the pid is enough and is deterministic /// within a run. fn scratch(name: &str) -> std::path::PathBuf { let dir = std::env::temp_dir().join(format!("makeover-build-{}-{name}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).expect("create scratch"); dir } #[test] fn themes_are_written_one_file_per_id() { let dir = scratch("themes"); themes(&dir); let count = std::fs::read_dir(&dir).unwrap().count(); assert_eq!(count, makeover::embedded_themes().count()); assert!(count > 0, "makeover ships no themes?"); } #[test] fn a_theme_removed_upstream_does_not_linger() { // The detail that makes this worth sharing rather than retyping. let dir = scratch("stale"); std::fs::write(dir.join("gone-upstream.toml"), "# stale").unwrap(); themes(&dir); assert!(!dir.join("gone-upstream.toml").exists()); } #[test] fn a_non_theme_file_is_left_alone() { // Only .toml is cleared, so a README or a .gitignore in the bundle // directory survives a rebuild. let dir = scratch("keep"); std::fs::write(dir.join("README.md"), "not a theme").unwrap(); themes(&dir); assert!(dir.join("README.md").exists()); } #[test] fn the_stylesheet_lands_and_names_no_colour() { let dir = scratch("css"); let path = dir.join("layout.css"); layout_css(&path, &makeover_webview::Emit::default()); let css = std::fs::read_to_string(&path).unwrap(); assert!(css.contains("--bevel-raised")); assert!( !css.contains('#'), "a colour literal reached a build output" ); } #[test] fn the_geometry_file_carries_the_crates_policy_and_a_banner() { // The policy itself is tested in makeover-geometry. What is this // crate's job is that the banner is there and the policy reached the // file at all. let dir = scratch("geometry"); let path = dir.join("geometry.css"); geometry_css(&path, Some(".ui-mode-mobile")); let css = std::fs::read_to_string(&path).unwrap(); assert!(css.starts_with("/* Generated by makeover-build")); assert!(css.contains("@media (hover: none), (pointer: coarse)")); assert!(css.contains(".ui-mode-mobile")); } #[test] fn the_tauri_layout_puts_all_three_where_the_apps_look() { let root = scratch("tauri"); std::fs::create_dir_all(root.join("frontend").join("css")).unwrap(); tauri_frontend(&root, &makeover_webview::Emit::default(), None); assert!( root.join("frontend") .join("css") .join("geometry.css") .exists() ); assert!( root.join("frontend") .join("css") .join("layout.css") .exists() ); assert!(root.join("themes").is_dir()); } }