// SPDX-License-Identifier: MPL-2.0 //! The *libui-ng-sys* build script. use std::{env, path::PathBuf, process}; use error_stack::{Result, ResultExt as _}; /// The error type returned by [`main`]. #[derive(thiserror::Error, Debug)] enum Error { /// Failed to vendor a dependency into the build directory. #[error("failed to vendor {name} into {}", .out_dir.display())] VendorDep { name: &'static str, out_dir: PathBuf, }, /// Failed to fix file permissions for Ninja. #[cfg(unix)] #[error("failed to fix file permissions for Ninja")] FixNinjaPermissions, /// Failed to build *libui-ng*. #[error("failed to build libui-ng")] BuildLibui, /// Failed to link required system libraries. #[error("failed to link required system libraries")] LinkSystemLibs, /// Failed to generate bindings to *libui-ng*. #[error("failed to generate bindings")] GenBindings, } fn main() -> process::ExitCode { match main_impl() { Ok(_) => process::ExitCode::SUCCESS, Err(e) => { eprintln!("{e:?}"); process::ExitCode::FAILURE } } } fn main_impl() -> Result<(), Error> { let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap()); let libui_dir = out_dir.join("libui-ng"); let meson_dir = out_dir.join("meson"); let ninja_dir = out_dir.join("ninja"); // Cargo will prevent the crate from being published if this build script modifies files outside // `$OUT_DIR`. To work around this when building *libui-ng*, we copy vendored build dependencies // to `$OUT_DIR` as necessary. let vendor_dep = |name, dir| { dep::sync(name, dir) .change_context_lazy(|| Error::VendorDep { name, out_dir: out_dir.clone() }) }; vendor_dep("libui-ng", libui_dir.as_path())?; let strategy = if env::var("DOCS_RS").is_ok() { // Don't bother linking with *libui-ng* at all; we're only generating documentation. Strategy::GenBindingsOnly } else if cfg!(feature = "probe-system-libui") { // We will probe for the system *libui-ng* library. If it exists, we'll link dynamically to // it, and if not, we'll build from source. let maybe_lib = pkg_config::Config::new() // We don't want *pkg-config* to emit Cargo metadata because we'll handle that // ourselves. .cargo_metadata(false) // Environment metadata is OK though. .env_metadata(true) // We will link dynamically, not statically. .statik(false) .probe("libui-ng"); match maybe_lib { Ok(lib) => Strategy::UseSystem { link_paths: lib.link_paths }, Err(_) => Strategy::BuildFromSource, } } else { Strategy::BuildFromSource }; match strategy { Strategy::UseSystem { ref link_paths } => { // Tell Cargo where to find the system *libui-ng*. for path in link_paths { println!("cargo:rustc-link-search=native={}", path.display()); } // Link *libui-ng* dynamically. println!("cargo:rustc-link-lib=dylib=ui"); } Strategy::BuildFromSource => { let backend = build::Backend::from_cfg(); // Meson is necessary for building. vendor_dep("meson", meson_dir.as_path())?; if matches!(backend, build::Backend::Ninja) { vendor_dep("ninja", ninja_dir.as_path())?; // When downloading crates from *crates.io*, file execute permissions are *not* // respected. This is a problem for Ninja, which attempts to execute a file named // *inline.sh*. For this reason, we manually mark it as executable on Unix // platforms. (This is not an issue on Windows.) #[cfg(unix)] unix::mark_executable(ninja_dir.join("src/inline.sh")) .change_context(Error::FixNinjaPermissions)?; } // Build *libui-ng* from source. let libui_build_dir = backend .build_libui(&libui_dir, &meson_dir, &ninja_dir) .change_context(Error::BuildLibui) .map_err(|report| { #[cfg(target_os = "windows")] { // This is a common source of build errors. report.attach_printable( "Have you tried building libui-ng-sys from the Microsoft Developer \ Command Prompt or Developer Powershell?" ) } #[cfg(not(target_os = "windows"))] report })?; // Tell Cargo where to find the copy of *libui-ng* that we just built. println!("cargo:rustc-link-search=native={}", libui_build_dir.display()); // Link *libui-ng* statically. println!("cargo:rustc-link-lib=static=ui"); // Static libraries do not encode information on the system libraries that must be // linked, so we must tell Cargo (and, by extension, the dynamic linker) what we need. link_system_libs().change_context(Error::LinkSystemLibs)?; } Strategy::GenBindingsOnly => { // Do nothing; we will generate the bindings for all strategies in a moment. } } // Generate bindings. bindings::generate(&libui_dir, &out_dir).change_context(Error::GenBindings)?; // Recompile *libui-ng-sys* whenever this build script is modified. println!("cargo:rerun-if-changed=build.rs"); Ok(()) } #[derive(Clone)] enum Strategy { UseSystem { link_paths: Vec, }, BuildFromSource, GenBindingsOnly, } #[derive(thiserror::Error, Debug)] enum LinkSystemLibsError { #[cfg(target_os = "macos")] #[error("failed to link clang_rt")] ClangRt, #[cfg(target_os = "linux")] #[error("failed to link GTK")] Gtk, } fn link_system_libs() -> Result<(), LinkSystemLibsError> { #[allow(unused)] macro_rules! link { ($kind:literal : $($name:literal)*) => { $( println!(concat!("cargo:rustc-link-lib=", $kind, "=", $name)); )* }; ($($name:literal)*) => { $( println!(concat!("cargo:rustc-link-lib=", $name)); )* }; } #[cfg(target_os = "macos")] macro_rules! frameworks { ($($name:literal)*) => { link!("framework": $($name)*) }; } #[allow(unused)] macro_rules! dylibs { ($($name:literal)*) => { link!("dylib": $($name)*) }; } #[cfg(target_os = "macos")] { use error_stack::IntoReport as _; // See *dep/libui-ng/darwin/meson.build*. frameworks! { "AppKit" "Foundation" } #[derive(thiserror::Error, Debug)] enum LinkClangRtError { #[error("failed to spawn clang")] SpawnClang, } let clang_rt_dir = process::Command::new("clang") .arg("-print-runtime-dir") .output() .into_report() .change_context(LinkClangRtError::SpawnClang) .map(|out| unix::make_path_from_ascii(out.stdout)) .change_context(LinkSystemLibsError::ClangRt)?; println!("cargo:rustc-link-search=native={}", clang_rt_dir.display()); link!("clang_rt.osx"); } #[cfg(target_os = "linux")] { gtk::link().change_context(LinkSystemLibsError::Gtk)?; } #[cfg(target_os = "windows")] { // See *dep/libui-ng/windows/meson.build*. dylibs! { "comctl32" "comdlg32" "d2d1" "dwrite" "gdi32" "kernel32" "msimg32" "ole32" "oleacc" "oleaut32" "user32" "uuid" "uxtheme" "windowscodecs" }; } Ok(()) } #[cfg(unix)] mod unix { //! Functionality exclusive to Linux and macOS. use std::{ ffi::OsString, fmt, fs, os::unix::{ffi::OsStringExt as _, fs::PermissionsExt as _}, path::{Path, PathBuf}, }; use error_stack::{IntoReport as _, Result, ResultExt as _}; #[derive(Debug)] pub struct MarkExecutableError { path: PathBuf, } impl std::error::Error for MarkExecutableError {} impl fmt::Display for MarkExecutableError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "failed to mark {} as executable", self.path.display()) } } pub fn mark_executable(path: impl AsRef) -> Result<(), MarkExecutableError> { let path = path.as_ref(); fs::set_permissions(path, fs::Permissions::from_mode(0o755)) .into_report() .change_context_lazy(|| MarkExecutableError { path: path.to_path_buf() }) } #[allow(dead_code)] pub fn make_path_from_ascii(mut path: Vec) -> PathBuf { if let Some(newline_idx) = memchr::memchr(b'\n', path.as_slice()) { // Exclude the newline and everything after. path.truncate(newline_idx); } PathBuf::from(OsString::from_vec(path)) } } #[cfg(target_os = "linux")] mod gtk { use error_stack::{IntoReport as _, Result}; pub use pkg_config::Error; static MIN_VERSION: &str = "3.10.0"; static PACKAGE: &str = "gtk+-3.0"; pub fn find() -> Result { probe(false) } pub fn link() -> Result<(), pkg_config::Error> { probe(true).map(|_| ()) } fn probe(should_link: bool) -> Result { pkg_config::Config::new() .atleast_version(MIN_VERSION) .print_system_cflags(should_link) .print_system_libs(should_link) .probe(PACKAGE) .into_report() } } mod dep { use std::{fs, os, path::Path}; use error_stack::{IntoReport as _, Result, ResultExt as _}; #[derive(thiserror::Error, Debug)] pub enum SyncError { #[error("fs::create_dir() failed")] CreateDir, #[error("fs::read_dir() failed")] ReadDir, #[error("failed to read directory entry")] DirEntry, #[error("fs::DirEntry::file_type() failed")] DirEntryFileType, #[error("fs::copy() failed")] Copy, #[error("fs::read_link() failed")] ReadLink, #[error("failed to create symbolic link")] Symlink, } pub fn sync(name: &str, to: &Path) -> Result<(), SyncError> { copy_dir_contents(Path::new("dep").join(name), to) } fn copy_dir_contents( from: impl AsRef, to: impl AsRef, ) -> Result<(), SyncError> { let to = to.as_ref(); if !to.exists() { fs::create_dir(to).into_report().change_context(SyncError::CreateDir)?; } let maybe_entries = fs::read_dir(from.as_ref()) .into_report() .change_context(SyncError::ReadDir)?; for maybe_entry in maybe_entries { let entry = maybe_entry.into_report().change_context(SyncError::DirEntry)?; let kind = entry .file_type() .into_report() .change_context(SyncError::DirEntryFileType)?; let from = entry.path(); let to = to.join(entry.file_name()); if kind.is_dir() { copy_dir_contents(from, to)?; } else if kind.is_file() { fs::copy(from, to).into_report().change_context(SyncError::Copy)?; } else if kind.is_symlink() { let original = fs::read_link(from) .into_report() .change_context(SyncError::ReadLink)?; #[cfg(windows)] { use os::windows::fs; if original.is_dir() { fs::symlink_dir(original, to) } else { fs::symlink_file(original, to) } .into_report() .change_context(SyncError::Symlink)?; } #[cfg(not(windows))] { #[cfg(unix)] { os::unix::fs::symlink(original, to) .into_report() .change_context(SyncError::Symlink)?; } #[cfg(not(unix))] { fs::copy(from, to).into_report().change_context(SyncError::Copy)?; } } } else { panic!("what is this thing?"); } } Ok(()) } } mod build { use std::{env, path::{Path, PathBuf}, process}; use error_stack::{IntoReport as _, Result, ResultExt as _}; /// The error type returned by [`Backend`] functions. #[derive(thiserror::Error, Debug)] pub enum Error { /// Failed to setup *libui-ng*. #[error("failed to setup libui-ng")] SetupLibui, /// Failed to build Ninja. #[error("failed to build Ninja")] BuildNinja, /// Failed to compile *libui-ng*. #[error("failed to compile libui-ng")] CompileLibui, /// Failed to hard-link *ui.lib* to *libui.a* for compatibility with MSVC. #[cfg(target_os = "windows")] #[error("failed to fix libui-ng archive filename")] FixLibuiName, } #[derive(thiserror::Error, Debug)] pub enum PythonError { /// Failed to run Python. #[error("failed to run Python")] RunPython, /// The process run by Python failed. #[error("{:?}", out)] Python { out: process::Output }, } impl Backend { pub fn from_cfg() -> Self { if cfg!(feature = "build-with-msvc") { Self::Msvc } else if cfg!(feature = "build-with-xcode") { Self::Xcode } else { Self::Ninja } } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum Backend { Msvc, Ninja, Xcode, } impl Backend { /// Builds *libui*. pub fn build_libui( self, libui_dir: &Path, meson_dir: &Path, ninja_dir: &Path, ) -> Result { if let Self::Ninja = self { // This must precede setting up *libui* as Meson requires Ninja even in the // configuration phase. Self::build_ninja(ninja_dir).change_context(Error::BuildNinja)?; } self.setup_libui(libui_dir, meson_dir, ninja_dir).change_context(Error::SetupLibui)?; self.compile_libui(libui_dir, meson_dir, ninja_dir) .change_context(Error::CompileLibui)?; let build_dir = libui_dir.join("build/meson-out"); #[cfg(target_os = "windows")] self.fix_libui_name(build_dir.as_path()) .change_context(Error::FixLibuiName)?; Ok(build_dir) } fn ninja_path(ninja_dir: &Path) -> PathBuf { let ext = env::consts::EXE_EXTENSION; ninja_dir.join("ninja").with_extension(ext) } fn run_python( f: impl Fn(&mut process::Command), ninja_dir: Option<&Path>, ) -> Result<(), PythonError> { let mut cmd = process::Command::new("python3"); f(&mut cmd); if let Some(dir) = ninja_dir { cmd.env("NINJA", Self::ninja_path(dir)); } let out = cmd.output().into_report().change_context(PythonError::RunPython)?; if out.status.success() { Ok(()) } else { Err(error_stack::report!(PythonError::Python { out })) } } /// Builds Ninja. fn build_ninja(ninja_dir: &Path) -> Result<(), PythonError> { if Self::ninja_path(ninja_dir).exists() { // We'll give the benefit of the doubt that this is actually a complete, working // binary. return Ok(()); } Self::run_python( |cmd| { cmd .arg("configure.py") .arg("--bootstrap") .current_dir(ninja_dir); }, None, ) } /// Prepares *libui* to be built. fn setup_libui( &self, libui_dir: &Path, meson_dir: &Path, ninja_dir: &Path, ) -> Result<(), PythonError> { Self::run_python( |cmd| { cmd .arg(meson_dir.join("meson.py")) .arg("setup") .arg("--default-library=static") .arg("--buildtype=release") .arg(format!("--optimization={}", Self::optimization_level())) .arg(format!("--backend={}", self.as_str())) .arg(libui_dir.join("build")) .arg(libui_dir); }, Some(ninja_dir), ) } #[allow(dead_code)] fn is_debug() -> bool { !matches!(env::var("DEBUG").as_deref(), Ok("0" | "false")) } fn optimization_level() -> String { let level = env::var("OPT_LEVEL").expect("$OPT_LEVEL is unset"); match level.as_str() { // Meson doesn't support "-Oz"; we'll try the next-closest option. "z" => String::from("s"), _ => level, } } fn as_str(&self) -> &'static str { match self { Self::Msvc => "vs", Self::Ninja => "ninja", Self::Xcode => "xcode", } } fn compile_libui( &self, libui_dir: &Path, meson_dir: &Path, ninja_dir: &Path, ) -> Result<(), PythonError> { Self::run_python( |cmd| { cmd .arg(meson_dir.join("meson.py")) .arg("compile") .arg(format!("-C={}", libui_dir.join("build").display())); }, Some(ninja_dir), ) } #[cfg(target_os = "windows")] fn fix_libui_name(&self, build_dir: &Path) -> Result<(), std::io::Error> { // Meson unconditionally names the library "libui.a", which prevents MSVC's `link.exe` // from finding it; we must manually rename it to "ui.lib". // // Note: we must do this for all Windows builds, not just where `self = Self::Msvc`; // whether `rustc` uses MSVC is independent of whether we build *libui-ng* with MSVC. use std::fs; let old_path = build_dir.join("libui.a"); let new_path = build_dir.join("ui.lib"); if old_path.exists() { if new_path.exists() { let _ = fs::remove_file(new_path.as_path()); } fs::hard_link(old_path, new_path)?; } else { // If it doesn't exist, it's probably because we built with MSVC, so the file is // already named "ui.lib". Let's make sure. assert_eq!(*self, Self::Msvc); } Ok(()) } } } mod bindings { use std::{borrow::Cow, fmt, iter, path::{Path, PathBuf}}; use error_stack::{IntoReport as _, Result, ResultExt as _}; /// The error type returned by binding functions. #[derive(thiserror::Error, Debug)] pub enum Error { /// Failed to generate bindings. #[error("failed to generate bindings")] Generate, /// Failed to write bindings to a file. #[error("failed to write bindings to {}", .to.display())] WriteToFile { to: PathBuf, }, } /// Generates bindings to *libui* and writes them to the given directory. pub fn generate(libui_dir: &Path, out_dir: &Path) -> Result<(), Error> { Header::main().generate(libui_dir, out_dir)?; Header::control_sigs().generate(libui_dir, out_dir)?; #[cfg(target_os = "macos")] Header::darwin().generate(libui_dir, out_dir)?; #[cfg(target_os = "linux")] Header::unix().generate(libui_dir, out_dir)?; #[cfg(target_os = "windows")] Header::windows().generate(libui_dir, out_dir)?; Ok(()) } struct Header { lang: Language, include_stmts: Vec, filename: String, blocklists_main: bool, } impl Header { fn main() -> Self { Self { lang: Language::C, include_stmts: vec![ IncludeStmt { flavor: IncludeStmtFlavor::Include, scope: IncludeStmtScope::Local, arg: "ui.h".to_string(), }, ], filename: "bindings".to_string(), blocklists_main: false, } } fn control_sigs() -> Self { Self { lang: Language::C, include_stmts: vec![ IncludeStmt { flavor: IncludeStmtFlavor::Include, scope: IncludeStmtScope::Local, arg: "common/controlsigs.h".to_string(), }, ], filename: "bindings-control-sigs".to_string(), blocklists_main: true, } } #[cfg(target_os = "macos")] fn darwin() -> Self { Self::ext( "darwin", Language::ObjC, iter::once({ IncludeStmt { flavor: IncludeStmtFlavor::Import, scope: IncludeStmtScope::System, arg: "Cocoa/Cocoa.h".into(), } }) ) } #[cfg(target_os = "linux")] fn unix() -> Self { Self::ext( "unix", Language::C, iter::once({ IncludeStmt { flavor: IncludeStmtFlavor::Include, scope: IncludeStmtScope::System, arg: "gtk/gtk.h".into(), } }) ) } #[cfg(target_os = "windows")] fn windows() -> Self { Self::ext( "windows", Language::C, iter::once({ IncludeStmt { flavor: IncludeStmtFlavor::Include, scope: IncludeStmtScope::System, arg: "windows.h".into(), } }) ) } fn ext( name: impl fmt::Display, lang: Language, deps: impl IntoIterator, ) -> Self { Self { lang, include_stmts: iter::once({ IncludeStmt { flavor: IncludeStmtFlavor::Include, scope: IncludeStmtScope::Local, arg: "ui.h".to_string(), } }) .chain(deps) .chain(iter::once({ IncludeStmt { flavor: IncludeStmtFlavor::Include, scope: IncludeStmtScope::Local, arg: format!("ui_{}.h", name), } })) .collect(), filename: format!("bindings-{}", name), blocklists_main: true, } } fn generate(self, libui_dir: &Path, out_dir: &Path) -> Result<(), Error> { static LIBUI_REGEX: &str = "ui(?:[A-Z][a-z0-9]*)*"; let mut builder = bindgen::builder() .header_contents("wrapper.h", &self.contents(libui_dir)) .parse_callbacks(Box::new(bindgen::CargoCallbacks)) .allowlist_recursively(false) .allowlist_function(LIBUI_REGEX) .allowlist_type(LIBUI_REGEX) .allowlist_var(LIBUI_REGEX); // Note: Virtually every wrapper except that for "ui.h" should blocklist "ui.h". if self.blocklists_main { builder = builder.blocklist_file(".*ui\\.h"); } let bindings = builder .clang_args(ClangArgs::new().as_args()) .clang_arg("-x") .clang_arg(match self.lang { Language::C => "c", #[cfg(target_os = "macos")] Language::ObjC => "objective-c", }) .layout_tests(false) .generate() .map_err(|_| Error::Generate)?; bindings.emit_warnings(); let to = PathBuf::from(format!("{}.rs", self.filename)); bindings .write_to_file(out_dir.join(to.as_path())) .into_report() .change_context_lazy(|| Error::WriteToFile { to }) } fn contents(&self, libui_dir: &Path) -> String { self .include_stmts .iter() .map(|stmt| stmt.to_string(libui_dir)) .collect::>() .join("\n") } } enum Language { C, #[cfg(target_os = "macos")] ObjC, } struct IncludeStmt { flavor: IncludeStmtFlavor, scope: IncludeStmtScope, arg: String, } enum IncludeStmtFlavor { Include, #[cfg(target_os = "macos")] Import, } enum IncludeStmtScope { System, Local, } impl IncludeStmt { fn to_string(&self, libui_dir: &Path) -> String { format!( "#{} {}", match self.flavor { IncludeStmtFlavor::Include => "include", #[cfg(target_os = "macos")] IncludeStmtFlavor::Import => "import", }, match self.scope { IncludeStmtScope::System => format!("<{}>", self.arg), IncludeStmtScope::Local => format!( "\"{}\"", libui_dir.join(&self.arg).display(), ), }, ) } } struct ClangArgs { defines: Vec, include_paths: Vec, isysroot: Option>, } struct ClangDefine { key: String, value: Option, } impl ClangArgs { fn new() -> Self { #[cfg(target_os = "macos")] return Self::macos(); #[cfg(target_os = "linux")] return Self::linux(); #[cfg(target_os = "windows")] return Self::windows(); #[allow(unreachable_code)] { unimplemented!("unsupported target OS"); } } #[cfg(target_os = "macos")] fn macos() -> Self { use std::process; static DEFAULT_SDK_PATH: &str = concat!( "/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer", "/SDKs/MacOSX.sdk", ); let sdk_path = process::Command::new("xcrun") .args(["--sdk", "macosx", "--show-sdk-path"]) .output() .ok() .map(|out| crate::unix::make_path_from_ascii(out.stdout)) .map(Cow::Owned) .unwrap_or_else(|| Cow::Borrowed(Path::new(DEFAULT_SDK_PATH))); Self { defines: Vec::new(), include_paths: Vec::new(), // On macOS, *bindgen* looks in the wrong location for system headers, so we need to // manually specify it here. // // For more information, see // . isysroot: Some(sdk_path), } } #[cfg(target_os = "linux")] fn linux() -> Self { let gtk = crate::gtk::find().unwrap(); let defines = gtk .defines .into_iter() .map(|(key, value)| { ClangDefine { key, value } }) .collect(); Self { defines, include_paths: gtk.include_paths, isysroot: None, } } #[cfg(target_os = "windows")] fn windows() -> Self { Self { defines: Vec::new(), include_paths: Vec::new(), isysroot: None, } } fn as_args(self) -> Vec { let defines = self .defines .into_iter() .flat_map(|define| { vec![ "-D".to_string(), format!( "{}{}", define.key, define.value.map(|it| format!("={}", it)).unwrap_or_default(), ), ] }); let includes = self .include_paths .into_iter() .flat_map(|path| { vec![ "-I".to_string(), path.display().to_string(), ] }); let isysroot = self .isysroot .into_iter() .flat_map(|path| { vec![ "-isysroot".to_string(), path.display().to_string(), ] }); defines.chain(includes).chain(isysroot).collect() } } }