Coverage Report

Created: 2026-09-05 23:00

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
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
}