diff --git a/README.md b/README.md index 494dab1..143d80e 100644 --- a/README.md +++ b/README.md @@ -210,6 +210,23 @@ Shows information about a scenario (duration, scene count, dimensions, ...). rustmotion info scenario.json ``` +### `rustmotion icons` + +Fills the icon cache so a later render needs no network. `icon` resolves names +like `lucide:home` through `api.iconify.design`, and a scenario chooses that +name, so an icon missing from the cache is refused by default — the same rule as +Google Fonts. + +```bash +rustmotion icons check -f scenario.json # list what is missing, fetch nothing +rustmotion icons prefetch -f scenario.json # download them once +rustmotion render -f scenario.json # renders offline from here on +``` + +Running `prefetch` is itself the opt-in, so it needs no flag. For a one-shot +render where filling the cache first is not worth a second command, pass +`--allow-remote-icons`. + ### `rustmotion skills` Manages the built-in Claude Code skills — `install [--global]`, `uninstall [--global]`, `list`, `show `. See [Claude Code Skills](#claude-code-skills). diff --git a/crates/rustmotion-core/src/engine/renderer/assets.rs b/crates/rustmotion-core/src/engine/renderer/assets.rs index e915d09..6e6f9ce 100644 --- a/crates/rustmotion-core/src/engine/renderer/assets.rs +++ b/crates/rustmotion-core/src/engine/renderer/assets.rs @@ -71,6 +71,35 @@ pub fn icon_source_cache_file(cache_dir: &Path, icon: &str) -> PathBuf { cache_dir.join(format!("{}.svg", icon.replace(':', "_"))) } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum RemoteIconPolicy { + #[default] + Deny, + Allow, +} + +static REMOTE_ICON_POLICY: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + +pub fn set_remote_icon_policy(policy: RemoteIconPolicy) { + REMOTE_ICON_POLICY.store( + policy == RemoteIconPolicy::Allow, + std::sync::atomic::Ordering::Release, + ); +} + +pub fn remote_icon_policy() -> RemoteIconPolicy { + if REMOTE_ICON_POLICY.load(std::sync::atomic::Ordering::Acquire) { + RemoteIconPolicy::Allow + } else { + RemoteIconPolicy::Deny + } +} + +pub fn icon_source_url(prefix: &str, name: &str) -> String { + format!("https://api.iconify.design/{prefix}/{name}.svg") +} + fn set_root_attribute(svg: &str, attribute: &str, value: &str) -> String { let Some(tag_start) = svg.find(" bool { } fn fetch_icon_source(icon: &str, prefix: &str, name: &str) -> Result> { - let url = format!("https://api.iconify.design/{prefix}/{name}.svg"); + let url = icon_source_url(prefix, name); let mut backoff = Duration::from_millis(250); let mut last_reason = String::new(); @@ -219,6 +248,13 @@ pub fn fetch_icon_svg_in( migrated } None => { + if remote_icon_policy() != RemoteIconPolicy::Allow { + return Err(RustmotionError::RemoteIconDenied { + icon: icon.to_string(), + url: icon_source_url(prefix, name), + cache_hint: source_file.display().to_string(), + }); + } let fetched = fetch_icon_source(icon, prefix, name)?; if std::fs::create_dir_all(cache_dir).is_ok() { let _ = std::fs::write(&source_file, &fetched); @@ -928,3 +964,102 @@ mod icon_source_cache_tests { ); } } + +#[cfg(test)] +mod remote_icon_policy_tests { + use super::*; + + struct Restore(RemoteIconPolicy); + impl Drop for Restore { + fn drop(&mut self) { + set_remote_icon_policy(self.0); + } + } + + fn a_cache_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "rm_icon_policy_{}_{}_{name}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).expect("cache dir"); + dir + } + + const A_SQUARE: &str = r#" + + "#; + + #[test] + fn the_default_is_deny() { + assert_eq!( + RemoteIconPolicy::default(), + RemoteIconPolicy::Deny, + "a scenario chooses which icon is fetched, so it chooses the target of the \ + request — the same reason fonts are denied by default" + ); + } + + #[test] + fn an_icon_that_is_not_cached_is_refused_by_name_without_touching_the_network() { + let _restore = Restore(remote_icon_policy()); + set_remote_icon_policy(RemoteIconPolicy::Deny); + let dir = a_cache_dir("denied"); + + let message = fetch_icon_svg_in("lucide:sparkles", "#FFFFFF", 24, 24, &dir) + .expect_err("an icon absent from the cache would have to be fetched") + .to_string(); + + assert!( + message.contains("lucide:sparkles"), + "the refusal has to name the icon: {message}" + ); + assert!( + message.contains("api.iconify.design"), + "and the target it declined to reach: {message}" + ); + assert!( + message.contains("rustmotion icons prefetch") + && message.contains("--allow-remote-icons"), + "and both ways out — fill the cache once, or opt in for this run: {message}" + ); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn an_icon_already_in_the_cache_needs_no_flag() { + let _restore = Restore(remote_icon_policy()); + set_remote_icon_policy(RemoteIconPolicy::Deny); + let dir = a_cache_dir("cached"); + std::fs::write(icon_source_cache_file(&dir, "lucide:check"), A_SQUARE).expect("seed"); + + let svg = fetch_icon_svg_in("lucide:check", "#FF3366", 24, 24, &dir) + .expect("nothing has to be fetched, so nothing is denied"); + assert!( + String::from_utf8_lossy(&svg).contains("ff3366"), + "the cached source must still be recoloured and returned under a deny policy" + ); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn a_cache_left_by_the_old_naming_is_migrated_without_the_flag() { + let _restore = Restore(remote_icon_policy()); + set_remote_icon_policy(RemoteIconPolicy::Deny); + let dir = a_cache_dir("legacy"); + let baked = A_SQUARE.replace("currentColor", "#ffffff"); + std::fs::write(dir.join("lucide_home-ffffff-24x24.svg"), baked).expect("seed"); + + let svg = fetch_icon_svg_in("lucide:home", "#33CCFF", 24, 24, &dir) + .expect("the legacy entry is on disk, so the deny gate must never be reached"); + assert!( + String::from_utf8_lossy(&svg).contains("33ccff"), + "an offline render that already has its icons under the pre-#425 naming must keep \ + working: the gate belongs after the migration, not before it" + ); + std::fs::remove_dir_all(&dir).ok(); + } +} diff --git a/crates/rustmotion-core/src/error.rs b/crates/rustmotion-core/src/error.rs index 61fd0fd..49a1e11 100644 --- a/crates/rustmotion-core/src/error.rs +++ b/crates/rustmotion-core/src/error.rs @@ -52,7 +52,8 @@ pub enum RustmotionError { #[error( "{count} icon(s) could not be loaded — checked the disk cache at {cache_dir} and the \ network, both failed:\n - {details}\nA render must not silently omit an icon: fix \ - the identifier(s), or connect once so they are downloaded and cached for offline use." + the identifier(s), or run `rustmotion icons prefetch -f ` once to fill the \ + cache for offline use." )] IconsUnresolved { count: usize, @@ -108,6 +109,18 @@ pub enum RustmotionError { cache_hint: String, }, + #[error( + "Icons: '{icon}' is not in the icon cache, and fetching it would reach {url} — a \ + scenario chooses that target, so it is denied by default.\n\ + Run `rustmotion icons prefetch -f ` once to fill the cache, pass \ + --allow-remote-icons to opt in for this run, or place the SVG at:\n {cache_hint}" + )] + RemoteIconDenied { + icon: String, + url: String, + cache_hint: String, + }, + #[error( "Google Fonts: no font URLs found in CSS response for '{family}' — \ the family name may be misspelled or unavailable" diff --git a/crates/rustmotion/CLAUDE.md b/crates/rustmotion/CLAUDE.md index 40faf9c..10bffb3 100644 --- a/crates/rustmotion/CLAUDE.md +++ b/crates/rustmotion/CLAUDE.md @@ -50,6 +50,32 @@ A face **already in the cache** asks for nothing: the refusal fires at the exact moment a request would leave. An offline render that has its fonts keeps working without the flag. +## Icons: the network is denied by default too + +`icon` resolves `lucide:home` through `api.iconify.design`, and a scenario picks +that name, so the same argument as fonts applies: the file chooses the target of +the request. An icon **missing from the cache** (`~/.cache/rustmotion/icons`) is +refused by name, with the URL that was not called and the path to drop an SVG +into. An icon already cached asks for nothing, so an offline render that has its +icons keeps working. + +Two ways in, and the first is the one to reach for: + +```bash +rustmotion icons check -f scenario.json # what is missing, fetches nothing +rustmotion icons prefetch -f scenario.json # fill the cache once +rustmotion render -f scenario.json # then render offline, no flag +``` + +`prefetch` needs no `--allow-remote-icons`: running it *is* the opt-in. The flag +exists for a one-shot render where filling the cache first is not worth the extra +command. + +> The gate sits **after** the cache read and after the pre-#425 name migration, so +> a cache filled under the old `{slug}-{colour}-{w}x{h}.svg` naming still resolves +> offline. Denying the network must not break a render that already has what it +> needs. + ## Encodage - ffmpeg is auto-detected and used by default. H.264 output is **8-bit** (`yuv420p`, `high` profile), which QuickTime and Safari play; `--codec h264_10bit` trades that for `yuv420p10le`/`high10`, which is banding-free on dark gradients and refused by both players. diff --git a/crates/rustmotion/src/cli/commands/icons.rs b/crates/rustmotion/src/cli/commands/icons.rs new file mode 100644 index 0000000..9027273 --- /dev/null +++ b/crates/rustmotion/src/cli/commands/icons.rs @@ -0,0 +1,93 @@ +use rustmotion::engine::preload::scenario_icon_names; +use rustmotion::engine::renderer::{ + icon_cache_dir, icon_source_cache_file, set_remote_icon_policy, RemoteIconPolicy, +}; +use rustmotion::error::Result; +use rustmotion::loader::load_scenario_from_source; +use std::path::{Path, PathBuf}; + +fn scenario_at(file: &Path) -> Result { + load_scenario_from_source(Some(&PathBuf::from(file)), None) +} + +fn icons_named_by(file: &Path) -> Result> { + Ok(scenario_icon_names(&scenario_at(file)?)) +} + +fn split_by_cache_presence(icons: &[String]) -> (Vec, Vec) { + let cache_dir = icon_cache_dir(); + icons.iter().cloned().partition(|icon| { + icon_source_cache_file(&cache_dir, icon) + .metadata() + .is_ok_and(|m| m.len() > 0) + }) +} + +pub fn cmd_icons_check(file: &Path, quiet: bool) -> Result<()> { + let icons = icons_named_by(file)?; + let (cached, missing) = split_by_cache_presence(&icons); + + if quiet { + for icon in &missing { + println!("{icon}"); + } + return Ok(()); + } + + println!("Icon cache: {}", icon_cache_dir().display()); + println!( + "{} icon(s) named, {} already cached", + icons.len(), + cached.len() + ); + + if missing.is_empty() { + println!("Nothing to fetch — this scenario renders offline."); + return Ok(()); + } + + println!("\n{} missing:", missing.len()); + for icon in &missing { + println!(" {icon}"); + } + println!( + "\nRun `rustmotion icons prefetch -f {}` to download them.", + file.display() + ); + Ok(()) +} + +pub fn cmd_icons_prefetch(file: &Path, quiet: bool) -> Result<()> { + let icons = icons_named_by(file)?; + let (_, missing) = split_by_cache_presence(&icons); + + if missing.is_empty() { + if !quiet { + println!( + "All {} icon(s) are already in {}.", + icons.len(), + icon_cache_dir().display() + ); + } + return Ok(()); + } + + set_remote_icon_policy(RemoteIconPolicy::Allow); + + let scenario = scenario_at(file)?; + for view in &scenario.views { + rustmotion::engine::preload::prefetch_icons(&view.scenes)?; + } + + let (cached_after, still_missing) = split_by_cache_presence(&icons); + if !quiet { + println!( + "Fetched {} icon(s) into {}. {} of {} now cached.", + missing.len() - still_missing.len(), + icon_cache_dir().display(), + cached_after.len(), + icons.len() + ); + } + Ok(()) +} diff --git a/crates/rustmotion/src/cli/commands/mod.rs b/crates/rustmotion/src/cli/commands/mod.rs index 6fd7b9e..99197a1 100644 --- a/crates/rustmotion/src/cli/commands/mod.rs +++ b/crates/rustmotion/src/cli/commands/mod.rs @@ -2,6 +2,7 @@ mod audio_report; mod batch; mod captions; mod geometry; +mod icons; mod info; mod migrate; mod render; @@ -15,6 +16,7 @@ pub mod validation; pub use batch::cmd_batch; pub use captions::cmd_captions; +pub use icons::{cmd_icons_check, cmd_icons_prefetch}; pub use info::cmd_info; pub use migrate::cmd_migrate; pub use render::{cmd_render, cmd_watch}; diff --git a/crates/rustmotion/src/cli/mod.rs b/crates/rustmotion/src/cli/mod.rs index fcee6c6..3168357 100644 --- a/crates/rustmotion/src/cli/mod.rs +++ b/crates/rustmotion/src/cli/mod.rs @@ -7,7 +7,9 @@ mod skills; mod tui; use clap::{CommandFactory, Parser, Subcommand}; -use rustmotion::engine::renderer::{set_remote_font_policy, RemoteFontPolicy}; +use rustmotion::engine::renderer::{ + set_remote_font_policy, set_remote_icon_policy, RemoteFontPolicy, RemoteIconPolicy, +}; use rustmotion::error::{Result, RustmotionError}; use rustmotion::schema::ResolvedScenario; use std::collections::HashMap; @@ -36,6 +38,12 @@ struct Cli { /// chooses the target of that request, so it is denied by default. #[arg(long, global = true)] allow_remote_fonts: bool, + + /// Allow fetching an icon that is not already in the icon cache. A scenario chooses + /// the target of that request, so it is denied by default. `rustmotion icons prefetch` + /// fills the cache once instead of opting in on every run. + #[arg(long, global = true)] + allow_remote_icons: bool, } #[derive(Subcommand)] @@ -381,6 +389,12 @@ enum Commands { file: PathBuf, }, + /// Fill the icon cache so a later render needs no network + Icons { + #[command(subcommand)] + action: IconsAction, + }, + /// Manage Claude Code skills for rustmotion Skills { #[command(subcommand)] @@ -408,6 +422,24 @@ Examples: # ... { \"type\": \"caption\", \"mode\": \"word_pop\", \"words\": \"$words\" } ... rustmotion render -f scenario.json --props words.json"; +#[derive(Subcommand)] +enum IconsAction { + /// Download every icon a scenario names into the icon cache. Running this is + /// itself the opt-in, so it needs no --allow-remote-icons. + Prefetch { + /// Path to the JSON scenario file + #[arg(short, long)] + file: PathBuf, + }, + /// Report which of a scenario's icons are missing from the cache, without + /// fetching anything + Check { + /// Path to the JSON scenario file + #[arg(short, long)] + file: PathBuf, + }, +} + #[derive(Subcommand)] enum SkillsAction { /// Install skills to current project or globally @@ -683,6 +715,10 @@ pub fn run() -> Result<()> { set_remote_font_policy(RemoteFontPolicy::Allow); } + if cli.allow_remote_icons { + set_remote_icon_policy(RemoteIconPolicy::Allow); + } + match cli.command { Commands::Render { file, @@ -914,6 +950,10 @@ pub fn run() -> Result<()> { } Commands::Schema { output } => commands::cmd_schema(output.as_deref()), Commands::Info { file } => commands::cmd_info(&file), + Commands::Icons { action } => match action { + IconsAction::Prefetch { file } => commands::cmd_icons_prefetch(&file, cli.quiet), + IconsAction::Check { file } => commands::cmd_icons_check(&file, cli.quiet), + }, Commands::Skills { action } => match action { SkillsAction::Install { global } => skills::install(global), SkillsAction::Uninstall { global } => skills::uninstall(global), diff --git a/crates/rustmotion/src/engine/preload.rs b/crates/rustmotion/src/engine/preload.rs index bf87334..88633f6 100644 --- a/crates/rustmotion/src/engine/preload.rs +++ b/crates/rustmotion/src/engine/preload.rs @@ -1,3 +1,4 @@ +use std::collections::HashSet; use std::io::Read; use std::sync::Arc; @@ -51,9 +52,7 @@ pub fn preload_scenario_assets(scenario: &ResolvedScenario) -> crate::error::Res Ok(()) } -pub fn prefetch_icons(scenes: &[Scene]) -> crate::error::Result<()> { - use std::collections::HashSet; - +pub fn collect_icon_requests(scenes: &[Scene]) -> HashSet<(String, String, u32, u32)> { let mut seen = HashSet::new(); fn collect_from_component( @@ -99,6 +98,24 @@ pub fn prefetch_icons(scenes: &[Scene]) -> crate::error::Result<()> { } } + seen +} + +pub fn scenario_icon_names(scenario: &ResolvedScenario) -> Vec { + let mut names: Vec = scenario + .views + .iter() + .flat_map(|view| collect_icon_requests(&view.scenes)) + .map(|(icon, _, _, _)| icon) + .collect(); + names.sort(); + names.dedup(); + names +} + +pub fn prefetch_icons(scenes: &[Scene]) -> crate::error::Result<()> { + let seen = collect_icon_requests(scenes); + let cache = asset_cache(); let mut unresolved: Vec = Vec::new(); for (icon, color, w, h) in &seen {