super-stt-daemon/src/daemon/settings_handlers.rs
Line | Count | Source |
1 | | // SPDX-License-Identifier: GPL-3.0-only |
2 | | |
3 | | use crate::config::DaemonConfig; |
4 | | use crate::daemon::types::SuperSTTDaemon; |
5 | | use crate::output::keyboard::Simulator; |
6 | | use log::{info, warn}; |
7 | | use super_stt_shared::models::notification_method::NotificationMethod; |
8 | | use super_stt_shared::models::protocol::{DaemonResponse, ErrorCode}; |
9 | | use super_stt_shared::models::recording_stop_mode::RecordingStopMode; |
10 | | use super_stt_shared::models::update_beta_optin::UpdateBetaOptIn; |
11 | | use super_stt_shared::models::write_method::WriteMethod; |
12 | | |
13 | | impl SuperSTTDaemon { |
14 | | /// What `POST /write_method/test` types. Fixed and documented in |
15 | | /// `docs/protocol/endpoints/v1/write_method/test.md`, so a client can tell |
16 | | /// the user what to expect; kept ASCII so a pass means the common case |
17 | | /// works rather than exercising high-keysym paths a backend may not map. |
18 | | const WRITE_METHOD_TEST_TEXT: &str = "Super STT input test 123"; |
19 | | |
20 | | /// Mutate the config under the write lock, then persist it. Centralizes the |
21 | | /// lock → mutate → persist sequence so a settings handler can't hand-roll it |
22 | | /// and forget the persist (see Tier 1 #3). Returns the persist outcome so the |
23 | | /// caller can fold a save failure into its response. |
24 | 8 | pub(in crate::daemon) async fn set_config_field<F>(&self, mutate: F) -> anyhow::Result<()> |
25 | 8 | where |
26 | 8 | F: FnOnce(&mut DaemonConfig), |
27 | 8 | { |
28 | | { |
29 | 8 | let mut config = self.config.write().await; |
30 | 8 | mutate(&mut config); |
31 | | } |
32 | 8 | self.persist_config().await |
33 | 8 | } |
34 | | |
35 | | /// Fold a persist outcome into a settings response. `base` already carries |
36 | | /// the setting-specific `.with_*` field and `message` is the success text. |
37 | | /// A save failure keeps the (already-applied) in-memory change — the daemon |
38 | | /// stays authoritative for the process lifetime — and appends the error to |
39 | | /// the message, logging a warning. |
40 | 8 | pub(in crate::daemon) fn settings_saved( |
41 | 8 | base: DaemonResponse, |
42 | 8 | message: String, |
43 | 8 | persist: anyhow::Result<()>, |
44 | 8 | ) -> DaemonResponse { |
45 | 8 | match persist { |
46 | 8 | Ok(()) => base.with_message(message), |
47 | 0 | Err(e) => { |
48 | 0 | warn!("Setting changed but config save failed: {e}"); |
49 | 0 | base.with_message(format!("{message} (save failed: {e})")) |
50 | | } |
51 | | } |
52 | 8 | } |
53 | | /// Handle set preview typing command - enable or disable preview typing |
54 | | #[must_use] |
55 | 0 | pub async fn handle_set_preview_typing(&self, enabled: bool) -> DaemonResponse { |
56 | | // Update the in-memory setting. |
57 | 0 | self.preview_typing_enabled |
58 | 0 | .store(enabled, std::sync::atomic::Ordering::Relaxed); |
59 | | |
60 | 0 | let persist = self |
61 | 0 | .set_config_field(|c| c.transcription.preview_typing_enabled = enabled) |
62 | 0 | .await; |
63 | | |
64 | 0 | let state = if enabled { "enabled" } else { "disabled" }; |
65 | 0 | info!("Preview typing {state}"); |
66 | 0 | Self::settings_saved( |
67 | 0 | DaemonResponse::success().with_preview_typing_enabled(enabled), |
68 | 0 | format!("Preview typing {state}"), |
69 | 0 | persist, |
70 | | ) |
71 | 0 | } |
72 | | |
73 | | /// Handle get preview typing command - return current preview typing setting |
74 | | #[must_use] |
75 | 0 | pub fn handle_get_preview_typing(&self) -> DaemonResponse { |
76 | 0 | let enabled = self |
77 | 0 | .preview_typing_enabled |
78 | 0 | .load(std::sync::atomic::Ordering::Relaxed); |
79 | | |
80 | 0 | DaemonResponse::success() |
81 | 0 | .with_preview_typing_enabled(enabled) |
82 | 0 | .with_message("Preview typing setting retrieved successfully".to_string()) |
83 | 0 | } |
84 | | |
85 | | /// Handle set recording stop mode command |
86 | 0 | pub async fn handle_set_recording_stop_mode(&self, mode: RecordingStopMode) -> DaemonResponse { |
87 | 0 | let persist = self |
88 | 0 | .set_config_field(|c| c.transcription.recording_stop_mode = mode) |
89 | 0 | .await; |
90 | | |
91 | 0 | info!("Recording stop mode set to {mode}"); |
92 | 0 | Self::settings_saved( |
93 | 0 | DaemonResponse::success().with_recording_stop_mode(mode.to_string()), |
94 | 0 | format!("Recording stop mode set to {mode}"), |
95 | 0 | persist, |
96 | | ) |
97 | 0 | } |
98 | | |
99 | | /// Handle get recording stop mode command |
100 | 0 | pub async fn handle_get_recording_stop_mode(&self) -> DaemonResponse { |
101 | 0 | let config = self.config.read().await; |
102 | 0 | let mode = config.transcription.recording_stop_mode; |
103 | 0 | DaemonResponse::success().with_recording_stop_mode(mode.to_string()) |
104 | 0 | } |
105 | | |
106 | | /// Handle set write method command |
107 | 0 | pub async fn handle_set_write_method(&self, method: WriteMethod) -> DaemonResponse { |
108 | 0 | let persist = self |
109 | 0 | .set_config_field(|c| c.transcription.write_method = method) |
110 | 0 | .await; |
111 | | // Invalidate the cached simulator so the next recording creates a fresh one. |
112 | 0 | *self.simulator.write().await = None; |
113 | | |
114 | 0 | info!("Write method set to {method}"); |
115 | 0 | Self::settings_saved( |
116 | 0 | DaemonResponse::success().with_write_method(method.to_string()), |
117 | 0 | format!("Write method set to {method}"), |
118 | 0 | persist, |
119 | | ) |
120 | 0 | } |
121 | | |
122 | | /// Handle test write method command: type a fixed string with the |
123 | | /// configured method so the user can see whether it reaches their focused |
124 | | /// window. Contract: `docs/protocol/endpoints/v1/write_method/test.md`. |
125 | 4 | pub async fn handle_test_write_method(&self) -> DaemonResponse { |
126 | 4 | if *self.busy.read().await { |
127 | 2 | return DaemonResponse::error_with_code( |
128 | 2 | ErrorCode::RecordingInProgress, |
129 | 2 | "recording_in_progress", |
130 | | ); |
131 | 2 | } |
132 | | |
133 | 2 | let method = self.config.read().await.transcription.write_method; |
134 | | |
135 | | // Borrow the cached simulator rather than building a second one: a |
136 | | // fresh portal session costs three D-Bus round-trips and may re-prompt |
137 | | // for authorization, and the test would leave that session behind. |
138 | 2 | let cached = self.simulator.write().await.take(); |
139 | 2 | let mut simulator = match cached { |
140 | 2 | Some(s) => s, |
141 | 0 | None => match Simulator::new(method).await { |
142 | 0 | Ok(s) => s, |
143 | 0 | Err(e) => { |
144 | 0 | warn!("Write-method test could not build a simulator: {e}"); |
145 | 0 | return DaemonResponse::error_with_code( |
146 | 0 | ErrorCode::Internal, |
147 | 0 | "write_method_unavailable", |
148 | | ); |
149 | | } |
150 | | }, |
151 | | }; |
152 | | |
153 | 2 | let resolved = simulator.resolved_method(); |
154 | 2 | let result = simulator.type_text(Self::WRITE_METHOD_TEST_TEXT).await; |
155 | | |
156 | | // Same cache discipline as a recording (see `Simulator::is_cacheable`). |
157 | 2 | if simulator.is_cacheable() { |
158 | 2 | *self.simulator.write().await = Some(simulator); |
159 | 0 | } |
160 | | |
161 | 2 | match result { |
162 | | Ok(()) => { |
163 | 2 | info!("Write-method test typed via {resolved}"); |
164 | 2 | DaemonResponse::success() |
165 | 2 | .with_message(format!("Typed test text via {}", resolved.pretty_name())) |
166 | 2 | .with_write_method(method.to_string()) |
167 | 2 | .with_resolved_write_method(resolved.to_string()) |
168 | | } |
169 | 0 | Err(e) => { |
170 | 0 | warn!("Write-method test failed to type via {resolved}: {e}"); |
171 | 0 | DaemonResponse::error_with_code(ErrorCode::Internal, "typing_failed") |
172 | | } |
173 | | } |
174 | 4 | } |
175 | | |
176 | | /// Handle get write method command |
177 | 0 | pub async fn handle_get_write_method(&self) -> DaemonResponse { |
178 | 0 | let config = self.config.read().await; |
179 | 0 | let method = config.transcription.write_method; |
180 | 0 | DaemonResponse::success().with_write_method(method.to_string()) |
181 | 0 | } |
182 | | |
183 | | /// Handle set notification method command. An unknown method name is |
184 | | /// rejected with `invalid_notification_method` (HTTP 400) per |
185 | | /// `docs/protocol/endpoints/v1/notification_method.md`, rather than |
186 | | /// silently applying the default and reporting success. |
187 | 4 | pub async fn handle_set_notification_method(&self, method_str: String) -> DaemonResponse { |
188 | 4 | let Ok(method2 ) = method_str.parse::<NotificationMethod>() else { |
189 | 2 | return DaemonResponse::error_with_code( |
190 | 2 | ErrorCode::InvalidValue, |
191 | 2 | "invalid_notification_method", |
192 | | ); |
193 | | }; |
194 | | |
195 | 2 | let persist = self |
196 | 2 | .set_config_field(|c| c.transcription.notification_method = method) |
197 | 2 | .await; |
198 | | |
199 | 2 | info!("Notification method set to {method}"); |
200 | 2 | Self::settings_saved( |
201 | 2 | DaemonResponse::success().with_notification_method(method.to_string()), |
202 | 2 | format!("Notification method set to {method}"), |
203 | 2 | persist, |
204 | | ) |
205 | 4 | } |
206 | | |
207 | | /// Handle get notification method command |
208 | 2 | pub async fn handle_get_notification_method(&self) -> DaemonResponse { |
209 | 2 | let config = self.config.read().await; |
210 | 2 | let method = config.transcription.notification_method; |
211 | 2 | DaemonResponse::success().with_notification_method(method.to_string()) |
212 | 2 | } |
213 | | |
214 | | /// Handle set update-check-enabled command |
215 | 2 | pub async fn handle_set_update_check_enabled(&self, enabled: bool) -> DaemonResponse { |
216 | 2 | let persist = self |
217 | 2 | .set_config_field(|c| c.update.check_enabled = enabled) |
218 | 2 | .await; |
219 | 2 | self.publish_settings_changed("update_check_enabled"); |
220 | 2 | info!( |
221 | | "Update checks {}", |
222 | 0 | if enabled { "enabled" } else { "disabled" } |
223 | | ); |
224 | 2 | Self::settings_saved( |
225 | 2 | DaemonResponse::success().with_update_check_enabled(enabled), |
226 | 2 | format!( |
227 | | "Update checks {}", |
228 | 2 | if enabled { "enabled"0 } else { "disabled" } |
229 | | ), |
230 | 2 | persist, |
231 | | ) |
232 | 2 | } |
233 | | |
234 | | /// Handle get update-check-enabled command |
235 | 4 | pub async fn handle_get_update_check_enabled(&self) -> DaemonResponse { |
236 | 4 | let enabled = self.config.read().await.update.check_enabled; |
237 | 4 | DaemonResponse::success().with_update_check_enabled(enabled) |
238 | 4 | } |
239 | | |
240 | | /// Handle set update-beta-optin command. An unknown value is rejected |
241 | | /// with `invalid_update_beta_optin` (HTTP 400) per |
242 | | /// `docs/protocol/endpoints/v1/update_beta_optin.md`, rather than |
243 | | /// silently applying the default and reporting success. |
244 | 4 | pub async fn handle_set_update_beta_optin(&self, value: String) -> DaemonResponse { |
245 | 4 | let Ok(optin2 ) = value.parse::<UpdateBetaOptIn>() else { |
246 | 2 | return DaemonResponse::error_with_code( |
247 | 2 | ErrorCode::InvalidValue, |
248 | 2 | "invalid_update_beta_optin", |
249 | | ); |
250 | | }; |
251 | 2 | let persist = self.set_config_field(|c| c.update.beta_optin = optin).await; |
252 | 2 | self.publish_settings_changed("update_beta_optin"); |
253 | 2 | info!("Update beta opt-in set to {optin}"); |
254 | 2 | Self::settings_saved( |
255 | 2 | DaemonResponse::success().with_update_beta_optin(optin.to_string()), |
256 | 2 | format!("Update beta opt-in set to {optin}"), |
257 | 2 | persist, |
258 | | ) |
259 | 4 | } |
260 | | |
261 | | /// Handle get update-beta-optin command |
262 | 4 | pub async fn handle_get_update_beta_optin(&self) -> DaemonResponse { |
263 | 4 | let optin = self.config.read().await.update.beta_optin; |
264 | 4 | DaemonResponse::success().with_update_beta_optin(optin.to_string()) |
265 | 4 | } |
266 | | |
267 | | /// Handle get custom models directory command |
268 | 0 | pub async fn handle_get_custom_models_dir(&self) -> DaemonResponse { |
269 | 0 | let path = self |
270 | 0 | .config |
271 | 0 | .read() |
272 | 0 | .await |
273 | | .transcription |
274 | | .custom_models_dir |
275 | 0 | .clone(); |
276 | 0 | DaemonResponse::success().with_custom_models_dir(path) |
277 | 0 | } |
278 | | |
279 | | /// Handle set custom models directory command |
280 | 0 | pub async fn handle_set_custom_models_dir(&self, path: Option<String>) -> DaemonResponse { |
281 | 0 | let path_display = path.as_deref().unwrap_or("none").to_string(); |
282 | | |
283 | 0 | let persist = self |
284 | 0 | .set_config_field(|c| c.transcription.custom_models_dir = path) |
285 | 0 | .await; |
286 | | |
287 | 0 | info!("Custom models directory set to {path_display}"); |
288 | 0 | Self::settings_saved( |
289 | 0 | DaemonResponse::success(), |
290 | 0 | format!("Custom models directory set to {path_display}"), |
291 | 0 | persist, |
292 | | ) |
293 | 0 | } |
294 | | } |