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}