pub mod archive;
pub mod events;
pub mod fetch;
pub mod github_api;
pub mod install;
pub mod probe;
pub mod resolve;
pub mod token;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tokio::sync::mpsc::UnboundedSender;
use crate::downloader::events::{DownloaderError, ProgressEvent, UnsupportedReason};
use crate::downloader::resolve::CandidateUrl;
pub struct InstallSpec {
pub name: String,
pub version: String,
pub repo_url: Option<String>,
pub bins: Vec<String>,
}
pub struct InstallOutcome {
pub name: String,
pub old_version: Option<String>,
pub new_version: String,
}
async fn try_api_winning_url(
client: &reqwest::Client,
spec: &InstallSpec,
repo_url: &str,
targets: &[String],
name_candidates: &[String],
) -> Option<CandidateUrl> {
let (owner, repo) = github_api::parse_owner_repo(repo_url)?;
let token = token::discover_token();
let expected = Arc::new(resolve::expected_filenames(
name_candidates,
&spec.version,
targets,
));
let pkg = name_candidates.first()?;
let tag_strings: Vec<String> = vec![
format!("v{}", spec.version),
spec.version.clone(),
format!("{pkg}-v{}", spec.version),
format!("{pkg}-{}", spec.version),
format!("{pkg}/v{}", spec.version),
format!("{pkg}/{}", spec.version),
];
crate::display::status_debug(
"downloader",
&format!(
"{}: github={}/{} token={} tags={} expected={}",
spec.name,
owner,
repo,
token::discover_token_source(),
tag_strings.len(),
expected.len()
),
);
let tag_count = tag_strings.len();
use futures_util::stream::{FuturesUnordered, StreamExt};
let sem = Arc::new(tokio::sync::Semaphore::new(2));
let mut tasks = FuturesUnordered::new();
for tag in tag_strings {
let sem = sem.clone();
let client = client.clone();
let owner = owner.clone();
let repo = repo.clone();
let expected = expected.clone();
let spec_name = spec.name.clone();
tasks.push(async move {
let _permit = sem.acquire_owned().await.ok()?;
match github_api::fetch_release_assets(
&client,
"https://api.github.com",
&owner,
&repo,
&tag,
token,
)
.await
{
Ok(assets) => {
if let Some(asset) = github_api::match_winning_asset(&assets, &expected) {
let asset = asset.clone();
crate::display::status_debug(
"downloader",
&format!(
"{}: API tag={} matched asset={}",
spec_name, tag, asset.name
),
);
return Some(Ok((asset, tag)));
}
crate::display::status_debug(
"downloader",
&format!(
"{}: API tag={} 200 but none of {} assets matched",
spec_name,
tag,
assets.len()
),
);
None
}
Err(github_api::GithubApiError::NotFound) => {
crate::display::status_debug(
"downloader",
&format!("{spec_name}: API tag={tag} 404"),
);
None
}
Err(e) => {
crate::display::status_debug(
"downloader",
&format!(
"{spec_name}: API tag={tag} error={e}, falling back to URL enumeration"
),
);
Some(Err(e))
}
}
});
}
let mut result: Option<github_api::ReleaseAsset> = None;
while let Some(res) = tasks.next().await {
match res {
Some(Ok((asset, _tag))) => {
result = Some(asset);
break; }
Some(Err(_)) => {
return None; }
None => {} }
}
drop(tasks);
match result {
Some(asset) => {
let archive_fmt = if asset.name.ends_with(".zip") {
resolve::ArchiveFmt::Zip
} else if asset.name.ends_with(".tar.gz") || asset.name.ends_with(".tgz") {
resolve::ArchiveFmt::TarGz
} else {
resolve::ArchiveFmt::Bin
};
Some(CandidateUrl {
url: asset.browser_download_url.clone(),
archive_fmt,
})
}
None => {
crate::display::status_debug(
"downloader",
&format!(
"{}: API exhausted {} tags with no match, falling back to URL enumeration",
spec.name, tag_count
),
);
None
}
}
}
pub async fn download_and_install(
client: &reqwest::Client,
spec: InstallSpec,
old_version: Option<String>,
events: UnboundedSender<ProgressEvent>,
cancel: Arc<AtomicBool>,
) -> Result<InstallOutcome, DownloaderError> {
let _ = events.send(ProgressEvent::Resolving {
name: spec.name.clone(),
});
if cancel.load(Ordering::SeqCst) {
return Err(DownloaderError::Cancelled);
}
let targets = resolve::current_targets();
if targets.is_empty() {
return Err(DownloaderError::Unsupported(
UnsupportedReason::UnsupportedPlatform,
));
}
let repo_url = spec
.repo_url
.as_deref()
.ok_or(DownloaderError::Unsupported(
UnsupportedReason::NoMetadataAndNoConvention,
))?;
let mut name_candidates: Vec<String> = vec![spec.name.clone()];
for b in &spec.bins {
if !name_candidates.contains(b) {
name_candidates.push(b.clone());
}
}
let candidates =
match try_api_winning_url(client, &spec, repo_url, &targets, &name_candidates).await {
Some(winner) => {
crate::display::status_debug(
"downloader",
&format!("{}: 1 candidate (API winner)", spec.name),
);
vec![winner]
}
None => {
let urls =
resolve::candidate_urls(&name_candidates, &spec.version, repo_url, &targets)?;
crate::display::status_debug(
"downloader",
&format!(
"{}: {} candidates (URL enumeration fallback)",
spec.name,
urls.len()
),
);
urls
}
};
let fetched = fetch::fetch(client, &spec.name, &candidates, &events, cancel.clone()).await?;
if cancel.load(Ordering::SeqCst) {
return Err(DownloaderError::Cancelled);
}
let _ = events.send(ProgressEvent::Extracting {
name: spec.name.clone(),
});
let fmt = if fetched.winning_url.ends_with(".zip") {
resolve::ArchiveFmt::Zip
} else {
resolve::ArchiveFmt::TarGz
};
let mut bin_candidates: Vec<String> = spec.bins.clone();
if bin_candidates.is_empty() {
bin_candidates.push(spec.name.clone());
}
let extracted = archive::extract(&fetched.archive_path, fmt, &bin_candidates)?;
if cancel.load(Ordering::SeqCst) {
return Err(DownloaderError::Cancelled);
}
let _ = events.send(ProgressEvent::Installing {
name: spec.name.clone(),
});
let _installed_path = install::install_binary(
&extracted.binary_path,
&extracted.binary_name,
&spec.version,
)?;
let _ = events.send(ProgressEvent::Done {
name: spec.name.clone(),
version: spec.version.clone(),
});
Ok(InstallOutcome {
name: spec.name,
old_version,
new_version: spec.version,
})
}