use crate::{
config::Config, detector::SystemDetector, downloader::Downloader, error::AstudiosError,
model::InstalledAndroidStudio,
};
use colored::Colorize;
use std::{
fs,
path::{Path, PathBuf},
process::Command,
};
#[derive(Debug, Clone, Copy)]
pub enum ArchiveType {
Dmg,
Unsupported,
}
pub struct Installer {
install_dir: PathBuf,
applications_dir: PathBuf,
}
impl Installer {
pub fn new() -> Result<Self, AstudiosError> {
let install_dir = Config::versions_dir();
let applications_dir = Config::default_applications_dir();
fs::create_dir_all(&install_dir)?;
Ok(Self {
install_dir,
applications_dir,
})
}
pub fn with_directories(
install_dir: PathBuf,
applications_dir: PathBuf,
) -> Result<Self, AstudiosError> {
fs::create_dir_all(&install_dir)?;
Ok(Self {
install_dir,
applications_dir,
})
}
pub fn install_version(
&self,
version: &str,
full_name: &str,
custom_dir: Option<&str>,
) -> Result<(), AstudiosError> {
self.install_version_with_checks(version, full_name, custom_dir, true)
}
pub fn install_version_with_checks(
&self,
version: &str,
full_name: &str,
custom_dir: Option<&str>,
run_checks: bool,
) -> Result<(), AstudiosError> {
let target_dir = if let Some(dir) = custom_dir {
PathBuf::from(dir)
} else {
self.applications_dir.clone()
};
if run_checks {
println!(
"{} {} Checking system requirements...",
"[1/5]".bold().blue(),
"🔍".blue()
);
let detection_result =
SystemDetector::detect_system_requirements(&self.install_dir, &target_dir)?;
if detection_result.has_warnings() {
println!();
for warning in &detection_result.warnings {
println!(" {} {}", "⚠️".yellow(), warning.yellow());
}
println!();
}
if !detection_result.is_valid() {
println!(" {} System requirements not met:", "❌".red());
for issue in &detection_result.issues {
println!(" • {}", issue.red());
}
println!();
println!(
" {} Please resolve the above issues and try again.",
"💡".blue()
);
println!(
" {} Use --skip-checks to bypass these checks (not recommended)",
"⚠️".yellow()
);
return Err(AstudiosError::PrerequisiteNotMet(
"System requirements not met".to_string(),
));
}
println!(" {} System requirements verified", "✅".green());
println!();
}
let download_path = self.download_version(version, full_name)?;
let extracted_path = self.extract_archive(&download_path, version)?;
let app_path = self.move_to_applications(version, &extracted_path, custom_dir)?;
if custom_dir.is_none() || custom_dir == Some("/Applications") {
self.create_symlink(&app_path)?;
} else {
println!(
"{} {} Skipping symlink creation for custom directory",
"[5/5]".bold().blue(),
"🔗".blue()
);
println!(
" {} Custom installation directory detected",
"ℹ️".blue()
);
}
let _ = self.cleanup_files(&download_path, &extracted_path);
self.verify_installation(&app_path)?;
Ok(())
}
fn download_version(&self, version: &str, full_name: &str) -> Result<PathBuf, AstudiosError> {
use crate::list::AndroidStudioLister;
let version_dir = self.install_dir.join(version);
fs::create_dir_all(&version_dir)?;
let lister = AndroidStudioLister::new()?;
let releases = lister.get_releases()?;
let target_item = releases
.items
.iter()
.find(|item| item.version == version)
.ok_or_else(|| {
AstudiosError::VersionNotFound(format!("Version {version} not found"))
})?;
let download = target_item
.get_platform_download()
.ok_or(AstudiosError::Download(
"No download available for current platform".to_string(),
))?;
let default_filename = format!("android-studio-{version}.dmg");
let filename = Path::new(&download.link)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(&default_filename);
let download_path = version_dir.join(filename);
if download_path.exists() {
let metadata = fs::metadata(&download_path)?;
if metadata.len() > 0 {
println!(
"{} {} File already downloaded",
"[2/5]".bold().blue(),
"📦".blue()
);
println!(
" {} {}",
"Location:".dimmed(),
download_path.display().to_string().cyan()
);
return Ok(download_path);
}
}
println!(
"{} {} Downloading Android Studio...",
"[2/5]".bold().blue(),
"📥".blue()
);
println!(" {} {}", "Version:".dimmed(), version.cyan());
println!(" {} {}", "Size:".dimmed(), download.size.yellow());
let downloader = Downloader::detect_best();
downloader.download(&download.link, &download_path, Some(full_name))?;
println!(" {} Download completed", "✅".green());
Ok(download_path)
}
fn extract_archive(
&self,
archive_path: &Path,
version: &str,
) -> Result<PathBuf, AstudiosError> {
let extract_dir = self.install_dir.join(version).join("extracted");
fs::create_dir_all(&extract_dir)?;
let archive_type = self.detect_archive_type(archive_path);
match archive_type {
ArchiveType::Dmg => self.extract_dmg(archive_path, &extract_dir)?,
ArchiveType::Unsupported => {
return Err(AstudiosError::Extraction(format!(
"Unsupported archive format: {}. Only DMG files are supported on macOS.",
archive_path.display()
)));
}
}
Ok(extract_dir)
}
fn detect_archive_type(&self, path: &Path) -> ArchiveType {
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if file_name.ends_with(".dmg") {
ArchiveType::Dmg
} else {
ArchiveType::Unsupported
}
}
fn extract_dmg(&self, archive_path: &Path, destination: &Path) -> Result<(), AstudiosError> {
let temp_mount = tempfile::tempdir()?;
let mount_point = temp_mount.path();
println!(
"{} {} Mounting disk image...",
"[3/5]".bold().blue(),
"💿".blue()
);
let output = Command::new("hdiutil")
.args([
"attach",
archive_path
.to_str()
.ok_or(AstudiosError::Path("Invalid path".to_string()))?,
"-mountpoint",
mount_point
.to_str()
.ok_or(AstudiosError::Path("Invalid path".to_string()))?,
"-nobrowse",
"-noverify", ])
.output()?;
if !output.status.success() {
let error_msg = String::from_utf8_lossy(&output.stderr);
return Err(AstudiosError::Extraction(format!(
"Failed to mount DMG: {}",
error_msg.trim()
)));
}
println!(" {} Disk image mounted successfully", "✅".green());
let mut app_paths = Vec::new();
if let Ok(entries) = fs::read_dir(mount_point) {
for entry in entries.filter_map(|e| e.ok()) {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.ends_with(".app") {
println!(" {} Found Android Studio app bundle", "📱".blue());
app_paths.push(entry.path());
}
}
}
if app_paths.is_empty() {
let android_studio_paths: Vec<PathBuf> = fs::read_dir(mount_point)?
.filter_map(|entry| entry.ok())
.filter(|entry| {
let name = entry.file_name();
let name_str = name.to_string_lossy();
name_str.contains("Android") && name_str.ends_with(".app")
})
.map(|entry| entry.path())
.collect();
if !android_studio_paths.is_empty() {
app_paths = android_studio_paths;
println!(" {} Found Android Studio app bundle", "📱".blue());
} else {
self.detach_dmg(mount_point)?;
return Err(AstudiosError::Extraction(
"No Android Studio .app bundle found in disk image".to_string(),
));
}
}
for app_path in app_paths {
let app_name = app_path.file_name().unwrap();
let dest_path = destination.join(app_name);
let status = Command::new("cp")
.args([
"-R",
app_path.to_str().unwrap(),
dest_path.to_str().unwrap(),
])
.status()?;
if !status.success() {
self.detach_dmg(mount_point)?;
return Err(AstudiosError::Extraction(
"Failed to copy app bundle".to_string(),
));
}
}
self.detach_dmg(mount_point)?;
Ok(())
}
fn detach_dmg(&self, mount_point: &Path) -> Result<(), AstudiosError> {
let output = Command::new("hdiutil")
.args(["detach", mount_point.to_str().unwrap(), "-force"])
.output();
match output {
Ok(output) => {
if !output.status.success() {
let error_msg = String::from_utf8_lossy(&output.stderr);
println!(
" {} Failed to unmount disk image: {}",
"⚠️".yellow(),
error_msg.trim()
);
} else {
println!(" {} Disk image unmounted", "✅".green());
}
}
Err(e) => {
println!(" {} Could not unmount disk image: {e}", "⚠️".yellow());
}
}
Ok(())
}
fn move_to_applications(
&self,
version: &str,
extracted_path: &Path,
custom_dir: Option<&str>,
) -> Result<PathBuf, AstudiosError> {
let target_dir = if let Some(dir) = custom_dir {
PathBuf::from(dir)
} else {
self.applications_dir.clone()
};
fs::create_dir_all(&target_dir)?;
let app_path = target_dir.join(format!("Android Studio {version}.app"));
let mut app_source = None;
if let Ok(entries) = fs::read_dir(extracted_path) {
for entry in entries.filter_map(|e| e.ok()) {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.contains("Android Studio") && name_str.ends_with(".app") {
app_source = Some(entry.path());
break;
}
}
}
let source = app_source.ok_or(AstudiosError::Installation(
"Android Studio.app not found in extracted files".to_string(),
))?;
println!(
"{} {} Installing to Applications...",
"[4/5]".bold().blue(),
"📲".blue()
);
println!(
" {} {}",
"Target:".dimmed(),
app_path.display().to_string().cyan()
);
if app_path.exists() {
println!(" {} Removing existing installation...", "🗑️".yellow());
fs::remove_dir_all(&app_path)?;
}
let output = Command::new("ditto")
.args([
source
.to_str()
.ok_or(AstudiosError::Path("Invalid source path".to_string()))?,
app_path
.to_str()
.ok_or(AstudiosError::Path("Invalid target path".to_string()))?,
])
.output()?;
if !output.status.success() {
let error_msg = String::from_utf8_lossy(&output.stderr);
return Err(AstudiosError::Installation(format!(
"Failed to install app bundle: {}",
error_msg.trim()
)));
}
if !app_path.exists() {
return Err(AstudiosError::Installation(
"Installation completed but app bundle not found at target location".to_string(),
));
}
println!(" {} Application installed successfully", "✅".green());
Ok(app_path)
}
fn cleanup_files(
&self,
archive_path: &Path,
extracted_path: &Path,
) -> Result<(), AstudiosError> {
if archive_path.exists() {
fs::remove_file(archive_path)?;
}
if extracted_path.exists() {
fs::remove_dir_all(extracted_path)?;
}
Ok(())
}
fn verify_installation(&self, app_path: &Path) -> Result<(), AstudiosError> {
if !app_path.exists() {
return Err(AstudiosError::Installation(format!(
"Installation not found at: {}",
app_path.display()
)));
}
let required_dirs = ["Contents", "Contents/MacOS", "Contents/Resources"];
for dir in required_dirs {
let path = app_path.join(dir);
if !path.exists() {
return Err(AstudiosError::Installation(format!(
"Required directory missing: {}",
path.display()
)));
}
}
let _status = Command::new("codesign")
.args(["-v", app_path.to_str().unwrap()])
.status();
Ok(())
}
fn create_symlink(&self, app_path: &Path) -> Result<(), AstudiosError> {
let symlink_path = self.applications_dir.join("Android Studio.app");
println!(
"{} {} Creating symlink...",
"[5/5]".bold().blue(),
"🔗".blue()
);
if symlink_path.exists() || symlink_path.is_symlink() {
match fs::symlink_metadata(&symlink_path) {
Ok(metadata) => {
if metadata.file_type().is_symlink() {
println!(" {} Updating existing symlink...", "🔄".yellow());
fs::remove_file(&symlink_path)?;
} else if metadata.is_dir() {
println!(" {} Removing existing directory...", "🗑️".yellow());
fs::remove_dir_all(&symlink_path)?;
} else {
println!(" {} Removing existing file...", "🗑️".yellow());
fs::remove_file(&symlink_path)?;
}
}
Err(_) => {
if fs::remove_file(&symlink_path).is_err() {
fs::remove_dir_all(&symlink_path)?;
}
}
}
}
if !app_path.exists() {
return Err(AstudiosError::Installation(format!(
"Cannot create symlink: target does not exist: {}",
app_path.display()
)));
}
match std::os::unix::fs::symlink(app_path, &symlink_path) {
Ok(_) => {
println!(" {} Symlink created successfully", "✅".green());
println!(
" {} {}",
"Link:".dimmed(),
symlink_path.display().to_string().blue()
);
Ok(())
}
Err(e) => Err(AstudiosError::Installation(format!(
"Failed to create symlink from {} to {}: {}",
symlink_path.display(),
app_path.display(),
e
))),
}
}
pub fn uninstall_version(&self, version: &str) -> Result<(), AstudiosError> {
let installations = self.list_installed_studios()?;
let matching_installations: Vec<_> = installations
.iter()
.filter(|install| {
install.version.short_version == version ||
install.version.build_version == version ||
install.identifier() == version ||
install.get_full_version_from_api().unwrap_or(None).as_ref() == Some(&version.to_string()) ||
install.version.short_version.starts_with(version) ||
install.get_full_version_from_api().unwrap_or(None).as_ref().is_some_and(|v| v.starts_with(version))
})
.collect();
if matching_installations.is_empty() {
return Err(AstudiosError::VersionNotFound(format!(
"Android Studio version '{version}' is not installed. Use 'astudios installed' to see available versions."
)));
}
if matching_installations.len() > 1 {
let mut error_msg = format!(
"Multiple Android Studio installations match '{version}'. Please be more specific:\n"
);
for install in &matching_installations {
let detailed_version = install.extract_detailed_version();
error_msg.push_str(&format!(
" - {}\n Version: {} | Build: {}\n Path: {}\n",
install.enhanced_display_name(),
detailed_version,
install.identifier(),
install.path.display()
));
}
error_msg.push_str("\nUse the full build version (e.g., 'AI-251.26094.121.2512.13840223') for exact matching.");
return Err(AstudiosError::General(error_msg));
}
let installation = &matching_installations[0];
let app_path = &installation.path;
let detailed_version = installation.extract_detailed_version();
println!(
"Uninstalling {} from {}...",
installation.enhanced_display_name().green(),
app_path.display().to_string().dimmed()
);
println!(
"Version: {} | Build: {}",
detailed_version.cyan(),
installation.identifier().blue()
);
if let Ok(Some(active)) = self.get_active_studio()
&& active.path == *app_path
{
println!("Removing symlink for currently active version...");
let symlink_path = self.applications_dir.join("Android Studio.app");
if symlink_path.exists() || symlink_path.is_symlink() {
fs::remove_file(&symlink_path)?;
}
}
if app_path.exists() {
fs::remove_dir_all(app_path)?;
println!("Removed application bundle: {}", app_path.display());
}
let possible_version_dirs = vec![
self.install_dir.join(&installation.version.short_version),
self.install_dir.join(&installation.version.build_version),
self.install_dir.join(version), ];
for version_dir in possible_version_dirs {
if version_dir.exists() {
fs::remove_dir_all(&version_dir)?;
println!("Removed installation files: {}", version_dir.display());
break;
}
}
Ok(())
}
pub fn list_installed_studios(&self) -> Result<Vec<InstalledAndroidStudio>, AstudiosError> {
let mut installations = Vec::new();
if let Ok(entries) = fs::read_dir(&self.applications_dir) {
for entry in entries.filter_map(|e| e.ok()) {
let path = entry.path();
let name = path.file_name().unwrap_or_default().to_string_lossy();
if name.contains("Android Studio") && name.ends_with(".app") {
if path.is_symlink() {
continue;
}
if let Ok(Some(installed)) = InstalledAndroidStudio::new(path) {
installations.push(installed);
}
}
}
}
installations.sort_by(|a, b| b.cmp(a));
Ok(installations)
}
pub fn list_installed_versions(&self) -> Result<Vec<String>, AstudiosError> {
let installations = self.list_installed_studios()?;
Ok(installations
.into_iter()
.map(|install| install.version.short_version)
.collect())
}
pub fn get_active_studio(&self) -> Result<Option<InstalledAndroidStudio>, AstudiosError> {
let symlink_path = self.applications_dir.join("Android Studio.app");
if symlink_path.exists()
&& symlink_path.is_symlink()
&& let Ok(target) = fs::read_link(&symlink_path)
&& let Ok(Some(installed)) = InstalledAndroidStudio::new(target)
{
return Ok(Some(installed));
}
Ok(None)
}
pub fn get_active_version(&self) -> Result<Option<String>, AstudiosError> {
if let Some(active) = self.get_active_studio()? {
Ok(Some(active.version.short_version))
} else {
Ok(None)
}
}
pub fn switch_to_studio(&self, identifier: &str) -> Result<(), AstudiosError> {
let installations = self.list_installed_studios()?;
let target_installation = installations
.iter()
.find(|install| {
install.identifier() == identifier ||
install.version.short_version == identifier ||
install.get_full_version_from_api().unwrap_or(None).as_ref() == Some(&identifier.to_string()) ||
install.version.short_version.starts_with(identifier) ||
install.get_full_version_from_api().unwrap_or(None).as_ref().is_some_and(|v| v.starts_with(identifier))
})
.ok_or_else(|| {
AstudiosError::VersionNotFound(format!(
"Android Studio with identifier '{identifier}' is not installed.\nUse 'astudios installed' to see installed versions or 'astudios install {identifier}' to install it."
))
})?;
self.create_symlink(&target_installation.path)?;
Ok(())
}
pub fn switch_to_version(&self, version: &str) -> Result<(), AstudiosError> {
self.switch_to_studio(version)
}
}