fix: ClientsPanel inputs hardcoded dark colors break light theme #4739
9 changed files+259−62
Modifiedsrc-tauri/src/audio/capture.rs+12−0View fileUnifiedSplit
@@ -51,6 +51,18 @@ pub fn start_capture_with_options(
5151 let sample_rate = config.sample_rate().0;
5252 let channels = config.channels();
5353
54 // Reject misreporting / virtual devices up front. A zero sample rate or
55 // channel count would yield a zero chunk size, turning the chunk-draining
56 // loop into an infinite tight loop (app appears frozen) and causing
57 // divide-by-zero downstream.
58 if sample_rate == 0 || channels == 0 {
59 return Err(anyhow::anyhow!(
60 "Input device reported an invalid configuration (rate={}, channels={})",
61 sample_rate,
62 channels
63 ));
64 }
65
5466 log::info!(
5567 "Starting capture: device={:?}, rate={}, channels={}",
5668 device_id,
Modifiedsrc-tauri/src/audio/devices.rs+26−3View fileUnifiedSplit
@@ -81,15 +81,38 @@ fn format_device_name(name: &str) -> String {
8181 }
8282 }
8383
84 // Truncate very long names
85 if cleaned.len() > 50 {
86 cleaned.truncate(47);
84 // Truncate very long names on a char boundary. String::truncate panics if
85 // the cut lands mid-UTF-8-codepoint, which crashes device enumeration for
86 // localized device names (accented / non-Latin characters).
87 if cleaned.chars().count() > 50 {
88 cleaned = cleaned.chars().take(47).collect::<String>();
8789 cleaned.push_str("...");
8890 }
8991
9092 cleaned
9193}
9294
95
96mod tests {
97 use super::*;
98
99
100 fn format_device_name_truncates_unicode_without_panic() {
101 // Regression: String::truncate on a non-char-boundary used to panic and
102 // crash device enumeration for localized device names.
103 let name = "é".repeat(60); // 60 chars, 120 bytes, multi-byte
104 let out = format_device_name(&name);
105 assert!(out.ends_with("..."));
106 assert_eq!(out.chars().count(), 50); // 47 chars + "..."
107 }
108
109
110 fn format_device_name_strips_prefix_and_keeps_short_names() {
111 assert_eq!(format_device_name("Microphone - Blue Yeti"), "Blue Yeti");
112 assert_eq!(format_device_name(" Built-in Mic "), "Built-in Mic");
113 }
114}
115
93116pub fn get_device_by_id(device_id: &str) -> Option<cpal::Device> {
94117 let host = cpal::default_host();
95118
Modifiedsrc-tauri/src/commands/grammar.rs+20−4View fileUnifiedSplit
@@ -104,21 +104,37 @@ pub async fn correct_grammar(
104104 .filter(|s| !s.is_empty())
105105 .or_else(|| config.voxlen_context.clone());
106106
107 // Prefer Voxlen proxy (no user API key needed) over direct provider calls
107 let has_direct_key = config.api_key.as_ref().filter(|k| !k.is_empty()).is_some();
108
109 // Prefer Voxlen proxy (no user API key needed) over direct provider calls.
110 // Voxlen-first with BYOK fallback: if the proxy call fails and the user has
111 // their own provider key, fall back to it rather than failing the request.
108112 if let Some(voxlen_key) = config.voxlen_api_key.as_ref().filter(|k| !k.is_empty()) {
109113 let mut proxy_config = config.clone();
110114 proxy_config.voxlen_context = effective_context.clone();
111 return correct_with_voxlen_proxy(
115 match correct_with_voxlen_proxy(
112116 &text, voxlen_key,
113117 proxy_config.voxlen_context.as_deref(),
114118 &proxy_config, &vocab
115 ).await;
119 ).await {
120 Ok(result) => return Ok(result),
121 Err(e) => {
122 if has_direct_key {
123 log::warn!(
124 "Voxlen grammar proxy failed ({e}); falling back to direct provider key"
125 );
126 } else {
127 return Err(e);
128 }
129 }
130 }
116131 }
117132
118133 let api_key = config
119134 .api_key
120135 .as_ref()
121 .ok_or("Not connected to a Voxlen account. Open Settings → Account, sign in at voxlen.ai/dashboard, and paste your account key.")?;
136 .filter(|k| !k.is_empty())
137 .ok_or("No grammar AI key configured. Open Settings → Account to connect a Voxlen account, or add your own provider key under Grammar.")?;
122138
123139 let mut effective_config = config.clone();
124140 if let Some(ctx) = effective_context {
Modifiedsrc-tauri/src/commands/keyring.rs+16−0View fileUnifiedSplit
@@ -31,3 +31,19 @@ pub fn keyring_delete(key: String) -> Result<(), String> {
3131 Err(e) => Err(format!("Keyring delete error: {e}")),
3232 }
3333}
34
35/// Read a secret straight from the OS keychain for use by backend (non-command)
36/// startup code. Returns `None` on a missing entry OR on any keychain backend
37/// error (e.g. no Secret Service on a headless Linux box) so startup never
38/// fails because of the keychain.
39pub fn read_secret(key: &str) -> Option<String> {
40 let entry = entry_for(key).ok()?;
41 match entry.get_password() {
42 Ok(val) => Some(val),
43 Err(keyring::Error::NoEntry) => None,
44 Err(e) => {
45 log::warn!("Could not read '{key}' from keychain: {e}");
46 None
47 }
48 }
49}
Modifiedsrc-tauri/src/commands/settings.rs+24−12View fileUnifiedSplit
@@ -250,13 +250,11 @@ fn apply_settings_to_engines(
250250
251251 let voxlen_key = s.voxlen_api_key.clone().filter(|k| !k.is_empty());
252252
253 // When a Voxlen account key is present, STT is proxied through
254 // voxlen.ai/api — user does not need their own provider keys.
255 let resolved_api_key = if voxlen_key.is_some() {
256 None // voxlen_api_key takes precedence; direct key unused
257 } else {
258 s.stt_api_key.clone().filter(|k| !k.is_empty())
259 };
253 // Keep the user's direct provider key even when a Voxlen account key is
254 // present. The engine layer applies Voxlen-first precedence and uses the
255 // direct key as a fallback; blanking it here silently destroyed a working
256 // BYOK key the moment a (possibly bad) Voxlen key was entered.
257 let resolved_api_key = s.stt_api_key.clone().filter(|k| !k.is_empty());
260258
261259 let stt_config = SttConfig {
262260 engine: stt_engine_type,
@@ -286,11 +284,9 @@ fn apply_settings_to_engines(
286284 "technical" => WritingStyle::Technical,
287285 _ => WritingStyle::Professional,
288286 };
289 let grammar_api_key = if voxlen_key.is_some() {
290 None
291 } else {
292 s.grammar_api_key.clone().filter(|k| !k.is_empty())
293 };
287 // Keep the direct grammar key as a fallback even when a Voxlen key is set
288 // (engine applies Voxlen-first precedence with BYOK fallback).
289 let grammar_api_key = s.grammar_api_key.clone().filter(|k| !k.is_empty());
294290
295291 let grammar_config = GrammarConfig {
296292 enabled: s.grammar_enabled,
@@ -345,6 +341,22 @@ pub fn load_settings_from_disk(app: AppHandle) -> Result<AppSettings, String> {
345341 }
346342 };
347343
344 // API keys are deliberately NOT persisted to the settings store (privacy:
345 // they live in the OS keychain only). Hydrate them back from the keychain
346 // here so the backend STT / grammar engines have the keys on first launch.
347 // Without this, the first dictation after a restart fails with
348 // "No API key configured" and the streaming layer enters a reconnect storm.
349 let mut loaded = loaded;
350 if loaded.stt_api_key.as_deref().unwrap_or("").is_empty() {
351 loaded.stt_api_key = crate::commands::keyring::read_secret("sttApiKey");
352 }
353 if loaded.grammar_api_key.as_deref().unwrap_or("").is_empty() {
354 loaded.grammar_api_key = crate::commands::keyring::read_secret("grammarApiKey");
355 }
356 if loaded.voxlen_api_key.as_deref().unwrap_or("").is_empty() {
357 loaded.voxlen_api_key = crate::commands::keyring::read_secret("voxlenApiKey");
358 }
359
348360 *get_settings_store().write() = loaded.clone();
349361 Ok(loaded)
350362}
Modifiedsrc-tauri/src/stt/cloud.rs+20−4View fileUnifiedSplit
@@ -80,7 +80,8 @@ pub async fn whisper_transcribe(
8080 let api_key = config
8181 .api_key
8282 .as_ref()
83 .ok_or_else(|| anyhow::anyhow!("OpenAI API key not configured"))?;
83 .filter(|k| !k.is_empty())
84 .ok_or_else(|| anyhow::anyhow!("OpenAI API key not configured. Add an OpenAI key in Settings, or switch the STT engine to Deepgram / connect a Voxlen account."))?;
8485
8586 let part = reqwest::multipart::Part::bytes(wav_data.to_vec())
8687 .file_name("audio.wav")
@@ -146,15 +147,30 @@ pub async fn deepgram_transcribe(
146147 wav_data: &[u8],
147148 config: &SttConfig,
148149) -> anyhow::Result<TranscriptionResult> {
149 // Route through Voxlen proxy when account key is present
150 let has_direct_key = config.api_key.as_ref().filter(|k| !k.is_empty()).is_some();
151
152 // Route through the Voxlen proxy when an account key is present (Voxlen
153 // first). On failure, fall back to the user's direct Deepgram key if set.
150154 if let Some(voxlen_key) = config.voxlen_api_key.as_ref().filter(|k| !k.is_empty()) {
151 return voxlen_proxy_transcribe(wav_data, voxlen_key, config).await;
155 match voxlen_proxy_transcribe(wav_data, voxlen_key, config).await {
156 Ok(result) => return Ok(result),
157 Err(e) => {
158 if has_direct_key {
159 log::warn!(
160 "Voxlen transcription proxy failed ({e}); falling back to direct Deepgram key"
161 );
162 } else {
163 return Err(e);
164 }
165 }
166 }
152167 }
153168
154169 let api_key = config
155170 .api_key
156171 .as_ref()
157 .ok_or_else(|| anyhow::anyhow!("Not connected to a Voxlen account. Open Settings → Account, sign in at voxlen.ai/dashboard, and paste your account key."))?;
172 .filter(|k| !k.is_empty())
173 .ok_or_else(|| anyhow::anyhow!("No transcription key configured. Open Settings → Account to connect a Voxlen account, or add your own Deepgram key."))?;
158174
159175 let mut url = String::from("https://api.deepgram.com/v1/listen?model=nova-3&mip_opt_out=true");
160176
Modifiedsrc-tauri/src/stt/processor.rs+37−2View fileUnifiedSplit
@@ -117,8 +117,12 @@ impl AudioProcessor {
117117
118118 // --- Batch processing path (Whisper / fallback) ---
119119 sample_rate = chunk.sample_rate;
120 let chunk_duration_ms = (chunk.samples.len() as u64 * 1000)
121 / (chunk.sample_rate as u64 * chunk.channels as u64);
120 // Guard the denominator: a misreporting / virtual device can
121 // report sample_rate == 0 or channels == 0, which would make
122 // this an integer divide-by-zero and panic the processor task.
123 let denom =
124 (chunk.sample_rate as u64).max(1) * (chunk.channels as u64).max(1);
125 let chunk_duration_ms = (chunk.samples.len() as u64 * 1000) / denom;
122126
123127 let mono_samples = if chunk.channels > 1 {
124128 to_mono(&chunk.samples, chunk.channels)
@@ -259,3 +263,34 @@ fn resample(samples: &[f32], from_rate: u32, to_rate: u32) -> Vec<f32> {
259263
260264 output
261265}
266
267
268mod tests {
269 use super::*;
270
271
272 fn resample_upsampling_does_not_panic() {
273 // Regression: a low-rate (e.g. 8kHz telephony/Bluetooth HFP) device
274 // upsampled to 16kHz used to index one past the end and panic.
275 let samples: Vec<f32> = (0..160).map(|i| (i as f32) * 0.01).collect();
276 let out = resample(&samples, 8000, 16000);
277 assert!(out.len() >= samples.len());
278 assert!(out.iter().all(|s| s.is_finite()));
279 }
280
281
282 fn resample_downsampling_and_identity() {
283 let samples: Vec<f32> = (0..480).map(|i| (i as f32) * 0.01).collect();
284 let down = resample(&samples, 48000, 16000);
285 assert!(down.len() < samples.len());
286 let same = resample(&samples, 16000, 16000);
287 assert_eq!(same.len(), samples.len());
288 }
289
290
291 fn resample_handles_single_and_empty_input() {
292 assert!(resample(&[], 8000, 16000).is_empty());
293 let one = resample(&[0.5], 8000, 16000);
294 assert!(one.iter().all(|s| s.is_finite()));
295 }
296}
Modifiedsrc-tauri/src/stt/streaming.rs+83−35View fileUnifiedSplit
@@ -72,6 +72,44 @@ async fn fetch_deepgram_temp_key(voxlen_key: &str) -> anyhow::Result<String> {
7272 .ok_or_else(|| anyhow::anyhow!("No key in Voxlen deepgram-token response"))
7373}
7474
75/// Acquire a Deepgram key following the product's precedence rule:
76/// the Voxlen account key takes precedence (exchanged for a short-lived temp
77/// key via the proxy), and the user's direct BYOK key is used as a fallback if
78/// the exchange fails or no Voxlen key is configured. Returns `None` (after
79/// emitting a clear `transcription-error`) when no key can be obtained.
80async fn acquire_deepgram_key(
81 direct_key: Option<&str>,
82 voxlen_key: Option<&str>,
83 app_handle: &AppHandle,
84) -> Option<String> {
85 if let Some(vk) = voxlen_key {
86 match fetch_deepgram_temp_key(vk).await {
87 Ok(k) => return Some(k),
88 Err(e) => {
89 if let Some(dk) = direct_key {
90 log::warn!(
91 "Voxlen token exchange failed ({e}); falling back to direct provider key"
92 );
93 return Some(dk.to_string());
94 }
95 let _ = app_handle.emit(
96 "transcription-error",
97 format!("Voxlen account key rejected: {}. Check it in Settings.", e),
98 );
99 return None;
100 }
101 }
102 }
103 if let Some(dk) = direct_key {
104 return Some(dk.to_string());
105 }
106 let _ = app_handle.emit(
107 "transcription-error",
108 "No API key configured — add a Voxlen account key or a provider key in Settings",
109 );
110 None
111}
112
75113/// Start a real-time streaming session with Deepgram using full SttConfig.
76114pub fn start_streaming(
77115 config: SttConfig,
@@ -99,17 +137,15 @@ pub fn start_streaming(
99137 let stop_on_exit = stop_flag.clone();
100138 let handle = tokio::spawn(async move {
101139 let session_task = async {
102 // Fetch the initial Deepgram key
103 let initial_key = match direct_key.as_deref() {
104 Some(k) => k.to_string(),
105 None => match fetch_deepgram_temp_key(voxlen_key.as_deref().unwrap_or("")).await {
106 Ok(k) => k,
107 Err(e) => {
108 let _ = app_handle.emit("transcription-error", format!("Token exchange failed: {}", e));
109 return;
110 }
111 },
112 };
140 // Acquire the initial Deepgram key using the Voxlen-first / direct
141 // BYOK fallback precedence.
142 let initial_key =
143 match acquire_deepgram_key(direct_key.as_deref(), voxlen_key.as_deref(), &app_handle)
144 .await
145 {
146 Some(k) => k,
147 None => return, // error already emitted
148 };
113149
114150 run_streaming_session(
115151 &initial_key,
@@ -151,6 +187,11 @@ async fn run_streaming_session(
151187) {
152188 let mut backoff_ms: u64 = 1000;
153189 let mut attempt: u32 = 0;
190 // Stop retrying after this many consecutive unhealthy sessions so a bad key
191 // or a persistently failing endpoint produces ONE clear error instead of an
192 // endless reconnect storm of toasts.
193 const MAX_CONSECUTIVE_FAILURES: u32 = 5;
194 let mut consecutive_failures: u32 = 0;
154195 let mut current_key = initial_key.to_string();
155196
156197 loop {
@@ -171,22 +212,18 @@ async fn run_streaming_session(
171212 ).await {
172213 Ok(SessionOutcome::StoppedByUser) => break,
173214 Ok(SessionOutcome::AuthFailed) => {
174 // For Voxlen users the temp key may have expired (30s TTL).
175 // Try to fetch a fresh one before giving up.
176 if let Some(vk) = voxlen_key.filter(|_| direct_key.is_none()) {
177 match fetch_deepgram_temp_key(vk).await {
178 Ok(new_key) => {
179 log::info!("Refreshed Deepgram temp key after auth failure");
215 // For Voxlen users the temp key may simply have expired (30s
216 // TTL) — re-acquire (which also keeps the BYOK fallback) before
217 // giving up. For a direct-only key, auth failure means the key
218 // itself is bad, so fail fast with a clear message.
219 if voxlen_key.is_some() {
220 match acquire_deepgram_key(direct_key, voxlen_key, &app_handle).await {
221 Some(new_key) => {
222 log::info!("Re-acquired Deepgram key after auth failure");
180223 current_key = new_key;
181224 // Fall through to reconnect logic below
182225 }
183 Err(e) => {
184 let _ = app_handle.emit(
185 "transcription-error",
186 format!("Token refresh failed: {}", e),
187 );
188 break;
189 }
226 None => break, // error already emitted
190227 }
191228 } else {
192229 let _ = app_handle.emit(
@@ -211,19 +248,30 @@ async fn run_streaming_session(
211248 if session_duration_was_healthy {
212249 backoff_ms = 1000;
213250 attempt = 0;
251 consecutive_failures = 0;
252 } else {
253 consecutive_failures += 1;
254 if consecutive_failures >= MAX_CONSECUTIVE_FAILURES {
255 let _ = app_handle.emit(
256 "transcription-error",
257 "Could not connect to the transcription service after several \
258 attempts. Check your internet connection and API key in Settings.",
259 );
260 log::error!(
261 "Giving up streaming after {} consecutive failures",
262 consecutive_failures
263 );
264 break;
265 }
214266 }
215267
216 // Refresh Deepgram temp key for Voxlen users before reconnecting
217 if let Some(vk) = voxlen_key.filter(|_| direct_key.is_none()) {
218 match fetch_deepgram_temp_key(vk).await {
219 Ok(new_key) => { current_key = new_key; }
220 Err(e) => {
221 let _ = app_handle.emit(
222 "transcription-error",
223 format!("Token refresh failed: {}", e),
224 );
225 break;
226 }
268 // Re-acquire the Deepgram key before reconnecting. Voxlen temp
269 // keys are short-lived, so this refreshes them (and re-applies
270 // the BYOK fallback). Direct-only keys need no refresh.
271 if voxlen_key.is_some() {
272 match acquire_deepgram_key(direct_key, voxlen_key, &app_handle).await {
273 Some(new_key) => { current_key = new_key; }
274 None => break, // error already emitted
227275 }
228276 }
229277
Modifiedsrc/components/settings/SettingsPanel.tsx+21−2View fileUnifiedSplit
@@ -931,7 +931,23 @@ function UsageMeter({ apiKey }: { apiKey: string }) {
931931 headers: { Authorization: `Bearer ${apiKey}` },
932932 })
933933 .then((r) => (r.ok ? r.json() : Promise.reject()))
934 .then((data: AccountInfo) => { if (!cancelled) setInfo(data); })
934 .then((data: unknown) => {
935 if (cancelled) return;
936 // Validate the payload shape before storing it — a freshly deployed or
937 // misconfigured API can return HTTP 200 with an unexpected body (error
938 // envelope, missing fields). Accessing info.plan/info.features blindly
939 // throws during render and crashes the app right after connecting a key.
940 if (!data || typeof data !== "object") return;
941 const d = data as Partial<AccountInfo>;
942 if (typeof d.plan !== "string") return;
943 setInfo({
944 plan: d.plan,
945 features: Array.isArray(d.features) ? d.features : [],
946 name: typeof d.name === "string" ? d.name : "",
947 email: typeof d.email === "string" ? d.email : "",
948 isAdmin: Boolean(d.isAdmin),
949 });
950 })
935951 .catch(() => {});
936952 return () => { cancelled = true; };
937953 }, [apiKey]);
@@ -1012,9 +1028,12 @@ function VoxlenApiSettings() {
10121028 setKeyError("Token invalid or expired. Sign in again at voxlen.ai/dashboard.");
10131029 }
10141030 } catch {
1015 // Network unreachable — accept token anyway
1031 // Network unreachable — accept the token so offline setup still works,
1032 // but warn the user it could not be verified rather than silently
1033 // treating an unchecked (possibly invalid) token as fully connected.
10161034 settings.updateSetting("voxlenApiKey", key);
10171035 setKeyInput("");
1036 setKeyError("Saved, but couldn't verify the token (no connection). It will be checked when you go online.");
10181037 }
10191038 setVerifying(false);
10201039 };
10211040
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts