diff --git a/Cargo.lock b/Cargo.lock index 7b17820..0152ee5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1311,15 +1311,19 @@ version = "0.1.1" dependencies = [ "anyhow", "bevy", + "bytes", "cmd_lib", "config", "convert_case 0.5.0", "dip_core", + "dip_macro", "dirs", "flate2", + "hex", "pathdiff", "reqwest", "serde", + "sha2", "tar", "tempfile", "tokio", @@ -3584,6 +3588,17 @@ dependencies = [ "digest", ] +[[package]] +name = "sha2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82e6b795fe2e3b1e845bafcb27aa35405c4d47cdfc92af5fc8d3002f76cebdc0" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.4" diff --git a/Cargo.toml b/Cargo.toml index 861b006..ca80801 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ keywords = ["declarative-ui", "ecs", "bevy", "dioxus", "cross-platform"] anyhow = "1.0" bevy = { version = "0.8", default-features = false } bevy_ecs = "0.8" +bytes = "1" cmd_lib = "1" config = "0.13" convert_case = "0.5" @@ -39,6 +40,7 @@ dip_macro = { path = "./plugins/macro" } dip_task = { path = "./plugins/task" } dirs = "4.0" flate2 = "1.0" +hex = "0.4" pathdiff = "0.2" reqwest = { version = "0.11", features = ["json", "blocking"] } tar = "0.4" @@ -46,6 +48,7 @@ tokio = { version = "1.18", features = ["rt-multi-thread", "sync", "macros", "fs serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" serde_repr = "0.1" +sha2 = "0.10" tempfile = "3" walkdir = "2" diff --git a/docs/handbook/Internal b/docs/handbook/Internal index dec29cf..bf58828 160000 --- a/docs/handbook/Internal +++ b/docs/handbook/Internal @@ -1 +1 @@ -Subproject commit dec29cf7e481cb719ff806f427d937359f74a5d1 +Subproject commit bf588282bc4f1b1bceee5af61cbe3da37fb4c184 diff --git a/plugins/bundle/Cargo.toml b/plugins/bundle/Cargo.toml index bd53957..e917551 100644 --- a/plugins/bundle/Cargo.toml +++ b/plugins/bundle/Cargo.toml @@ -12,15 +12,19 @@ keywords.workspace = true [dependencies] anyhow.workspace = true bevy.workspace = true +bytes.workspace = true dip_core.workspace = true +dip_macro.workspace = true dirs.workspace = true cmd_lib.workspace = true config.workspace = true convert_case.workspace = true flate2.workspace = true +hex.workspace = true pathdiff.workspace = true reqwest.workspace = true serde.workspace = true +sha2.workspace = true tar.workspace = true tempfile.workspace = true tokio.workspace = true @@ -29,7 +33,6 @@ walkdir.workspace = true [features] default = ["full"] full = ["brew", "dotfiles", "nodejs", "scripts", "tailwindcss", "vm"] -# full = ["vm", "nodejs", "tailwindcss"] brew = [] dotfiles = [] nodejs = [] diff --git a/plugins/bundle/src/installer.rs b/plugins/bundle/src/installer.rs new file mode 100644 index 0000000..ac90bb0 --- /dev/null +++ b/plugins/bundle/src/installer.rs @@ -0,0 +1,83 @@ +use anyhow::{bail, Context}; +use bytes::Bytes; +use flate2::read::GzDecoder; +use reqwest::{header, StatusCode}; +use sha2::{Digest, Sha256}; +use std::{fs, os::unix::fs::PermissionsExt, path::PathBuf}; +use tar::Archive; + +pub trait Installer { + /// 1. Download package: e.g. https://nodejs.org/dist/v16.18.1/node-v16.18.1-darwin-arm64.tar.gz + /// 2. Verify checkum + /// 3. Unpack to path: e.g. /User/Application Support/dip/bundle/installs/nodejs/16.18.1/ + fn install( + &self, + download_url: &String, + install_path: &PathBuf, + file_name: &String, + checksum: Option<&String>, + ) -> anyhow::Result<()> { + let res = reqwest::blocking::get(download_url) + .context("Failed to download. Check internet connection.")?; + + match res.status() { + StatusCode::OK => { + match res.headers()[header::CONTENT_TYPE].to_str()? { + "application/gzip" => { + let bytes = res.bytes()?; + + if let Some(checksum) = checksum { + self.verify_checksum(&bytes, checksum)?; + } + + let mut cloned_path = install_path.clone(); + cloned_path.pop(); + + let tar = GzDecoder::new(&bytes[..]); + let mut archive = Archive::new(tar); + + archive.unpack(&cloned_path)?; + + fs::rename( + // e.g. /User/Application Support/dip/bundle/installs/nodejs/node-v16.18.1-darwin-arm64 + cloned_path.join(&file_name), + // e.g. /User/Application Support/dip/bundle/installs/nodejs/16.18.1/ + &install_path, + )?; + Ok(()) + } + "application/octet-stream" => { + let file_path = &install_path.join(file_name); + + fs::create_dir_all(&install_path)?; + fs::write(&file_path, &res.bytes()?)?; + fs::set_permissions(&file_path, fs::Permissions::from_mode(0o755))?; + + Ok(()) + } + unsupported_content_type => { + bail!( + "Content-Type is not supported: {}", + unsupported_content_type + ); + } + } + } + StatusCode::NOT_FOUND => { + bail!("Download URL not found: {download_url}"); + } + _ => { + bail!("Fail to download binary") + } + } + } + + fn verify_checksum(&self, bytes: &Bytes, checksum: &String) -> anyhow::Result<()> { + let hash = Sha256::digest(&bytes); + if hash[..] == hex::decode(checksum)? { + Ok(()) + } else { + bail!("Checksum doesn't match") + } + } +} diff --git a/plugins/bundle/src/lib.rs b/plugins/bundle/src/lib.rs index 2695249..d6d7158 100644 --- a/plugins/bundle/src/lib.rs +++ b/plugins/bundle/src/lib.rs @@ -1,10 +1,12 @@ mod config; +mod installer; mod platform; mod schedule; mod tool; pub use crate::{ config::{BundleConfig, BundleConfigPlugin}, + installer::Installer, schedule::{BundleSchedulePlugin, BundleStage}, tool::{InstallTools, ToolPlugin}, }; diff --git a/plugins/bundle/src/tool/vm.rs b/plugins/bundle/src/tool/vm.rs index aea4149..ed3c0bc 100644 --- a/plugins/bundle/src/tool/vm.rs +++ b/plugins/bundle/src/tool/vm.rs @@ -9,7 +9,7 @@ use nodejs::NodeJSPlugin; #[cfg(feature = "tailwindcss")] use tailwindcss::TailwindCSSPlugin; -use crate::Bundler; +use crate::{Bundler, Installer}; use anyhow::Context; use bevy::app::{App, Plugin}; use std::{fs, path::PathBuf}; @@ -26,7 +26,15 @@ impl Plugin for VersionManagerPlugin { } } -pub trait VersionManager: Bundler { +pub trait VersionManager: Bundler + Installer { + fn file_name(&self, version: &String) -> String; + + fn file_name_without_ext(&self, version: &String) -> String; + + fn download_file_name(&self, version: &String) -> String; + + fn download_url(&self, version: &String) -> String; + fn installs_dir(&self) -> PathBuf { self.bundle_config().install_root().join(Self::key()) } @@ -41,9 +49,9 @@ pub trait VersionManager: Bundler { self.installs_dir().join(version) } - fn download_url(&self, version: &String) -> String; - - fn install(&self, version: &String) -> anyhow::Result<()>; + fn install_path(&self, version: &String) -> PathBuf { + self.installs_dir().join(version) + } fn list_shims() -> Vec<&'static str>; @@ -80,7 +88,12 @@ pub trait VersionManager: Bundler { continue; } - self.install(v)?; + self.install( + &self.download_url(v), + &self.install_path(v), + &self.download_file_name(v), + self.checksum(v)?.to_owned().as_ref(), + )?; println!("Installed: {}", &p.display()); } @@ -94,6 +107,8 @@ pub trait VersionManager: Bundler { Ok(()) } + fn checksum(&self, version: &String) -> anyhow::Result>; + /// Iterate over each versions currently installed but removed from the user bundle config fn clean_all(&self) -> anyhow::Result<()> { if self.installs_dir().is_dir() { diff --git a/plugins/bundle/src/tool/vm/nodejs.rs b/plugins/bundle/src/tool/vm/nodejs.rs index fdbf6a4..5f7a869 100644 --- a/plugins/bundle/src/tool/vm/nodejs.rs +++ b/plugins/bundle/src/tool/vm/nodejs.rs @@ -7,10 +7,8 @@ use bevy::{ app::{App, Plugin}, ecs::{event::EventReader, system::Res}, }; -use flate2::read::GzDecoder; -use reqwest::StatusCode; +use dip_macro::Installer; use std::{fs, io::Write, os::unix::fs::PermissionsExt}; -use tar::Archive; pub struct NodeJSPlugin; @@ -49,6 +47,7 @@ fn apply(mut events: EventReader, config: Res) { }); } +#[derive(Installer)] struct NodeJS { bundle_config: BundleConfig, platform: Platform, @@ -62,20 +61,8 @@ impl NodeJS { } } - fn file_name_without_ext(&self, version: &String) -> String { - format!( - "node-v{version}-{name}-{arch}", - name = self.platform.name(), - arch = Platform::arch(), - ) - } - - fn file_name(&self, version: &String) -> String { - format!( - "{}{archive_ext}", - self.file_name_without_ext(version), - archive_ext = self.platform.archive_ext(), - ) + fn version_url(&self, version: &String) -> String { + format!("https://nodejs.org/dist/v{version}") } } @@ -94,53 +81,57 @@ impl Bundler for NodeJS { } impl VersionManager for NodeJS { - fn versions(&self) -> &Vec { - &self.bundle_config().runtime().nodejs + fn file_name(&self, version: &String) -> String { + format!( + "{}{archive_ext}", + self.file_name_without_ext(version), + archive_ext = self.platform.archive_ext(), + ) + } + + fn file_name_without_ext(&self, version: &String) -> String { + format!( + "node-v{version}-{name}-{arch}", + name = self.platform.name(), + arch = Platform::arch(), + ) + } + + fn download_file_name(&self, version: &String) -> String { + self.file_name_without_ext(version) } fn download_url(&self, version: &String) -> String { format!( - "https://nodejs.org/dist/v{version}/{file_name}", + "{version_url}/{file_name}", + version_url = &self.version_url(version), file_name = &self.file_name(&version), ) } - fn install(&self, version: &String) -> anyhow::Result<()> { - let download_url = self.download_url(version); - - let res = reqwest::blocking::get(&download_url) - .context("Failed to download. Check internet connection.")?; + fn versions(&self) -> &Vec { + &self.bundle_config().runtime().nodejs + } - match res.status() { - StatusCode::NOT_FOUND => { - bail!("Download URL not found: {download_url}"); - } - StatusCode::OK => { - if res.status() == StatusCode::NOT_FOUND { - bail!("Download URL not found: {download_url}"); - } - let bytes = res.bytes()?; - - if cfg!(unix) { - let tar = GzDecoder::new(&bytes[..]); - let mut archive = Archive::new(tar); - - archive.unpack(&self.installs_dir())?; - fs::rename( - &self - .installs_dir() - .join(&self.file_name_without_ext(&version)), - &self.installs_dir().join(&version), - )?; - } else if cfg!(windows) { - // win: zip - todo!("Implement zip extraction logic for Windows"); - } - - Ok(()) + fn checksum(&self, version: &String) -> anyhow::Result> { + let url = format!( + "{version_url}/SHASUMS256.txt", + version_url = &self.version_url(version) + ); + let res = reqwest::blocking::get(&url) + .context("Failed to fetch checksum. Check internet connection.")?; + + match res + .text()? + .lines() + .find(|ln| ln.contains(&self.file_name(version))) + { + Some(ln) => { + let checksum = ln.split(" ").next().context("Cannot find checksum")?; + Ok(Some(checksum.to_string())) } - _ => { - bail!("Fail to download binary") + None => { + bail!("Cannot find checksum"); } } } diff --git a/plugins/bundle/src/tool/vm/tailwindcss.rs b/plugins/bundle/src/tool/vm/tailwindcss.rs index 44eb935..c46e132 100644 --- a/plugins/bundle/src/tool/vm/tailwindcss.rs +++ b/plugins/bundle/src/tool/vm/tailwindcss.rs @@ -2,13 +2,12 @@ use crate::{ config::BundleConfig, platform::Platform, schedule::BundleStage, tool::vm::VersionManager, ApplyBundle, Bundler, }; -use anyhow::{bail, Context}; use bevy::{ app::{App, Plugin}, ecs::{event::EventReader, system::Res}, }; -use reqwest::StatusCode; -use std::{fs, io::Write, os::unix::fs::PermissionsExt}; +use dip_macro::Installer; +use std::{fs, os::unix::fs::PermissionsExt}; pub struct TailwindCSSPlugin; @@ -47,6 +46,7 @@ fn apply(mut events: EventReader, config: Res) { }); } +#[derive(Installer)] struct TailwindCSS { bundle_config: BundleConfig, platform: Platform, @@ -76,46 +76,39 @@ impl Bundler for TailwindCSS { } impl VersionManager for TailwindCSS { - fn versions(&self) -> &Vec { - &self.bundle_config().runtime().tailwindcss + fn file_name(&self, version: &String) -> String { + format!( + "{}{optional_ext}", + self.file_name_without_ext(version), + optional_ext = self.platform.ext(), + ) } - fn download_url(&self, version: &String) -> String { + fn file_name_without_ext(&self, _version: &String) -> String { format!( - "https://github.com/tailwindlabs/tailwindcss/releases/download/v{version}/tailwindcss-{target}-{arch}{optional_ext}", + "tailwindcss-{target}-{arch}", target = self.platform.to_string(), arch = Platform::arch(), - optional_ext = self.platform.ext(), ) } - fn install(&self, version: &String) -> anyhow::Result<()> { - let download_url = self.download_url(version); - - let res = reqwest::blocking::get(&download_url) - .context("Failed to download. Check internet connection.")?; - - match res.status() { - StatusCode::NOT_FOUND => { - bail!("Download URL not found: {download_url}"); - } - StatusCode::OK => { - let version_dir = self.version_dir(version); - fs::create_dir_all(&version_dir)?; + fn download_file_name(&self, _file_name: &String) -> String { + Self::key().to_string() + } - let mut file = fs::File::create(version_dir.join(Self::key())) - .context("Failed to create download target file")?; - file.set_permissions(fs::Permissions::from_mode(0o755)) - .context("Failed to give permission to download target file")?; + fn versions(&self) -> &Vec { + &self.bundle_config().runtime().tailwindcss + } - file.write(&res.bytes()?)?; + fn download_url(&self, version: &String) -> String { + format!( + "https://github.com/tailwindlabs/tailwindcss/releases/download/v{version}/{file_name}", + file_name = &self.file_name(&version), + ) + } - Ok(()) - } - _ => { - bail!("Fail to download binary") - } - } + fn checksum(&self, _version: &String) -> anyhow::Result> { + Ok(None) } fn list_shims() -> Vec<&'static str> { @@ -127,12 +120,8 @@ impl VersionManager for TailwindCSS { let runtime_path = self.version_dir(version).join(&bin_name); let shim_path = &self.shims_dir().join(&bin_name); - let mut shim_file = fs::File::create(shim_path)?; - shim_file - .set_permissions(fs::Permissions::from_mode(0o755)) - .context("Failed to give permission to shim")?; - - shim_file.write_all(&Self::format_shim(&runtime_path)?.as_bytes())?; + fs::write(&shim_path, &Self::format_shim(&runtime_path)?.as_bytes())?; + fs::set_permissions(&shim_path, fs::Permissions::from_mode(0o755))?; Ok(()) } diff --git a/plugins/macro/src/lib.rs b/plugins/macro/src/lib.rs index 261ed72..4b84641 100644 --- a/plugins/macro/src/lib.rs +++ b/plugins/macro/src/lib.rs @@ -11,6 +11,7 @@ use crate::{ subcommand::SubcommandParser, ui_state::UiStateParser, }; use proc_macro::TokenStream; +use quote::quote; use syn::{parse_macro_input, ItemEnum, ItemImpl, ItemStruct}; #[proc_macro_attribute] @@ -54,3 +55,14 @@ pub fn async_action(_attr: TokenStream, tokens: TokenStream) -> TokenStream { ActionParser::async_action(input).parse().gen() } + +#[proc_macro_derive(Installer)] +pub fn derive_installer(tokens: TokenStream) -> TokenStream { + let input = parse_macro_input!(tokens as ItemStruct); + let ident = input.ident; + + quote! { + impl crate::Installer for #ident {} + } + .into() +}