1use super::{Download, Entry};
6use crate::client::PluginFormat;
7use crate::resolve::{read_installed, write_installed, InstalledRecord, SoundKind, SoundsEnv};
8use crate::scan::{self, ScanOptions};
9use ferraus_core::{Error, ErrorKind, Result};
10use sha2::{Digest, Sha256};
11use std::io::{Read, Write};
12use std::path::{Path, PathBuf};
13use std::time::Duration;
14
15const INSTALLERS: &[&str] = &["dmg", "pkg", "exe", "msi", "deb", "rpm", "appimage"];
16
17#[derive(Clone, Debug)]
18pub struct InstallOptions {
19 pub yes: bool,
21 pub offline: bool,
22 pub allow_unverified: bool,
24 pub dry_run: bool,
25 pub os: String,
27 pub watchdog: Duration,
28}
29
30fn agent() -> ureq::Agent {
31 ureq::Agent::config_builder()
32 .timeout_connect(Some(Duration::from_secs(30)))
33 .timeout_recv_response(Some(Duration::from_secs(60)))
34 .http_status_as_error(false)
35 .user_agent(concat!("ferraus/", env!("CARGO_PKG_VERSION")))
36 .build()
37 .into()
38}
39
40fn net_err(url: &str, e: impl std::fmt::Display) -> Error {
41 Error::new(ErrorKind::Network, "download_failed", format!("cannot download {url}: {e}"))
42 .with_hint("check the network connection and retry; partial downloads resume")
43}
44
45pub fn http_get_small(url: &str) -> Result<Vec<u8>> {
47 let mut resp = agent().get(url).call().map_err(|e| net_err(url, e))?;
48 let status = resp.status().as_u16();
49 if status != 200 {
50 return Err(net_err(url, format!("HTTP {status}")));
51 }
52 resp.body_mut().with_config().limit(16 << 20).read_to_vec().map_err(|e| net_err(url, e))
53}
54
55pub fn download(url: &str, dest: &Path, progress: &mut dyn FnMut(u64, Option<u64>)) -> Result<()> {
57 if let Some(p) = dest.parent() {
58 std::fs::create_dir_all(p)?;
59 }
60 let have = std::fs::metadata(dest).map(|m| m.len()).unwrap_or(0);
61 let mut req = agent().get(url);
62 if have > 0 {
63 req = req.header("Range", format!("bytes={have}-"));
64 }
65 let resp = req.call().map_err(|e| net_err(url, e))?;
66 let status = resp.status().as_u16();
67 let len = resp.headers().get("content-length").and_then(|v| v.to_str().ok()).and_then(|v| v.parse::<u64>().ok());
68 let (mut file, mut done, total) = match status {
69 206 if have > 0 => (std::fs::OpenOptions::new().append(true).open(dest)?, have, len.map(|l| l + have)),
70 200 => (std::fs::File::create(dest)?, 0, len),
71 416 if have > 0 => return Ok(()), s => return Err(net_err(url, format!("HTTP {s}"))),
73 };
74 let mut reader = resp.into_body().into_reader();
75 let mut buf = vec![0u8; 1 << 16];
76 loop {
77 let n = reader.read(&mut buf).map_err(|e| net_err(url, e))?;
78 if n == 0 {
79 break;
80 }
81 file.write_all(&buf[..n])?;
82 done += n as u64;
83 progress(done, total);
84 }
85 file.flush()?;
86 if let Some(t) = total {
87 if done < t {
88 return Err(net_err(url, format!("connection closed after {done} of {t} bytes")));
89 }
90 }
91 Ok(())
92}
93
94pub fn sha256_file(p: &Path) -> Result<String> {
95 let mut f = std::fs::File::open(p)?;
96 let mut h = Sha256::new();
97 let mut buf = vec![0u8; 1 << 16];
98 loop {
99 let n = f.read(&mut buf)?;
100 if n == 0 {
101 break;
102 }
103 h.update(&buf[..n]);
104 }
105 Ok(hex::encode(h.finalize()))
106}
107
108fn url_file_name(url: &str) -> String {
109 let path = url.split(['?', '#']).next().unwrap_or(url);
110 let name = path.rsplit('/').next().unwrap_or("download");
111 let name: String = name.chars().map(|c| if c.is_alphanumeric() || "._-+ ".contains(c) { c } else { '_' }).collect();
112 if name.is_empty() || name == "content" {
113 "download".into()
114 } else {
115 name
116 }
117}
118
119pub fn extract(archive: &Path, kind: &str, dest: &Path, file_name: &str) -> Result<()> {
121 std::fs::create_dir_all(dest)?;
122 let bad = |e: &dyn std::fmt::Display| {
123 Error::new(ErrorKind::Unsupported, "bad_archive", format!("cannot unpack {}: {e}", archive.display()))
124 };
125 match kind {
126 "zip" => {
127 let f = std::fs::File::open(archive)?;
128 let mut z = zip::ZipArchive::new(f).map_err(|e| bad(&e))?;
129 for i in 0..z.len() {
130 let mut entry = z.by_index(i).map_err(|e| bad(&e))?;
131 let Some(rel) = entry.enclosed_name() else {
132 continue; };
134 let out = dest.join(rel);
135 if entry.is_dir() {
136 std::fs::create_dir_all(&out)?;
137 continue;
138 }
139 if let Some(p) = out.parent() {
140 std::fs::create_dir_all(p)?;
141 }
142 let mut w = std::fs::File::create(&out)?;
143 std::io::copy(&mut entry, &mut w)?;
144 #[cfg(unix)]
145 if let Some(mode) = entry.unix_mode() {
146 use std::os::unix::fs::PermissionsExt;
147 let _ = std::fs::set_permissions(&out, std::fs::Permissions::from_mode(mode & 0o777));
148 }
149 }
150 }
151 "tar.gz" | "tgz" | "tar.xz" | "txz" | "tar" => {
152 let f = std::fs::File::open(archive)?;
153 let r: Box<dyn Read> = match kind {
154 "tar.gz" | "tgz" => Box::new(flate2::read::GzDecoder::new(f)),
155 "tar.xz" | "txz" => Box::new(lzma_rust2::XzReader::new(f, true)),
156 _ => Box::new(f),
157 };
158 let mut t = tar::Archive::new(r);
159 for entry in t.entries().map_err(|e| bad(&e))? {
160 let mut entry = entry.map_err(|e| bad(&e))?;
161 entry.unpack_in(dest).map_err(|e| bad(&e))?;
163 }
164 }
165 k if INSTALLERS.contains(&k) => {
166 return Err(Error::unsupported(format!(
167 "{} is an installer ({k}); ferraus never runs installers",
168 archive.display()
169 )))
170 }
171 _ => {
172 std::fs::copy(archive, dest.join(file_name))?;
173 }
174 }
175 Ok(())
176}
177
178fn move_path(from: &Path, to: &Path) -> Result<()> {
179 if let Some(p) = to.parent() {
180 std::fs::create_dir_all(p)?;
181 }
182 if to.exists() {
183 remove_path(to)?;
184 }
185 if std::fs::rename(from, to).is_ok() {
186 return Ok(());
187 }
188 copy_recursive(from, to)?;
189 remove_path(from)
190}
191
192fn copy_recursive(from: &Path, to: &Path) -> Result<()> {
193 if from.is_dir() {
194 std::fs::create_dir_all(to)?;
195 for e in std::fs::read_dir(from)? {
196 let e = e?;
197 copy_recursive(&e.path(), &to.join(e.file_name()))?;
198 }
199 } else {
200 std::fs::copy(from, to)?;
201 }
202 Ok(())
203}
204
205fn remove_path(p: &Path) -> Result<()> {
206 if p.is_dir() {
207 std::fs::remove_dir_all(p)?;
208 } else if p.exists() || p.symlink_metadata().is_ok() {
209 std::fs::remove_file(p)?;
210 }
211 Ok(())
212}
213
214fn content_root(dir: &Path) -> PathBuf {
217 let entries: Vec<PathBuf> =
218 std::fs::read_dir(dir).map(|rd| rd.flatten().map(|e| e.path()).collect()).unwrap_or_default();
219 let visible: Vec<&PathBuf> = entries
220 .iter()
221 .filter(|p| {
222 let n = p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
223 !n.starts_with('.') && n != "__MACOSX"
224 })
225 .collect();
226 if visible.len() == 1 && visible[0].is_dir() && PluginFormat::from_path(visible[0]).is_none() {
227 visible[0].clone()
228 } else {
229 dir.to_path_buf()
230 }
231}
232
233fn place(env: &SoundsEnv, kind: SoundKind, id: &str, staging: &Path) -> Result<Vec<PathBuf>> {
235 let mut paths = Vec::new();
236 if kind.is_plugin() {
237 let bundles = scan::find_bundles(staging, &[PluginFormat::Clap, PluginFormat::Vst3]);
238 if bundles.is_empty() {
239 return Err(Error::new(
240 ErrorKind::Unsupported,
241 "no_plugin_in_download",
242 format!("the download for '{id}' contains no .clap or .vst3 bundle"),
243 ));
244 }
245 for (f, b) in bundles {
246 let dest = env.plugin_dir(f).join(b.file_name().unwrap());
247 move_path(&b, &dest)?;
248 paths.push(dest);
249 }
250 } else {
251 let root = content_root(staging);
252 let dest = env.sounds_dir.join(id);
253 move_path(&root, &dest)?;
254 paths.push(dest);
255 }
256 Ok(paths)
257}
258
259fn now() -> u64 {
260 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
261}
262
263fn record(env: &SoundsEnv, rec: InstalledRecord) -> Result<()> {
264 let mut all = read_installed(env);
265 all.retain(|r| r.id != rec.id);
266 all.push(rec);
267 all.sort_by(|a, b| a.id.cmp(&b.id));
268 write_installed(env, &all)
269}
270
271fn rescan(env: &SoundsEnv, watchdog: Duration) {
272 let opts = ScanOptions {
273 extra: vec![env.clap_dir.clone(), env.vst3_dir.clone()],
274 standard: false,
275 watchdog,
276 cache_path: env.scan_cache(),
277 ..Default::default()
278 };
279 let _ = scan::scan(&opts);
280}
281
282pub fn install_entry(
284 env: &SoundsEnv,
285 e: &Entry,
286 opts: &InstallOptions,
287 progress: &mut dyn FnMut(f64),
288) -> Result<Option<InstalledRecord>> {
289 if e.is_vendor_only() {
290 let url = e.install_from.as_ref().map(|i| i.url.clone()).unwrap_or_default();
291 return Err(Error::new(
292 ErrorKind::Usage,
293 "vendor_install_required",
294 format!("{} is only distributed by its vendor ({url}); download it there, then register the file", e.name),
295 )
296 .with_hint(format!("ferraus sounds install {} --from <downloaded file>", e.id)));
297 }
298 let parts: Vec<&Download> = e.downloads_for(&opts.os);
299 if parts.is_empty() {
300 let mut err = Error::unsupported(format!("{} has no download for {}", e.name, opts.os));
301 if let Some(i) = &e.install_from {
302 err =
303 err.with_hint(format!("get it from {} and run: ferraus sounds install {} --from <file>", i.url, e.id));
304 }
305 return Err(err);
306 }
307 if let Some(d) = parts.iter().find(|d| INSTALLERS.contains(&d.archive.as_str())) {
308 return Err(Error::unsupported(format!(
309 "{} is only available as an installer ({}) on {}; ferraus never runs installers",
310 e.name, d.archive, opts.os
311 ))
312 .with_hint(format!(
313 "install it yourself from {} , or download an archive and run: ferraus sounds install {} --from <file>",
314 if e.homepage.is_empty() { &d.url } else { &e.homepage },
315 e.id
316 )));
317 }
318 if let Some(existing) = read_installed(env).into_iter().find(|r| r.id == e.id) {
319 if existing.paths.iter().all(|p| p.exists()) {
320 return Ok(Some(existing));
321 }
322 }
323 let unverified = parts.iter().any(|d| d.sha256.as_deref().map(|s| s.len() != 64).unwrap_or(true));
324 if unverified && !opts.allow_unverified {
325 return Err(Error::new(
326 ErrorKind::Verification,
327 "sound_unverified",
328 format!("{} has no pinned SHA-256 for {} in the registry, so ferraus will not install it", e.name, opts.os),
329 )
330 .with_hint(format!("FERRAUS_ALLOW_UNVERIFIED=1 ferraus sounds install {} -y", e.id)));
331 }
332 if opts.dry_run {
333 return Ok(None);
334 }
335 if opts.offline {
336 return Err(Error::new(
337 ErrorKind::Network,
338 "offline",
339 format!("{} must be downloaded, but --offline is set", e.name),
340 ));
341 }
342 if !opts.yes {
343 let size: Option<u64> = e.size_for(&opts.os);
344 return Err(Error::new(
345 ErrorKind::Usage,
346 "download_needs_consent",
347 format!(
348 "installing {} downloads {}; pass --yes to allow downloads (or enable auto_download in the config)",
349 e.name,
350 size.map(|s| format!("{:.1} MB", s as f64 / 1048576.0)).unwrap_or_else(|| "an unknown amount".into())
351 ),
352 )
353 .with_hint(format!("ferraus sounds install {} -y", e.id)));
354 }
355 let total: u64 = parts.iter().filter_map(|d| d.size_bytes).sum();
356 let mut base = 0u64;
357 let staging = env.downloads_dir().join(format!("staging-{}-{}", e.id, std::process::id()));
358 let _ = remove_path(&staging);
359 let result = (|| -> Result<Vec<PathBuf>> {
360 let mut files = Vec::new();
361 for (i, d) in parts.iter().enumerate() {
362 let tag = d.sha256.clone().unwrap_or_else(|| format!("{}-{i}", e.id));
363 let part = env.downloads_dir().join(format!("{tag}.part"));
364 let mut last_err = None;
365 let mut ok = false;
366 for url in std::iter::once(&d.url).chain(d.mirrors.iter()) {
367 let mut cb = |done: u64, t: Option<u64>| {
368 let denom = if total > 0 { total } else { t.unwrap_or(0) };
369 if denom > 0 {
370 progress(((base + done) as f64 / denom as f64).min(1.0));
371 }
372 };
373 match download(url, &part, &mut cb) {
374 Ok(()) => {
375 ok = true;
376 break;
377 }
378 Err(err) => last_err = Some(err),
379 }
380 }
381 if !ok {
382 return Err(last_err.unwrap_or_else(|| net_err(&d.url, "no URL")));
383 }
384 let got = sha256_file(&part)?;
385 if let Some(want) = &d.sha256 {
386 if !got.eq_ignore_ascii_case(want) {
387 let _ = std::fs::remove_file(&part);
388 return Err(Error::new(
389 ErrorKind::Verification,
390 "checksum_mismatch",
391 format!(
392 "SHA-256 of {} does not match the registry (expected {want}, got {got}); the file was deleted and nothing was installed",
393 d.url
394 ),
395 ));
396 }
397 }
398 base += std::fs::metadata(&part).map(|m| m.len()).unwrap_or(0);
399 extract(&part, &d.archive, &staging, &url_file_name(&d.url))?;
400 files.push(part);
401 }
402 let placed = place(env, e.kind, &e.id, &staging)?;
403 for f in files {
404 let _ = std::fs::remove_file(f);
405 }
406 Ok(placed)
407 })();
408 let _ = remove_path(&staging);
409 let paths = result?;
410 let rec = InstalledRecord {
411 id: e.id.clone(),
412 name: e.name.clone(),
413 kind: e.kind,
414 version: e.version.clone().unwrap_or_default(),
415 license: e.license.spdx.clone(),
416 paths,
417 tags: e.tags.clone(),
418 category: e.category.clone(),
419 source: "registry".into(),
420 installed_at: now(),
421 sha256: parts.first().and_then(|d| d.sha256.clone()),
422 };
423 record(env, rec.clone())?;
424 if e.kind.is_plugin() {
425 rescan(env, opts.watchdog);
426 }
427 Ok(Some(rec))
428}
429
430fn infer_kind(p: &Path) -> Option<SoundKind> {
431 let ext = p.extension()?.to_string_lossy().to_ascii_lowercase();
432 match ext.as_str() {
433 "clap" => Some(SoundKind::Clap),
434 "vst3" => Some(SoundKind::Vst3),
435 "sf2" | "sf3" => Some(SoundKind::Sf2),
436 "sfz" => Some(SoundKind::Sfz),
437 "wav" | "aif" | "aiff" | "flac" => Some(SoundKind::Ir),
438 _ => None,
439 }
440}
441
442fn archive_kind(p: &Path) -> Option<&'static str> {
443 let n = p.file_name()?.to_string_lossy().to_ascii_lowercase();
444 if n.ends_with(".zip") {
445 Some("zip")
446 } else if n.ends_with(".tar.gz") || n.ends_with(".tgz") {
447 Some("tar.gz")
448 } else if n.ends_with(".tar.xz") || n.ends_with(".txz") {
449 Some("tar.xz")
450 } else if n.ends_with(".tar") {
451 Some("tar")
452 } else {
453 None
454 }
455}
456
457fn slug(s: &str) -> String {
458 let mut out = String::new();
459 for c in s.chars() {
460 if c.is_alphanumeric() {
461 out.extend(c.to_lowercase());
462 } else if !out.ends_with('-') && !out.is_empty() {
463 out.push('-');
464 }
465 }
466 out.trim_end_matches('-').to_string()
467}
468
469fn detect_kind(dir: &Path) -> Option<SoundKind> {
471 let b = scan::find_bundles(dir, &[PluginFormat::Clap, PluginFormat::Vst3]);
472 if let Some((f, _)) = b.first() {
473 return Some(if *f == PluginFormat::Clap { SoundKind::Clap } else { SoundKind::Vst3 });
474 }
475 let files = crate::resolve::discover_files(dir, "user");
476 if files.iter().any(|s| s.kind == SoundKind::Sf2) {
477 Some(SoundKind::Sf2)
478 } else if files.iter().any(|s| s.kind == SoundKind::Sfz) {
479 Some(SoundKind::Sfz)
480 } else {
481 let any_audio = walkdir_has_audio(dir, 0);
482 any_audio.then_some(SoundKind::Ir)
483 }
484}
485
486fn walkdir_has_audio(d: &Path, depth: usize) -> bool {
487 if depth > 6 {
488 return false;
489 }
490 std::fs::read_dir(d)
491 .map(|rd| {
492 rd.flatten().any(|e| {
493 let p = e.path();
494 if p.is_dir() {
495 walkdir_has_audio(&p, depth + 1)
496 } else {
497 infer_kind(&p) == Some(SoundKind::Ir)
498 }
499 })
500 })
501 .unwrap_or(false)
502}
503
504pub fn install_from(
507 env: &SoundsEnv,
508 from: &Path,
509 entry: Option<&Entry>,
510 name: Option<&str>,
511 dry_run: bool,
512) -> Result<InstalledRecord> {
513 if !from.exists() {
514 return Err(Error::new(ErrorKind::InputMissing, "input_not_found", format!("not found: {}", from.display())));
515 }
516 let ext = from.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
517 if INSTALLERS.contains(&ext.as_str()) {
518 return Err(Error::unsupported(format!(
519 "{} is an installer; ferraus never runs installers. Run it yourself, then 'ferraus plugin scan' finds the plugin",
520 from.display()
521 ))
522 .with_hint("ferraus plugin scan"));
523 }
524 let display = name.map(|s| s.to_string()).or_else(|| entry.map(|e| e.name.clone())).unwrap_or_else(|| {
525 let n = from.file_name().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
526 n.split('.').next().unwrap_or(&n).to_string()
527 });
528 let id = entry.map(|e| e.id.clone()).unwrap_or_else(|| slug(&display));
529 let staging = env.downloads_dir().join(format!("staging-from-{id}-{}", std::process::id()));
530 let _ = remove_path(&staging);
531 std::fs::create_dir_all(&staging)?;
532 let result = (|| -> Result<(SoundKind, Vec<PathBuf>)> {
533 if let Some(k) = archive_kind(from) {
534 extract(from, k, &staging, "")?;
535 } else {
536 let target = staging.join(from.file_name().unwrap());
537 copy_recursive(from, &target)?;
538 }
539 let kind = entry
540 .map(|e| e.kind)
541 .filter(|k| !k.is_plugin() || detect_kind(&staging).map(|d| d.is_plugin()).unwrap_or(false))
542 .or_else(|| detect_kind(&staging))
543 .ok_or_else(|| {
544 Error::unsupported(format!(
545 "{} contains no .clap/.vst3 bundle, .sf2, .sfz, or audio files",
546 from.display()
547 ))
548 })?;
549 if dry_run {
550 return Ok((kind, Vec::new()));
551 }
552 let paths = place(env, kind, &id, &staging)?;
553 Ok((kind, paths))
554 })();
555 let _ = remove_path(&staging);
556 let (kind, paths) = result?;
557 let rec = InstalledRecord {
558 id: id.clone(),
559 name: display,
560 kind,
561 version: entry.and_then(|e| e.version.clone()).unwrap_or_default(),
562 license: entry.map(|e| e.license.spdx.clone()).unwrap_or_else(|| "unknown".into()),
563 paths,
564 tags: entry.map(|e| e.tags.clone()).unwrap_or_default(),
565 category: entry
566 .and_then(|e| e.category.clone())
567 .or_else(|| crate::resolve::infer_category(&id).map(|s| s.to_string())),
568 source: "user".into(),
569 installed_at: now(),
570 sha256: if from.is_file() { sha256_file(from).ok() } else { None },
571 };
572 if !dry_run {
573 record(env, rec.clone())?;
574 if kind.is_plugin() {
575 rescan(env, crate::client::default_watchdog());
576 }
577 }
578 Ok(rec)
579}
580
581pub fn remove(env: &SoundsEnv, id: &str, dry_run: bool, watchdog: Duration) -> Result<Vec<PathBuf>> {
584 let mut all = read_installed(env);
585 let Some(i) = all.iter().position(|r| r.id == id || r.name.eq_ignore_ascii_case(id)) else {
586 return Err(Error::usage(format!("'{id}' is not installed")).with_hint("ferraus sounds list"));
587 };
588 let rec = all[i].clone();
589 let roots = [&env.sounds_dir, &env.clap_dir, &env.vst3_dir];
590 let mut removed = Vec::new();
591 for p in &rec.paths {
592 let abs = ferraus_core::ctx::absolutize(p);
593 if !roots.iter().any(|r| abs.starts_with(ferraus_core::ctx::absolutize(r))) {
594 return Err(Error::usage(format!(
595 "refusing to delete {}: it is outside ferraus' sounds and plugin folders",
596 p.display()
597 )));
598 }
599 if !dry_run {
600 remove_path(p)?;
601 }
602 removed.push(p.clone());
603 }
604 if !dry_run {
605 all.remove(i);
606 write_installed(env, &all)?;
607 if rec.kind.is_plugin() {
608 rescan(env, watchdog);
609 }
610 }
611 Ok(removed)
612}