1use std::path::{Path, PathBuf};
16
17use super::super::Client;
18use super::{Manifest, ManifestWithNameAndSource};
19
20async fn parse_manifest_file(path: &Path) -> Option<ManifestWithNameAndSource> {
26 let bytes = tokio::fs::read(path).await.ok()?;
27 if let Ok(full) = serde_json::from_slice::<ManifestWithNameAndSource>(&bytes) {
28 full.manifest.validate().ok()?;
31 return Some(full);
32 }
33 let manifest: Manifest = serde_json::from_slice(&bytes).ok()?;
34 manifest.validate().ok()?;
35 let name = path.file_stem()?.to_str()?.to_string();
36 let source = path.to_string_lossy().into_owned();
37 Some(ManifestWithNameAndSource { name, manifest, source })
38}
39
40impl Client {
41 pub fn plugins_dir(&self) -> PathBuf {
43 self.base_dir().join("plugins")
44 }
45
46 pub fn plugin_dir(&self, name: &str) -> PathBuf {
50 self.plugins_dir().join(name)
51 }
52
53 pub fn plugin_binary_path(&self, name: &str) -> PathBuf {
58 self.plugin_dir(name)
59 .join(if cfg!(windows) { "plugin.exe" } else { "plugin" })
60 }
61
62 pub async fn resolve_plugin(&self, name: &str) -> Option<PathBuf> {
84 let dir = self.plugin_dir(name);
85
86 #[cfg(windows)]
88 let priority: [&str; 2] = ["plugin.exe", "plugin"];
89 #[cfg(not(windows))]
90 let priority: [&str; 2] = ["plugin", "plugin.exe"];
91
92 for filename in priority {
93 let path = dir.join(filename);
94 if tokio::fs::metadata(&path)
95 .await
96 .map(|m| m.is_file())
97 .unwrap_or(false)
98 {
99 return Some(path);
100 }
101 }
102
103 let mut read_dir = tokio::fs::read_dir(&dir).await.ok()?;
105 while let Ok(Some(entry)) = read_dir.next_entry().await {
106 let path = entry.path();
107 let Some(file_name) = path.file_name().and_then(|s| s.to_str()) else {
108 continue;
109 };
110 if file_name == "plugin" || file_name == "plugin.exe" {
111 continue;
113 }
114 if path.file_stem().and_then(|s| s.to_str()) != Some("plugin") {
115 continue;
116 }
117 if path.extension().is_none() {
118 continue;
122 }
123 if entry.metadata().await.map(|m| m.is_file()).unwrap_or(false) {
124 return Some(path);
125 }
126 }
127
128 None
129 }
130
131 pub async fn get_plugin(&self, name: &str) -> Option<ManifestWithNameAndSource> {
138 let path = self.plugins_dir().join(format!("{name}.json"));
139 parse_manifest_file(&path).await
140 }
141
142 pub async fn list_plugins(&self, offset: usize, limit: usize) -> Vec<ManifestWithNameAndSource> {
159 let dir = self.plugins_dir();
160 let Ok(mut read_dir) = tokio::fs::read_dir(&dir).await else {
161 return Vec::new();
162 };
163 let mut paths: Vec<PathBuf> = Vec::new();
164 while let Ok(Some(entry)) = read_dir.next_entry().await {
165 let path = entry.path();
166 if path.extension().and_then(|e| e.to_str()) == Some("json") {
167 paths.push(path);
168 }
169 }
170 let futures = paths.into_iter().map(|p| async move {
171 let bundle = parse_manifest_file(&p).await?;
172 let modified = tokio::fs::metadata(&p)
173 .await
174 .ok()?
175 .modified()
176 .ok()?
177 .duration_since(std::time::SystemTime::UNIX_EPOCH)
178 .ok()?
179 .as_secs();
180 Some((modified, bundle))
181 });
182 let mut entries: Vec<(u64, ManifestWithNameAndSource)> = futures::future::join_all(futures)
183 .await
184 .into_iter()
185 .flatten()
186 .collect();
187 entries.sort_by(|a, b| b.0.cmp(&a.0));
188 let iter = entries.into_iter().map(|(_, m)| m);
189 if offset > 0 || limit < usize::MAX {
190 iter.skip(offset).take(limit).collect()
191 } else {
192 iter.collect()
193 }
194 }
195}
196
197#[cfg(feature = "http")]
198impl Client {
199 pub async fn install_plugin(
224 &self,
225 owner: &str,
226 repository: &str,
227 commit_sha: Option<&str>,
228 headers: Option<&indexmap::IndexMap<String, String>>,
229 upgrade: bool,
230 ) -> Result<bool, super::super::Error> {
231 check_repository_name(repository)?;
232 let manifest = self
233 .fetch_plugin_manifest(owner, repository, commit_sha, headers)
234 .await?;
235 let source = raw_manifest_url(owner, repository, commit_sha);
236 self.install_plugin_from_manifest(owner, repository, &manifest, &source, headers, upgrade)
237 .await
238 }
239
240 pub async fn fetch_plugin_manifest(
245 &self,
246 owner: &str,
247 repository: &str,
248 commit_sha: Option<&str>,
249 headers: Option<&indexmap::IndexMap<String, String>>,
250 ) -> Result<Manifest, super::super::Error> {
251 self.fetch_plugin_manifest_impl(
252 "https://raw.githubusercontent.com",
253 owner,
254 repository,
255 commit_sha,
256 headers,
257 )
258 .await
259 }
260
261 pub async fn install_plugin_from_manifest(
266 &self,
267 owner: &str,
268 repository: &str,
269 manifest: &Manifest,
270 source: &str,
271 headers: Option<&indexmap::IndexMap<String, String>>,
272 upgrade: bool,
273 ) -> Result<bool, super::super::Error> {
274 check_repository_name(repository)?;
275 self.install_from_manifest_impl(
276 "https://github.com",
277 owner,
278 repository,
279 manifest,
280 source,
281 headers,
282 upgrade,
283 )
284 .await
285 }
286
287 #[cfg(test)]
292 pub(super) async fn install_plugin_at(
293 &self,
294 raw_base: &str,
295 releases_base: &str,
296 owner: &str,
297 repository: &str,
298 commit_sha: Option<&str>,
299 headers: Option<&indexmap::IndexMap<String, String>>,
300 upgrade: bool,
301 ) -> Result<bool, super::super::Error> {
302 check_repository_name(repository)?;
303 let manifest = self
304 .fetch_plugin_manifest_impl(raw_base, owner, repository, commit_sha, headers)
305 .await?;
306 let reference = commit_sha.unwrap_or("HEAD");
307 let source = format!("{raw_base}/{owner}/{repository}/{reference}/objectiveai.json");
308 self.install_from_manifest_impl(
309 releases_base,
310 owner,
311 repository,
312 &manifest,
313 &source,
314 headers,
315 upgrade,
316 )
317 .await
318 }
319
320 #[cfg(test)]
322 pub(super) async fn fetch_plugin_manifest_at(
323 &self,
324 raw_base: &str,
325 owner: &str,
326 repository: &str,
327 commit_sha: Option<&str>,
328 headers: Option<&indexmap::IndexMap<String, String>>,
329 ) -> Result<Manifest, super::super::Error> {
330 self.fetch_plugin_manifest_impl(raw_base, owner, repository, commit_sha, headers)
331 .await
332 }
333
334 async fn fetch_plugin_manifest_impl(
335 &self,
336 raw_base: &str,
337 owner: &str,
338 repository: &str,
339 commit_sha: Option<&str>,
340 headers: Option<&indexmap::IndexMap<String, String>>,
341 ) -> Result<Manifest, super::super::Error> {
342 let http = reqwest::Client::new();
343 let header_map = build_headers(headers)?;
344 let reference = commit_sha.unwrap_or("HEAD");
345 let manifest_url =
346 format!("{raw_base}/{owner}/{repository}/{reference}/objectiveai.json");
347 let resp = http
348 .get(&manifest_url)
349 .headers(header_map)
350 .send()
351 .await
352 .map_err(super::InstallError::ManifestRequest)?;
353 let status = resp.status();
354 let bytes = resp
355 .bytes()
356 .await
357 .map_err(super::InstallError::ManifestResponse)?;
358 if !status.is_success() {
359 return Err(super::InstallError::ManifestBadStatus {
360 code: status,
361 url: manifest_url,
362 body: String::from_utf8_lossy(&bytes).into_owned(),
363 }
364 .into());
365 }
366 let mut de = serde_json::Deserializer::from_slice(&bytes);
367 let manifest: Manifest = serde_path_to_error::deserialize(&mut de)
368 .map_err(super::InstallError::ManifestParse)?;
369 manifest
370 .validate()
371 .map_err(super::InstallError::ManifestInvalid)?;
372 Ok(manifest)
373 }
374
375 async fn install_from_manifest_impl(
376 &self,
377 releases_base: &str,
378 owner: &str,
379 repository: &str,
380 manifest: &Manifest,
381 source: &str,
382 headers: Option<&indexmap::IndexMap<String, String>>,
383 upgrade: bool,
384 ) -> Result<bool, super::super::Error> {
385 let Some(platform) = super::Platform::current() else {
387 return Ok(false);
388 };
389 let Some(binary_name) = manifest.binaries.get(platform) else {
390 return Ok(false);
391 };
392
393 let plugins_dir = self.plugins_dir();
394 let plugin_dir = self.plugin_dir(repository);
395 let binary_path = self.plugin_binary_path(repository);
396 let viewer_dir = plugin_dir.join("viewer");
397 let manifest_path = plugins_dir.join(format!("{repository}.json"));
398
399 let manifest_exists = tokio::fs::metadata(&manifest_path).await.is_ok();
402 if manifest_exists && !upgrade {
403 return Err(super::InstallError::AlreadyInstalled {
404 repository: repository.to_string(),
405 }
406 .into());
407 }
408
409 if upgrade {
414 let _ = tokio::fs::remove_file(&manifest_path).await;
415 let _ = tokio::fs::remove_file(&binary_path).await;
416 let _ = tokio::fs::remove_dir_all(&viewer_dir).await;
417 }
418
419 let http = reqwest::Client::new();
425 let bin_bytes: Vec<u8> = {
426 let binary_url = format!(
427 "{releases_base}/{owner}/{repository}/releases/download/v{version}/{binary_name}",
428 version = manifest.version,
429 );
430 let resp = http
431 .get(&binary_url)
432 .headers(build_headers(headers)?)
433 .send()
434 .await
435 .map_err(super::InstallError::BinaryRequest)?;
436 let status = resp.status();
437 if !status.is_success() {
438 return Err(super::InstallError::BinaryBadStatus {
439 code: status,
440 url: binary_url,
441 }
442 .into());
443 }
444 resp.bytes()
445 .await
446 .map_err(super::InstallError::BinaryResponse)?
447 .to_vec()
448 };
449
450 let zip_bytes: Option<Vec<u8>> = if let Some(viewer_zip_name) = &manifest.viewer_zip {
451 let viewer_url = format!(
452 "{releases_base}/{owner}/{repository}/releases/download/v{version}/{viewer_zip_name}",
453 version = manifest.version,
454 );
455 let resp = http
456 .get(&viewer_url)
457 .headers(build_headers(headers)?)
458 .send()
459 .await
460 .map_err(super::InstallError::ViewerZipRequest)?;
461 let status = resp.status();
462 if !status.is_success() {
463 return Err(super::InstallError::ViewerZipBadStatus {
464 code: status,
465 url: viewer_url,
466 }
467 .into());
468 }
469 Some(
470 resp.bytes()
471 .await
472 .map_err(super::InstallError::ViewerZipResponse)?
473 .to_vec(),
474 )
475 } else {
476 None
477 };
478
479 let manifest_bytes: Vec<u8> = {
480 let bundle = ManifestWithNameAndSource {
481 name: repository.to_string(),
482 manifest: manifest.clone(),
483 source: source.to_string(),
484 };
485 serde_json::to_vec_pretty(&bundle).map_err(super::InstallError::ManifestSerialize)?
486 };
487
488 tokio::fs::create_dir_all(&plugin_dir)
491 .await
492 .map_err(|e| super::InstallError::PluginDirCreate(plugin_dir.clone(), e))?;
493
494 tokio::try_join!(
497 write_binary_branch(binary_path, bin_bytes),
498 write_viewer_branch(viewer_dir, zip_bytes),
499 write_manifest_branch(manifest_path, manifest_bytes),
500 )?;
501
502 Ok(true)
503 }
504}
505
506#[cfg(feature = "http")]
507async fn write_binary_branch(
508 binary_path: PathBuf,
509 bytes: Vec<u8>,
510) -> Result<(), super::InstallError> {
511 tokio::fs::write(&binary_path, &bytes)
512 .await
513 .map_err(|e| super::InstallError::BinaryWrite(binary_path.clone(), e))?;
514 #[cfg(unix)]
515 {
516 use std::os::unix::fs::PermissionsExt;
517 let perms = std::fs::Permissions::from_mode(0o755);
518 tokio::fs::set_permissions(&binary_path, perms)
519 .await
520 .map_err(|e| super::InstallError::Chmod(binary_path.clone(), e))?;
521 }
522 Ok(())
523}
524
525#[cfg(feature = "http")]
526async fn write_viewer_branch(
527 viewer_dir: PathBuf,
528 zip_bytes: Option<Vec<u8>>,
529) -> Result<(), super::InstallError> {
530 let Some(bytes) = zip_bytes else {
531 return Ok(());
532 };
533 tokio::fs::create_dir_all(&viewer_dir)
534 .await
535 .map_err(|e| super::InstallError::ViewerZipExtract(viewer_dir.clone(), e.to_string()))?;
536 let viewer_dir_for_blocking = viewer_dir.clone();
537 tokio::task::spawn_blocking(move || {
538 let cursor = std::io::Cursor::new(bytes);
539 let mut archive = zip::ZipArchive::new(cursor)
540 .map_err(|e| format!("zip archive open: {e}"))?;
541 archive
542 .extract(&viewer_dir_for_blocking)
543 .map_err(|e| format!("extract: {e}"))
544 })
545 .await
546 .map_err(|e| super::InstallError::ViewerZipExtract(viewer_dir.clone(), format!("join: {e}")))?
547 .map_err(|e| super::InstallError::ViewerZipExtract(viewer_dir.clone(), e))?;
548 Ok(())
549}
550
551#[cfg(feature = "http")]
552async fn write_manifest_branch(
553 manifest_path: PathBuf,
554 bytes: Vec<u8>,
555) -> Result<(), super::InstallError> {
556 tokio::fs::write(&manifest_path, &bytes)
557 .await
558 .map_err(|e| super::InstallError::ManifestPersist(manifest_path.clone(), e))
559}
560
561#[cfg(feature = "http")]
566fn check_repository_name(repository: &str) -> Result<(), super::InstallError> {
567 if repository.eq_ignore_ascii_case("objectiveai") {
568 return Err(super::InstallError::ReservedRepositoryName {
569 repository: repository.to_string(),
570 });
571 }
572 Ok(())
573}
574
575pub fn raw_manifest_url(owner: &str, repository: &str, commit_sha: Option<&str>) -> String {
580 let reference = commit_sha.unwrap_or("HEAD");
581 format!(
582 "https://raw.githubusercontent.com/{owner}/{repository}/{reference}/objectiveai.json"
583 )
584}
585
586#[cfg(feature = "http")]
587pub(super) fn build_headers(
588 headers: Option<&indexmap::IndexMap<String, String>>,
589) -> Result<reqwest::header::HeaderMap, super::InstallError> {
590 let mut out = reqwest::header::HeaderMap::new();
591 let Some(h) = headers else {
592 return Ok(out);
593 };
594 for (k, v) in h {
595 let name = reqwest::header::HeaderName::from_bytes(k.as_bytes()).map_err(|e| {
596 super::InstallError::InvalidHeaderName {
597 name: k.clone(),
598 reason: e.to_string(),
599 }
600 })?;
601 let value = reqwest::header::HeaderValue::from_str(v).map_err(|e| {
602 super::InstallError::InvalidHeaderValue {
603 name: k.clone(),
604 reason: e.to_string(),
605 }
606 })?;
607 out.insert(name, value);
608 }
609 Ok(out)
610}