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()?;
if let Ok(full) = serde_json::from_slice::<ManifestWithNameAndSource>(&bytes) {
full.manifest.validate().ok()?;
return Some(full);
}
let manifest: Manifest = serde_json::from_slice(&bytes).ok()?;
manifest.validate().ok()?;
let name = path.file_stem()?.to_str()?.to_string();
let source = path.to_string_lossy().into_owned();
Some(ManifestWithNameAndSource { name, manifest, source })
}
impl Client {
pub fn plugins_dir(&self) -> PathBuf {
self.base_dir().join("plugins")
}
pub fn plugin_dir(&self, name: &str) -> PathBuf {
self.plugins_dir().join(name)
}
pub fn plugin_binary_path(&self, name: &str) -> PathBuf {
self.plugin_dir(name)
.join(if cfg!(windows) { "plugin.exe" } else { "plugin" })
}
pub async fn resolve_plugin(&self, name: &str) -> Option<PathBuf> {
let dir = self.plugin_dir(name);
#[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, name: &str) -> Option<ManifestWithNameAndSource> {
let path = self.plugins_dir().join(format!("{name}.json"));
parse_manifest_file(&path).await
}
pub async fn list_plugins(&self, offset: usize, limit: usize) -> Vec<ManifestWithNameAndSource> {
let dir = self.plugins_dir();
let Ok(mut read_dir) = tokio::fs::read_dir(&dir).await else {
return Vec::new();
};
let mut paths: Vec<PathBuf> = Vec::new();
while let Ok(Some(entry)) = read_dir.next_entry().await {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("json") {
paths.push(path);
}
}
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()
}
}
}
#[cfg(feature = "http")]
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> {
check_repository_name(repository)?;
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> {
check_repository_name(repository)?;
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> {
check_repository_name(repository)?;
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 Some(platform) = super::Platform::current() else {
return Ok(false);
};
let Some(binary_name) = manifest.binaries.get(platform) else {
return Ok(false);
};
let plugins_dir = self.plugins_dir();
let plugin_dir = self.plugin_dir(repository);
let binary_path = self.plugin_binary_path(repository);
let viewer_dir = plugin_dir.join("viewer");
let manifest_path = plugins_dir.join(format!("{repository}.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 bundle = ManifestWithNameAndSource {
name: repository.to_string(),
manifest: manifest.clone(),
source: source.to_string(),
};
serde_json::to_vec_pretty(&bundle).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)
}
}
#[cfg(feature = "http")]
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(())
}
#[cfg(feature = "http")]
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(())
}
#[cfg(feature = "http")]
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))
}
#[cfg(feature = "http")]
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(())
}
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"
)
}
#[cfg(feature = "http")]
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)
}