use std::path::{Path, PathBuf};
use super::super::Client;
use super::{Manifest, ManifestWithNameAndSource};
async fn parse_manifest_file(path: &Path) -> Option<ManifestWithNameAndSource> {
let bytes = tokio::fs::read(path).await.ok()?;
let manifest: Manifest = serde_json::from_slice(&bytes).ok()?;
manifest.validate().ok()?;
let name = path.parent()?.parent()?.file_name()?.to_str()?.to_string();
let source = path.to_string_lossy().into_owned();
Some(ManifestWithNameAndSource {
name,
manifest,
source,
})
}
async fn collect_manifest_paths(root: PathBuf) -> Vec<PathBuf> {
let mut out: Vec<PathBuf> = Vec::new();
let Ok(mut owners) = tokio::fs::read_dir(&root).await else {
return out;
};
while let Ok(Some(owner_e)) = owners.next_entry().await {
let Ok(mut names) = tokio::fs::read_dir(owner_e.path()).await else {
continue;
};
while let Ok(Some(name_e)) = names.next_entry().await {
let Ok(mut versions) = tokio::fs::read_dir(name_e.path()).await else {
continue;
};
while let Ok(Some(ver_e)) = versions.next_entry().await {
let manifest = ver_e.path().join("objectiveai.json");
if tokio::fs::metadata(&manifest)
.await
.map(|m| m.is_file())
.unwrap_or(false)
{
out.push(manifest);
}
}
}
}
out
}
impl Client {
pub fn plugins_dir(&self) -> PathBuf {
self.base_dir().join("plugins")
}
pub fn plugin_dir(&self, owner: &str, name: &str, version: &str) -> PathBuf {
self.plugins_dir().join(owner).join(name).join(version)
}
pub fn plugin_binary_path(&self, owner: &str, name: &str, version: &str) -> PathBuf {
self.plugin_dir(owner, name, version).join(if cfg!(windows) {
"plugin.exe"
} else {
"plugin"
})
}
pub async fn resolve_plugin(
&self,
owner: &str,
name: &str,
version: &str,
) -> Option<PathBuf> {
let dir = self.plugin_dir(owner, name, version);
#[cfg(windows)]
let priority: [&str; 2] = ["plugin.exe", "plugin"];
#[cfg(not(windows))]
let priority: [&str; 2] = ["plugin", "plugin.exe"];
for filename in priority {
let path = dir.join(filename);
if tokio::fs::metadata(&path)
.await
.map(|m| m.is_file())
.unwrap_or(false)
{
return Some(path);
}
}
let mut read_dir = tokio::fs::read_dir(&dir).await.ok()?;
while let Ok(Some(entry)) = read_dir.next_entry().await {
let path = entry.path();
let Some(file_name) = path.file_name().and_then(|s| s.to_str())
else {
continue;
};
if file_name == "plugin" || file_name == "plugin.exe" {
continue;
}
if path.file_stem().and_then(|s| s.to_str()) != Some("plugin") {
continue;
}
if path.extension().is_none() {
continue;
}
if entry.metadata().await.map(|m| m.is_file()).unwrap_or(false) {
return Some(path);
}
}
None
}
pub async fn get_plugin(
&self,
owner: &str,
name: &str,
version: &str,
) -> Option<ManifestWithNameAndSource> {
let path = self
.plugin_dir(owner, name, version)
.join("objectiveai.json");
parse_manifest_file(&path).await
}
pub async fn list_plugins(
&self,
offset: usize,
limit: usize,
) -> Vec<ManifestWithNameAndSource> {
let paths = collect_manifest_paths(self.plugins_dir()).await;
let futures = paths.into_iter().map(|p| async move {
let bundle = parse_manifest_file(&p).await?;
let modified = tokio::fs::metadata(&p)
.await
.ok()?
.modified()
.ok()?
.duration_since(std::time::SystemTime::UNIX_EPOCH)
.ok()?
.as_secs();
Some((modified, bundle))
});
let mut entries: Vec<(u64, ManifestWithNameAndSource)> =
futures::future::join_all(futures)
.await
.into_iter()
.flatten()
.collect();
entries.sort_by(|a, b| b.0.cmp(&a.0));
let iter = entries.into_iter().map(|(_, m)| m);
if offset > 0 || limit < usize::MAX {
iter.skip(offset).take(limit).collect()
} else {
iter.collect()
}
}
}
impl Client {
pub async fn install_plugin(
&self,
owner: &str,
repository: &str,
commit_sha: Option<&str>,
headers: Option<&indexmap::IndexMap<String, String>>,
upgrade: bool,
) -> Result<bool, super::super::Error> {
validate_install_inputs(owner, repository, commit_sha)?;
let manifest = self
.fetch_plugin_manifest(owner, repository, commit_sha, headers)
.await?;
let source = raw_manifest_url(owner, repository, commit_sha);
self.install_plugin_from_manifest(
owner, repository, &manifest, &source, headers, upgrade,
)
.await
}
pub async fn fetch_plugin_manifest(
&self,
owner: &str,
repository: &str,
commit_sha: Option<&str>,
headers: Option<&indexmap::IndexMap<String, String>>,
) -> Result<Manifest, super::super::Error> {
self.fetch_plugin_manifest_impl(
"https://raw.githubusercontent.com",
owner,
repository,
commit_sha,
headers,
)
.await
}
pub async fn install_plugin_from_manifest(
&self,
owner: &str,
repository: &str,
manifest: &Manifest,
source: &str,
headers: Option<&indexmap::IndexMap<String, String>>,
upgrade: bool,
) -> Result<bool, super::super::Error> {
validate_install_inputs(owner, repository, None)?;
self.install_from_manifest_impl(
"https://github.com",
owner,
repository,
manifest,
source,
headers,
upgrade,
)
.await
}
#[cfg(test)]
pub(super) async fn install_plugin_at(
&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, super::super::Error> {
validate_install_inputs(owner, repository, commit_sha)?;
let manifest = self
.fetch_plugin_manifest_impl(
raw_base, owner, repository, commit_sha, headers,
)
.await?;
let reference = commit_sha.unwrap_or("HEAD");
let source = format!(
"{raw_base}/{owner}/{repository}/{reference}/objectiveai.json"
);
self.install_from_manifest_impl(
releases_base,
owner,
repository,
&manifest,
&source,
headers,
upgrade,
)
.await
}
#[cfg(test)]
pub(super) async fn fetch_plugin_manifest_at(
&self,
raw_base: &str,
owner: &str,
repository: &str,
commit_sha: Option<&str>,
headers: Option<&indexmap::IndexMap<String, String>>,
) -> Result<Manifest, super::super::Error> {
self.fetch_plugin_manifest_impl(
raw_base, owner, repository, commit_sha, headers,
)
.await
}
async fn fetch_plugin_manifest_impl(
&self,
raw_base: &str,
owner: &str,
repository: &str,
commit_sha: Option<&str>,
headers: Option<&indexmap::IndexMap<String, String>>,
) -> Result<Manifest, super::super::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(super::InstallError::ManifestRequest)?;
let status = resp.status();
let bytes = resp
.bytes()
.await
.map_err(super::InstallError::ManifestResponse)?;
if !status.is_success() {
return Err(super::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: Manifest = serde_path_to_error::deserialize(&mut de)
.map_err(super::InstallError::ManifestParse)?;
manifest
.validate()
.map_err(super::InstallError::ManifestInvalid)?;
Ok(manifest)
}
async fn install_from_manifest_impl(
&self,
releases_base: &str,
owner: &str,
repository: &str,
manifest: &Manifest,
_source: &str,
headers: Option<&indexmap::IndexMap<String, String>>,
upgrade: bool,
) -> Result<bool, super::super::Error> {
let tool_name = manifest.tool_name(repository);
if tool_name.len() > 100 {
return Err(super::InstallError::ToolNameTooLong {
len: tool_name.len(),
tool_name,
}
.into());
}
let Some(platform) = super::Platform::current() else {
return Ok(false);
};
let Some(binary_name) = manifest.binaries.get(platform) else {
return Ok(false);
};
let version = manifest.version.clone();
let plugin_dir = self.plugin_dir(owner, repository, &version);
let binary_path = self.plugin_binary_path(owner, repository, &version);
let viewer_dir = plugin_dir.join("viewer");
let manifest_path = plugin_dir.join("objectiveai.json");
let manifest_exists = tokio::fs::metadata(&manifest_path).await.is_ok();
if manifest_exists && !upgrade {
return Err(super::InstallError::AlreadyInstalled {
repository: repository.to_string(),
}
.into());
}
if upgrade {
let _ = tokio::fs::remove_file(&manifest_path).await;
let _ = tokio::fs::remove_file(&binary_path).await;
let _ = tokio::fs::remove_dir_all(&viewer_dir).await;
}
let http = reqwest::Client::new();
let bin_bytes: Vec<u8> = {
let binary_url = format!(
"{releases_base}/{owner}/{repository}/releases/download/v{version}/{binary_name}",
version = manifest.version,
);
let resp = http
.get(&binary_url)
.headers(build_headers(headers)?)
.send()
.await
.map_err(super::InstallError::BinaryRequest)?;
let status = resp.status();
if !status.is_success() {
return Err(super::InstallError::BinaryBadStatus {
code: status,
url: binary_url,
}
.into());
}
resp.bytes()
.await
.map_err(super::InstallError::BinaryResponse)?
.to_vec()
};
let zip_bytes: Option<Vec<u8>> = if let Some(viewer_zip_name) =
&manifest.viewer_zip
{
let viewer_url = format!(
"{releases_base}/{owner}/{repository}/releases/download/v{version}/{viewer_zip_name}",
version = manifest.version,
);
let resp = http
.get(&viewer_url)
.headers(build_headers(headers)?)
.send()
.await
.map_err(super::InstallError::ViewerZipRequest)?;
let status = resp.status();
if !status.is_success() {
return Err(super::InstallError::ViewerZipBadStatus {
code: status,
url: viewer_url,
}
.into());
}
Some(
resp.bytes()
.await
.map_err(super::InstallError::ViewerZipResponse)?
.to_vec(),
)
} else {
None
};
let manifest_bytes: Vec<u8> = {
let mut manifest = manifest.clone();
manifest.owner = owner.to_string();
serde_json::to_vec_pretty(&manifest)
.map_err(super::InstallError::ManifestSerialize)?
};
tokio::fs::create_dir_all(&plugin_dir).await.map_err(|e| {
super::InstallError::PluginDirCreate(plugin_dir.clone(), e)
})?;
tokio::try_join!(
write_binary_branch(binary_path, bin_bytes),
write_viewer_branch(viewer_dir, zip_bytes),
write_manifest_branch(manifest_path, manifest_bytes),
)?;
Ok(true)
}
}
async fn write_binary_branch(
binary_path: PathBuf,
bytes: Vec<u8>,
) -> Result<(), super::InstallError> {
tokio::fs::write(&binary_path, &bytes).await.map_err(|e| {
super::InstallError::BinaryWrite(binary_path.clone(), e)
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o755);
tokio::fs::set_permissions(&binary_path, perms)
.await
.map_err(|e| super::InstallError::Chmod(binary_path.clone(), e))?;
}
Ok(())
}
async fn write_viewer_branch(
viewer_dir: PathBuf,
zip_bytes: Option<Vec<u8>>,
) -> Result<(), super::InstallError> {
let Some(bytes) = zip_bytes else {
return Ok(());
};
tokio::fs::create_dir_all(&viewer_dir).await.map_err(|e| {
super::InstallError::ViewerZipExtract(viewer_dir.clone(), e.to_string())
})?;
let viewer_dir_for_blocking = viewer_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(&viewer_dir_for_blocking)
.map_err(|e| format!("extract: {e}"))
})
.await
.map_err(|e| {
super::InstallError::ViewerZipExtract(
viewer_dir.clone(),
format!("join: {e}"),
)
})?
.map_err(|e| {
super::InstallError::ViewerZipExtract(viewer_dir.clone(), e)
})?;
Ok(())
}
async fn write_manifest_branch(
manifest_path: PathBuf,
bytes: Vec<u8>,
) -> Result<(), super::InstallError> {
tokio::fs::write(&manifest_path, &bytes).await.map_err(|e| {
super::InstallError::ManifestPersist(manifest_path.clone(), e)
})
}
fn check_repository_name(repository: &str) -> Result<(), super::InstallError> {
if repository.eq_ignore_ascii_case("objectiveai") {
return Err(super::InstallError::ReservedRepositoryName {
repository: repository.to_string(),
});
}
Ok(())
}
fn validate_identifier(
kind: &'static str,
value: &str,
) -> Result<(), super::InstallError> {
let valid_len = !value.is_empty() && value.len() <= 128;
let valid_chars = value
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'));
if !valid_len || !valid_chars {
return Err(super::InstallError::InvalidIdentifier {
kind,
value: value.to_string(),
});
}
Ok(())
}
fn validate_install_inputs(
owner: &str,
repository: &str,
commit_sha: Option<&str>,
) -> Result<(), super::InstallError> {
check_repository_name(repository)?;
validate_identifier("owner", owner)?;
validate_identifier("repository", repository)?;
if let Some(sha) = commit_sha {
validate_identifier("commit", sha)?;
}
Ok(())
}
pub fn raw_manifest_url(
owner: &str,
repository: &str,
commit_sha: Option<&str>,
) -> String {
let reference = commit_sha.unwrap_or("HEAD");
format!(
"https://raw.githubusercontent.com/{owner}/{repository}/{reference}/objectiveai.json"
)
}
pub(super) fn build_headers(
headers: Option<&indexmap::IndexMap<String, String>>,
) -> Result<reqwest::header::HeaderMap, super::InstallError> {
let mut out = reqwest::header::HeaderMap::new();
let Some(h) = headers else {
return Ok(out);
};
for (k, v) in h {
let name = reqwest::header::HeaderName::from_bytes(k.as_bytes())
.map_err(|e| super::InstallError::InvalidHeaderName {
name: k.clone(),
reason: e.to_string(),
})?;
let value = reqwest::header::HeaderValue::from_str(v).map_err(|e| {
super::InstallError::InvalidHeaderValue {
name: k.clone(),
reason: e.to_string(),
}
})?;
out.insert(name, value);
}
Ok(out)
}