use std::path::PathBuf;
use super::{InstallError, build_headers, validate_install_inputs};
use crate::filesystem::Client;
use crate::filesystem::tools::CliZip;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum InstallKind {
Tool,
Plugin,
}
pub(crate) struct ExtraAsset {
pub filename: String,
pub subdir: &'static str,
}
pub(crate) trait InstallManifest:
serde::Serialize + serde::de::DeserializeOwned + Send
{
const KIND: InstallKind;
fn validate_manifest(&self) -> Result<(), &'static str>;
fn version(&self) -> &str;
fn cli_zip_for_platform(&self) -> Option<&str>;
fn extra_assets(&self) -> Vec<ExtraAsset>;
}
pub(crate) fn platform_cli_zip(cli_zip: &CliZip) -> Option<&str> {
let per_os = if cfg!(target_os = "windows") {
&cli_zip.windows
} else if cfg!(target_os = "macos") {
&cli_zip.macos
} else {
&cli_zip.linux
};
if cfg!(target_arch = "x86_64") {
per_os.x86_64.as_deref()
} else if cfg!(target_arch = "aarch64") {
per_os.aarch64.as_deref()
} else {
None
}
}
#[derive(Clone, Copy)]
enum AssetSlot {
Cli,
Viewer,
}
impl AssetSlot {
fn request_err(self, e: reqwest::Error) -> InstallError {
match self {
AssetSlot::Cli => InstallError::CliZipRequest(e),
AssetSlot::Viewer => InstallError::ViewerZipRequest(e),
}
}
fn bad_status_err(self, code: reqwest::StatusCode, url: String) -> InstallError {
match self {
AssetSlot::Cli => InstallError::CliZipBadStatus { code, url },
AssetSlot::Viewer => InstallError::ViewerZipBadStatus { code, url },
}
}
fn response_err(self, e: reqwest::Error) -> InstallError {
match self {
AssetSlot::Cli => InstallError::CliZipResponse(e),
AssetSlot::Viewer => InstallError::ViewerZipResponse(e),
}
}
}
fn asset_url(
releases_base: &str,
owner: &str,
repository: &str,
version: &str,
filename: &str,
) -> String {
format!(
"{releases_base}/{owner}/{repository}/releases/download/v{version}/{filename}"
)
}
async fn download_zip(
http: &reqwest::Client,
url: String,
header_map: reqwest::header::HeaderMap,
slot: AssetSlot,
) -> Result<Vec<u8>, InstallError> {
let resp = http
.get(&url)
.headers(header_map)
.send()
.await
.map_err(|e| slot.request_err(e))?;
let status = resp.status();
if !status.is_success() {
return Err(slot.bad_status_err(status, url));
}
Ok(resp.bytes().await.map_err(|e| slot.response_err(e))?.to_vec())
}
impl Client {
fn install_dirs(
&self,
kind: InstallKind,
owner: &str,
repository: &str,
version: &str,
) -> (PathBuf, PathBuf) {
match kind {
InstallKind::Tool => (
self.tool_dir(owner, repository, version),
self.tool_cli_dir(owner, repository, version),
),
InstallKind::Plugin => (
self.plugin_dir(owner, repository, version),
self.plugin_cli_dir(owner, repository, version),
),
}
}
pub(crate) async fn fetch_manifest_at<M: InstallManifest>(
&self,
raw_base: &str,
owner: &str,
repository: &str,
commit_sha: Option<&str>,
headers: Option<&indexmap::IndexMap<String, String>>,
) -> Result<M, crate::filesystem::Error> {
let http = reqwest::Client::new();
let header_map = build_headers(headers)?;
let reference = commit_sha.unwrap_or("HEAD");
let manifest_url = format!(
"{raw_base}/{owner}/{repository}/{reference}/objectiveai.json"
);
let resp = http
.get(&manifest_url)
.headers(header_map)
.send()
.await
.map_err(InstallError::ManifestRequest)?;
let status = resp.status();
let bytes = resp
.bytes()
.await
.map_err(InstallError::ManifestResponse)?;
if !status.is_success() {
return Err(InstallError::ManifestBadStatus {
code: status,
url: manifest_url,
body: String::from_utf8_lossy(&bytes).into_owned(),
}
.into());
}
let mut de = serde_json::Deserializer::from_slice(&bytes);
let manifest: M = serde_path_to_error::deserialize(&mut de)
.map_err(InstallError::ManifestParse)?;
manifest
.validate_manifest()
.map_err(InstallError::ManifestInvalid)?;
Ok(manifest)
}
pub(crate) async fn install_parsed_at<M: InstallManifest>(
&self,
releases_base: &str,
owner: &str,
repository: &str,
manifest: &M,
headers: Option<&indexmap::IndexMap<String, String>>,
upgrade: bool,
) -> Result<bool, crate::filesystem::Error> {
let version = manifest.version();
let (version_dir, cli_dir) =
self.install_dirs(M::KIND, owner, repository, version);
let manifest_path = version_dir.join("objectiveai.json");
let manifest_exists = tokio::fs::metadata(&manifest_path).await.is_ok();
if manifest_exists && !upgrade {
return Err(InstallError::AlreadyInstalled {
repository: repository.to_string(),
}
.into());
}
let http = reqwest::Client::new();
let header_map = build_headers(headers)?;
let cli_bytes: Option<Vec<u8>> =
if let Some(name) = manifest.cli_zip_for_platform() {
let url =
asset_url(releases_base, owner, repository, version, name);
Some(
download_zip(&http, url, header_map.clone(), AssetSlot::Cli)
.await?,
)
} else {
None
};
let mut extra_branches: Vec<(PathBuf, Option<Vec<u8>>)> = Vec::new();
for extra in manifest.extra_assets() {
let url = asset_url(
releases_base,
owner,
repository,
version,
&extra.filename,
);
let bytes =
download_zip(&http, url, header_map.clone(), AssetSlot::Viewer)
.await?;
extra_branches.push((version_dir.join(extra.subdir), Some(bytes)));
}
let manifest_bytes: Vec<u8> = serde_json::to_vec_pretty(manifest)
.map_err(InstallError::ManifestSerialize)?;
let _ = tokio::fs::remove_file(&manifest_path).await;
let _ = tokio::fs::remove_dir_all(&cli_dir).await;
for (dir, _) in &extra_branches {
let _ = tokio::fs::remove_dir_all(dir).await;
}
tokio::fs::create_dir_all(&version_dir).await.map_err(|e| {
InstallError::PluginDirCreate(version_dir.clone(), e)
})?;
let mut branches = Vec::with_capacity(1 + extra_branches.len());
branches.push(write_zip_branch(cli_dir, cli_bytes));
for (dir, bytes) in extra_branches {
branches.push(write_zip_branch(dir, bytes));
}
futures::future::try_join_all(branches).await?;
write_manifest_branch(manifest_path, manifest_bytes).await?;
Ok(true)
}
pub(crate) async fn install_from_github_at<M: InstallManifest>(
&self,
raw_base: &str,
releases_base: &str,
owner: &str,
repository: &str,
commit_sha: Option<&str>,
headers: Option<&indexmap::IndexMap<String, String>>,
upgrade: bool,
) -> Result<bool, crate::filesystem::Error> {
validate_install_inputs(M::KIND, owner, repository, commit_sha)?;
let manifest = self
.fetch_manifest_at::<M>(raw_base, owner, repository, commit_sha, headers)
.await?;
self.install_parsed_at::<M>(
releases_base,
owner,
repository,
&manifest,
headers,
upgrade,
)
.await
}
}
pub(crate) async fn write_zip_branch(
dir: PathBuf,
zip_bytes: Option<Vec<u8>>,
) -> Result<(), InstallError> {
let Some(bytes) = zip_bytes else {
return Ok(());
};
tokio::fs::create_dir_all(&dir).await.map_err(|e| {
InstallError::ZipExtract(dir.clone(), e.to_string())
})?;
let dir_for_blocking = dir.clone();
tokio::task::spawn_blocking(move || {
let cursor = std::io::Cursor::new(bytes);
let mut archive = zip::ZipArchive::new(cursor)
.map_err(|e| format!("zip archive open: {e}"))?;
archive
.extract(&dir_for_blocking)
.map_err(|e| format!("extract: {e}"))
})
.await
.map_err(|e| InstallError::ZipExtract(dir.clone(), format!("join: {e}")))?
.map_err(|e| InstallError::ZipExtract(dir.clone(), e))?;
Ok(())
}
pub(crate) async fn write_manifest_branch(
manifest_path: PathBuf,
bytes: Vec<u8>,
) -> Result<(), InstallError> {
crate::filesystem::util::write_atomic(&manifest_path, &bytes)
.await
.map_err(|e| {
InstallError::ManifestPersist(manifest_path.clone(), e)
})
}