irongit

Open source voice dictation for your whole desktop. Local whisper.cpp transcription, global hotkeys, optional LLM enhancement via Claude Code, Gemini CLI, Codex, or any API. Wayland-first.

355 lines12 KBRust
1mod audio;
2mod inject;
3mod llm;
4mod models;
5mod pipeline;
6mod settings;
7mod transcribe;
8
9use pipeline::AppState;
10use std::sync::Mutex;
11use tauri::menu::{CheckMenuItem, IsMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu};
12use tauri::tray::TrayIconBuilder;
13use tauri::{AppHandle, Emitter, Manager, WindowEvent, Wry};
14
15#[cfg(desktop)]
16fn register_hotkey(app: &AppHandle, accel: &str) -> Result<(), String> {
17 use tauri_plugin_global_shortcut::GlobalShortcutExt;
18 let shortcuts = app.global_shortcut();
19 let _ = shortcuts.unregister_all();
20 if accel.trim().is_empty() {
21 return Ok(());
22 }
23 let shortcut: tauri_plugin_global_shortcut::Shortcut = accel
24 .parse()
25 .map_err(|e| format!("invalid hotkey {accel:?}: {e:?}"))?;
26 shortcuts
27 .register(shortcut)
28 .map_err(|e| format!("register hotkey: {e}"))?;
29 Ok(())
30}
31
32#[tauri::command]
33fn get_settings(state: tauri::State<AppState>) -> settings::Settings {
34 state.settings.lock().unwrap().clone()
35}
36
37#[tauri::command]
38fn set_settings(
39 app: AppHandle,
40 state: tauri::State<AppState>,
41 new_settings: settings::Settings,
42) -> Result<(), String> {
43 let hotkey_changed = {
44 let mut current = state.settings.lock().unwrap();
45 let changed = current.hotkey != new_settings.hotkey;
46 *current = new_settings.clone();
47 changed
48 };
49 settings::save(&app, &new_settings)?;
50 refresh_tray_menu(&app);
51 #[cfg(desktop)]
52 if hotkey_changed {
53 register_hotkey(&app, &new_settings.hotkey)?;
54 }
55 Ok(())
56}
57
58#[tauri::command]
59fn list_models(app: AppHandle) -> Result<Vec<models::ModelInfo>, String> {
60 models::list(&app)
61}
62
63#[tauri::command]
64async fn download_model(app: AppHandle, id: String) -> Result<(), String> {
65 models::download(app, id).await
66}
67
68#[tauri::command]
69fn delete_model(app: AppHandle, id: String) -> Result<(), String> {
70 models::delete(&app, &id)
71}
72
73#[tauri::command]
74fn toggle_dictation(app: AppHandle) {
75 pipeline::toggle(&app);
76}
77
78#[tauri::command]
79fn enhance_key_present(state: tauri::State<AppState>) -> bool {
80 state
81 .settings
82 .lock()
83 .unwrap()
84 .enhance
85 .active()
86 .map(llm::key_present)
87 .unwrap_or(false)
88}
89
90/// Tray menu, rebuilt whenever settings change so the profile list stays live.
91fn build_tray_menu(app: &AppHandle) -> tauri::Result<Menu<Wry>> {
92 let settings = app.state::<AppState>().settings.lock().unwrap().clone();
93 let toggle = MenuItem::with_id(app, "toggle", "Toggle dictation", true, None::<&str>)?;
94 let open = MenuItem::with_id(app, "open", "Open mutterbox", true, None::<&str>)?;
95 let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
96 let sep1 = PredefinedMenuItem::separator(app)?;
97 let sep2 = PredefinedMenuItem::separator(app)?;
98
99 let profile_items: Vec<CheckMenuItem<Wry>> = settings
100 .enhance
101 .profiles
102 .iter()
103 .map(|p| {
104 CheckMenuItem::with_id(
105 app,
106 format!("profile:{}", p.name),
107 &p.name,
108 true,
109 p.name == settings.enhance.active_profile,
110 None::<&str>,
111 )
112 })
113 .collect::<Result<_, _>>()?;
114 let profile_refs: Vec<&dyn IsMenuItem<Wry>> = profile_items
115 .iter()
116 .map(|i| i as &dyn IsMenuItem<Wry>)
117 .collect();
118 let profiles = Submenu::with_items(app, "Enhance profile", true, &profile_refs)?;
119
120 Menu::with_items(app, &[&toggle, &open, &sep1, &profiles, &sep2, &quit])
121}
122
123fn refresh_tray_menu(app: &AppHandle) {
124 if let (Some(tray), Ok(menu)) = (app.tray_by_id("tray"), build_tray_menu(app)) {
125 let _ = tray.set_menu(Some(menu));
126 }
127}
128
129#[derive(serde::Serialize, Clone)]
130struct EnvironmentInfo {
131 wayland: bool,
132 compositor: String,
133 exe: String,
134}
135
136#[tauri::command]
137fn get_environment() -> EnvironmentInfo {
138 let exe = std::env::current_exe()
139 .ok()
140 .and_then(|p| p.to_str().map(String::from))
141 .unwrap_or_else(|| "mutterbox".into());
142
143 #[cfg(target_os = "linux")]
144 {
145 let wayland = std::env::var("XDG_SESSION_TYPE")
146 .map(|v| v == "wayland")
147 .unwrap_or(false)
148 || std::env::var_os("WAYLAND_DISPLAY").is_some();
149 let desktop = std::env::var("XDG_CURRENT_DESKTOP")
150 .unwrap_or_default()
151 .to_lowercase();
152 let compositor = if std::env::var_os("HYPRLAND_INSTANCE_SIGNATURE").is_some()
153 || desktop.contains("hyprland")
154 {
155 "hyprland"
156 } else if desktop.contains("sway") {
157 "sway"
158 } else if desktop.contains("kde") {
159 "kde"
160 } else if desktop.contains("gnome") {
161 "gnome"
162 } else {
163 ""
164 };
165 return EnvironmentInfo {
166 wayland,
167 compositor: compositor.into(),
168 exe,
169 };
170 }
171
172 #[cfg(not(target_os = "linux"))]
173 EnvironmentInfo {
174 wayland: false,
175 compositor: "".into(),
176 exe,
177 }
178}
179
180/// In hold mode, X11 autorepeat produces synthetic release/press pairs while
181/// the key is held. A release only counts if no new press lands within 200 ms.
182#[cfg(desktop)]
183fn hold_release(app: &AppHandle) {
184 use std::sync::atomic::Ordering;
185 let state = app.state::<AppState>();
186 let my_gen = state.hold_gen.fetch_add(1, Ordering::SeqCst) + 1;
187 let app = app.clone();
188 std::thread::spawn(move || {
189 std::thread::sleep(std::time::Duration::from_millis(200));
190 let state = app.state::<AppState>();
191 if state.hold_gen.load(Ordering::SeqCst) == my_gen {
192 pipeline::stop(&app);
193 }
194 });
195}
196
197#[cfg_attr(mobile, tauri::mobile_entry_point)]
198pub fn run() {
199 // The system tray goes through libayatana-appindicator, which prints a
200 // deprecation warning on every start. Cosmetic only; silence that domain.
201 #[cfg(target_os = "linux")]
202 glib::log_set_handler(
203 Some("libayatana-appindicator"),
204 glib::LogLevels::all(),
205 false,
206 false,
207 |_, _, _| {},
208 );
209
210 let mut builder = tauri::Builder::default();
211
212 #[cfg(desktop)]
213 {
214 builder = builder
215 .plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| {
216 if argv.iter().any(|a| a == "--toggle") {
217 pipeline::toggle(app);
218 } else if argv.iter().any(|a| a == "--start") {
219 pipeline::start(app);
220 } else if argv.iter().any(|a| a == "--stop") {
221 pipeline::stop(app);
222 } else {
223 pipeline::show_main(app);
224 }
225 }))
226 .plugin(tauri_plugin_autostart::Builder::new().args(["--hidden"]).build())
227 .plugin(
228 tauri_plugin_global_shortcut::Builder::new()
229 .with_handler(|app, _shortcut, event| {
230 use std::sync::atomic::Ordering;
231 use tauri_plugin_global_shortcut::ShortcutState;
232 let state = app.state::<AppState>();
233 let hold = state.settings.lock().unwrap().activation == "hold";
234 match event.state {
235 ShortcutState::Pressed => {
236 if hold {
237 state.hold_gen.fetch_add(1, Ordering::SeqCst);
238 pipeline::start(app);
239 } else {
240 pipeline::toggle(app);
241 }
242 }
243 ShortcutState::Released => {
244 if hold {
245 hold_release(app);
246 }
247 }
248 }
249 })
250 .build(),
251 );
252 }
253
254 builder
255 .plugin(tauri_plugin_notification::init())
256 .plugin(tauri_plugin_clipboard_manager::init())
257 .plugin(tauri_plugin_opener::init())
258 .on_window_event(|window, event| {
259 if let WindowEvent::CloseRequested { api, .. } = event {
260 if window.label() == "main" {
261 let _ = window.hide();
262 api.prevent_close();
263 }
264 }
265 })
266 .setup(|app| {
267 let handle = app.handle().clone();
268 let loaded = settings::load(&handle);
269 app.manage(AppState {
270 recorder: Mutex::new(audio::Recorder::default()),
271 settings: Mutex::new(loaded.clone()),
272 whisper: transcribe::WhisperCache::default(),
273 hold_gen: std::sync::atomic::AtomicU64::new(0),
274 });
275
276 // Recording indicator: frameless pill, hidden until dictation starts
277 tauri::WebviewWindowBuilder::new(
278 app,
279 "overlay",
280 tauri::WebviewUrl::App("index.html#overlay".into()),
281 )
282 .title("mutterbox overlay")
283 .inner_size(280.0, 72.0)
284 .decorations(false)
285 .resizable(false)
286 .transparent(true)
287 .always_on_top(true)
288 .skip_taskbar(true)
289 .focused(false)
290 .shadow(false)
291 .visible(false)
292 .build()?;
293
294 let menu = build_tray_menu(&handle)?;
295 TrayIconBuilder::with_id("tray")
296 .icon(app.default_window_icon().unwrap().clone())
297 .menu(&menu)
298 .on_menu_event(|app, event| {
299 let id = event.id.as_ref();
300 if let Some(name) = id.strip_prefix("profile:") {
301 let updated = {
302 let state = app.state::<AppState>();
303 let mut settings = state.settings.lock().unwrap();
304 settings.enhance.active_profile = name.to_string();
305 settings.clone()
306 };
307 let _ = settings::save(app, &updated);
308 refresh_tray_menu(app);
309 let _ = app.emit("settings-changed", ());
310 return;
311 }
312 match id {
313 "toggle" => pipeline::toggle(app),
314 "open" => pipeline::show_main(app),
315 "quit" => app.exit(0),
316 _ => {}
317 }
318 })
319 .build(app)?;
320
321 #[cfg(desktop)]
322 if let Err(e) = register_hotkey(&handle, &loaded.hotkey) {
323 eprintln!("hotkey registration failed (normal on Wayland, use a compositor keybind running `mutterbox --toggle`): {e}");
324 }
325
326 let args: Vec<String> = std::env::args().collect();
327 let triggered = args
328 .iter()
329 .any(|a| a == "--hidden" || a == "--toggle" || a == "--start" || a == "--stop");
330 if triggered {
331 if let Some(window) = app.get_webview_window("main") {
332 let _ = window.hide();
333 }
334 }
335 if args.iter().any(|a| a == "--toggle") {
336 pipeline::toggle(&handle);
337 } else if args.iter().any(|a| a == "--start") {
338 pipeline::start(&handle);
339 }
340
341 Ok(())
342 })
343 .invoke_handler(tauri::generate_handler![
344 get_settings,
345 set_settings,
346 list_models,
347 download_model,
348 delete_model,
349 toggle_dictation,
350 enhance_key_present,
351 get_environment
352 ])
353 .run(tauri::generate_context!())
354 .expect("error while running tauri application");
355}