use std::{
collections::HashSet,
fs,
path::{Path, PathBuf},
process::Command,
};
use dunce::simplified;
use path_slash::PathExt;
use yaml_rust::{Yaml, YamlLoader};
use crate::{
artifacts_emitter::ArtifactsEmitter,
error::BuildError,
plugins::Plugins,
util::{copy_to, find_executable, get_artifacts_dir, run_command},
BuildResult, FileOperation, IOResultExt,
};
#[derive(Debug)]
pub struct FlutterOptions<'a> {
pub project_root: Option<&'a Path>,
pub target_file: &'a Path,
pub flutter_path: Option<&'a Path>,
pub local_engine: Option<&'a str>,
pub local_engine_src_path: Option<&'a Path>,
pub dart_defines: &'a [&'a str],
pub macos_extra_pods: &'a [&'a str],
}
impl Default for FlutterOptions<'_> {
fn default() -> Self {
Self {
project_root: None,
target_file: "lib/main.dart".as_path(),
flutter_path: None,
local_engine: None,
local_engine_src_path: None,
dart_defines: &[],
macos_extra_pods: &[],
}
}
}
pub trait AsPath {
fn as_path(&self) -> &Path;
}
impl AsPath for str {
fn as_path(&self) -> &Path {
Path::new(self)
}
}
impl FlutterOptions<'_> {
pub(super) fn find_flutter_executable(&self) -> BuildResult<PathBuf> {
let executable = if cfg!(target_os = "windows") {
"flutter.bat"
} else {
"flutter"
};
match &self.flutter_path {
Some(path) => {
let out_dir: PathBuf = std::env::var("CARGO_MANIFEST_DIR").unwrap().into();
let path = out_dir.join(path);
let executable = path.join("bin").join(executable);
if executable.exists() {
Ok(executable)
} else {
Err(BuildError::FlutterPathInvalidError { path })
}
}
None => {
let flutter_root = std::env::var("FLUTTER_ROOT").ok();
if let Some(flutter_root) = flutter_root {
let executable = Path::new(&flutter_root).join("bin").join(executable);
if executable.exists() {
return Ok(executable);
}
}
let executable =
find_executable(executable).ok_or(BuildError::FlutterNotFoundError)?;
let executable = executable
.canonicalize()
.wrap_error(FileOperation::Canonicalize, || executable)?;
let executable = simplified(&executable).into();
Ok(executable)
}
}
}
pub(super) fn find_flutter_bin(&self) -> BuildResult<PathBuf> {
let executable = self.find_flutter_executable()?;
Ok(executable.parent().unwrap().into())
}
pub(super) fn local_engine_src_path(&self) -> BuildResult<PathBuf> {
match &self.local_engine_src_path {
Some(path) => Ok(path.into()),
None => self.find_local_engine_src_path(),
}
}
fn find_local_engine_src_path(&self) -> BuildResult<PathBuf> {
let bin = self.find_flutter_bin()?;
let path = bin
.parent()
.and_then(|p| p.parent())
.map(|p| p.join("engine").join("src"));
if let Some(path) = path {
if path.exists() {
return Ok(path);
}
}
Err(BuildError::FlutterLocalEngineNotFound)
}
}
#[derive(Debug)]
pub enum TargetOS {
Mac,
Windows,
Linux,
}
#[derive(Debug)]
pub struct Flutter<'a> {
pub(super) root_dir: PathBuf,
pub(super) out_dir: PathBuf,
pub(super) options: FlutterOptions<'a>,
pub(super) build_mode: String,
pub(super) target_os: TargetOS,
pub(super) target_platform: String,
pub(super) darwin_arch: Option<String>,
}
impl Flutter<'_> {
pub fn build(options: FlutterOptions) -> BuildResult<()> {
let build = Flutter::new(options);
build.do_build()
}
fn new(options: FlutterOptions) -> Flutter {
Flutter {
root_dir: std::env::var("CARGO_MANIFEST_DIR")
.unwrap()
.as_path()
.join(options.project_root.unwrap_or_else(|| "".as_path())),
out_dir: std::env::var("OUT_DIR").unwrap().into(),
options,
build_mode: Flutter::build_mode(),
target_os: Flutter::target_os(),
target_platform: Flutter::target_platform(),
darwin_arch: Flutter::darwin_arch(),
}
}
fn do_flutter_pub_get(&self) -> BuildResult<()> {
let mut command = self.create_flutter_command()?;
command.arg("pub").arg("get");
self.run_flutter_command(command)
}
fn do_build(&self) -> BuildResult<()> {
let flutter_out_root = self.out_dir.join("flutter");
let flutter_out_dart_tool = flutter_out_root.join(".dart_tool");
fs::create_dir_all(&flutter_out_dart_tool)
.wrap_error(FileOperation::CreateDir, || flutter_out_dart_tool.clone())?;
let package_config = self.root_dir.join(".dart_tool").join("package_config.json");
let package_config_out = flutter_out_dart_tool.join("package_config.json");
if !Path::exists(&package_config) {
self.do_flutter_pub_get()?;
}
Self::copy(&package_config, &package_config_out)?;
let mut local_roots = HashSet::<PathBuf>::new();
self.update_package_config_paths(package_config, package_config_out, &mut local_roots)?;
Self::copy(
self.root_dir
.join(".dart_tool")
.join("package_config_subset"),
flutter_out_root
.join(".dart_tool")
.join("package_config_subset"),
)?;
let assets = self.copy_pubspec_yaml(
&self.root_dir.join("pubspec.yaml"),
&flutter_out_root.join("pubspec.yaml"),
)?;
self.precache()?;
self.run_flutter_assemble(&flutter_out_root)?;
self.emit_flutter_artifacts(&flutter_out_root)?;
self.emit_flutter_checks(&local_roots, &assets).unwrap();
if Self::build_mode() == "profile" {
cargo_emit::rustc_cfg!("flutter_profile");
}
Ok(())
}
fn link_asset(
&self,
from_dir: &Path,
to_dir: &Path,
asset: &str,
) -> BuildResult<Option<PathBuf>> {
let mut segments = asset.split('/');
if let Some(first) = segments.next() {
if first != "packages" {
let asset = from_dir.join(first);
copy_to(&asset, to_dir, true)?;
return Ok(Some(asset));
}
}
Ok(None)
}
fn extract_assets(pub_spec: &str) -> BuildResult<Vec<String>> {
let pub_spec = YamlLoader::load_from_str(pub_spec)
.map_err(|err| BuildError::YamlError { source: err })?;
let mut res = Vec::new();
let flutter = &pub_spec[0];
if let Yaml::Hash(hash) = flutter {
let flutter = hash.get(&Yaml::String("flutter".into()));
if let Some(Yaml::Hash(flutter)) = flutter {
let assets = flutter.get(&Yaml::String("assets".into()));
if let Some(Yaml::Array(assets)) = assets {
for asset in assets {
if let Yaml::String(str) = asset {
res.push(str.clone());
}
}
}
let fonts = flutter.get(&Yaml::String("fonts".into()));
if let Some(Yaml::Array(fonts)) = fonts {
for font in fonts {
if let Yaml::Hash(font) = font {
let fonts = font.get(&Yaml::String("fonts".into()));
if let Some(Yaml::Array(fonts)) = fonts {
for font in fonts {
if let Yaml::Hash(font) = font {
let asset = font.get(&Yaml::String("asset".into()));
if let Some(Yaml::String(str)) = asset {
res.push(str.clone());
}
}
}
}
}
}
}
}
}
Ok(res)
}
fn copy_pubspec_yaml(&self, from: &Path, to: &Path) -> BuildResult<Vec<PathBuf>> {
let pub_spec = fs::read_to_string(from).wrap_error(FileOperation::Read, || from.into())?;
let from_dir = from.parent().unwrap();
let to_dir = to.parent().unwrap();
let assets: BuildResult<Vec<PathBuf>> = Self::extract_assets(&pub_spec)?
.iter()
.filter_map(|asset| {
let res = self.link_asset(from_dir, to_dir, asset);
match res {
Ok(None) => None,
Ok(Some(value)) => Some(Ok(value)),
Err(err) => Some(Err(err)),
}
})
.collect();
Self::copy(from, to)?;
assets
}
pub fn build_mode() -> String {
let mut build_mode: String = std::env::var("PROFILE").unwrap();
let profile = std::env::var("FLUTTER_PROFILE").unwrap_or_else(|_| "false".into());
let profile = profile == "true" || profile == "1";
if profile && build_mode != "release" {
panic!("Profile option (FLUTTER_PROFILE) must only be enabled for release builds")
}
if profile {
build_mode = "profile".into();
}
build_mode
}
fn target_platform() -> String {
let env_arch = std::env::var("CARGO_CFG_TARGET_ARCH");
let arch = match env_arch.as_deref() {
Ok("x86_64") => "x64",
Ok("aarch64") => "arm64",
_ => panic!("Unsupported target architecture {:?}", env_arch),
};
match Flutter::target_os() {
TargetOS::Mac => "darwin".into(),
TargetOS::Windows => format!("windows-{}", arch),
TargetOS::Linux => format!("linux-{}", arch),
}
}
fn darwin_arch() -> Option<String> {
let env_arch = std::env::var("CARGO_CFG_TARGET_ARCH");
match Flutter::target_os() {
TargetOS::Mac => match env_arch.as_deref() {
Ok("x86_64") => Some("x86_64".into()),
Ok("aarch64") => Some("arm64".into()),
_ => panic!("Unsupported target architecture {:?}", env_arch),
},
_ => None,
}
}
pub(crate) fn macosx_deployment_target() -> String {
match Flutter::target_os() {
TargetOS::Mac => {
std::env::var("MACOSX_DEPLOYMENT_TARGET").unwrap_or_else(|_| "10.13".into())
}
_ => {
panic!("Deployment target can only be called on Mac")
}
}
}
fn target_os() -> TargetOS {
let target_os = std::env::var("CARGO_CFG_TARGET_OS");
match target_os.as_deref() {
Ok("macos") => TargetOS::Mac,
Ok("windows") => TargetOS::Windows,
Ok("linux") => TargetOS::Linux,
_ => panic!("Unsupported target operating system {:?}", target_os),
}
}
fn update_package_config_paths<PathRef: AsRef<Path>>(
&self,
original: PathRef,
new: PathRef,
local_roots: &mut HashSet<PathBuf>,
) -> BuildResult<()> {
let string =
fs::read_to_string(&new).wrap_error(FileOperation::Read, || new.as_ref().into())?;
let mut package_config: PackageConfig =
serde_json::from_str(&string).map_err(|e| BuildError::JsonError {
text: Some(string),
source: e,
})?;
for package in &mut package_config.packages {
if package.root_uri.starts_with("..") {
let absolute = original.as_ref().parent().unwrap().join(&package.root_uri);
let absolute = absolute
.canonicalize()
.wrap_error(FileOperation::Canonicalize, || absolute)?;
{
let mut local_root = absolute.clone();
local_root.extend(Path::new(&package.package_uri).iter());
local_roots.insert(local_root);
}
let absolute = simplified(&absolute);
let mut absolute = absolute.to_slash_lossy();
if !absolute.starts_with('/') {
absolute = format!("/{}", absolute);
}
absolute = format!("file://{}", absolute);
package.root_uri = absolute;
}
}
let serialized =
serde_json::to_string_pretty(&package_config).map_err(|e| BuildError::JsonError {
text: None,
source: e,
})?;
fs::write(&new, serialized).wrap_error(FileOperation::Write, || new.as_ref().into())?;
Ok(())
}
fn create_flutter_command(&self) -> BuildResult<Command> {
let executable = self.options.find_flutter_executable()?;
if cfg!(target_os = "windows") {
let mut c = Command::new("cmd");
c.arg("/C").arg(executable);
Ok(c)
} else {
Ok(Command::new(executable))
}
}
fn run_flutter_command(&self, command: Command) -> BuildResult<()> {
run_command(command, "flutter")
}
fn run_flutter_assemble<PathRef: AsRef<Path>>(&self, working_dir: PathRef) -> BuildResult<()> {
let rebased = pathdiff::diff_paths(&self.root_dir, &working_dir).unwrap();
let actions: Vec<String> = match (&self.target_os, self.build_mode.as_str()) {
(TargetOS::Mac, _) => vec![format!("{}_macos_bundle_flutter_assets", self.build_mode)],
(TargetOS::Windows, "debug") => vec!["kernel_snapshot".into(), "copy_assets".into()],
(TargetOS::Windows, _) => vec![format!("{}_bundle_windows_assets", self.build_mode)],
(TargetOS::Linux, "debug") => vec!["kernel_snapshot".into(), "copy_assets".into()],
(TargetOS::Linux, _) => vec![format!(
"{}_bundle_{}_assets",
self.build_mode, self.target_platform
)],
};
let defines: Vec<String> = self
.options
.dart_defines
.iter()
.map(base64::encode)
.collect();
let defines = format!("--DartDefines={}", defines.join(","));
let mut command = self.create_flutter_command()?;
command.current_dir(&working_dir);
if let Some(local_engine) = &self.options.local_engine {
command.arg(format!("--local-engine={}", local_engine));
command.arg(format!(
"--local-engine-src-path={}",
self.options.local_engine_src_path()?.to_slash_lossy()
));
}
command
.arg("assemble")
.arg("--output=.")
.arg(format!("--define=BuildMode={}", self.build_mode))
.arg(format!("--define=TargetPlatform={}", self.target_platform))
.arg(format!(
"--define=DarwinArchs={}",
self.darwin_arch.as_ref().unwrap_or(&String::default())
))
.arg(format!(
"--define=TargetFile={}",
rebased.join(self.options.target_file).to_str().unwrap()
))
.arg(defines)
.arg("-v")
.arg("--suppress-analytics")
.args(actions);
self.run_flutter_command(command)
}
fn emit_flutter_artifacts<PathRef: AsRef<Path>>(
&self,
working_dir: PathRef,
) -> BuildResult<()> {
let artifacts_dir = get_artifacts_dir()?;
let flutter_out_root = self.out_dir.join("flutter");
let emitter = ArtifactsEmitter::new(self, flutter_out_root, artifacts_dir)?;
let plugins = Plugins::new(self, &emitter);
plugins.process()?;
match self.target_os {
TargetOS::Mac => {
cargo_emit::rustc_link_search! {
working_dir.as_ref().to_str().unwrap() => "framework",
};
emitter.emit_app_framework()?;
emitter.emit_external_libraries()?;
emitter.emit_linker_flags()?;
}
TargetOS::Windows => {
emitter.emit_flutter_data()?;
emitter.emit_external_libraries()?;
emitter.emit_linker_flags()?;
}
TargetOS::Linux => {
emitter.emit_flutter_data()?;
emitter.emit_external_libraries()?;
emitter.emit_linker_flags()?;
}
}
Ok(())
}
fn emit_flutter_checks(&self, roots: &HashSet<PathBuf>, assets: &[PathBuf]) -> BuildResult<()> {
cargo_emit::rerun_if_changed! {
self.root_dir.join("pubspec.yaml").to_str().unwrap(),
self.root_dir.join("pubspec.lock").to_str().unwrap(),
};
for path in roots {
Self::emit_checks_for_dir(path)?;
}
for asset in assets {
if asset.is_dir() {
Self::emit_checks_for_dir(asset)?;
} else {
cargo_emit::rerun_if_changed! {
asset.to_string_lossy()
}
}
}
cargo_emit::rerun_if_env_changed!("FLUTTER_PROFILE");
Ok(())
}
fn emit_checks_for_dir(path: &Path) -> BuildResult<()> {
for entry in fs::read_dir(path).wrap_error(FileOperation::ReadDir, || path.into())? {
let entry = entry.wrap_error(FileOperation::ReadDir, || path.into())?;
let metadata = entry
.metadata()
.wrap_error(FileOperation::MetaData, || entry.path())?;
if metadata.is_dir() {
Self::emit_checks_for_dir(entry.path().as_path())?;
} else {
cargo_emit::rerun_if_changed! {
entry.path().to_string_lossy(),
}
}
}
Ok(())
}
fn copy<P: AsRef<Path>, Q: AsRef<Path>>(from: P, to: Q) -> Result<u64, BuildError> {
fs::copy(&from, &to).wrap_error_with_src(
FileOperation::Copy,
|| to.as_ref().into(),
|| from.as_ref().into(),
)
}
pub fn precache(&self) -> BuildResult<()> {
if self.options.local_engine.is_some() {
return Ok(());
}
let engine_version = self
.options
.find_flutter_bin()?
.join("internal")
.join("engine.version");
let engine_version = fs::read_to_string(&engine_version)
.wrap_error(FileOperation::Read, || engine_version)?;
let last_engine_version_path = self.out_dir.join("last_precached_engine_version");
if last_engine_version_path.exists() {
let last_engine_version = fs::read_to_string(&last_engine_version_path)
.wrap_error(FileOperation::Read, || last_engine_version_path.clone())?;
if last_engine_version == engine_version {
return Ok(());
}
}
let mut command = self.create_flutter_command()?;
command
.arg("precache")
.arg("-v")
.arg("--suppress-analytics")
.arg(match self.target_os {
TargetOS::Mac => "--macos",
TargetOS::Windows => "--windows",
TargetOS::Linux => "--linux",
});
self.run_flutter_command(command)?;
fs::write(&last_engine_version_path, &engine_version)
.wrap_error(FileOperation::Write, || last_engine_version_path)?;
Ok(())
}
}
#[derive(serde::Serialize, serde::Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
struct Package {
root_uri: String,
package_uri: String,
#[serde(flatten)]
other: serde_json::Map<String, serde_json::Value>,
}
#[derive(serde::Serialize, serde::Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
struct PackageConfig {
packages: Vec<Package>,
#[serde(flatten)]
other: serde_json::Map<String, serde_json::Value>,
}
#[test]
fn test_extract_assets() {
let pub_spec = r#"
flutter:
assets:
- asset1
- asset2
fonts:
- family: Raleway
fonts:
- asset: fonts/Raleway-Regular.ttf
- asset: fonts/Raleway-Italic.ttf
style: italic
- family: RobotoMono
fonts:
- asset: fonts/RobotoMono-Regular.ttf
- asset: fonts/RobotoMono-Bold.ttf
weight: 700
"#;
let assets = Flutter::extract_assets(pub_spec).unwrap();
let expected: Vec<String> = vec![
"asset1".into(),
"asset2".into(),
"fonts/Raleway-Regular.ttf".into(),
"fonts/Raleway-Italic.ttf".into(),
"fonts/RobotoMono-Regular.ttf".into(),
"fonts/RobotoMono-Bold.ttf".into(),
];
assert_eq!(assets, expected);
}