1pub mod install;
11
12use crate::client::default_watchdog;
13use crate::resolve::{self, SoundKind, SoundsEnv};
14use base64::Engine;
15use ed25519_dalek::{Signature, Verifier, VerifyingKey};
16use ferraus_core::op::Inputs;
17use ferraus_core::prelude::*;
18use serde::{Deserialize, Serialize};
19use std::collections::BTreeMap;
20use std::path::{Path, PathBuf};
21
22pub const EMBEDDED_REGISTRY: &[u8] = include_bytes!("../../sounds/registry.json");
24pub const EMBEDDED_SIGNATURE: &str = include_str!("../../sounds/registry.json.sig");
25pub const REGISTRY_PUBLIC_KEY: &str = include_str!("../../sounds/registry-public-key.txt");
28pub const DEFAULT_REGISTRY_URL: &str = "https://djynnius.github.io/ferraus/sounds/registry.json";
30
31#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
32pub struct License {
33 pub spdx: String,
34 #[serde(default)]
35 pub text: String,
36 #[serde(default)]
37 pub url: String,
38}
39
40#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
41pub struct Download {
42 pub platform: String,
44 pub url: String,
45 #[serde(default, skip_serializing_if = "Vec::is_empty")]
46 pub mirrors: Vec<String>,
47 pub archive: String,
50 #[serde(default)]
51 pub size_bytes: Option<u64>,
52 #[serde(default)]
53 pub sha256: Option<String>,
54 #[serde(default, skip_serializing_if = "String::is_empty")]
56 pub contents: String,
57}
58
59#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
60pub struct InstallFrom {
61 pub url: String,
62 #[serde(default)]
63 pub note: String,
64}
65
66#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
67pub struct Entry {
68 pub id: String,
69 pub name: String,
70 #[serde(rename = "type")]
71 pub kind: SoundKind,
72 #[serde(default, skip_serializing_if = "Vec::is_empty")]
73 pub formats: Vec<String>,
74 #[serde(default)]
75 pub version: Option<String>,
76 #[serde(default)]
77 pub homepage: String,
78 #[serde(default)]
79 pub description: String,
80 pub license: License,
81 #[serde(default)]
82 pub platforms: Vec<String>,
83 #[serde(default)]
84 pub downloads: Vec<Download>,
85 pub status: String,
87 #[serde(default)]
88 pub category: Option<String>,
89 #[serde(default)]
90 pub tags: Vec<String>,
91 #[serde(default)]
93 pub aliases: Vec<String>,
94 #[serde(default)]
95 pub install_from: Option<InstallFrom>,
96 #[serde(default, skip_serializing_if = "String::is_empty")]
97 pub notes: String,
98}
99
100impl Entry {
101 pub fn downloads_for(&self, os: &str) -> Vec<&Download> {
104 let own: Vec<&Download> = self.downloads.iter().filter(|d| d.platform == os).collect();
105 if !own.is_empty() {
106 return own;
107 }
108 self.downloads.iter().filter(|d| d.platform == "any").collect()
109 }
110
111 pub fn size_for(&self, os: &str) -> Option<u64> {
112 let d = self.downloads_for(os);
113 if d.is_empty() || d.iter().any(|d| d.size_bytes.is_none()) {
114 return None;
115 }
116 Some(d.iter().filter_map(|d| d.size_bytes).sum())
117 }
118
119 pub fn verified_for(&self, os: &str) -> bool {
120 let d = self.downloads_for(os);
121 !d.is_empty() && d.iter().all(|d| d.sha256.as_deref().map(|s| s.len() == 64).unwrap_or(false))
122 }
123
124 pub fn is_vendor_only(&self) -> bool {
125 self.install_from.is_some() && self.downloads.is_empty()
126 }
127}
128
129#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
130pub struct Manifest {
131 pub schema: u32,
132 pub updated: String,
134 pub entries: Vec<Entry>,
135}
136
137impl Manifest {
138 pub fn get(&self, id: &str) -> Option<&Entry> {
141 self.entries
142 .iter()
143 .find(|e| e.id == id)
144 .or_else(|| self.entries.iter().find(|e| e.id.eq_ignore_ascii_case(id)))
145 .or_else(|| self.entries.iter().find(|e| e.name.eq_ignore_ascii_case(id)))
146 .or_else(|| {
147 let os = current_os();
148 let mut hits: Vec<&Entry> =
149 self.entries.iter().filter(|e| e.aliases.iter().any(|a| a.eq_ignore_ascii_case(id))).collect();
150 hits.sort_by_key(|e| (!e.verified_for(os), e.is_vendor_only()));
151 hits.first().copied()
152 })
153 }
154
155 pub fn search(&self, query: &str) -> Vec<&Entry> {
158 let words: Vec<String> = query.to_ascii_lowercase().split_whitespace().map(|s| s.to_string()).collect();
159 let mut scored: Vec<(u32, &Entry)> = Vec::new();
160 for e in &self.entries {
161 let hay = format!(
162 "{} {} {} {} {} {} {} {}",
163 e.id,
164 e.name,
165 e.tags.join(" "),
166 e.category.clone().unwrap_or_default(),
167 e.aliases.join(" "),
168 e.kind.as_str(),
169 e.formats.join(" "),
170 e.license.spdx
171 )
172 .to_ascii_lowercase();
173 if !words.iter().all(|w| hay.contains(w.as_str())) {
174 continue;
175 }
176 let q = query.trim().to_ascii_lowercase();
177 let score = if e.id == q || e.name.to_ascii_lowercase() == q {
178 0
179 } else if e.aliases.iter().any(|a| a == &q) {
180 1
181 } else if e.tags.iter().any(|t| t == &q) || e.category.as_deref() == Some(q.as_str()) {
182 2
183 } else {
184 3
185 };
186 scored.push((score, e));
187 }
188 scored.sort_by_key(|(s, e)| (*s, e.id.clone()));
189 scored.into_iter().map(|(_, e)| e).collect()
190 }
191}
192
193pub fn current_os() -> &'static str {
195 match std::env::consts::OS {
196 "macos" => "macos",
197 "windows" => "windows",
198 _ => "linux",
199 }
200}
201
202fn decode_key(b64: &str) -> Result<VerifyingKey> {
203 let bytes = base64::engine::general_purpose::STANDARD
204 .decode(b64.trim())
205 .map_err(|e| Error::internal(format!("bad registry public key: {e}")))?;
206 let arr: [u8; 32] = bytes.try_into().map_err(|_| Error::internal("registry public key must be 32 bytes"))?;
207 VerifyingKey::from_bytes(&arr).map_err(|e| Error::internal(format!("bad registry public key: {e}")))
208}
209
210pub fn verify_signature(bytes: &[u8], signature_b64: &str, public_key_b64: &str) -> Result<()> {
212 let key = decode_key(public_key_b64)?;
213 let sig = base64::engine::general_purpose::STANDARD
214 .decode(signature_b64.trim())
215 .map_err(|_| Error::verification("registry signature is not valid base64"))?;
216 let sig: [u8; 64] = sig.try_into().map_err(|_| Error::verification("registry signature must be 64 bytes"))?;
217 key.verify(bytes, &Signature::from_bytes(&sig))
218 .map_err(|_| Error::verification("sounds registry signature does not match its contents"))
219}
220
221pub fn parse_signed(bytes: &[u8], signature_b64: &str, public_key_b64: &str) -> Result<Manifest> {
223 verify_signature(bytes, signature_b64, public_key_b64)?;
224 let m: Manifest =
225 serde_json::from_slice(bytes).map_err(|e| Error::verification(format!("sounds registry is malformed: {e}")))?;
226 Ok(m)
227}
228
229fn cached_registry(env: &SoundsEnv) -> PathBuf {
230 env.cache_dir.join("sounds").join("registry.json")
231}
232
233pub fn load_manifest(env: &SoundsEnv) -> Result<Manifest> {
236 let embedded = parse_signed(EMBEDDED_REGISTRY, EMBEDDED_SIGNATURE, REGISTRY_PUBLIC_KEY)
237 .map_err(|e| Error::internal(format!("embedded sounds registry failed verification: {}", e.message)))?;
238 let cp = cached_registry(env);
239 if let (Ok(b), Ok(s)) = (std::fs::read(&cp), std::fs::read_to_string(cp.with_extension("json.sig"))) {
240 if let Ok(m) = parse_signed(&b, &s, REGISTRY_PUBLIC_KEY) {
241 if m.updated > embedded.updated {
242 return Ok(m);
243 }
244 }
245 }
246 Ok(embedded)
247}
248
249fn human_size(n: Option<u64>) -> String {
250 match n {
251 None => "unknown".into(),
252 Some(b) if b >= 1 << 30 => format!("{:.1} GB", b as f64 / (1u64 << 30) as f64),
253 Some(b) if b >= 1 << 20 => format!("{:.1} MB", b as f64 / (1u64 << 20) as f64),
254 Some(b) => format!("{:.0} KB", b as f64 / 1024.0),
255 }
256}
257
258fn entry_summary(e: &Entry, env: &SoundsEnv) -> Value {
259 let os = current_os();
260 let installed = resolve::read_installed(env).iter().any(|r| r.id == e.id);
261 json!({
262 "id": e.id, "name": e.name, "type": e.kind, "formats": e.formats,
263 "version": e.version, "license": e.license.spdx, "category": e.category,
264 "tags": e.tags, "aliases": e.aliases,
265 "status": if e.is_vendor_only() { "vendor".to_string() } else if e.verified_for(os) { "verified".to_string() } else { e.status.clone() },
266 "available_here": !e.downloads_for(os).is_empty() || e.install_from.is_some(),
267 "size_bytes": e.size_for(os), "size": human_size(e.size_for(os)),
268 "installed": installed,
269 "install_from": e.install_from,
270 })
271}
272
273pub fn ops() -> Vec<OpSpec> {
274 vec![
275 OpSpec::custom("sounds search", Category::Plugins, "Search the sounds registry", sounds_search)
276 .about("Searches the curated registry of free instruments, SoundFonts, SFZ libraries, and impulse responses by name, tag (piano, synth, drums, strings), type, or license. Shows size, license, and whether each entry is verified (has a pinned SHA-256) for this platform.")
277 .positional("query")
278 .param(param("query", ParamKind::Str).help("Words to match in name, id, tags, category, type, or license (empty lists everything)"))
279 .param(param("type", ParamKind::List).help("Only these types: clap, vst3, sf2, sfz, ir"))
280 .param(param("license", ParamKind::Str).help("Only entries whose SPDX license contains this text (e.g. CC0, GPL, MIT)"))
281 .example("ferraus sounds search piano", "Find piano sounds")
282 .example("ferraus sounds search reverb --type ir,clap --license CC0 --json", "CC0 reverbs and impulse responses as JSON")
283 .example("ferraus sounds search strings --type sfz", "String libraries in SFZ format"),
284 OpSpec::custom("sounds info", Category::Plugins, "Show a registry entry's license, size, and downloads", sounds_info)
285 .about("Prints the license text and download size before anything is downloaded, plus the files that would be fetched on this platform and whether they have pinned SHA-256 hashes.")
286 .positional("id")
287 .param(param("id", ParamKind::Str).required().help("Registry id or name (see 'sounds search')"))
288 .example("ferraus sounds info salamander-grand-piano", "License and size of the Salamander piano")
289 .example("ferraus sounds info gm-basic --json", "Entry details as JSON")
290 .example("ferraus sounds info surge-xt", "Version, license, and download size of the Surge XT synthesizer"),
291 OpSpec::custom("sounds install", Category::Plugins, "Download, verify, and install a sound", sounds_install)
292 .about("Downloads a registry entry (resuming partial downloads), verifies its SHA-256, refuses any mismatch, unpacks zip and tar archives, and places plugins in the per-user plugin folder and SoundFonts, SFZ libraries, and impulse responses in <data>/ferraus/sounds/, then rescans. The license is printed first. Downloads need --yes (or auto-download enabled in the config); --offline forbids them. Entries without a pinned hash are refused unless FERRAUS_ALLOW_UNVERIFIED=1. With --from, registers a bundle you downloaded yourself (for free-but-proprietary instruments whose vendors require their own installer); ferraus never runs installers or asks for administrator rights.")
293 .positional("id")
294 .param(param("id", ParamKind::Str).help("Registry id (optional with --from, where it names the entry the file belongs to)"))
295 .param(param("from", ParamKind::Path).help("Install a file or folder you downloaded yourself: a .clap/.vst3 bundle, .sf2, .sfz library folder, IR folder, or a zip/tar archive of those"))
296 .param(param("name", ParamKind::Str).help("Name for a --from install (default: the file name)"))
297 .example("ferraus sounds install gm-basic -y", "Download and install the default General MIDI SoundFont")
298 .example("ferraus sounds install vital --from ~/Downloads/VitalInstaller.zip", "Register a plugin downloaded from its vendor")
299 .example("ferraus sounds install --from ~/Downloads/MyStrings.sf2 --name my-strings", "Install a SoundFont you already have under your own name"),
300 OpSpec::custom("sounds list", Category::Plugins, "List installed sounds, or what a folder contains", sounds_list)
301 .about("Without --path, lists installed sounds and everything in --sound-path folders. With --path, shows what that folder contains (CLAP/VST3 plugins, SF2, SFZ, impulse responses) with the category ferraus inferred for each: keys, strings, guitar, bass, drums, synth, pad, brass, winds, vocal, fx.")
302 .inputs(Inputs::NONE)
303 .param(param("path", ParamKind::Path).help("Folder to inspect instead of the installed sounds"))
304 .param(param("category", ParamKind::Str).help("Only sounds in this category (keys, strings, guitar, bass, drums, synth, pad, brass, winds, vocal, fx)"))
305 .example("ferraus sounds list", "Installed sounds and sound-path folders")
306 .example("ferraus sounds list --path ~/Samples --json", "What a folder holds, with inferred categories, as JSON")
307 .example("ferraus sounds list --category keys", "Only installed keyboard sounds (pianos, electric pianos, organs)"),
308 OpSpec::custom("sounds remove", Category::Plugins, "Remove an installed sound", sounds_remove)
309 .about("Deletes the files an install created (only inside ferraus' sounds folder and the per-user plugin folders) and updates the plugin scan cache.")
310 .positional("id")
311 .param(param("id", ParamKind::Str).required().help("Installed sound id (see 'sounds list')"))
312 .example("ferraus sounds remove gm-basic", "Uninstall the GM SoundFont")
313 .example("ferraus sounds remove dexed --json", "Uninstall a plugin and report the removed paths")
314 .example("ferraus sounds remove salamander-grand-piano", "Free the disk space used by the Salamander piano"),
315 OpSpec::custom("sounds update", Category::Plugins, "Fetch the latest signed sounds registry", sounds_update)
316 .about("Downloads the newest registry manifest and its signature, verifies the Ed25519 signature against the key built into ferraus, and caches it when it is newer than the built-in one. Also reports installed sounds whose registry version changed.")
317 .inputs(Inputs::NONE)
318 .param(param("url", ParamKind::Str).help("Manifest URL (default: the project's published registry, or FERRAUS_SOUNDS_REGISTRY_URL)").advanced())
319 .example("ferraus sounds update", "Refresh the registry")
320 .example("ferraus sounds update --json", "Refresh and report changes as JSON")
321 .example("ferraus sounds update --url https://example.com/sounds/registry.json", "Use a mirror or private registry instead of the published one"),
322 OpSpec::custom("sounds alias", Category::Plugins, "Set what an instrument name resolves to", sounds_alias)
323 .about("Writes [aliases] in <config>/sounds/aliases.toml so --instrument piano or --to piano picks the sound you chose. The target is an installed sound id, a registry id, or an explicit source such as plugin:\"Surge XT\" or sf2:/path/gm.sf2. With no arguments, lists the aliases.")
324 .positional("assignment")
325 .param(param("assignment", ParamKind::Str).repeatable().help("name=target pairs, e.g. piano=salamander-grand-piano"))
326 .param(param("remove", ParamKind::List).help("Alias names to delete"))
327 .example("ferraus sounds alias piano=salamander-grand-piano", "Make 'piano' mean the Salamander Grand Piano")
328 .example("ferraus sounds alias strings=sfz:/libs/vsco/Strings.sfz pad='plugin:\"Surge XT\"'", "Point two names at explicit sources")
329 .example("ferraus sounds alias --remove piano,pad", "Delete two aliases so the names resolve to their defaults again"),
330 OpSpec::custom("sounds scan", Category::Plugins, "Index a folder of sounds", sounds_scan)
331 .about("Indexes a folder once (plugins are probed in a host subprocess) and caches the result in <cache>/sounds/index/. With --watch, keeps running and re-indexes whenever files in the folder change (polling every --interval).")
332 .positional("dir")
333 .param(param("dir", ParamKind::Path).required().help("Folder to index"))
334 .param(param("watch", ParamKind::Flag).help("Keep watching and re-index on changes until interrupted"))
335 .param(param("interval", ParamKind::Time).default("2s").help("Polling interval for --watch"))
336 .param(param("watch-cycles", ParamKind::Int).help("Stop --watch after this many polls (for scripts and tests)").advanced())
337 .example("ferraus sounds scan ~/Samples", "Index a sample folder")
338 .example("ferraus sounds scan ~/Samples --watch --interval 5s", "Re-index whenever the folder changes")
339 .example("ferraus sounds scan /Volumes/Libraries/SFZ --json", "Index an external drive of SFZ libraries and report what was found"),
340 ]
341}
342
343fn sounds_search(a: &Args, _ctx: &mut OpCtx) -> Result<Value> {
344 let env = SoundsEnv::from_env();
345 let m = load_manifest(&env)?;
346 let q = a.str("query").unwrap_or_default();
347 let types: Vec<SoundKind> = a
348 .list("type")
349 .iter()
350 .map(|t| {
351 SoundKind::parse(t).ok_or_else(|| Error::usage(format!("--type: '{t}' is not clap, vst3, sf2, sfz, or ir")))
352 })
353 .collect::<Result<_>>()?;
354 let lic = a.str("license").map(|s| s.to_ascii_lowercase());
355 let hits: Vec<Value> = m
356 .search(&q)
357 .into_iter()
358 .filter(|e| {
359 types.is_empty()
360 || types.contains(&e.kind)
361 || e.formats.iter().any(|f| types.iter().any(|t| t.as_str() == f))
362 })
363 .filter(|e| lic.as_ref().map(|l| e.license.spdx.to_ascii_lowercase().contains(l)).unwrap_or(true))
364 .map(|e| entry_summary(e, &env))
365 .collect();
366 Ok(json!({"query": q, "count": hits.len(), "results": hits, "registry_updated": m.updated}))
367}
368
369fn find_entry<'a>(m: &'a Manifest, id: &str) -> Result<&'a Entry> {
370 m.get(id).ok_or_else(|| {
371 let near: Vec<&str> = m.search(id).iter().take(5).map(|e| e.id.as_str()).collect();
372 Error::usage(format!(
373 "no sound '{id}' in the registry{}",
374 if near.is_empty() { String::new() } else { format!("; did you mean: {}", near.join(", ")) }
375 ))
376 .with_hint(format!("ferraus sounds search {id}"))
377 })
378}
379
380fn license_block(e: &Entry) -> String {
381 format!(
382 "{} — license {}{}\n{}",
383 e.name,
384 e.license.spdx,
385 if e.license.url.is_empty() { String::new() } else { format!(" ({})", e.license.url) },
386 e.license.text
387 )
388}
389
390fn sounds_info(a: &Args, ctx: &mut OpCtx) -> Result<Value> {
391 let env = SoundsEnv::from_env();
392 let m = load_manifest(&env)?;
393 let e = find_entry(&m, &a.str("id").unwrap_or_default())?;
394 ctx.progress.log(&license_block(e));
395 let os = current_os();
396 let mut v = entry_summary(e, &env);
397 v["license"] = serde_json::to_value(&e.license)?;
398 v["license_text"] = json!(license_block(e));
399 v["homepage"] = json!(e.homepage);
400 v["description"] = json!(e.description);
401 v["notes"] = json!(e.notes);
402 v["platform"] = json!(os);
403 v["downloads"] = serde_json::to_value(e.downloads_for(os))?;
404 v["all_downloads"] = serde_json::to_value(&e.downloads)?;
405 Ok(v)
406}
407
408fn sounds_install(a: &Args, ctx: &mut OpCtx) -> Result<Value> {
409 let env = SoundsEnv::from_env();
410 let m = load_manifest(&env)?;
411 let id = a.str("id");
412 if let Some(from) = a.path("from") {
413 ctx.check_allowed(&from)?;
414 let entry = match &id {
415 Some(i) => Some(find_entry(&m, i)?.clone()),
416 None => None,
417 };
418 let rec = install::install_from(&env, &from, entry.as_ref(), a.str("name").as_deref(), ctx.globals.dry_run)?;
419 return Ok(json!({"installed": rec, "source": "user", "dry_run": ctx.globals.dry_run}));
420 }
421 let id = id.ok_or_else(|| {
422 Error::usage("name a registry id, or pass --from <file>").with_hint("ferraus sounds search piano")
423 })?;
424 let e = find_entry(&m, &id)?.clone();
425 if e.id != id {
426 ctx.note("resolved_id", e.id.clone());
427 }
428 ctx.progress.log(&license_block(&e));
429 let opts = install::InstallOptions {
430 yes: ctx.globals.yes || std::env::var("FERRAUS_AUTO_DOWNLOAD").map(|v| v == "1").unwrap_or(false),
431 offline: ctx.globals.offline || std::env::var("FERRAUS_OFFLINE").map(|v| v == "1").unwrap_or(false),
432 allow_unverified: std::env::var("FERRAUS_ALLOW_UNVERIFIED").map(|v| v == "1").unwrap_or(false),
433 dry_run: ctx.globals.dry_run,
434 os: current_os().to_string(),
435 watchdog: default_watchdog(),
436 };
437 let progress = ctx.progress;
438 let command = ctx.command.clone();
439 let rec = install::install_entry(&env, &e, &opts, &mut |f| progress.progress(&command, f))?;
440 if let Some(r) = &rec {
441 ctx.metric("installed_paths", r.paths.len());
444 if e.aliases.iter().any(|a| a.eq_ignore_ascii_case(&id)) && !id.eq_ignore_ascii_case(&e.id) {
446 let mut aliases = resolve::read_aliases(&env);
447 if !aliases.contains_key(&id) {
448 aliases.insert(id.clone(), e.id.clone());
449 resolve::write_aliases(&env, &aliases)?;
450 ctx.note("alias_set", format!("{id}={}", e.id));
451 }
452 }
453 }
454 Ok(json!({
455 "id": e.id,
456 "license": e.license,
457 "license_text": license_block(&e),
458 "installed": rec,
459 "dry_run": ctx.globals.dry_run,
460 "plan": if rec.is_none() { serde_json::to_value(e.downloads_for(current_os()))? } else { Value::Null },
461 }))
462}
463
464fn sound_json(s: &resolve::Sound) -> Value {
465 json!({
466 "id": s.id, "name": s.name, "type": s.kind, "category": s.category,
467 "instrument": s.instrument, "path": s.path.display().to_string(),
468 "plugin_id": s.plugin_id, "source": s.source(), "origin": s.origin,
469 "registry_id": s.registry_id, "tags": s.tags,
470 })
471}
472
473fn sounds_list(a: &Args, ctx: &mut OpCtx) -> Result<Value> {
474 let env = SoundsEnv::from_env();
475 let cat = a.str("category").map(|c| c.to_ascii_lowercase());
476 let keep = |s: &resolve::Sound| cat.as_ref().map(|c| s.category.as_deref() == Some(c.as_str())).unwrap_or(true);
477 if let Some(dir) = a.path("path") {
478 ctx.check_allowed(&dir)?;
479 let sounds = resolve::discover_folder(&env, &dir, default_watchdog())?;
480 let list: Vec<Value> = sounds.iter().filter(|s| keep(s)).map(sound_json).collect();
481 let mut by_cat: BTreeMap<String, usize> = BTreeMap::new();
482 for s in sounds.iter().filter(|s| keep(s)) {
483 *by_cat.entry(s.category.clone().unwrap_or_else(|| "unknown".into())).or_default() += 1;
484 }
485 return Ok(
486 json!({"path": dir.display().to_string(), "count": list.len(), "categories": by_cat, "sounds": list}),
487 );
488 }
489 let installed = resolve::read_installed(&env);
490 let sounds = resolve::discover(&env, &ctx.globals.sound_paths, default_watchdog())?;
491 let list: Vec<Value> = sounds
492 .iter()
493 .filter(|s| s.origin != "plugin-scan" || s.instrument)
494 .filter(|s| keep(s))
495 .map(sound_json)
496 .collect();
497 Ok(json!({
498 "installed": installed,
499 "sounds": list,
500 "count": list.len(),
501 "sounds_dir": env.sounds_dir.display().to_string(),
502 "aliases": resolve::read_aliases(&env),
503 }))
504}
505
506fn sounds_remove(a: &Args, ctx: &mut OpCtx) -> Result<Value> {
507 let env = SoundsEnv::from_env();
508 let id = a.str("id").unwrap_or_default();
509 let removed = install::remove(&env, &id, ctx.globals.dry_run, default_watchdog())?;
510 Ok(json!({"id": id, "removed": removed, "dry_run": ctx.globals.dry_run}))
511}
512
513fn sounds_update(a: &Args, ctx: &mut OpCtx) -> Result<Value> {
514 let env = SoundsEnv::from_env();
515 let url = a
516 .str("url")
517 .or_else(|| std::env::var("FERRAUS_SOUNDS_REGISTRY_URL").ok())
518 .unwrap_or_else(|| DEFAULT_REGISTRY_URL.to_string());
519 if ctx.globals.offline || std::env::var("FERRAUS_OFFLINE").map(|v| v == "1").unwrap_or(false) {
520 return Err(Error::new(ErrorKind::Network, "offline", "cannot update the sounds registry with --offline"));
521 }
522 let before = load_manifest(&env)?;
523 if ctx.globals.dry_run {
524 return Ok(json!({"url": url, "current": before.updated, "dry_run": true}));
525 }
526 let body = install::http_get_small(&url)?;
527 let sig = install::http_get_small(&format!("{url}.sig"))?;
528 let sig = String::from_utf8_lossy(&sig).to_string();
529 let m = parse_signed(&body, &sig, REGISTRY_PUBLIC_KEY)?;
530 let newer = m.updated > before.updated;
531 if newer {
532 let cp = cached_registry(&env);
533 if let Some(p) = cp.parent() {
534 std::fs::create_dir_all(p)?;
535 }
536 std::fs::write(&cp, &body)?;
537 std::fs::write(cp.with_extension("json.sig"), sig.as_bytes())?;
538 }
539 let installed = resolve::read_installed(&env);
540 let changed: Vec<Value> = installed
541 .iter()
542 .filter_map(|r| {
543 let e = m.get(&r.id)?;
544 let v = e.version.clone().unwrap_or_default();
545 (v != r.version && !v.is_empty()).then(|| json!({"id": r.id, "installed": r.version, "available": v}))
546 })
547 .collect();
548 Ok(
549 json!({"url": url, "previous": before.updated, "updated": m.updated, "replaced": newer, "entries": m.entries.len(), "upgrades": changed}),
550 )
551}
552
553fn sounds_alias(a: &Args, _ctx: &mut OpCtx) -> Result<Value> {
554 let env = SoundsEnv::from_env();
555 let mut aliases = resolve::read_aliases(&env);
556 let mut changed = Vec::new();
557 let mut warnings = Vec::new();
558 let manifest = load_manifest(&env).ok();
559 let installed = resolve::read_installed(&env);
560 let mut pairs: Vec<String> = Vec::new();
563 for assign in a.all("assignment") {
564 for part in assign.split_whitespace().filter(|s| !s.is_empty()) {
565 match pairs.last_mut() {
566 Some(last) if !part.contains('=') => {
567 last.push(' ');
568 last.push_str(part);
569 }
570 _ => pairs.push(part.to_string()),
571 }
572 }
573 }
574 {
575 for part in pairs.iter().map(|s| s.as_str()) {
576 let (k, v) = part.split_once('=').ok_or_else(|| {
577 Error::usage(format!("'{part}' must be name=target, e.g. piano=salamander-grand-piano"))
578 })?;
579 let (k, v) = (k.trim(), v.trim().trim_matches('\''));
580 if k.is_empty() || v.is_empty() {
581 return Err(Error::usage(format!("'{part}' must be name=target")));
582 }
583 let known = v.contains(':')
584 || installed.iter().any(|r| r.id == v)
585 || manifest.as_ref().map(|m| m.get(v).is_some()).unwrap_or(false);
586 if !known {
587 warnings.push(format!("'{v}' is not an installed sound, registry id, or explicit source yet"));
588 }
589 aliases.insert(k.to_string(), v.to_string());
590 changed.push(json!({"name": k, "target": v}));
591 }
592 }
593 let mut removed = Vec::new();
594 for r in a.list("remove") {
595 if aliases.remove(&r).is_some() {
596 removed.push(r);
597 }
598 }
599 if !changed.is_empty() || !removed.is_empty() {
600 resolve::write_aliases(&env, &aliases)?;
601 }
602 for w in &warnings {
603 _ctx.warn("alias_target_unknown", w.clone());
604 }
605 Ok(
606 json!({"aliases": aliases, "set": changed, "removed": removed, "file": env.aliases_file().display().to_string()}),
607 )
608}
609
610pub fn tree_stamp(dir: &Path) -> u64 {
612 use std::hash::{Hash, Hasher};
613 let mut h = std::collections::hash_map::DefaultHasher::new();
614 fn rec(d: &Path, depth: usize, h: &mut std::collections::hash_map::DefaultHasher) {
615 if depth > 10 {
616 return;
617 }
618 let Ok(rd) = std::fs::read_dir(d) else { return };
619 let mut v: Vec<PathBuf> = rd.flatten().map(|e| e.path()).collect();
620 v.sort();
621 for p in v {
622 p.hash(h);
623 if let Ok(m) = std::fs::metadata(&p) {
624 m.len().hash(h);
625 if let Ok(t) = m.modified() {
626 t.hash(h);
627 }
628 if m.is_dir() {
629 rec(&p, depth + 1, h);
630 }
631 }
632 }
633 }
634 rec(dir, 0, &mut h);
635 h.finish()
636}
637
638pub fn index_folder(env: &SoundsEnv, dir: &Path) -> Result<(PathBuf, Vec<resolve::Sound>)> {
640 let sounds = resolve::discover_folder(env, dir, default_watchdog())?;
641 let abs = ferraus_core::ctx::absolutize(dir);
642 use sha2::Digest;
643 let key = hex::encode(&sha2::Sha256::digest(abs.to_string_lossy().as_bytes())[..8]);
644 let f = env.index_dir().join(format!("{key}.json"));
645 std::fs::create_dir_all(env.index_dir())?;
646 std::fs::write(
647 &f,
648 serde_json::to_vec_pretty(
649 &json!({"dir": abs.display().to_string(), "stamp": tree_stamp(dir), "sounds": sounds}),
650 )?,
651 )?;
652 Ok((f, sounds))
653}
654
655fn sounds_scan(a: &Args, ctx: &mut OpCtx) -> Result<Value> {
656 let env = SoundsEnv::from_env();
657 let dir = a.path("dir").ok_or_else(|| Error::usage("name a folder to scan"))?;
658 ctx.check_allowed(&dir)?;
659 if !dir.is_dir() {
660 return Err(Error::new(
661 ErrorKind::InputMissing,
662 "input_not_found",
663 format!("folder not found: {}", dir.display()),
664 ));
665 }
666 let (mut index, mut sounds) = index_folder(&env, &dir)?;
667 let mut reindexed = 0usize;
668 if a.flag("watch") {
669 let interval = std::time::Duration::from_secs_f64(a.seconds("interval")?.max(0.05));
670 let cycles = if a.has("watch-cycles") { Some(a.usize("watch-cycles")?) } else { None };
671 let mut stamp = tree_stamp(&dir);
672 let mut n = 0usize;
673 ctx.progress.log(&format!("watching {} (Ctrl-C to stop)", dir.display()));
674 loop {
675 if cycles.map(|c| n >= c).unwrap_or(false) || ctx.check_cancel().is_err() {
676 break;
677 }
678 std::thread::sleep(interval);
679 n += 1;
680 let s = tree_stamp(&dir);
681 if s != stamp {
682 stamp = s;
683 let r = index_folder(&env, &dir)?;
684 index = r.0;
685 sounds = r.1;
686 reindexed += 1;
687 ctx.progress.log(&format!("re-indexed {}: {} sounds", dir.display(), sounds.len()));
688 }
689 }
690 }
691 Ok(json!({
692 "dir": dir.display().to_string(),
693 "index": index.display().to_string(),
694 "count": sounds.len(),
695 "reindexed": reindexed,
696 "sounds": sounds.iter().map(sound_json).collect::<Vec<_>>(),
697 }))
698}
699
700#[cfg(test)]
701mod tests {
702 use super::*;
703
704 #[test]
705 fn embedded_registry_verifies_and_parses() {
706 let m = parse_signed(EMBEDDED_REGISTRY, EMBEDDED_SIGNATURE, REGISTRY_PUBLIC_KEY).unwrap();
707 assert!(m.entries.len() >= 12);
708 for id in ["surge-xt", "gm-basic", "ir-pack-basic", "salamander-grand-piano", "vital"] {
709 assert!(m.get(id).is_some(), "{id} missing");
710 }
711 for e in &m.entries {
713 match e.status.as_str() {
714 "verified" => assert!(e.downloads.iter().all(|d| d.sha256.is_some()), "{}", e.id),
715 "vendor" => assert!(e.install_from.is_some(), "{}", e.id),
716 _ => {}
717 }
718 assert!(!e.license.spdx.is_empty() && !e.license.text.is_empty(), "{}", e.id);
719 }
720 assert_eq!(m.search("piano")[0].id, "salamander-grand-piano");
721 }
722
723 #[test]
724 fn tampered_registry_is_rejected() {
725 let mut bytes = EMBEDDED_REGISTRY.to_vec();
726 let i = bytes.iter().position(|b| *b == b'G').unwrap();
727 bytes[i] = b'H';
728 let e = parse_signed(&bytes, EMBEDDED_SIGNATURE, REGISTRY_PUBLIC_KEY).unwrap_err();
729 assert_eq!(e.kind, ErrorKind::Verification);
730 }
731}