1use std::path::{Path, PathBuf};
12
13use super::super::Client;
14use super::{Manifest, ManifestWithNameAndSource};
15
16async fn parse_manifest_file(path: &Path) -> Option<ManifestWithNameAndSource> {
22 let bytes = tokio::fs::read(path).await.ok()?;
23 if let Ok(full) = serde_json::from_slice::<ManifestWithNameAndSource>(&bytes) {
24 return Some(full);
25 }
26 let manifest: Manifest = serde_json::from_slice(&bytes).ok()?;
27 let name = path.file_stem()?.to_str()?.to_string();
28 let source = path.to_string_lossy().into_owned();
29 Some(ManifestWithNameAndSource { name, manifest, source })
30}
31
32impl Client {
33 pub fn plugins_dir(&self) -> PathBuf {
35 self.base_dir().join("plugins")
36 }
37
38 pub fn plugin_dir(&self, name: &str) -> PathBuf {
42 self.plugins_dir().join(name)
43 }
44
45 pub fn plugin_binary_path(&self, name: &str) -> PathBuf {
50 self.plugin_dir(name)
51 .join(if cfg!(windows) { "plugin.exe" } else { "plugin" })
52 }
53
54 pub async fn resolve_plugin(&self, name: &str) -> Option<PathBuf> {
60 let path = self.plugin_binary_path(name);
61 tokio::fs::metadata(&path)
62 .await
63 .map(|m| m.is_file())
64 .unwrap_or(false)
65 .then_some(path)
66 }
67
68 pub async fn get_plugin(&self, name: &str) -> Option<ManifestWithNameAndSource> {
75 let path = self.plugins_dir().join(format!("{name}.json"));
76 parse_manifest_file(&path).await
77 }
78
79 pub async fn list_plugins(&self, offset: usize, limit: usize) -> Vec<ManifestWithNameAndSource> {
96 let dir = self.plugins_dir();
97 let Ok(mut read_dir) = tokio::fs::read_dir(&dir).await else {
98 return Vec::new();
99 };
100 let mut paths: Vec<PathBuf> = Vec::new();
101 while let Ok(Some(entry)) = read_dir.next_entry().await {
102 let path = entry.path();
103 if path.extension().and_then(|e| e.to_str()) == Some("json") {
104 paths.push(path);
105 }
106 }
107 let futures = paths.into_iter().map(|p| async move {
108 let bundle = parse_manifest_file(&p).await?;
109 let modified = tokio::fs::metadata(&p)
110 .await
111 .ok()?
112 .modified()
113 .ok()?
114 .duration_since(std::time::SystemTime::UNIX_EPOCH)
115 .ok()?
116 .as_secs();
117 Some((modified, bundle))
118 });
119 let mut entries: Vec<(u64, ManifestWithNameAndSource)> = futures::future::join_all(futures)
120 .await
121 .into_iter()
122 .flatten()
123 .collect();
124 entries.sort_by(|a, b| b.0.cmp(&a.0));
125 let iter = entries.into_iter().map(|(_, m)| m);
126 if offset > 0 || limit < usize::MAX {
127 iter.skip(offset).take(limit).collect()
128 } else {
129 iter.collect()
130 }
131 }
132}
133
134#[cfg(feature = "http")]
135impl Client {
136 pub async fn install_plugin(
161 &self,
162 owner: &str,
163 repository: &str,
164 commit_sha: Option<&str>,
165 headers: Option<&indexmap::IndexMap<String, String>>,
166 upgrade: bool,
167 ) -> Result<bool, super::super::Error> {
168 check_repository_name(repository)?;
169 let manifest = self
170 .fetch_plugin_manifest(owner, repository, commit_sha, headers)
171 .await?;
172 let source = raw_manifest_url(owner, repository, commit_sha);
173 self.install_plugin_from_manifest(owner, repository, &manifest, &source, headers, upgrade)
174 .await
175 }
176
177 pub async fn fetch_plugin_manifest(
182 &self,
183 owner: &str,
184 repository: &str,
185 commit_sha: Option<&str>,
186 headers: Option<&indexmap::IndexMap<String, String>>,
187 ) -> Result<Manifest, super::super::Error> {
188 self.fetch_plugin_manifest_impl(
189 "https://raw.githubusercontent.com",
190 owner,
191 repository,
192 commit_sha,
193 headers,
194 )
195 .await
196 }
197
198 pub async fn install_plugin_from_manifest(
203 &self,
204 owner: &str,
205 repository: &str,
206 manifest: &Manifest,
207 source: &str,
208 headers: Option<&indexmap::IndexMap<String, String>>,
209 upgrade: bool,
210 ) -> Result<bool, super::super::Error> {
211 check_repository_name(repository)?;
212 self.install_from_manifest_impl(
213 "https://github.com",
214 owner,
215 repository,
216 manifest,
217 source,
218 headers,
219 upgrade,
220 )
221 .await
222 }
223
224 #[cfg(test)]
229 pub(super) async fn install_plugin_at(
230 &self,
231 raw_base: &str,
232 releases_base: &str,
233 owner: &str,
234 repository: &str,
235 commit_sha: Option<&str>,
236 headers: Option<&indexmap::IndexMap<String, String>>,
237 upgrade: bool,
238 ) -> Result<bool, super::super::Error> {
239 check_repository_name(repository)?;
240 let manifest = self
241 .fetch_plugin_manifest_impl(raw_base, owner, repository, commit_sha, headers)
242 .await?;
243 let reference = commit_sha.unwrap_or("HEAD");
244 let source = format!("{raw_base}/{owner}/{repository}/{reference}/objectiveai.json");
245 self.install_from_manifest_impl(
246 releases_base,
247 owner,
248 repository,
249 &manifest,
250 &source,
251 headers,
252 upgrade,
253 )
254 .await
255 }
256
257 #[cfg(test)]
259 pub(super) async fn fetch_plugin_manifest_at(
260 &self,
261 raw_base: &str,
262 owner: &str,
263 repository: &str,
264 commit_sha: Option<&str>,
265 headers: Option<&indexmap::IndexMap<String, String>>,
266 ) -> Result<Manifest, super::super::Error> {
267 self.fetch_plugin_manifest_impl(raw_base, owner, repository, commit_sha, headers)
268 .await
269 }
270
271 async fn fetch_plugin_manifest_impl(
272 &self,
273 raw_base: &str,
274 owner: &str,
275 repository: &str,
276 commit_sha: Option<&str>,
277 headers: Option<&indexmap::IndexMap<String, String>>,
278 ) -> Result<Manifest, super::super::Error> {
279 let http = reqwest::Client::new();
280 let header_map = build_headers(headers)?;
281 let reference = commit_sha.unwrap_or("HEAD");
282 let manifest_url =
283 format!("{raw_base}/{owner}/{repository}/{reference}/objectiveai.json");
284 let resp = http
285 .get(&manifest_url)
286 .headers(header_map)
287 .send()
288 .await
289 .map_err(super::InstallError::ManifestRequest)?;
290 let status = resp.status();
291 let bytes = resp
292 .bytes()
293 .await
294 .map_err(super::InstallError::ManifestResponse)?;
295 if !status.is_success() {
296 return Err(super::InstallError::ManifestBadStatus {
297 code: status,
298 url: manifest_url,
299 body: String::from_utf8_lossy(&bytes).into_owned(),
300 }
301 .into());
302 }
303 let mut de = serde_json::Deserializer::from_slice(&bytes);
304 let manifest: Manifest = serde_path_to_error::deserialize(&mut de)
305 .map_err(super::InstallError::ManifestParse)?;
306 Ok(manifest)
307 }
308
309 async fn install_from_manifest_impl(
310 &self,
311 releases_base: &str,
312 owner: &str,
313 repository: &str,
314 manifest: &Manifest,
315 source: &str,
316 headers: Option<&indexmap::IndexMap<String, String>>,
317 upgrade: bool,
318 ) -> Result<bool, super::super::Error> {
319 let Some(platform) = super::Platform::current() else {
321 return Ok(false);
322 };
323 let Some(binary_name) = manifest.binaries.get(platform) else {
324 return Ok(false);
325 };
326
327 let plugins_dir = self.plugins_dir();
328 let plugin_dir = self.plugin_dir(repository);
329 let binary_path = self.plugin_binary_path(repository);
330 let viewer_dir = plugin_dir.join("viewer");
331 let manifest_path = plugins_dir.join(format!("{repository}.json"));
332
333 let manifest_exists = tokio::fs::metadata(&manifest_path).await.is_ok();
336 if manifest_exists && !upgrade {
337 return Err(super::InstallError::AlreadyInstalled {
338 repository: repository.to_string(),
339 }
340 .into());
341 }
342
343 if upgrade {
348 let _ = tokio::fs::remove_file(&manifest_path).await;
349 let _ = tokio::fs::remove_file(&binary_path).await;
350 let _ = tokio::fs::remove_dir_all(&viewer_dir).await;
351 }
352
353 let http = reqwest::Client::new();
359 let bin_bytes: Vec<u8> = {
360 let binary_url = format!(
361 "{releases_base}/{owner}/{repository}/releases/download/v{version}/{binary_name}",
362 version = manifest.version,
363 );
364 let resp = http
365 .get(&binary_url)
366 .headers(build_headers(headers)?)
367 .send()
368 .await
369 .map_err(super::InstallError::BinaryRequest)?;
370 let status = resp.status();
371 if !status.is_success() {
372 return Err(super::InstallError::BinaryBadStatus {
373 code: status,
374 url: binary_url,
375 }
376 .into());
377 }
378 resp.bytes()
379 .await
380 .map_err(super::InstallError::BinaryResponse)?
381 .to_vec()
382 };
383
384 let zip_bytes: Option<Vec<u8>> = if let Some(viewer_zip_name) = &manifest.viewer_zip {
385 let viewer_url = format!(
386 "{releases_base}/{owner}/{repository}/releases/download/v{version}/{viewer_zip_name}",
387 version = manifest.version,
388 );
389 let resp = http
390 .get(&viewer_url)
391 .headers(build_headers(headers)?)
392 .send()
393 .await
394 .map_err(super::InstallError::ViewerZipRequest)?;
395 let status = resp.status();
396 if !status.is_success() {
397 return Err(super::InstallError::ViewerZipBadStatus {
398 code: status,
399 url: viewer_url,
400 }
401 .into());
402 }
403 Some(
404 resp.bytes()
405 .await
406 .map_err(super::InstallError::ViewerZipResponse)?
407 .to_vec(),
408 )
409 } else {
410 None
411 };
412
413 let manifest_bytes: Vec<u8> = {
414 let bundle = ManifestWithNameAndSource {
415 name: repository.to_string(),
416 manifest: manifest.clone(),
417 source: source.to_string(),
418 };
419 serde_json::to_vec_pretty(&bundle).map_err(super::InstallError::ManifestSerialize)?
420 };
421
422 tokio::fs::create_dir_all(&plugin_dir)
425 .await
426 .map_err(|e| super::InstallError::PluginDirCreate(plugin_dir.clone(), e))?;
427
428 tokio::try_join!(
431 write_binary_branch(binary_path, bin_bytes),
432 write_viewer_branch(viewer_dir, zip_bytes),
433 write_manifest_branch(manifest_path, manifest_bytes),
434 )?;
435
436 Ok(true)
437 }
438}
439
440#[cfg(feature = "http")]
441async fn write_binary_branch(
442 binary_path: PathBuf,
443 bytes: Vec<u8>,
444) -> Result<(), super::InstallError> {
445 tokio::fs::write(&binary_path, &bytes)
446 .await
447 .map_err(|e| super::InstallError::BinaryWrite(binary_path.clone(), e))?;
448 #[cfg(unix)]
449 {
450 use std::os::unix::fs::PermissionsExt;
451 let perms = std::fs::Permissions::from_mode(0o755);
452 tokio::fs::set_permissions(&binary_path, perms)
453 .await
454 .map_err(|e| super::InstallError::Chmod(binary_path.clone(), e))?;
455 }
456 Ok(())
457}
458
459#[cfg(feature = "http")]
460async fn write_viewer_branch(
461 viewer_dir: PathBuf,
462 zip_bytes: Option<Vec<u8>>,
463) -> Result<(), super::InstallError> {
464 let Some(bytes) = zip_bytes else {
465 return Ok(());
466 };
467 tokio::fs::create_dir_all(&viewer_dir)
468 .await
469 .map_err(|e| super::InstallError::ViewerZipExtract(viewer_dir.clone(), e.to_string()))?;
470 let viewer_dir_for_blocking = viewer_dir.clone();
471 tokio::task::spawn_blocking(move || {
472 let cursor = std::io::Cursor::new(bytes);
473 let mut archive = zip::ZipArchive::new(cursor)
474 .map_err(|e| format!("zip archive open: {e}"))?;
475 archive
476 .extract(&viewer_dir_for_blocking)
477 .map_err(|e| format!("extract: {e}"))
478 })
479 .await
480 .map_err(|e| super::InstallError::ViewerZipExtract(viewer_dir.clone(), format!("join: {e}")))?
481 .map_err(|e| super::InstallError::ViewerZipExtract(viewer_dir.clone(), e))?;
482 Ok(())
483}
484
485#[cfg(feature = "http")]
486async fn write_manifest_branch(
487 manifest_path: PathBuf,
488 bytes: Vec<u8>,
489) -> Result<(), super::InstallError> {
490 tokio::fs::write(&manifest_path, &bytes)
491 .await
492 .map_err(|e| super::InstallError::ManifestPersist(manifest_path.clone(), e))
493}
494
495#[cfg(feature = "http")]
500fn check_repository_name(repository: &str) -> Result<(), super::InstallError> {
501 if repository.eq_ignore_ascii_case("objectiveai") {
502 return Err(super::InstallError::ReservedRepositoryName {
503 repository: repository.to_string(),
504 });
505 }
506 Ok(())
507}
508
509pub fn raw_manifest_url(owner: &str, repository: &str, commit_sha: Option<&str>) -> String {
514 let reference = commit_sha.unwrap_or("HEAD");
515 format!(
516 "https://raw.githubusercontent.com/{owner}/{repository}/{reference}/objectiveai.json"
517 )
518}
519
520#[cfg(feature = "http")]
521pub(super) fn build_headers(
522 headers: Option<&indexmap::IndexMap<String, String>>,
523) -> Result<reqwest::header::HeaderMap, super::InstallError> {
524 let mut out = reqwest::header::HeaderMap::new();
525 let Some(h) = headers else {
526 return Ok(out);
527 };
528 for (k, v) in h {
529 let name = reqwest::header::HeaderName::from_bytes(k.as_bytes()).map_err(|e| {
530 super::InstallError::InvalidHeaderName {
531 name: k.clone(),
532 reason: e.to_string(),
533 }
534 })?;
535 let value = reqwest::header::HeaderValue::from_str(v).map_err(|e| {
536 super::InstallError::InvalidHeaderValue {
537 name: k.clone(),
538 reason: e.to_string(),
539 }
540 })?;
541 out.insert(name, value);
542 }
543 Ok(out)
544}