CodeIssuesPull RequestsActionsSecurityInsights
✨ AI
More
Settings

Crawl audit: fix 7 critical/major Tauri issues #3998

Merged⚡ AI-generatedXSccantynz wants to mergeclaude/crawl-fixesmainopened Mar 27, 2026
4 changed files+95−121
Modifiedtauri-app/package.json+2−1View fileUnifiedSplit
1111 },
1212 "dependencies": {
1313 "react": "^18.3.0",
14 "react-dom": "^18.3.0"
14 "react-dom": "^18.3.0",
15 "@tauri-apps/plugin-store": "^2.0.0"
1516 },
1617 "devDependencies": {
1718 "@tauri-apps/cli": "^2.0.0",
Modifiedtauri-app/src-tauri/Cargo.toml+10−11View fileUnifiedSplit
1616tauri = { version = "2", features = [
1717 "tray-icon",
1818 "global-shortcut",
19 "clipboard-manager",
2019 "shell-open",
2120] }
2221tauri-plugin-shell = "2"
2827tauri-plugin-notification = "2"
2928serde = { version = "1", features = ["derive"] }
3029serde_json = "1"
31enigo = "0.2" # Cross-platform keyboard/mouse simulation (replaces nut-tree)
30enigo = "0.2" # Cross-platform keyboard/mouse simulation
3231reqwest = { version = "0.12", features = ["json", "multipart"] }
3332tokio = { version = "1", features = ["full"] }
34cpal = "0.15" # Cross-platform audio capture
35hound = "3.5" # WAV encoding for Whisper API
36regex = "1" # Text processing patterns
37once_cell = "1" # Lazy static initialization
38candle-core = { version = "0.8", optional = true } # Local LLM inference (Phase 3)
39candle-transformers = { version = "0.8", optional = true } # Model loading
33cpal = "0.15" # Cross-platform audio capture
34hound = "3.5" # WAV encoding for Whisper API
35regex = "1" # Text processing patterns
36once_cell = "1" # Lazy static initialization
37whisper-rs = "0.12" # whisper.cpp Rust bindings — on-device speech-to-text
38dirs = "5" # OS-standard directories for model storage
39candle-core = { version = "0.8", optional = true } # Local LLM inference (future)
40candle-transformers = { version = "0.8", optional = true }
4041candle-nn = { version = "0.8", optional = true }
4142
4243[features]
4344default = []
44local-grammar = ["candle-core", "candle-transformers", "candle-nn"] # Enable for on-device grammar
45whisper-rs = "0.12" # whisper.cpp Rust bindings — on-device speech-to-text
46dirs = "5" # OS-standard directories for model storage
45local-llm = ["candle-core", "candle-transformers", "candle-nn"]
Modifiedtauri-app/src-tauri/src/audio.rs+9−5View fileUnifiedSplit
7575 sample_format: hound::SampleFormat::Int,
7676 };
7777
78 let mut writer = hound::WavWriter::new(&mut cursor, spec).unwrap();
79 for &sample in samples {
80 let amplitude = (sample * 32767.0).clamp(-32768.0, 32767.0) as i16;
81 writer.write_sample(amplitude).unwrap();
78 // Use if-let to handle errors gracefully instead of panicking
79 if let Ok(mut writer) = hound::WavWriter::new(&mut cursor, spec) {
80 for &sample in samples {
81 let amplitude = (sample * 32767.0).clamp(-32768.0, 32767.0) as i16;
82 if writer.write_sample(amplitude).is_err() {
83 break;
84 }
85 }
86 let _ = writer.finalize();
8287 }
83 writer.finalize().unwrap();
8488
8589 cursor.into_inner()
8690}
Modifiedtauri-app/src-tauri/src/lib.rs+74−104View fileUnifiedSplit
33// System tray app with global hotkey. Records voice, transcribes via Whisper,
44// types text into any focused application using OS-level keyboard simulation.
55//
6// Architecture:
7// - Rust backend: audio capture, Whisper API, keyboard simulation, settings
8// - React frontend: settings UI only (the app is invisible during normal use)
9// - No Electron. No Node.js. Pure Rust + native APIs.
10//
11// Size: ~5MB (vs 150MB Electron). No antivirus warnings. No native compilation issues.
6// Built by Claude. Designed for humans.
7// Architecture: Pure Rust + React. No Electron. No Node.js.
8// Size: ~5MB. No antivirus warnings. No native compilation issues.
129
1310mod audio;
1411mod keyboard;
1815mod local_grammar;
1916
2017use std::sync::{Arc, Mutex};
21use tauri::{
22 AppHandle, Manager, SystemTray, SystemTrayMenu, SystemTrayMenuItem,
23 SystemTrayEvent, CustomMenuItem,
24};
18use tauri::{AppHandle, Manager};
19use tauri::menu::{Menu, MenuItem};
20use tauri::tray::TrayIconBuilder;
2521
2622// App state shared across commands
2723pub struct AppState {
3026 pub claude_api_key: Mutex<String>,
3127 pub language: Mutex<String>,
3228 pub ai_rewrite: Mutex<bool>,
33 pub use_local_whisper: Mutex<bool>, // true = on-device, false = API
34 pub local_model: Mutex<String>, // model filename e.g. "ggml-base.bin"
29 pub use_local_whisper: Mutex<bool>,
30 pub local_model: Mutex<String>,
3531}
3632
3733impl Default for AppState {
4844 }
4945}
5046
51// Tauri commands callable from the frontend
5247#[tauri::command]
53async fn toggle_recording(state: tauri::State<'_, Arc<AppState>>, app: AppHandle) -> Result<String, String> {
48async fn toggle_recording(app: AppHandle) -> Result<String, String> {
49 let state = app.state::<Arc<AppState>>();
5450 let mut recording = state.is_recording.lock().unwrap();
5551
5652 if *recording {
57 // Stop recording
5853 *recording = false;
59 drop(recording); // release lock before async work
54 drop(recording);
6055
61 // Get recorded audio and transcribe
6256 let audio_data = audio::stop_recording();
63
6457 let use_local = *state.use_local_whisper.lock().unwrap();
6558 let language = state.language.lock().unwrap().clone();
6659
6760 let text = if use_local {
68 // On-device transcription — no API key, no internet, no cost
6961 let model_name = state.local_model.lock().unwrap().clone();
7062 let model_file = local_whisper::model_path(&model_name);
7163
7264 if !model_file.exists() {
73 return Err(format!(
74 "Model '{}' not downloaded yet. Go to Settings → Download Model.",
75 model_name
76 ));
65 return Err(format!("Model '{}' not downloaded. Go to Settings → Download Model.", model_name));
7766 }
7867
7968 let model_str = model_file.to_string_lossy().to_string();
8069 let audio = audio_data.clone();
8170 let lang = language.clone();
8271
83 // Run in blocking thread (Whisper inference is CPU-bound)
8472 tokio::task::spawn_blocking(move || {
8573 local_whisper::transcribe_local(&audio, &model_str, &lang)
8674 })
8876 .map_err(|e| format!("Thread error: {}", e))?
8977 .map_err(|e| format!("Local transcription failed: {}", e))?
9078 } else {
91 // Cloud transcription via Whisper API
9279 let api_key = state.whisper_api_key.lock().unwrap().clone();
93
9480 if api_key.is_empty() {
9581 return Err("No API key set. Open Settings to add your OpenAI key, or enable Local Whisper.".to_string());
9682 }
97
9883 transcribe::whisper_api(&audio_data, &api_key, &language)
9984 .await
10085 .map_err(|e| format!("Transcription failed: {}", e))?
10489 return Ok("No speech detected.".to_string());
10590 }
10691
107 // Grammar correction pipeline:
108 // 1. If AI rewrite enabled + Claude key → use Claude API (best quality)
109 // 2. Otherwise → use local grammar engine (free, instant, offline)
92 // Grammar pipeline: post-process first, then either AI rewrite or local grammar
11093 let ai_enabled = *state.ai_rewrite.lock().unwrap();
11194 let claude_key = state.claude_api_key.lock().unwrap().clone();
95 let processed = grammar::post_process(&text);
11296
11397 let final_text = if ai_enabled && !claude_key.is_empty() {
114 // Cloud AI rewrite (Claude API — best quality)
115 grammar::rewrite(&text, &claude_key)
98 grammar::rewrite(&processed, &claude_key)
11699 .await
117 .unwrap_or_else(|_| local_grammar::fix_grammar(&text))
100 .unwrap_or_else(|_| local_grammar::fix_grammar(&processed))
118101 } else {
119 // Local grammar correction (free, instant, no API)
120 local_grammar::fix_grammar(&grammar::post_process(&text))
102 local_grammar::fix_grammar(&processed)
121103 };
122104
123 // Type into focused application
124105 keyboard::type_text(&final_text)
125106 .map_err(|e| format!("Typing failed: {}", e))?;
126107
127 // Update tray
128108 update_tray(&app, false);
129
130109 Ok(final_text)
131110 } else {
132 // Start recording
111 // Start recording — check we have either local model or API key
112 let use_local = *state.use_local_whisper.lock().unwrap();
133113 let api_key = state.whisper_api_key.lock().unwrap().clone();
134 if api_key.is_empty() {
135 return Err("No API key set. Open Settings to add your OpenAI key.".to_string());
114
115 if !use_local && api_key.is_empty() {
116 return Err("No API key set. Open Settings to add your key, or enable Local Whisper.".to_string());
136117 }
137118
138119 *recording = true;
139120 drop(recording);
140121
141 audio::start_recording()
142 .map_err(|e| format!("Mic error: {}", e))?;
143
122 audio::start_recording().map_err(|e| format!("Mic error: {}", e))?;
144123 update_tray(&app, true);
145
146124 Ok("Recording started".to_string())
147125 }
148126}
149127
150128#[tauri::command]
151fn set_api_key(state: tauri::State<'_, Arc<AppState>>, key: String) {
152 *state.whisper_api_key.lock().unwrap() = key;
129fn set_api_key(app: AppHandle, key: String) {
130 app.state::<Arc<AppState>>().whisper_api_key.lock().unwrap().clone_from(&key);
153131}
154132
155133#[tauri::command]
156fn set_claude_key(state: tauri::State<'_, Arc<AppState>>, key: String) {
157 *state.claude_api_key.lock().unwrap() = key;
134fn set_claude_key(app: AppHandle, key: String) {
135 app.state::<Arc<AppState>>().claude_api_key.lock().unwrap().clone_from(&key);
158136}
159137
160138#[tauri::command]
161fn set_language(state: tauri::State<'_, Arc<AppState>>, lang: String) {
162 *state.language.lock().unwrap() = lang;
139fn set_language(app: AppHandle, lang: String) {
140 app.state::<Arc<AppState>>().language.lock().unwrap().clone_from(&lang);
163141}
164142
165143#[tauri::command]
166fn set_ai_rewrite(state: tauri::State<'_, Arc<AppState>>, enabled: bool) {
167 *state.ai_rewrite.lock().unwrap() = enabled;
144fn set_ai_rewrite(app: AppHandle, enabled: bool) {
145 *app.state::<Arc<AppState>>().ai_rewrite.lock().unwrap() = enabled;
168146}
169147
170148#[tauri::command]
171fn set_use_local_whisper(state: tauri::State<'_, Arc<AppState>>, enabled: bool) {
172 *state.use_local_whisper.lock().unwrap() = enabled;
149fn set_use_local_whisper(app: AppHandle, enabled: bool) {
150 *app.state::<Arc<AppState>>().use_local_whisper.lock().unwrap() = enabled;
173151}
174152
175153#[tauri::command]
176fn set_local_model(state: tauri::State<'_, Arc<AppState>>, model: String) {
177 *state.local_model.lock().unwrap() = model;
154fn set_local_model(app: AppHandle, model: String) {
155 app.state::<Arc<AppState>>().local_model.lock().unwrap().clone_from(&model);
178156}
179157
180158#[tauri::command]
203181 }
204182}
205183
206fn build_tray_menu() -> SystemTrayMenu {
207 SystemTrayMenu::new()
208 .add_item(CustomMenuItem::new("toggle", "Start Recording"))
209 .add_native_item(SystemTrayMenuItem::Separator)
210 .add_item(CustomMenuItem::new("settings", "Settings"))
211 .add_native_item(SystemTrayMenuItem::Separator)
212 .add_item(CustomMenuItem::new("quit", "Quit 48co"))
213}
214
215184#[cfg_attr(mobile, tauri::mobile_entry_point)]
216185pub fn run() {
217186 let state = Arc::new(AppState::default());
226195 None,
227196 ))
228197 .plugin(tauri_plugin_notification::init())
229 .manage(state.clone())
230 .system_tray(SystemTray::new().with_menu(build_tray_menu()))
231 .on_system_tray_event(move |app, event| {
232 match event {
233 SystemTrayEvent::LeftClick { .. } => {
234 // Toggle recording on left click
235 let state = app.state::<Arc<AppState>>();
236 let app_handle = app.clone();
237 tauri::async_runtime::spawn(async move {
238 let _ = toggle_recording(state, app_handle).await;
239 });
240 }
241 SystemTrayEvent::MenuItemClick { id, .. } => {
242 match id.as_str() {
198 .manage(state)
199 .setup(|app| {
200 // Build tray menu (Tauri 2.0 API)
201 let toggle_item = MenuItem::with_id(app, "toggle", "Start Recording", true, None::<&str>)?;
202 let settings_item = MenuItem::with_id(app, "settings", "Settings", true, None::<&str>)?;
203 let quit_item = MenuItem::with_id(app, "quit", "Quit 48co", true, None::<&str>)?;
204 let menu = Menu::with_items(app, &[&toggle_item, &settings_item, &quit_item])?;
205
206 let _tray = TrayIconBuilder::with_id("main")
207 .tooltip("48co — Ready")
208 .menu(&menu)
209 .on_menu_event(move |app, event| {
210 match event.id.as_ref() {
243211 "toggle" => {
244 let state = app.state::<Arc<AppState>>();
245 let app_handle = app.clone();
212 let handle = app.clone();
246213 tauri::async_runtime::spawn(async move {
247 let _ = toggle_recording(state, app_handle).await;
214 match toggle_recording(handle).await {
215 Ok(msg) => println!("[48co] {}", msg),
216 Err(e) => eprintln!("[48co] Error: {}", e),
217 }
248218 });
249219 }
250220 "settings" => {
253223 let _ = window.set_focus();
254224 }
255225 }
256 "quit" => {
257 app.exit(0);
258 }
226 "quit" => { app.exit(0); }
259227 _ => {}
260228 }
261 }
262 _ => {}
263 }
264 })
265 .setup(|app| {
266 // Register global shortcut: Ctrl+Shift+Space
267 use tauri_plugin_global_shortcut::ShortcutState;
229 })
230 .on_tray_icon_event(|tray, event| {
231 if let tauri::tray::TrayIconEvent::Click { .. } = event {
232 let handle = tray.app_handle().clone();
233 tauri::async_runtime::spawn(async move {
234 match toggle_recording(handle).await {
235 Ok(msg) => println!("[48co] {}", msg),
236 Err(e) => eprintln!("[48co] Error: {}", e),
237 }
238 });
239 }
240 })
241 .build(app)?;
268242
269 let state = app.state::<Arc<AppState>>().inner().clone();
243 // Register global shortcut
244 use tauri_plugin_global_shortcut::ShortcutState;
270245 let app_handle = app.handle().clone();
271
272 app.global_shortcut().on_shortcut("CmdOrCtrl+Shift+Space", move |_, _, event| {
246 app.global_shortcut().on_shortcut("CmdOrCtrl+Shift+Space", move |_app, _shortcut, event| {
273247 if event.state == ShortcutState::Pressed {
274 let s = state.clone();
275248 let h = app_handle.clone();
276249 tauri::async_runtime::spawn(async move {
277 let _ = toggle_recording(
278 tauri::State::from(&s),
279 h,
280 ).await;
250 match toggle_recording(h).await {
251 Ok(msg) => println!("[48co] {}", msg),
252 Err(e) => eprintln!("[48co] Error: {}", e),
253 }
281254 });
282255 }
283256 })?;
284257
285 // Load saved settings from store
286 // Settings are loaded by the frontend on startup and sent via commands
287
288258 Ok(())
289259 })
290260 .invoke_handler(tauri::generate_handler![
291261
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts