Skip to main content

muninn/
lib.rs

1#![doc = include_str!("../README.md")]
2
3use std::path::PathBuf;
4use std::time::Duration;
5
6use async_trait::async_trait;
7use tracing::warn;
8
9pub mod audio;
10pub mod config;
11pub mod envelope;
12pub mod error;
13pub mod hotkeys;
14pub mod injector;
15pub mod mock;
16pub mod orchestrator;
17pub mod permissions;
18pub mod platform;
19pub mod runner;
20pub mod runtime_flow;
21pub mod scoring;
22pub mod secrets;
23pub mod state;
24pub mod target_context;
25pub mod transcription;
26
27pub const TARGET_RUNTIME: &str = "runtime";
28pub const TARGET_PIPELINE: &str = "pipeline";
29pub const TARGET_PROVIDER: &str = "provider";
30pub const TARGET_CONFIG: &str = "config";
31pub const TARGET_HOTKEY: &str = "hotkey";
32pub const TARGET_RECORDING: &str = "recording";
33pub const TARGET_DEFAULT: &str = "default";
34
35#[doc(hidden)]
36pub fn load_builtin_step_config<T, FDefaults, FResolved>(
37    step_label: &'static str,
38    on_not_found: FDefaults,
39    resolve: FResolved,
40) -> Result<T, String>
41where
42    FDefaults: FnOnce() -> T,
43    FResolved: FnOnce(&ResolvedBuiltinStepConfig) -> T,
44{
45    resolve_builtin_step_config_from_load_result(
46        step_label,
47        AppConfig::load(),
48        on_not_found,
49        resolve,
50    )
51}
52
53#[doc(hidden)]
54pub fn resolve_builtin_step_config_from_load_result<T, FDefaults, FResolved>(
55    step_label: &'static str,
56    load_result: Result<AppConfig, ConfigError>,
57    on_not_found: FDefaults,
58    resolve: FResolved,
59) -> Result<T, String>
60where
61    FDefaults: FnOnce() -> T,
62    FResolved: FnOnce(&ResolvedBuiltinStepConfig) -> T,
63{
64    load_result
65        .map(|config| resolve(&ResolvedBuiltinStepConfig::from_app_config(&config)))
66        .or_else(|error| match &error {
67            ConfigError::NotFound { .. } => {
68                warn!(
69                    target: TARGET_CONFIG,
70                    step = step_label,
71                    error = %error,
72                    "built-in step config missing; using default provider settings"
73                );
74                Ok(on_not_found())
75            }
76            _ => Err(format!(
77                "failed to load AppConfig for {step_label}: {error}"
78            )),
79        })
80}
81
82pub use audio::MacosAudioRecorder;
83pub use config::{
84    resolve_config_path, AppConfig, AppleSpeechProviderConfig, ConfigError, ConfigValidationError,
85    DeepgramProviderConfig, OnErrorPolicy, PayloadFormat, ProfileConfig, ProfileRuleConfig,
86    ProvidersConfig, RecordingConfig, RefineOverrides, ResolvedBuiltinStepConfig,
87    ResolvedProfileSelection, ResolvedUtteranceConfig, TranscriptOverrides, TranscriptionConfig,
88    TriggerType, VoiceConfig, WhisperCppDevicePreference, WhisperCppProviderConfig,
89};
90pub use envelope::MuninnEnvelopeV1;
91pub use error::{MacosAdapterError, MacosAdapterResult, PermissionKind};
92pub use hotkeys::{MacosHotkeyBinding, MacosHotkeyBindings, MacosHotkeyEventSource};
93pub use injector::MacosTextInjector;
94pub use mock::{
95    MockAudioRecorder, MockHotkeyEventSource, MockIndicatorAdapter, MockPermissionsAdapter,
96    MockTextInjector,
97};
98pub use orchestrator::{InjectionRoute, InjectionRouteReason, InjectionTarget, Orchestrator};
99pub use permissions::MacosPermissionsAdapter;
100pub use platform::{detect_platform, ensure_supported_platform, is_supported_platform, Platform};
101pub use runner::{
102    InProcessStepError, InProcessStepExecutor, PipelineOutcome, PipelinePolicyApplied,
103    PipelineRunner, PipelineStopReason, PipelineTraceEntry, StepFailureKind,
104};
105pub use runtime_flow::{map_hotkey_event, RuntimeFlowCoordinator};
106pub use scoring::{
107    DecisionReason, ReplacementDecision, ReplacementDecisionInput, SpanMetadata, Thresholds,
108};
109pub use secrets::{resolve_secret, resolve_secret_from_env};
110pub use state::{AppEvent, AppState};
111pub use target_context::{capture_frontmost_target_context, TargetContextSnapshot};
112pub use transcription::{
113    append_transcription_attempt, attach_transcription_route, resolved_transcription_route,
114    transcription_attempts, ResolvedTranscriptionRoute, TranscriptionAttempt,
115    TranscriptionAttemptOutcome, TranscriptionProvider, TranscriptionRouteSource,
116};
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum RecordingMode {
120    PushToTalk,
121    DoneMode,
122}
123
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125pub enum IndicatorState {
126    Idle,
127    Recording { mode: RecordingMode },
128    Transcribing,
129    Pipeline,
130    Output,
131    MissingCredentials,
132    Cancelled,
133}
134
135impl IndicatorState {
136    #[must_use]
137    pub const fn is_recording(self) -> bool {
138        matches!(self, Self::Recording { .. })
139    }
140
141    #[must_use]
142    pub const fn is_processing(self) -> bool {
143        matches!(self, Self::Transcribing | Self::Pipeline)
144    }
145}
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148pub enum PermissionStatus {
149    Granted,
150    Denied,
151    NotDetermined,
152    Restricted,
153    Unsupported,
154}
155
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
157pub struct PermissionPreflightStatus {
158    pub microphone: PermissionStatus,
159    pub accessibility: PermissionStatus,
160    pub input_monitoring: PermissionStatus,
161}
162
163impl Default for PermissionPreflightStatus {
164    fn default() -> Self {
165        Self {
166            microphone: PermissionStatus::NotDetermined,
167            accessibility: PermissionStatus::NotDetermined,
168            input_monitoring: PermissionStatus::NotDetermined,
169        }
170    }
171}
172
173impl PermissionPreflightStatus {
174    #[must_use]
175    pub const fn all_granted() -> Self {
176        Self {
177            microphone: PermissionStatus::Granted,
178            accessibility: PermissionStatus::Granted,
179            input_monitoring: PermissionStatus::Granted,
180        }
181    }
182
183    #[must_use]
184    pub const fn unsupported() -> Self {
185        Self {
186            microphone: PermissionStatus::Unsupported,
187            accessibility: PermissionStatus::Unsupported,
188            input_monitoring: PermissionStatus::Unsupported,
189        }
190    }
191
192    #[must_use]
193    pub const fn allows_recording(self) -> bool {
194        status_is_granted(self.microphone) && status_is_granted(self.input_monitoring)
195    }
196
197    #[must_use]
198    pub const fn allows_injection(self) -> bool {
199        status_is_granted(self.accessibility)
200    }
201
202    #[must_use]
203    pub const fn allows_hotkeys(self) -> bool {
204        status_is_granted(self.input_monitoring)
205    }
206
207    #[must_use]
208    pub fn missing_for_recording(self) -> Vec<PermissionKind> {
209        let mut missing = Vec::new();
210        if !status_is_granted(self.microphone) {
211            missing.push(PermissionKind::Microphone);
212        }
213        if !status_is_granted(self.input_monitoring) {
214            missing.push(PermissionKind::InputMonitoring);
215        }
216        missing
217    }
218
219    #[must_use]
220    pub fn missing_for_tray_recording(self) -> Vec<PermissionKind> {
221        let mut missing = Vec::new();
222        if status_blocks_recording_start(self.microphone) {
223            missing.push(PermissionKind::Microphone);
224        }
225        missing
226    }
227
228    #[must_use]
229    pub fn missing_for_injection(self) -> Vec<PermissionKind> {
230        let mut missing = Vec::new();
231        if !status_is_granted(self.accessibility) {
232            missing.push(PermissionKind::Accessibility);
233        }
234        missing
235    }
236
237    pub fn ensure_recording_allowed(self) -> MacosAdapterResult<()> {
238        let permissions = self.missing_for_recording();
239        if permissions.is_empty() {
240            return Ok(());
241        }
242        Err(MacosAdapterError::MissingPermissions { permissions })
243    }
244
245    pub fn ensure_tray_recording_allowed(self) -> MacosAdapterResult<()> {
246        let permissions = self.missing_for_tray_recording();
247        if permissions.is_empty() {
248            return Ok(());
249        }
250        Err(MacosAdapterError::MissingPermissions { permissions })
251    }
252
253    pub fn ensure_injection_allowed(self) -> MacosAdapterResult<()> {
254        let permissions = self.missing_for_injection();
255        if permissions.is_empty() {
256            return Ok(());
257        }
258        Err(MacosAdapterError::MissingPermissions { permissions })
259    }
260}
261
262const fn status_is_granted(status: PermissionStatus) -> bool {
263    matches!(status, PermissionStatus::Granted)
264}
265
266const fn status_blocks_recording_start(status: PermissionStatus) -> bool {
267    matches!(
268        status,
269        PermissionStatus::Denied | PermissionStatus::Restricted | PermissionStatus::Unsupported
270    )
271}
272
273#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
274pub enum HotkeyAction {
275    PushToTalk,
276    DoneModeToggle,
277    CancelCurrentCapture,
278}
279
280#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
281pub enum HotkeyEventKind {
282    Pressed,
283    Released,
284}
285
286#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
287pub struct HotkeyEvent {
288    pub action: HotkeyAction,
289    pub kind: HotkeyEventKind,
290}
291
292impl HotkeyEvent {
293    #[must_use]
294    pub const fn new(action: HotkeyAction, kind: HotkeyEventKind) -> Self {
295        Self { action, kind }
296    }
297
298    #[must_use]
299    pub const fn is_pressed(self) -> bool {
300        matches!(self.kind, HotkeyEventKind::Pressed)
301    }
302
303    #[must_use]
304    pub const fn is_released(self) -> bool {
305        matches!(self.kind, HotkeyEventKind::Released)
306    }
307}
308
309#[derive(Debug, Clone, PartialEq, Eq)]
310pub struct RecordedAudio {
311    pub wav_path: PathBuf,
312    pub duration_ms: u64,
313}
314
315impl RecordedAudio {
316    #[must_use]
317    pub fn new(wav_path: impl Into<PathBuf>, duration_ms: u64) -> Self {
318        Self {
319            wav_path: wav_path.into(),
320            duration_ms,
321        }
322    }
323}
324
325#[async_trait]
326pub trait IndicatorAdapter: Send + Sync {
327    async fn initialize(&mut self) -> MacosAdapterResult<()>;
328    async fn set_state(&mut self, state: IndicatorState) -> MacosAdapterResult<()>;
329    async fn set_state_with_glyph(
330        &mut self,
331        state: IndicatorState,
332        glyph: Option<char>,
333    ) -> MacosAdapterResult<()> {
334        let _ = glyph;
335        self.set_state(state).await
336    }
337    async fn set_temporary_state(
338        &mut self,
339        state: IndicatorState,
340        min_duration: Duration,
341        fallback_state: IndicatorState,
342    ) -> MacosAdapterResult<()> {
343        let _ = min_duration;
344        let _ = fallback_state;
345        self.set_state(state).await
346    }
347    async fn set_temporary_state_with_glyph(
348        &mut self,
349        state: IndicatorState,
350        glyph: Option<char>,
351        min_duration: Duration,
352        fallback_state: IndicatorState,
353        fallback_glyph: Option<char>,
354    ) -> MacosAdapterResult<()> {
355        let _ = glyph;
356        let _ = fallback_glyph;
357        self.set_temporary_state(state, min_duration, fallback_state)
358            .await
359    }
360    async fn state(&self) -> MacosAdapterResult<IndicatorState>;
361    async fn indicator_glyph(&self) -> MacosAdapterResult<Option<char>> {
362        Ok(None)
363    }
364}
365
366#[async_trait]
367pub trait PermissionsAdapter: Send + Sync {
368    async fn preflight(&self) -> MacosAdapterResult<PermissionPreflightStatus>;
369    async fn request_microphone_access(&self) -> MacosAdapterResult<bool>;
370    async fn request_input_monitoring_access(&self) -> MacosAdapterResult<bool>;
371    async fn request_accessibility_access(&self) -> MacosAdapterResult<bool>;
372}
373
374#[async_trait]
375pub trait HotkeyEventSource: Send {
376    async fn next_event(&mut self) -> MacosAdapterResult<HotkeyEvent>;
377}
378
379#[async_trait(?Send)]
380pub trait AudioRecorder {
381    async fn start_recording(&mut self) -> MacosAdapterResult<()>;
382    async fn stop_recording(&mut self) -> MacosAdapterResult<RecordedAudio>;
383    async fn cancel_recording(&mut self) -> MacosAdapterResult<()>;
384}
385
386#[async_trait]
387pub trait TextInjector: Send + Sync {
388    async fn inject_unicode_text(&self, text: &str) -> MacosAdapterResult<()>;
389
390    async fn inject_checked(&self, text: &str) -> MacosAdapterResult<()> {
391        if text.is_empty() {
392            return Err(MacosAdapterError::EmptyInjectionText);
393        }
394        self.inject_unicode_text(text).await
395    }
396}
397
398#[cfg(test)]
399mod tests {
400    use super::{
401        IndicatorState, MacosAdapterError, PermissionKind, PermissionPreflightStatus,
402        PermissionStatus, RecordingMode,
403    };
404
405    #[test]
406    fn indicator_state_helpers_reflect_recording_and_processing() {
407        assert!(!IndicatorState::Idle.is_recording());
408        assert!(IndicatorState::Recording {
409            mode: RecordingMode::PushToTalk
410        }
411        .is_recording());
412        assert!(IndicatorState::Transcribing.is_processing());
413        assert!(IndicatorState::Pipeline.is_processing());
414        assert!(!IndicatorState::Output.is_processing());
415        assert!(!IndicatorState::MissingCredentials.is_processing());
416        assert!(!IndicatorState::Cancelled.is_processing());
417    }
418
419    #[test]
420    fn recording_preflight_requires_microphone_and_input_monitoring() {
421        let status = PermissionPreflightStatus {
422            microphone: PermissionStatus::Denied,
423            accessibility: PermissionStatus::Granted,
424            input_monitoring: PermissionStatus::NotDetermined,
425        };
426
427        assert_eq!(
428            status.missing_for_recording(),
429            vec![PermissionKind::Microphone, PermissionKind::InputMonitoring]
430        );
431        assert_eq!(
432            status.ensure_recording_allowed().unwrap_err(),
433            MacosAdapterError::MissingPermissions {
434                permissions: vec![PermissionKind::Microphone, PermissionKind::InputMonitoring]
435            }
436        );
437    }
438
439    #[test]
440    fn tray_recording_preflight_only_blocks_on_microphone_failures() {
441        let status = PermissionPreflightStatus {
442            microphone: PermissionStatus::Denied,
443            accessibility: PermissionStatus::Granted,
444            input_monitoring: PermissionStatus::NotDetermined,
445        };
446
447        assert_eq!(
448            status.missing_for_tray_recording(),
449            vec![PermissionKind::Microphone]
450        );
451        assert_eq!(
452            status.ensure_tray_recording_allowed().unwrap_err(),
453            MacosAdapterError::MissingPermissions {
454                permissions: vec![PermissionKind::Microphone]
455            }
456        );
457    }
458
459    #[test]
460    fn tray_recording_allows_microphone_bootstrap_without_input_monitoring() {
461        let status = PermissionPreflightStatus {
462            microphone: PermissionStatus::NotDetermined,
463            accessibility: PermissionStatus::Granted,
464            input_monitoring: PermissionStatus::Denied,
465        };
466
467        assert!(status.missing_for_tray_recording().is_empty());
468        status
469            .ensure_tray_recording_allowed()
470            .expect("tray recording should allow microphone bootstrap");
471    }
472
473    #[test]
474    fn injection_preflight_requires_accessibility() {
475        let status = PermissionPreflightStatus {
476            microphone: PermissionStatus::Granted,
477            accessibility: PermissionStatus::Restricted,
478            input_monitoring: PermissionStatus::Granted,
479        };
480
481        assert_eq!(
482            status.missing_for_injection(),
483            vec![PermissionKind::Accessibility]
484        );
485        assert_eq!(
486            status.ensure_injection_allowed().unwrap_err(),
487            MacosAdapterError::MissingPermissions {
488                permissions: vec![PermissionKind::Accessibility]
489            }
490        );
491    }
492
493    #[test]
494    fn all_granted_allows_recording_injection_and_hotkeys() {
495        let status = PermissionPreflightStatus::all_granted();
496        assert!(status.allows_recording());
497        assert!(status.allows_injection());
498        assert!(status.allows_hotkeys());
499    }
500}