diff --git a/Cargo.lock b/Cargo.lock index 04b6464da..b9cf5ba8d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4920,6 +4920,7 @@ dependencies = [ "dispatch2", "hypercolor-types", "objc2", + "objc2-avf-audio", "objc2-core-foundation", "objc2-core-graphics", "objc2-core-media", diff --git a/Cargo.toml b/Cargo.toml index 7fa0beb51..0ccd57b22 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -253,6 +253,7 @@ wgpu-hal = { version = "30.0.1", default-features = false, features = ["vulkan"] objc2-core-foundation = { version = "0.3.2", default-features = false } objc2-core-graphics = { version = "0.3.2", default-features = false } objc2-app-kit = { version = "0.3.2", default-features = false } +objc2-avf-audio = { version = "0.3.2", default-features = false } objc2-core-media = { version = "0.3.2", default-features = false } objc2-core-video = { version = "0.3.2", default-features = false } objc2-foundation = { version = "0.3.2", default-features = false } diff --git a/crates/hypercolor-core/src/effect/loader.rs b/crates/hypercolor-core/src/effect/loader.rs index 2bcf2bd05..d675f3202 100644 --- a/crates/hypercolor-core/src/effect/loader.rs +++ b/crates/hypercolor-core/src/effect/loader.rs @@ -360,9 +360,21 @@ fn deterministic_html_effect_id(source_path: &Path) -> EffectId { } fn stable_bundled_html_effect_id(source_path: &Path) -> Option { - bundled_effect_slug(source_path).map(|slug| { - deterministic_html_effect_id_from_key(&format!("hypercolor:html-bundled:{slug}")) - }) + bundled_effect_slug(source_path).map(|slug| bundled_html_effect_id(&slug)) +} + +/// The id a bundled HTML effect carries, derived from its file stem alone. +/// +/// A file under an `effects/bundled` or `effects/hypercolor` root hashes only +/// its lowercased stem, never the directory it was installed into, so a stem +/// names the same effect on every install and platform. The same file under +/// any other root gets a path-derived id and never matches this one. +#[must_use] +pub fn bundled_html_effect_id(stem: &str) -> EffectId { + deterministic_html_effect_id_from_key(&format!( + "hypercolor:html-bundled:{}", + stem.trim().to_ascii_lowercase() + )) } fn html_effect_id( diff --git a/crates/hypercolor-core/src/effect/mod.rs b/crates/hypercolor-core/src/effect/mod.rs index af6202e65..877871bcd 100644 --- a/crates/hypercolor-core/src/effect/mod.rs +++ b/crates/hypercolor-core/src/effect/mod.rs @@ -29,8 +29,8 @@ pub use factory::{ }; pub use lightscript::{LightScriptFrameUpdateOptions, LightscriptRuntime, normalized_level_to_db}; pub use loader::{ - HtmlDiscoveryError, HtmlDiscoveryReport, default_effect_search_paths, load_html_effect_file, - register_html_effects, register_html_effects_stepped, + HtmlDiscoveryError, HtmlDiscoveryReport, bundled_html_effect_id, default_effect_search_paths, + load_html_effect_file, register_html_effects, register_html_effects_stepped, }; pub use meta_parser::{ HtmlControlKind, HtmlControlMetadata, ParsedHtmlEffectMetadata, parse_html_effect_metadata, diff --git a/crates/hypercolor-core/src/input/audio/mod.rs b/crates/hypercolor-core/src/input/audio/mod.rs index 5dd243585..16648c494 100644 --- a/crates/hypercolor-core/src/input/audio/mod.rs +++ b/crates/hypercolor-core/src/input/audio/mod.rs @@ -23,6 +23,7 @@ pub mod fft; pub mod linux; pub mod realtime; +use std::fmt; use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; use std::sync::mpsc; use std::sync::{Arc, Mutex}; @@ -40,7 +41,10 @@ use crate::input::traits::{ AudioSource, AudioSourceRole, InputData, InputSource, SourceRoleBinding, }; use crate::input::{SourceIssue, SourceKind, SourceStatusHandle, SourceStatusReporter}; -use hypercolor_types::audio::{AudioData, AudioPipelineConfig, AudioSourceType}; +use hypercolor_macos_capture::microphone_record_permission; +use hypercolor_types::audio::{ + AudioData, AudioInputPermission, AudioPipelineConfig, AudioSourceType, +}; use hypercolor_types::event::TimedInputEvent; use hypercolor_worker_retention::{retain_worker, spawn_worker}; @@ -324,6 +328,7 @@ pub(super) enum AudioFailureKind { PermissionDenied = 2, BackendUnavailable = 3, AnalysisWorkerExited = 4, + LoopbackUnavailable = 5, } impl AudioFailureKind { @@ -333,6 +338,7 @@ impl AudioFailureKind { 2 => Self::PermissionDenied, 3 => Self::BackendUnavailable, 4 => Self::AnalysisWorkerExited, + 5 => Self::LoopbackUnavailable, _ => Self::None, } } @@ -344,6 +350,7 @@ impl AudioFailureKind { Self::PermissionDenied => "audio_permission_denied", Self::BackendUnavailable => "audio_backend_unavailable", Self::AnalysisWorkerExited => "audio_analysis_worker_exited", + Self::LoopbackUnavailable => "audio_loopback_unavailable", } } @@ -354,11 +361,97 @@ impl AudioFailureKind { Self::PermissionDenied => "audio capture permission was denied", Self::BackendUnavailable => "audio capture backend stopped unexpectedly", Self::AnalysisWorkerExited => "audio analysis worker stopped unexpectedly", + Self::LoopbackUnavailable => "no system audio loopback device found", + } + } + + const fn remediation(self) -> Option<&'static str> { + match self { + Self::LoopbackUnavailable => Some( + "choose Default Microphone or a loopback input device in audio settings to \ + capture audio", + ), + Self::None + | Self::DeviceLost + | Self::PermissionDenied + | Self::BackendUnavailable + | Self::AnalysisWorkerExited => None, + } + } + + fn issue(self) -> SourceIssue { + let issue = SourceIssue::new(self.issue_code(), self.message(), true); + match self.remediation() { + Some(remediation) => issue.with_remediation(remediation), + None => issue, + } + } +} + +/// Whether system-monitor capture may fall back to the default input device. +/// +/// With no loopback device to open, the fallback is whatever the platform +/// calls its default input, usually a microphone. Opening it is acceptable +/// only when that cannot surprise the user with a permission prompt they +/// never asked for: the platform has no such prompt, or the user already +/// granted access. An explicit microphone or named-device choice is the +/// user's own request and never passes through this rule. +#[must_use] +pub const fn default_input_fallback_allowed(permission: AudioInputPermission) -> bool { + matches!( + permission, + AudioInputPermission::Granted | AudioInputPermission::NotRequired + ) +} + +/// System-monitor capture declined to open the default input in place of a +/// missing loopback device. +#[derive(Debug)] +struct DefaultInputFallbackRefused { + permission: AudioInputPermission, +} + +impl DefaultInputFallbackRefused { + const fn failure(&self) -> AudioFailureKind { + match self.permission { + AudioInputPermission::Denied => AudioFailureKind::PermissionDenied, + AudioInputPermission::Granted + | AudioInputPermission::Undetermined + | AudioInputPermission::NotRequired => AudioFailureKind::LoopbackUnavailable, + } + } +} + +impl fmt::Display for DefaultInputFallbackRefused { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.permission == AudioInputPermission::Denied { + formatter.write_str( + "no system audio loopback device found and audio input permission was denied; \ + allow Hypercolor to use the microphone in the system privacy settings to \ + capture audio", + ) + } else { + formatter.write_str( + "no system audio loopback device found; choose Default Microphone or a \ + loopback input device to capture audio", + ) } } } +impl std::error::Error for DefaultInputFallbackRefused {} + +/// The failure a refused default-input fallback stands for, if `error` is one. +fn refused_fallback_failure(error: &anyhow::Error) -> Option { + error + .downcast_ref::() + .map(DefaultInputFallbackRefused::failure) +} + fn classify_audio_start_failure(error: &anyhow::Error) -> AudioFailureKind { + if let Some(failure) = refused_fallback_failure(error) { + return failure; + } let message = format!("{error:#}").to_ascii_lowercase(); if ["permission", "access denied", "not authorized"] .iter() @@ -546,7 +639,9 @@ fn audio_reconnect_delay(failure: AudioFailureKind, attempt: u32) -> Duration { let exponent = attempt.min(4); let multiplier = 1_u32 << exponent; match failure { - AudioFailureKind::DeviceLost => Duration::from_millis(250 * u64::from(multiplier)), + AudioFailureKind::DeviceLost | AudioFailureKind::LoopbackUnavailable => { + Duration::from_millis(250 * u64::from(multiplier)) + } AudioFailureKind::AnalysisWorkerExited => { Duration::from_millis(500 * u64::from(multiplier)) } @@ -634,6 +729,8 @@ pub(super) struct PreparedAudioReconfiguration { latest_snapshot: Option>>, status: Option, terminal_failure: Arc, + /// A refused default-input fallback staged in place of a capture runtime. + staged_failure: Option, } pub(super) struct AudioPreparationRequest { @@ -667,11 +764,18 @@ impl PreparedAudioReconfiguration { ) -> anyhow::Result { let terminal_failure = Arc::new(AtomicU8::new(AudioFailureKind::None as u8)); let should_start_capture = request.running && request.capture_active; + let mut staged_failure = None; let capture = if should_start_capture { - Some(build_capture( - &request.config, - Arc::clone(&terminal_failure), - )?) + match build_capture(&request.config, Arc::clone(&terminal_failure)) { + Ok(capture) => Some(capture), + // A refused fallback is a source waiting for the user's + // choice, not a failed reconfiguration. The committed source + // stays silent and retries like a running one would. + Err(error) => { + staged_failure = Some(refused_fallback_failure(&error).ok_or(error)?); + None + } + } } else { None }; @@ -718,6 +822,7 @@ impl PreparedAudioReconfiguration { latest_snapshot, status, terminal_failure, + staged_failure, }) } @@ -886,20 +991,29 @@ impl AudioInput { Ok(()) } + /// Publish a new recoverable failure once, as its transition happens. + fn report_failure(&mut self, failure: AudioFailureKind) { + self.last_failure = failure; + if failure == AudioFailureKind::LoopbackUnavailable { + tracing::info!( + input = %self.name, + source = ?self.config.source, + "No system audio loopback device found; audio stays silent until an input \ + is chosen in audio settings" + ); + } + if let Some(status) = self.status.session() { + status.degraded(failure.issue()); + } + } + fn poll_recovery(&mut self) -> anyhow::Result<()> { let failure = self .recovery .as_ref() .map_or(AudioFailureKind::None, RecoveryWorker::failure); if failure != AudioFailureKind::None && failure != self.last_failure { - self.last_failure = failure; - if let Some(status) = self.status.session() { - status.degraded(SourceIssue::new( - failure.issue_code(), - failure.message(), - true, - )); - } + self.report_failure(failure); } let recovery_poll = self @@ -914,11 +1028,7 @@ impl AudioInput { let failure = AudioFailureKind::AnalysisWorkerExited; self.schedule_recovery(failure)?; if let Some(status) = self.status.session() { - status.degraded(SourceIssue::new( - failure.issue_code(), - failure.message(), - true, - )); + status.degraded(failure.issue()); } return Ok(()); } @@ -965,11 +1075,7 @@ impl AudioInput { self.last_status_snapshot = None; self.schedule_recovery(failure)?; if let Some(status) = self.status.session() { - status.degraded(SourceIssue::new( - failure.issue_code(), - failure.message(), - true, - )); + status.degraded(failure.issue()); } } self.poll_recovery()?; @@ -1043,10 +1149,21 @@ impl AudioInput { let should_capture = prepared.running && prepared.capture_active && !matches!(prepared.config.source, AudioSourceType::None); - if should_capture && prepared.capture.is_none() { + let staged_failure = prepared.staged_failure.take().filter(|_| should_capture); + if should_capture && prepared.capture.is_none() && staged_failure.is_none() { prepared.status = Some(status); anyhow::bail!("active prepared audio source is missing its capture runtime"); } + let recovery = match staged_failure + .map(|failure| RecoveryWorker::spawn(&prepared.config, failure)) + .transpose() + { + Ok(recovery) => recovery, + Err(error) => { + prepared.status = Some(status); + return Err(error); + } + }; if should_capture && let Err(error) = status.begin_session() { prepared.status = Some(status); return Err(error.into()); @@ -1056,7 +1173,7 @@ impl AudioInput { .latest_snapshot .take() .expect("prepared audio snapshot slot is committed exactly once"); - Ok(Self { + let mut input = Self { name: std::mem::take(&mut prepared.name), running: prepared.running, capture_active: prepared.capture_active, @@ -1072,12 +1189,17 @@ impl AudioInput { latest_snapshot, last_status_snapshot: None, capture, - recovery: None, + recovery, degraded_to_silence: false, last_failure: AudioFailureKind::None, prepared_failure: Arc::clone(&prepared.terminal_failure), status, - }) + }; + if let Some(failure) = staged_failure { + input.degraded_to_silence = true; + input.report_failure(failure); + } + Ok(input) } fn reset_analyzer(&mut self) { @@ -1188,11 +1310,7 @@ impl AudioInput { if self.degraded_to_silence && let Some(status) = status { - status.degraded(SourceIssue::new( - self.last_failure.issue_code(), - self.last_failure.message(), - true, - )); + status.degraded(self.last_failure.issue()); } self.capture_active = true; Ok(()) @@ -1244,11 +1362,7 @@ impl InputSource for AudioInput { if self.degraded_to_silence && let Some(status) = status { - status.degraded(SourceIssue::new( - self.last_failure.issue_code(), - self.last_failure.message(), - true, - )); + status.degraded(self.last_failure.issue()); } self.running = true; Ok(()) @@ -1593,8 +1707,18 @@ fn select_input_device( return Ok(selected); } - let device = find_monitor_input_device(host)? - .or_else(|| host.default_input_device()) + if let Some(device) = find_monitor_input_device(host)? { + return Ok(SelectedInputDevice::new(device)); + } + // Only macOS gates audio input behind a prompt; the capture crate + // reports `NotRequired` everywhere else, so Linux and Windows keep + // falling back to the default input unconditionally. + let permission = microphone_record_permission(); + if !default_input_fallback_allowed(permission) { + return Err(DefaultInputFallbackRefused { permission }.into()); + } + let device = host + .default_input_device() .ok_or_else(|| anyhow!("no input device available for system monitor capture"))?; Ok(SelectedInputDevice::new(device)) } @@ -2387,10 +2511,122 @@ fn analysis_dt_seconds(sample_count: usize, sample_rate_hz: u32) -> f32 { dt } -#[cfg(all(test, target_os = "linux"))] +#[cfg(test)] mod tests { use super::*; + #[test] + fn refused_fallback_waits_for_a_source_choice_until_access_is_answered() { + let error = anyhow::Error::from(DefaultInputFallbackRefused { + permission: AudioInputPermission::Undetermined, + }); + + let failure = classify_audio_start_failure(&error); + let issue = failure.issue(); + + assert_eq!(failure, AudioFailureKind::LoopbackUnavailable); + assert_eq!(issue.code.as_ref(), "audio_loopback_unavailable"); + assert!(issue.retryable); + assert!( + issue + .remediation + .as_deref() + .is_some_and(|remediation| remediation.contains("Default Microphone")) + ); + assert!(error.to_string().contains("choose Default Microphone")); + } + + #[test] + fn refused_fallback_reports_denied_access_as_a_permission_failure() { + let error = anyhow::Error::from(DefaultInputFallbackRefused { + permission: AudioInputPermission::Denied, + }); + + assert_eq!( + classify_audio_start_failure(&error), + AudioFailureKind::PermissionDenied + ); + assert!(error.to_string().contains("privacy settings")); + } + + fn staged_refusal_request() -> AudioPreparationRequest { + AudioPreparationRequest { + running: true, + source_graph_generation: 1, + predecessor_status: None, + // The staged source's recovery worker retries for real; a missing + // named device keeps those retries from opening any host input. + config: AudioPipelineConfig { + source: AudioSourceType::Named("__hypercolor_staged_refusal_probe__".to_owned()), + ..AudioPipelineConfig::default() + }, + name: "AudioInput(staged-refusal)".to_owned(), + capture_active: true, + } + } + + #[test] + fn live_reconfiguration_commits_a_refused_fallback_as_a_waiting_source() { + let mut prepared = + PreparedAudioReconfiguration::prepare_with_capture(staged_refusal_request(), |_, _| { + Err(DefaultInputFallbackRefused { + permission: AudioInputPermission::Undetermined, + } + .into()) + }) + .expect("a refused fallback stages a waiting source instead of failing"); + + let mut input = AudioInput::from_prepared(&mut prepared) + .expect("the staged source commits without a capture runtime"); + + assert!(input.capture.is_none()); + assert!( + input.recovery.is_some(), + "the waiting source keeps retrying" + ); + let snapshot = input.status.handle().snapshot(); + assert_eq!(snapshot.state, crate::input::SourceState::Degraded); + assert_eq!( + snapshot.issue.as_ref().map(|issue| issue.code.as_ref()), + Some("audio_loopback_unavailable") + ); + assert!( + snapshot + .issue + .as_ref() + .is_some_and(|issue| issue.remediation.is_some()) + ); + assert!(input.ensure_prepared_source_ready().is_ok()); + let data = input.sample().expect("a waiting source samples silence"); + assert!(matches!(data, InputData::Audio(audio) if audio == AudioData::silence())); + input.stop(); + } + + #[test] + fn live_reconfiguration_still_fails_on_other_capture_errors() { + let prepared = + PreparedAudioReconfiguration::prepare_with_capture(staged_refusal_request(), |_, _| { + Err(anyhow!("audio input device 'probe' not found")) + }); + + assert!(prepared.is_err()); + } + + #[test] + fn loopback_unavailable_retries_on_the_device_reappearance_cadence() { + for attempt in 0..6 { + assert_eq!( + audio_reconnect_delay(AudioFailureKind::LoopbackUnavailable, attempt), + audio_reconnect_delay(AudioFailureKind::DeviceLost, attempt) + ); + } + assert_eq!( + AudioFailureKind::from_code(AudioFailureKind::LoopbackUnavailable as u8), + AudioFailureKind::LoopbackUnavailable + ); + } + + #[cfg(target_os = "linux")] #[test] fn pulse_record_buffer_attr_targets_twenty_milliseconds() { let spec = pulse::sample::Spec { diff --git a/crates/hypercolor-core/tests/audio_pipeline_tests.rs b/crates/hypercolor-core/tests/audio_pipeline_tests.rs index 6c4759ff2..227dfa866 100644 --- a/crates/hypercolor-core/tests/audio_pipeline_tests.rs +++ b/crates/hypercolor-core/tests/audio_pipeline_tests.rs @@ -17,11 +17,12 @@ use hypercolor_core::input::audio::features::{ use hypercolor_core::input::audio::fft::{FftPipeline, RingBuffer, precompute_hann, spectral_flux}; use hypercolor_core::input::audio::realtime::{AudioFrameRing, PushStats, push_frames}; use hypercolor_core::input::audio::{ - AudioCaptureBackend, AudioInput, classify_audio_capture_backend, + AudioCaptureBackend, AudioInput, classify_audio_capture_backend, default_input_fallback_allowed, }; use hypercolor_core::input::{InputData, InputManager, InputSource, ManagedSourceRole}; use hypercolor_types::audio::{ - AudioData, AudioPipelineConfig, AudioSourceType, CHROMA_BINS, MEL_BANDS, SPECTRUM_BINS, + AudioData, AudioInputPermission, AudioPipelineConfig, AudioSourceType, CHROMA_BINS, MEL_BANDS, + SPECTRUM_BINS, }; // ── Helpers ────────────────────────────────────────────────────────────── @@ -711,6 +712,22 @@ fn audio_capture_backend_classification_is_platform_neutral() { assert_eq!(AudioCaptureBackend::Disabled.status_id(), "disabled"); } +#[test] +fn system_monitor_falls_back_to_default_input_only_without_a_surprise_prompt() { + assert!(default_input_fallback_allowed( + AudioInputPermission::NotRequired + )); + assert!(default_input_fallback_allowed( + AudioInputPermission::Granted + )); + assert!(!default_input_fallback_allowed( + AudioInputPermission::Undetermined + )); + assert!(!default_input_fallback_allowed( + AudioInputPermission::Denied + )); +} + #[test] fn audio_status_backend_tracks_committed_configuration() { let disabled = AudioInput::new(&manual_input_config()); diff --git a/crates/hypercolor-core/tests/html_loader_tests.rs b/crates/hypercolor-core/tests/html_loader_tests.rs index abdea28ee..850bd8336 100644 --- a/crates/hypercolor-core/tests/html_loader_tests.rs +++ b/crates/hypercolor-core/tests/html_loader_tests.rs @@ -7,8 +7,8 @@ use tempfile::TempDir; use hypercolor_core::effect::{ EffectRegistry, builtin::register_builtin_effects, bundled_effects_root, - default_effect_search_paths, load_html_effect_file, parse_html_effect_metadata, - register_html_effects, register_html_effects_stepped, + bundled_html_effect_id, default_effect_search_paths, load_html_effect_file, + parse_html_effect_metadata, register_html_effects, register_html_effects_stepped, }; use hypercolor_types::canvas::srgb_to_linear; use hypercolor_types::effect::{EffectCategory, EffectSource}; @@ -239,6 +239,38 @@ fn bundled_html_effect_ids_are_stable_across_build_roots() { assert_eq!(source_entry.metadata.id, installed_entry.metadata.id); } +#[test] +fn bundled_html_effect_id_names_only_the_bundled_copy_of_a_stem() { + let temp = TempDir::new().expect("failed to create tempdir"); + let root = temp.path().join("effects"); + let html = r#" + + Poisonous + + +"#; + let bundled_path = root.join("hypercolor/Poisonous.html"); + let user_path = root.join("user/poisonous.html"); + write_html(&bundled_path, html); + write_html(&user_path, html); + + let bundled = load_html_effect_file(&bundled_path) + .expect("bundled effect should load") + .expect("bundled effect should register"); + let user = load_html_effect_file(&user_path) + .expect("user effect should load") + .expect("user effect should register"); + + assert_eq!(bundled_html_effect_id("poisonous"), bundled.metadata.id); + assert_eq!(bundled_html_effect_id(" POISONOUS "), bundled.metadata.id); + assert_ne!(bundled_html_effect_id("poisonous"), user.metadata.id); + // Ids that installs on macOS and Linux both reported for shipped effects. + assert_eq!( + bundled_html_effect_id("hyperbloom").to_string(), + "040ddf5e-bd07-4164-a71c-6415936be19b" + ); +} + #[test] fn discovery_reports_legacy_bundled_ids_without_installing_runtime_aliases() { let temp = TempDir::new().expect("failed to create tempdir"); diff --git a/crates/hypercolor-daemon/src/domain/mod.rs b/crates/hypercolor-daemon/src/domain/mod.rs index a32fded95..bb632fc2e 100644 --- a/crates/hypercolor-daemon/src/domain/mod.rs +++ b/crates/hypercolor-daemon/src/domain/mod.rs @@ -562,6 +562,14 @@ impl MutationContext { trigger: ChangeTrigger::Mcp, } } + + /// Provenance for a mutation the daemon makes on its own behalf. + #[must_use] + pub const fn system() -> Self { + Self { + trigger: ChangeTrigger::System, + } + } } #[cfg(test)] diff --git a/crates/hypercolor-daemon/src/first_run.rs b/crates/hypercolor-daemon/src/first_run.rs new file mode 100644 index 000000000..6927762df --- /dev/null +++ b/crates/hypercolor-daemon/src/first_run.rs @@ -0,0 +1,154 @@ +//! Defaults a genuinely fresh install starts with. +//! +//! A new install would otherwise open on a black canvas and an empty +//! favorites list. Instead the default scene runs [`FIRST_RUN_SCENE_EFFECT`] +//! and the library favorites start with [`FIRST_RUN_FAVORITES`]. +//! +//! Both seeds fire only on a fresh install, one where initialization found +//! no runtime session (at its state or legacy path), no named-scene store, +//! and no library store. Any earlier run that reached a scene change or a +//! clean shutdown left the session behind, so an upgraded install never sees +//! a seed, even one that never touched its favorites. A user who clears the +//! scene or removes every favorite rewrites those stores, so the choice +//! survives every restart. +//! +//! Picks are bundled effect file stems. A bundled effect's id hashes its +//! stem alone ([`bundled_html_effect_id`]), so a stem names the same effect on +//! every install and platform. Only files under a bundled effect root carry +//! that id; a user effect that shares the file name or the title has a +//! path-derived id instead. A pick this build lacks, or cannot render, is +//! skipped. + +use std::collections::HashMap; + +use hypercolor_core::bus::HypercolorBus; +use hypercolor_core::effect::bundled_html_effect_id; +use hypercolor_types::effect::EffectCategory; +use hypercolor_types::event::{HypercolorEvent, LibraryChangeKind, LibraryCollection}; +use tracing::{debug, info, warn}; + +use crate::api::library::unix_epoch_ms; +use crate::domain::MutationContext; +use crate::domain::effect::{ + ApplyEffect, EffectContext, RequestedTransition, ResolvedEffect, apply_effect, +}; +use crate::library::LibraryStore; +use crate::resource_summary::is_runnable_source; + +/// Bundled effect the default scene starts with on a fresh install. +pub const FIRST_RUN_SCENE_EFFECT: &str = "hyperbloom"; + +/// Bundled effects the library favorites start with, in the order clients +/// list them. Every pick looks good with no devices attached. +pub const FIRST_RUN_FAVORITES: &[&str] = &[ + "hyperbloom", + "borealis", + "bubble-garden", + "plasma-engine", + "spectral-fire", +]; + +/// Resolve a pick to an effect this build can render on LEDs. +async fn runnable_pick(effects: &EffectContext, stem: &str) -> Option { + let effect = effects + .metadata_for_mutation(bundled_html_effect_id(stem)) + .await?; + (is_runnable_source(&effect.source) && effect.category != EffectCategory::Display) + .then_some(effect) +} + +/// Load [`FIRST_RUN_SCENE_EFFECT`] into the active scene's primary zone. +/// +/// The apply takes the same domain path a client's apply does, so the scene +/// revision, events, and runtime-session save all happen as usual. The saved +/// session is what keeps a later start from seeding again. +pub(crate) async fn seed_scene(effects: &EffectContext) { + if effects.active_primary_effect().await.is_some() { + debug!("First-run scene seed skipped; the active scene already runs an effect"); + return; + } + let Some(effect) = runnable_pick(effects, FIRST_RUN_SCENE_EFFECT).await else { + info!( + effect = FIRST_RUN_SCENE_EFFECT, + "First-run scene left empty; its effect is missing or cannot render in this build" + ); + return; + }; + + let command = ApplyEffect { + effect, + controls: HashMap::new(), + preset_id: None, + target_zone: None, + expected_revision: None, + transition: RequestedTransition::cut(), + // Starting up never changes the output power a user chose. + wake_output: false, + }; + match apply_effect(effects, command, MutationContext::system()).await { + Ok(applied) => info!( + effect = %applied.effect.name, + zone = %applied.zone.name, + "Seeded the default scene for a fresh install" + ), + Err(error) => warn!( + effect = FIRST_RUN_SCENE_EFFECT, + %error, + "Failed to seed the default scene for a fresh install" + ), + } +} + +/// Favorite every [`FIRST_RUN_FAVORITES`] pick this build can run. +/// +/// Favorites list newest first, so each pick is stamped one millisecond +/// before the one above it in the table and clients show the table's order. +pub(crate) async fn seed_favorites( + effects: &EffectContext, + library: &dyn LibraryStore, + event_bus: &HypercolorBus, +) { + // Revisions survive a removed favorite, so this also catches a library + // that saw any favorite change after it opened. + if !library.project_favorites().await.is_empty() { + debug!("First-run favorites seed skipped; the library already holds favorites"); + return; + } + + let _admission = effects.admit_current().await; + let newest = unix_epoch_ms(); + let mut seeded = Vec::new(); + let mut skipped = Vec::new(); + for (rank, stem) in (0_u64..).zip(FIRST_RUN_FAVORITES.iter().copied()) { + let Some(effect) = runnable_pick(effects, stem).await else { + skipped.push(stem); + continue; + }; + match library + .upsert_favorite(effect.id, newest.saturating_sub(rank)) + .await + { + Ok(favorite) => { + event_bus.publish(HypercolorEvent::LibraryStoreChanged { + collection: LibraryCollection::Favorites, + entry_id: favorite.effect_id.to_string(), + kind: LibraryChangeKind::Upserted, + }); + seeded.push(stem); + } + Err(error) => { + warn!(effect = stem, %error, "Failed to seed a first-run favorite"); + } + } + } + + if !skipped.is_empty() { + info!( + ?skipped, + "First-run favorites skipped effects that are missing or cannot render in this build" + ); + } + if !seeded.is_empty() { + info!(?seeded, "Seeded library favorites for a fresh install"); + } +} diff --git a/crates/hypercolor-daemon/src/lib.rs b/crates/hypercolor-daemon/src/lib.rs index b670715e8..a506fc149 100644 --- a/crates/hypercolor-daemon/src/lib.rs +++ b/crates/hypercolor-daemon/src/lib.rs @@ -25,6 +25,7 @@ pub mod domain; pub mod driver_inventory; pub mod durable_stores; pub mod extensions; +pub mod first_run; pub mod interaction_routing; pub mod interactive_preview; pub mod launcher_claim; diff --git a/crates/hypercolor-daemon/src/startup/lifecycle.rs b/crates/hypercolor-daemon/src/startup/lifecycle.rs index 7eba9770f..53f09703c 100644 --- a/crates/hypercolor-daemon/src/startup/lifecycle.rs +++ b/crates/hypercolor-daemon/src/startup/lifecycle.rs @@ -92,7 +92,19 @@ impl DaemonState { } // Restore persisted scene state before the render loop begins producing frames. - self.restore_runtime_session(&config).await; + // The effect registry finished scanning during initialization, and + // no client reaches the scene or the library until the API is served, + // so first-run seeds land here without racing anything. + let fresh_install = std::mem::take(&mut self.fresh_install); + self.restore_runtime_session(&config, fresh_install).await; + if fresh_install { + crate::first_run::seed_favorites( + &self.domains.effects, + self.library_store.as_ref(), + &self.event_bus, + ) + .await; + } let session_config = config.session.clone(); let monitors = self @@ -468,8 +480,9 @@ impl DaemonState { .map_err(Into::into) } - async fn restore_runtime_session(&mut self, config: &HypercolorConfig) { + async fn restore_runtime_session(&mut self, config: &HypercolorConfig, seed_scene: bool) { let scene_mode = config.daemon.start_scene.trim(); + let mut snapshot_unreadable = false; let snapshot = self.startup_runtime_snapshot.take().or_else(|| { match runtime_state::load(&self.runtime_state_path) { Ok(snapshot) => snapshot, @@ -479,6 +492,7 @@ impl DaemonState { %error, "Failed to load runtime session snapshot" ); + snapshot_unreadable = true; None } } @@ -500,6 +514,12 @@ impl DaemonState { path = %self.runtime_state_path.display(), "No runtime session snapshot found to restore" ); + // A fresh install's last session is the curated default. A + // session file written since initialization still belongs to + // someone, even when it fails to parse, so it is never replaced. + if seed_scene && !snapshot_unreadable { + crate::first_run::seed_scene(&self.domains.effects).await; + } return; }; // Restore active layout if persisted. diff --git a/crates/hypercolor-daemon/src/startup/mod.rs b/crates/hypercolor-daemon/src/startup/mod.rs index 6c6f05629..0d17dbb34 100644 --- a/crates/hypercolor-daemon/src/startup/mod.rs +++ b/crates/hypercolor-daemon/src/startup/mod.rs @@ -260,6 +260,10 @@ pub struct DaemonState { pub(super) startup_device_aliases: Option, pub(super) startup_runtime_snapshot: Option, + /// Whether initialization found no runtime session, named-scene store, + /// or library store, so the first start applies the first-run defaults. + pub(super) fresh_install: bool, + /// Global discovery scan lock shared across startup and API-triggered scans. pub discovery_in_progress: Arc, diff --git a/crates/hypercolor-daemon/src/startup/services.rs b/crates/hypercolor-daemon/src/startup/services.rs index 504a2e2f1..abf8a1cc6 100644 --- a/crates/hypercolor-daemon/src/startup/services.rs +++ b/crates/hypercolor-daemon/src/startup/services.rs @@ -428,6 +428,10 @@ impl DaemonState { info!(profiles, backup = %backup.display(), "Imported legacy profiles as scenes"); } } + // Read after the legacy import, which writes this store, and before + // the load, which never creates it. A failed probe counts as present + // so a first-run seed can never land on an existing install. + let scene_store_absent = !scenes_path.try_exists().unwrap_or(true); let mut scene_store_inner = SceneStore::load(&scenes_path) .with_context(|| format!("failed to load scenes from {}", scenes_path.display()))?; if scene_store_inner @@ -691,6 +695,9 @@ impl DaemonState { // ── Runtime Session Store ─────────────────────────────────── let runtime_state_path = state_dir.join("runtime-state.json"); + // Only a session that never existed at either path is fresh; an + // unreadable one is an existing install whose state failed to load. + let mut runtime_session_absent = false; let mut startup_runtime_snapshot = match crate::runtime_state::load_migrated( &data_dir.join("runtime-state.json"), &runtime_state_path, @@ -701,6 +708,11 @@ impl DaemonState { ?migration, "Runtime session store ready" ); + runtime_session_absent = snapshot.is_none() + && matches!( + migration, + crate::path_migration::MigrationOutcome::FreshInstall + ); snapshot } Err(error) => { @@ -762,6 +774,13 @@ impl DaemonState { .context("failed to build driver module registry")?, ); let library_path = ConfigManager::data_dir().join("library.json"); + // Opening never creates the file, and any library write leaves it + // behind, including one that removed the last favorite. + let library_absent = !library_path.try_exists().unwrap_or(true); + // Any one of these stores proves an earlier run, so an upgrade that + // never wrote its library still gets no seeded favorites. + let fresh_install = runtime_session_absent && scene_store_absent && library_absent; + info!(fresh_install, "First-run defaults eligibility resolved"); let (library_store, library_identity) = open_persisted_library_store_with_effect_id_migrations( &library_path, @@ -934,6 +953,7 @@ impl DaemonState { device_aliases_path, startup_device_aliases: Some(startup_device_aliases), startup_runtime_snapshot, + fresh_install, discovery_in_progress, output_power, scene_transactions, diff --git a/crates/hypercolor-daemon/tests/first_run_defaults_tests.rs b/crates/hypercolor-daemon/tests/first_run_defaults_tests.rs new file mode 100644 index 000000000..6334c62d1 --- /dev/null +++ b/crates/hypercolor-daemon/tests/first_run_defaults_tests.rs @@ -0,0 +1,501 @@ +//! First-run defaults: what a genuinely fresh install starts with, and the +//! durable state that keeps an existing install from ever seeing it. +//! +//! Every test boots a real daemon over private config, data, and state +//! directories, with the bundled effect root pointed at fixture copies of +//! the curated picks. Hardware discovery, audio, capture, host input, and +//! session monitoring stay off. + +use std::path::PathBuf; +use std::sync::{Arc, LazyLock}; + +use hypercolor_core::config::{BootConfig, ConfigManager}; +use hypercolor_core::effect::{bundled_html_effect_id, set_bundled_effects_root}; +use hypercolor_daemon::first_run::FIRST_RUN_FAVORITES; +use hypercolor_daemon::startup::{DaemonState, default_config}; +use hypercolor_types::config::{HypercolorConfig, RenderAccelerationMode, ServoGpuImportMode}; +use hypercolor_types::effect::EffectId; +use hypercolor_types::scene::SceneId; +use tokio::sync::Mutex; + +static PATH_OVERRIDE_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); + +/// Private daemon directories plus a bundled effect root holding fixture +/// copies of the named picks, released together when dropped. +struct Install { + _lock: tokio::sync::MutexGuard<'static, ()>, + root: tempfile::TempDir, +} + +impl Install { + async fn with_bundled(stems: &[&str]) -> Self { + let fixtures: Vec<_> = stems.iter().map(|stem| (*stem, "ambient")).collect(); + Self::with_fixtures(&fixtures).await + } + + /// Fixture copies of `(stem, category)` pairs under the bundled root. + async fn with_fixtures(fixtures: &[(&str, &str)]) -> Self { + let lock = PATH_OVERRIDE_LOCK.lock().await; + let root = tempfile::tempdir().expect("tempdir should be created"); + ConfigManager::set_config_dir_override(Some(root.path().join("config"))); + ConfigManager::set_data_dir_override(Some(root.path().join("data"))); + ConfigManager::set_state_dir_override(Some(root.path().join("state"))); + + // Ids come from the stem under an `effects/hypercolor` root, which + // is the layout a dev tree and every release share. + let bundled = root.path().join("effects").join("hypercolor"); + std::fs::create_dir_all(&bundled).expect("bundled effect root should build"); + for (stem, category) in fixtures { + std::fs::write( + bundled.join(format!("{stem}.html")), + format!( + "{stem}\ + \ + \ + " + ), + ) + .expect("fixture effect should write"); + } + set_bundled_effects_root(Some(bundled)); + + Self { _lock: lock, root } + } + + fn data_path(&self, file_name: &str) -> PathBuf { + self.root.path().join("data").join(file_name) + } + + fn library_path(&self) -> PathBuf { + self.data_path("library.json") + } + + fn initialize_with(&self, config: HypercolorConfig) -> DaemonState { + let config_manager = Arc::new(ConfigManager::from_config_unchecked( + self.root.path().join("config").join("hypercolor.toml"), + config.clone(), + )); + DaemonState::initialize(BootConfig::from_config_unchecked(config), config_manager) + .expect("daemon should initialize") + } + + async fn start(&self) -> DaemonState { + self.start_with(quiet_config()).await + } + + async fn start_with(&self, config: HypercolorConfig) -> DaemonState { + // Boxed so the state is not held inline across the await, which + // would make every caller's future several kilobytes. + let mut state = Box::new(self.initialize_with(config)); + state.start().await.expect("daemon should start"); + *state + } +} + +impl Drop for Install { + fn drop(&mut self) { + set_bundled_effects_root(None); + ConfigManager::set_config_dir_override(None); + ConfigManager::set_data_dir_override(None); + ConfigManager::set_state_dir_override(None); + } +} + +/// Production defaults, including `start_scene = "last"`, with every +/// subsystem that would reach hardware or the host session switched off. +fn quiet_config() -> HypercolorConfig { + let mut config = default_config(); + assert_eq!(config.daemon.start_scene, "last"); + config.audio.enabled = false; + config.capture.enabled = false; + config.input.enabled = false; + config.session.enabled = false; + config.effect_engine.compositor_acceleration_mode = RenderAccelerationMode::Cpu; + config.rendering.servo_gpu_import.mode = ServoGpuImportMode::Off; + config.effect_engine.watch_effects = false; + config.discovery.background_enabled = false; + config.discovery.mdns_enabled = false; + config.discovery.blocks_scan = false; + config.network.mdns_publish = false; + config +} + +fn ids(stems: &[&str]) -> Vec { + stems + .iter() + .map(|stem| bundled_html_effect_id(stem)) + .collect() +} + +async fn favorite_ids(state: &DaemonState) -> Vec { + state + .library_store() + .list_favorites() + .await + .into_iter() + .map(|favorite| favorite.effect_id) + .collect() +} + +/// The effect the default scene's primary zone runs, if any. +async fn primary_effect(state: &DaemonState) -> Option { + let scenes = state.scene_manager.snapshot().await; + assert_eq!(scenes.active_scene_id(), Some(&SceneId::DEFAULT)); + scenes.active_scene()?.primary_zone()?.effect_ids().next() +} + +#[tokio::test] +async fn an_install_with_none_of_the_picks_writes_nothing() { + let install = Install::with_bundled(&[]).await; + let mut state = install.start().await; + + assert_eq!(primary_effect(&state).await, None); + assert!(favorite_ids(&state).await.is_empty()); + // Nothing to seed leaves the library unwritten. + assert!(!install.library_path().exists()); + + state.shutdown().await.expect("shutdown should succeed"); +} + +#[cfg(not(feature = "servo"))] +#[tokio::test] +async fn a_build_that_cannot_render_html_seeds_nothing() { + let install = Install::with_bundled(FIRST_RUN_FAVORITES).await; + let mut state = install.start().await; + + // The picks are registered, but this build has no renderer for them. + let catalog = state.effect_registry.read().await; + for id in ids(FIRST_RUN_FAVORITES) { + assert!(catalog.get(&id).is_some(), "fixture {id} should register"); + } + drop(catalog); + assert_eq!(primary_effect(&state).await, None); + assert!(favorite_ids(&state).await.is_empty()); + assert!(!install.library_path().exists()); + + state.shutdown().await.expect("shutdown should succeed"); +} + +/// Seeds can only land where the build renders HTML effects. +#[cfg(feature = "servo")] +mod renderable { + use std::path::Path; + + use hypercolor_daemon::domain::scene_tree::{ClearScene, clear_scene}; + use hypercolor_daemon::first_run::FIRST_RUN_SCENE_EFFECT; + use hypercolor_daemon::runtime_state::{self, RuntimeSessionSnapshot}; + use hypercolor_types::layer::LayerSource; + + use super::*; + + impl Install { + fn initialize(&self) -> DaemonState { + self.initialize_with(quiet_config()) + } + + fn runtime_state_path(&self) -> PathBuf { + self.root.path().join("state").join("runtime-state.json") + } + } + + fn write_json(path: &Path, value: &serde_json::Value) { + std::fs::create_dir_all(path.parent().expect("fixture path should have a parent")) + .expect("fixture directory should build"); + std::fs::write( + path, + serde_json::to_vec_pretty(value).expect("fixture should serialize"), + ) + .expect("fixture should write"); + } + + #[tokio::test] + async fn a_fresh_install_starts_with_hyperbloom_and_the_curated_favorites() { + let install = Install::with_bundled(FIRST_RUN_FAVORITES).await; + let mut state = install.start().await; + + let hyperbloom = bundled_html_effect_id(FIRST_RUN_SCENE_EFFECT); + assert_eq!( + hyperbloom.to_string(), + "040ddf5e-bd07-4164-a71c-6415936be19b" + ); + assert_eq!(primary_effect(&state).await, Some(hyperbloom)); + { + let scenes = state.scene_manager.snapshot().await; + let zone = scenes + .active_scene() + .and_then(|scene| scene.primary_zone()) + .expect("default scene should have a primary zone"); + let [layer] = zone.layers.as_slice() else { + panic!( + "primary zone should hold one layer, found {:?}", + zone.layers + ); + }; + let LayerSource::Effect { controls, .. } = &layer.source else { + panic!("primary layer should run an effect"); + }; + // No overrides: the zone renders the effect's own defaults. + assert!(controls.is_empty()); + } + + assert_eq!(favorite_ids(&state).await, ids(FIRST_RUN_FAVORITES)); + // Both seeds leave their durable store behind, so neither repeats. + assert!(install.runtime_state_path().exists()); + assert!(install.library_path().exists()); + + state.shutdown().await.expect("shutdown should succeed"); + } + + #[tokio::test] + async fn seeds_apply_once_and_never_return_after_the_user_clears_them() { + let install = Install::with_bundled(FIRST_RUN_FAVORITES).await; + let hyperbloom = bundled_html_effect_id(FIRST_RUN_SCENE_EFFECT); + + let mut first = install.start().await; + let seeded = first.library_store().list_favorites().await; + assert_eq!(seeded.len(), FIRST_RUN_FAVORITES.len()); + first + .shutdown() + .await + .expect("first shutdown should succeed"); + drop(first); + + // A restart restores the seeded session and leaves the library as + // it was: same entries, same timestamps, nothing added. + let mut second = install.start().await; + assert_eq!(primary_effect(&second).await, Some(hyperbloom)); + assert_eq!(second.library_store().list_favorites().await, seeded); + + for favorite in &seeded { + assert!( + second + .library_store() + .remove_favorite(favorite.effect_id) + .await + .expect("favorite removal should persist") + ); + } + clear_scene( + &second.domains.scene_tree, + ClearScene { + zone: None, + expected_revision: None, + }, + ) + .await + .expect("scene clear should commit"); + assert_eq!(primary_effect(&second).await, None); + second + .shutdown() + .await + .expect("second shutdown should succeed"); + drop(second); + + let mut third = install.start().await; + assert_eq!(primary_effect(&third).await, None); + assert!(favorite_ids(&third).await.is_empty()); + third + .shutdown() + .await + .expect("third shutdown should succeed"); + } + + /// No seeds at all: the scene stays empty and the favorites list stays + /// empty. + async fn assert_unseeded(state: &DaemonState) { + assert_eq!(primary_effect(state).await, None); + assert!(favorite_ids(state).await.is_empty()); + } + + #[tokio::test] + async fn an_existing_empty_session_gets_no_seeds() { + let install = Install::with_bundled(FIRST_RUN_FAVORITES).await; + runtime_state::save( + &install.runtime_state_path(), + &RuntimeSessionSnapshot { + active_scene_id: Some(SceneId::DEFAULT.to_string()), + default_scene_zones: Vec::new(), + active_layout_id: None, + manual_paused: false, + }, + ) + .expect("runtime session should save"); + + let mut state = install.start().await; + + // The user left this scene black, and an upgrade that never wrote + // its library is still an existing install. + assert_unseeded(&state).await; + + state.shutdown().await.expect("shutdown should succeed"); + } + + #[tokio::test] + async fn a_legacy_session_counts_as_an_existing_install() { + let install = Install::with_bundled(FIRST_RUN_FAVORITES).await; + write_json( + &install.data_path("runtime-state.json"), + &serde_json::json!({ + "active_scene_id": SceneId::DEFAULT.to_string(), + "default_scene_zones": [], + "active_layout_id": null, + "manual_paused": false, + }), + ); + + let mut state = install.start().await; + + assert_unseeded(&state).await; + + state.shutdown().await.expect("shutdown should succeed"); + } + + #[tokio::test] + async fn an_unreadable_session_is_never_replaced() { + let install = Install::with_bundled(FIRST_RUN_FAVORITES).await; + write_json( + &install.runtime_state_path(), + &serde_json::json!({ "retired_field": true }), + ); + + let mut state = install.start().await; + + assert_unseeded(&state).await; + + state.shutdown().await.expect("shutdown should succeed"); + } + + #[tokio::test] + async fn a_session_written_after_initialization_is_never_replaced() { + let install = Install::with_bundled(FIRST_RUN_FAVORITES).await; + let mut state = install.initialize(); + // Initialization found no session, but one exists by the time the + // daemon restores; it fails to parse and still blocks the scene + // seed, so nothing replaces it. + write_json( + &install.runtime_state_path(), + &serde_json::json!({ "retired_field": true }), + ); + state.start().await.expect("daemon should start"); + + assert_eq!(primary_effect(&state).await, None); + + state.shutdown().await.expect("shutdown should succeed"); + } + + #[tokio::test] + async fn a_named_scene_store_blocks_the_seeds() { + let install = Install::with_bundled(FIRST_RUN_FAVORITES).await; + write_json( + &install.data_path("scenes.json"), + &serde_json::json!({ "schema_version": 2, "scenes": {} }), + ); + + let mut state = install.start().await; + + assert_unseeded(&state).await; + + state.shutdown().await.expect("shutdown should succeed"); + } + + #[tokio::test] + async fn an_existing_empty_library_gets_no_seeds() { + let install = Install::with_bundled(FIRST_RUN_FAVORITES).await; + write_json( + &install.library_path(), + &serde_json::json!({ + "version": 2, + "favorites": [], + "favorite_revisions": [], + "presets": [], + "playlists": [], + }), + ); + + let mut state = install.start().await; + + assert_unseeded(&state).await; + + state.shutdown().await.expect("shutdown should succeed"); + } + + #[tokio::test] + async fn a_library_whose_favorites_were_all_removed_stays_empty() { + let install = Install::with_bundled(FIRST_RUN_FAVORITES).await; + write_json( + &install.library_path(), + &serde_json::json!({ + "version": 2, + "favorites": [], + "favorite_revisions": [{ + "effect_id": bundled_html_effect_id("borealis"), + "revision": 2, + }], + "presets": [], + "playlists": [], + }), + ); + + let mut state = install.start().await; + + assert_unseeded(&state).await; + + state.shutdown().await.expect("shutdown should succeed"); + } + + #[tokio::test] + async fn missing_picks_are_skipped_and_the_rest_keep_their_order() { + let install = Install::with_bundled(&["spectral-fire", "borealis"]).await; + let mut state = install.start().await; + + assert_eq!( + favorite_ids(&state).await, + ids(&["borealis", "spectral-fire"]) + ); + // Without Hyperbloom the scene starts exactly as it did before. + assert_eq!(primary_effect(&state).await, None); + + state.shutdown().await.expect("shutdown should succeed"); + } + + #[tokio::test] + async fn the_scene_seed_needs_only_its_own_pick() { + let install = Install::with_bundled(&[FIRST_RUN_SCENE_EFFECT]).await; + let mut state = install.start().await; + + assert_eq!( + primary_effect(&state).await, + Some(bundled_html_effect_id(FIRST_RUN_SCENE_EFFECT)) + ); + assert_eq!(favorite_ids(&state).await, ids(&[FIRST_RUN_SCENE_EFFECT])); + + state.shutdown().await.expect("shutdown should succeed"); + } + + #[tokio::test] + async fn a_display_face_is_never_a_pick() { + let install = + Install::with_fixtures(&[(FIRST_RUN_SCENE_EFFECT, "display"), ("borealis", "ambient")]) + .await; + let mut state = install.start().await; + + assert_eq!(primary_effect(&state).await, None); + assert_eq!(favorite_ids(&state).await, ids(&["borealis"])); + + state.shutdown().await.expect("shutdown should succeed"); + } + + #[tokio::test] + async fn a_configured_start_scene_skips_only_the_scene_seed() { + let install = Install::with_bundled(FIRST_RUN_FAVORITES).await; + let mut config = quiet_config(); + config.daemon.start_scene = "default".to_owned(); + let mut state = install.start_with(config).await; + + // Only "last" restores a session, so only it starts the curated one. + assert_eq!(primary_effect(&state).await, None); + assert_eq!(favorite_ids(&state).await, ids(FIRST_RUN_FAVORITES)); + + state.shutdown().await.expect("shutdown should succeed"); + } +} diff --git a/crates/hypercolor-macos-capture/Cargo.toml b/crates/hypercolor-macos-capture/Cargo.toml index 5cc2ecd07..4bde2bfa7 100644 --- a/crates/hypercolor-macos-capture/Cargo.toml +++ b/crates/hypercolor-macos-capture/Cargo.toml @@ -30,6 +30,7 @@ uuid = { workspace = true } block2 = "0.6.2" dispatch2 = "0.3.1" objc2 = { workspace = true, features = ["std"] } +objc2-avf-audio = { workspace = true, features = ["std", "AVAudioApplication"] } objc2-core-foundation = { workspace = true, features = [ "std", "CFArray", diff --git a/crates/hypercolor-macos-capture/src/lib.rs b/crates/hypercolor-macos-capture/src/lib.rs index 9c11ac7ef..72ceaf083 100644 --- a/crates/hypercolor-macos-capture/src/lib.rs +++ b/crates/hypercolor-macos-capture/src/lib.rs @@ -2,6 +2,9 @@ //! //! Native framework ownership remains private to this crate. The public frame //! boundary contains only plain Rust metadata plus an opaque retained surface. +//! +//! The crate also owns the read-only microphone permission probe, since it +//! already holds the other privacy-gated capture authorization checks. mod clock; #[cfg(feature = "capture-fixtures")] @@ -11,6 +14,8 @@ mod frame; mod geometry; mod mailbox; #[cfg(target_os = "macos")] +mod microphone; +#[cfg(target_os = "macos")] mod native; #[cfg(target_os = "macos")] mod screenshot; @@ -22,6 +27,8 @@ mod stubs; #[cfg(any(target_os = "macos", test))] mod worker; +#[cfg(target_os = "macos")] +pub use microphone::microphone_record_permission; #[cfg(target_os = "macos")] pub use native::{ MacosNativeTransactionError, MacosNativeTransactionPhase, MacosScreenCaptureSession, @@ -38,6 +45,7 @@ pub use screenshot::{ #[cfg(not(target_os = "macos"))] pub use stubs::{ MacosScreenCaptureSession, MacosScreenshotReferenceCapture, MacosStreamRequestTransaction, + microphone_record_permission, }; pub use clock::{MacosDisplayClock, MacosDisplayClockError}; diff --git a/crates/hypercolor-macos-capture/src/microphone.rs b/crates/hypercolor-macos-capture/src/microphone.rs new file mode 100644 index 000000000..0e81bb22c --- /dev/null +++ b/crates/hypercolor-macos-capture/src/microphone.rs @@ -0,0 +1,39 @@ +//! Microphone record permission, read without ever requesting it. +//! +//! macOS gates every audio input device, virtual loopback drivers included, +//! behind the Microphone privacy permission. Opening an input before the user +//! has answered shows the system prompt, so callers that open inputs on their +//! own initiative read the current answer here first. + +use hypercolor_types::audio::AudioInputPermission; +use objc2::rc::autoreleasepool; +use objc2_avf_audio::{AVAudioApplication, AVAudioApplicationRecordPermission}; + +/// Report this process's current microphone record permission. +/// +/// This only reads the stored answer through `AVAudioApplication`; it never +/// requests access, so calling it cannot show the macOS microphone prompt. +#[must_use] +pub fn microphone_record_permission() -> AudioInputPermission { + autoreleasepool(|_| { + // SAFETY: `sharedInstance` takes no arguments and returns the + // process-wide AVAudioApplication singleton as a retained object. + // The class exists from macOS 14, below the 15.2 deployment floor. + let application = unsafe { AVAudioApplication::sharedInstance() }; + // SAFETY: `recordPermission` is a read-only property getter on the + // live singleton retained above. It queries the stored privacy + // answer and has no side effects. + let permission = unsafe { application.recordPermission() }; + record_permission(permission) + }) +} + +fn record_permission(permission: AVAudioApplicationRecordPermission) -> AudioInputPermission { + match permission { + AVAudioApplicationRecordPermission::Granted => AudioInputPermission::Granted, + AVAudioApplicationRecordPermission::Denied => AudioInputPermission::Denied, + // Undetermined, plus any value a later SDK introduces: access is not + // confirmed, so nothing may open an input on the strength of it. + _ => AudioInputPermission::Undetermined, + } +} diff --git a/crates/hypercolor-macos-capture/src/stubs.rs b/crates/hypercolor-macos-capture/src/stubs.rs index c29c48aff..b61a2dfc0 100644 --- a/crates/hypercolor-macos-capture/src/stubs.rs +++ b/crates/hypercolor-macos-capture/src/stubs.rs @@ -8,6 +8,8 @@ use std::sync::Arc; +use hypercolor_types::audio::AudioInputPermission; + use crate::{ MacosCaptureCallbackDiagnostics, MacosCaptureCapabilities, MacosCaptureError, MacosCaptureSelection, MacosCaptureSelector, MacosFrameMailbox, MacosProtectedSourceState, @@ -17,6 +19,15 @@ use crate::{ const UNSUPPORTED: MacosCaptureError = MacosCaptureError::CapabilityProbeFailed("ScreenCaptureKit is unavailable on this platform"); +/// Report that no macOS microphone privacy gate exists on this platform. +/// +/// Off macOS there is no Microphone TCC service, so opening an audio input +/// can never show its prompt. +#[must_use] +pub fn microphone_record_permission() -> AudioInputPermission { + AudioInputPermission::NotRequired +} + /// Stub ScreenCaptureKit session on hosts without ScreenCaptureKit. #[derive(Debug)] pub enum MacosScreenCaptureSession {} diff --git a/crates/hypercolor-macos-capture/tests/microphone_permission_tests.rs b/crates/hypercolor-macos-capture/tests/microphone_permission_tests.rs new file mode 100644 index 000000000..d030a4017 --- /dev/null +++ b/crates/hypercolor-macos-capture/tests/microphone_permission_tests.rs @@ -0,0 +1,28 @@ +use hypercolor_macos_capture::microphone_record_permission; +use hypercolor_types::audio::AudioInputPermission; + +#[cfg(target_os = "macos")] +#[test] +fn microphone_permission_reports_a_tcc_answer_on_macos() { + let permission = microphone_record_permission(); + println!("microphone record permission: {permission:?}"); + assert_ne!( + permission, + AudioInputPermission::NotRequired, + "macOS always gates audio input behind the Microphone permission" + ); + assert_eq!( + microphone_record_permission(), + permission, + "reading the permission must not change it" + ); +} + +#[cfg(not(target_os = "macos"))] +#[test] +fn microphone_permission_is_not_required_off_macos() { + assert_eq!( + microphone_record_permission(), + AudioInputPermission::NotRequired + ); +} diff --git a/crates/hypercolor-types/src/audio.rs b/crates/hypercolor-types/src/audio.rs index 9fcc5f8ad..6a419f117 100644 --- a/crates/hypercolor-types/src/audio.rs +++ b/crates/hypercolor-types/src/audio.rs @@ -153,6 +153,26 @@ pub enum AudioSourceType { None, } +// ─── AudioInputPermission ───────────────────────────────────────────── + +/// Whether the OS lets this process open an audio input device without +/// asking the user first. +/// +/// Platforms that gate audio input behind a privacy prompt report the +/// user's current answer; platforms without such a prompt report +/// [`NotRequired`](Self::NotRequired). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum AudioInputPermission { + /// The user already allowed audio input. + Granted, + /// The user refused audio input. + Denied, + /// The user has not been asked, so opening an input would prompt. + Undetermined, + /// The platform opens audio inputs without a permission prompt. + NotRequired, +} + // ─── AudioPipelineConfig ────────────────────────────────────────────── /// DSP pipeline configuration for the audio analysis engine. diff --git a/docs/content/api/rest.md b/docs/content/api/rest.md index 9c41a0a3c..6f8a152f0 100644 --- a/docs/content/api/rest.md +++ b/docs/content/api/rest.md @@ -1020,6 +1020,10 @@ The library holds favorites, presets, and playlists. ### Favorites +A fresh install starts with a few curated favorites, which the daemon seeds once on its +very first run. An existing install never gets them, even one whose favorites list is +empty. + {% %} List favorited effects. {% %} diff --git a/docs/content/guide/audio-setup.md b/docs/content/guide/audio-setup.md index aeb8760a5..c1d92b572 100644 --- a/docs/content/guide/audio-setup.md +++ b/docs/content/guide/audio-setup.md @@ -36,7 +36,7 @@ When `[audio].device` is set to `"default"` (the out-of-the-box value), the daem **On Linux**, it queries the PulseAudio compatibility layer (which PipeWire exposes) for the default sink name, constructs the monitor source name as `{sink_name}.monitor`, and verifies it exists before opening the stream. -**On Windows and macOS**, capture goes through the platform audio API (WASAPI and CoreAudio, via cpal). The daemon scans the available input devices for one whose name looks like a loopback; it matches "monitor", "loopback", "what u hear", and "stereo mix", and opens the first hit. If no loopback-named input device exists, system-audio capture is unavailable, while microphone capture (`device = "microphone"`) still works. +**On Windows and macOS**, capture goes through the platform audio API (WASAPI and CoreAudio, via cpal). The daemon scans the available input devices for one whose name looks like a loopback; it matches "monitor", "loopback", "what u hear", and "stereo mix", and opens the first hit. If no loopback-named input device exists, Windows falls back to the default input device, which is usually a microphone. macOS falls back the same way only when Hypercolor already has Microphone permission. Otherwise the daemon opens nothing, audio-reactive effects receive silence, and `hypercolor diagnose --check input` explains why: `audio_loopback_unavailable`, with a hint to choose Default Microphone or a loopback input device, while you have not answered the Microphone prompt yet, or `audio_permission_denied` if you turned it down. See [macOS](#macos-install-a-loopback-driver) below. You can confirm detection worked by applying any audio-reactive effect (the built-in `audio-pulse` effect is designed for exactly this) and watching the TUI spectrum strip described at the end of this page. @@ -89,7 +89,9 @@ macOS exposes no system-audio input at all, so an audio-reactive setup needs a l 1. Install [BlackHole](https://existential.audio/blackhole/) (free) or a commercial tool like Rogue Amoeba's Loopback. 2. Route your output through it. The usual pattern is a Multi-Output Device in Audio MIDI Setup containing both your speakers and BlackHole, so you hear the audio while Hypercolor captures it. -3. Leave `device = "default"` (the daemon spots the loopback by name), or set `device` to the loopback device's exact name. +3. Pick the loopback device under **Device** in the Audio settings, or set `device` to its exact name as `hypercolor audio devices` lists it (BlackHole appears as `BlackHole 2ch`). `device = "default"` finds a loopback on its own only when the device name contains "loopback" or "monitor". + +macOS treats every audio input as a microphone, loopback drivers included, so the first capture from any input asks for Microphone permission. The default source never falls back to the microphone just to trigger that prompt. The prompt appears when the daemon first opens an input you set up: Default Microphone, a named device such as BlackHole, or a loopback-named device that `"default"` found. Until you grant access, `"default"` with no loopback device waits silently instead of falling back to the microphone. If you denied the prompt earlier, allow Hypercolor under System Settings → Privacy & Security → Microphone. --- @@ -156,7 +158,7 @@ If the strip shows "No audio", the daemon is not receiving samples. Work through 1. Run `hypercolor audio devices` and confirm the active device is a monitor or loopback source. 2. On Linux, run `pactl list short sources` and check that the monitor source you configured actually exists. If you recently changed your audio hardware or switched PipeWire profiles, the source name may have changed. -3. On Windows and macOS, confirm a loopback-named input device exists at all (Stereo Mix, a virtual cable, or BlackHole) and that your output is routed through it. +3. On Windows and macOS, confirm a loopback input device exists at all (Stereo Mix, a virtual cable, or BlackHole) and that your output is routed through it. On macOS, `audio_loopback_unavailable` in `hypercolor diagnose --check input` means the daemon found no loopback device and is waiting for you to choose an input. 4. Check that `enabled = true` in `[audio]`. After editing the file, restart the daemon (or rely on `watch_config = true`, the default, to hot-reload) so the change takes effect. 5. On Linux, make sure nothing is preventing the daemon from connecting to the PulseAudio compatibility socket. On some minimal installs, `pipewire-pulse` is not running; start it with `systemctl --user start pipewire-pulse`. diff --git a/docs/content/guide/choose-your-install.md b/docs/content/guide/choose-your-install.md index 507604ba2..bb9384e6a 100644 --- a/docs/content/guide/choose-your-install.md +++ b/docs/content/guide/choose-your-install.md @@ -117,7 +117,10 @@ Hypercolor for macOS requires Apple silicon and macOS 15.2 (Sequoia) or newer; Intel Macs are not supported. Hypercolor requests Screen Recording permission when you enable screen capture. -For system audio, follow [Audio setup](@/guide/audio-setup.md). +It asks for Microphone permission only when it first opens an audio input you +set up; the default system-audio source never falls back to the microphone +before you grant access. For system audio, follow +[Audio setup](@/guide/audio-setup.md). ### Homebrew {% raw %}{#homebrew}{% endraw %} diff --git a/docs/content/guide/configuration.md b/docs/content/guide/configuration.md index 6ea0d894f..2bd55cf76 100644 --- a/docs/content/guide/configuration.md +++ b/docs/content/guide/configuration.md @@ -80,7 +80,7 @@ state_history_min_interval_secs = 30 # Content changed more recently than this **`canvas_width` / `canvas_height`** are the render canvas dimensions. LED spatial positions are normalized to `[0.0, 1.0]`, so layouts remain valid across different canvas sizes. Canvas resizes take effect at the next frame boundary. Both keys support live reload. -**`start_scene`**: `"last"` restores the scene that was active at shutdown. `"default"` selects the auto-managed Default scene. Any other non-empty string is treated as a saved scene name or id. +**`start_scene`**: `"last"` restores the scene that was active at shutdown, and on a fresh install with nothing to restore it starts the default scene with Hyperbloom. `"default"` selects the auto-managed Default scene. Any other non-empty string is treated as a saved scene name or id. **`shutdown_behavior`**: `hardware_default` leaves LEDs on their last hardware frame (most controllers hold it). `off` sends a black frame to every device. `static` sends the color in `shutdown_color`. @@ -210,7 +210,7 @@ beat_sensitivity = 1.2 # Beat onset threshold (higher = fewer, stronger bea **`beat_sensitivity`** is a threshold despite its name, and the settings page labels it Beat Threshold. It multiplies the beat detector's onset thresholds, so higher values fire fewer, stronger beats. Below `1.0` the detector fires on any held bass note several times a second, and beat-reactive effects strobe. The default of `1.2` comes from a sweep over synthetic tracks in five genres, where it kept most kicks and cut beats that land on neither a kick nor a snare by about two thirds compared with `0.6`. It still fires on some hats and other bright hits, and it can fire a second time 35 to 150 ms after a kick. A config file whose `schema_version` is 4 or 5 and that still carries the old `0.6` default moves to the current default when it loads. -**`device`** special values: `"default"` captures from the system monitor (what you hear); `"microphone"` uses the default input device; any other string is matched against PulseAudio/PipeWire device names. List available devices: +**`device`** special values: `"default"` captures from the system monitor (what you hear); `"microphone"` uses the default input device; any other string is matched against PulseAudio/PipeWire device names. When no monitor or loopback device exists, `"default"` falls back to the default input device on Linux and Windows, and on macOS only once Hypercolor already holds Microphone permission, so the fallback never triggers the permission prompt by itself. List available devices: ```bash curl http://localhost:9420/api/v1/system/audio-devices | jq diff --git a/docs/content/guide/first-launch.md b/docs/content/guide/first-launch.md index 75b24ad93..60c3d4633 100644 --- a/docs/content/guide/first-launch.md +++ b/docs/content/guide/first-launch.md @@ -173,7 +173,14 @@ launch. ## After the wizard The window lands on the Hypercolor dashboard. The daemon is running, the tray is live, -and device discovery is already in progress. From here: +and device discovery is already in progress. + +On a fresh install the dashboard is already lit. The first time the daemon starts with no +saved session, scenes, or library, it loads **Hyperbloom** into the default scene with its +default controls, so the preview glows on its own and reacts to sound, and it seeds your +favorites with Hyperbloom, Borealis, Bubble Garden, Plasma Engine, and Spectral Fire. This +happens once: clear the scene or remove the favorites and they stay that way, and an +upgraded install keeps exactly what it had. From here: - Head to **Devices** to confirm your hardware was found. - Open **Effects** to apply your first look. @@ -183,13 +190,22 @@ and device discovery is already in progress. From here: If devices are missing, the [Finding devices](@/guide/finding-devices.md) guide covers USB udev rules, network mDNS, and per-protocol pairing. +On macOS, Hyperbloom glows without reacting until it has something to listen to. The +default audio source never falls back to your microphone before you have granted it +access, so first boot asks for nothing unless a loopback-named device is already +installed. Choose an input under **Device** in the Audio settings, a loopback driver +such as BlackHole or Default Microphone, and macOS asks for Microphone permission then. +See [Audio setup](@/guide/audio-setup.md#macos-install-a-loopback-driver). + --- ## Launching without the desktop app If you installed the daemon only (Linux server, headless setup, or manual `just daemon` from source), there is no wizard and no tray. The daemon starts, writes its config to -`~/.config/hypercolor/hypercolor.toml` on first run, and listens on `:9420`. You can +`~/.config/hypercolor/hypercolor.toml` on first run, and listens on `:9420`. It applies +the same first-run defaults, Hyperbloom in the default scene and the curated favorites, +without any help from the app. You can reach the web UI at `http://localhost:9420` in a browser, or use the CLI or TUI: ```bash diff --git a/docs/content/guide/installation.md b/docs/content/guide/installation.md index b1496105b..7911ad4d8 100644 --- a/docs/content/guide/installation.md +++ b/docs/content/guide/installation.md @@ -156,7 +156,11 @@ support). Screen capture is off until an explicit authorization or source-selection action. Keyboard capture uses Input Monitoring. Passive pointer capture does -not use a TCC service. ScreenCaptureKit uses Screen Recording. The settings +not use a TCC service. ScreenCaptureKit uses Screen Recording. Audio capture +uses Microphone, which macOS applies to every audio input. The default +system-audio source never falls back to the microphone before you have +granted it, so the Microphone prompt appears only once the daemon opens an +input you set up (see [Audio setup](@/guide/audio-setup.md)). The settings page links directly to the matching System Settings privacy pane when manual remediation is needed. diff --git a/docs/content/studio/scenes.md b/docs/content/studio/scenes.md index 85d23a872..b0a2ece19 100644 --- a/docs/content/studio/scenes.md +++ b/docs/content/studio/scenes.md @@ -40,6 +40,8 @@ The picker always names what is actually rendering, even on a brand-new install When you first run Hypercolor, you are in the **ephemeral default scene**. It is the live working state of your rig before you have saved anything, and the daemon never lists it as a saved scene. The scene picker still names it so you always know what is on screen, but the rename and delete actions are hidden for it, because there is nothing persisted to rename or delete. +On a brand-new install the default scene starts with Hyperbloom in its primary zone, so something is on screen before you pick an effect. From then on it holds whatever you leave in it, including nothing at all. + You leave the default scene the moment you create your first scene. Creating a scene captures the current rig as a saved, named configuration and switches to it. To return to the default scene later, deactivate the active scene from the scene pill in the dashboard header. Deactivation rebuilds the ephemeral working state, and the picker only flips once the daemon confirms the switch. diff --git a/docs/content/troubleshooting/audio-not-reacting.md b/docs/content/troubleshooting/audio-not-reacting.md index 3d6f74886..2c86bd941 100644 --- a/docs/content/troubleshooting/audio-not-reacting.md +++ b/docs/content/troubleshooting/audio-not-reacting.md @@ -124,6 +124,8 @@ Audio capture stream configured source=SystemMonitor device=alsa_output.pci-0000 If that line never appears, the source name is wrong or PipeWire/PulseAudio refused the connection. Double-check the exact name from `pactl list sources short`. +On macOS, `No system audio loopback device found; audio stays silent until an input is chosen in audio settings` means `device = "default"` found no loopback device and Hypercolor does not have Microphone permission yet, so it opened nothing rather than prompt for your microphone. Choose a loopback device such as BlackHole, or Default Microphone, under **Device** in the Audio settings. See [Audio setup](@/guide/audio-setup.md#macos-install-a-loopback-driver). + --- ## Step 5: Apply an audio-reactive effect to test