Skip to main content

tauri_plugin_background_service/
desired_state.rs

1//! Desired-state persistence for background service reliability.
2//!
3//! The [`DesiredState`] struct captures the user's intent for whether the background
4//! service should be running, along with recovery metadata. Platform-specific backends
5//! implement [`DesiredStateBackend`] to persist this state across process kills and
6//! device reboots.
7
8use serde::{Deserialize, Serialize};
9use std::fs;
10use std::path::PathBuf;
11
12/// Persistent desired-state for the background service.
13///
14/// Captures the user's intent (`desired_running`) and recovery metadata so that
15/// platform-specific backends can restore service state after process death or reboot.
16#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
17#[serde(rename_all = "camelCase")]
18#[non_exhaustive]
19pub struct DesiredState {
20    /// Whether the user wants the service running.
21    pub desired_running: bool,
22    /// Last `StartConfig` used to start the service (JSON-serialized).
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub last_start_config: Option<serde_json::Value>,
25    /// Epoch millis when the service was last started.
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub last_start_epoch_ms: Option<u64>,
28    /// Epoch millis of the last heartbeat from the service task.
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub last_heartbeat_epoch_ms: Option<u64>,
31    /// Last native platform state (e.g. "timeout", "expired").
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub last_native_state: Option<String>,
34    /// Last platform-specific error message.
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub last_platform_error: Option<String>,
37    /// How many restart attempts have been made.
38    #[serde(default)]
39    pub restart_attempt: u32,
40    /// Whether a recovery is pending (e.g. after boot).
41    #[serde(default)]
42    pub recovery_pending: bool,
43    /// Why recovery was initiated.
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub recovery_reason: Option<String>,
46}
47
48/// Backend for persisting desired-state across process restarts.
49///
50/// Each platform provides its own implementation:
51/// - **Desktop**: [`FileDesiredStateBackend`] (JSON file).
52/// - **Android**: `DurableState` in Kotlin (via `SharedPreferences`).
53/// - **iOS**: `UserDefaults` in Swift.
54pub trait DesiredStateBackend: Send + Sync {
55    /// Load the persisted desired state.
56    ///
57    /// Returns the default state if no persisted data exists.
58    fn load(&self) -> Result<DesiredState, String>;
59    /// Save the desired state.
60    fn save(&self, state: &DesiredState) -> Result<(), String>;
61    /// Clear persisted state (delete storage).
62    fn clear(&self) -> Result<(), String>;
63}
64
65const FILE_NAME: &str = "bg-desired-state.json";
66
67/// File-based desired-state backend for desktop platforms.
68///
69/// Stores a JSON file at `{dir}/bg-desired-state.json`.
70pub struct FileDesiredStateBackend {
71    path: PathBuf,
72}
73
74impl FileDesiredStateBackend {
75    /// Create a new backend that reads/writes to `dir/bg-desired-state.json`.
76    pub fn new(dir: PathBuf) -> Self {
77        Self {
78            path: dir.join(FILE_NAME),
79        }
80    }
81}
82
83impl DesiredStateBackend for FileDesiredStateBackend {
84    fn load(&self) -> Result<DesiredState, String> {
85        match fs::read_to_string(&self.path) {
86            Ok(data) => serde_json::from_str(&data).map_err(|e| e.to_string()),
87            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(DesiredState::default()),
88            Err(e) => Err(e.to_string()),
89        }
90    }
91
92    fn save(&self, state: &DesiredState) -> Result<(), String> {
93        if let Some(parent) = self.path.parent() {
94            fs::create_dir_all(parent).map_err(|e| e.to_string())?;
95        }
96        let json = serde_json::to_string_pretty(state).map_err(|e| e.to_string())?;
97        fs::write(&self.path, json).map_err(|e| e.to_string())
98    }
99
100    fn clear(&self) -> Result<(), String> {
101        match fs::remove_file(&self.path) {
102            Ok(()) => Ok(()),
103            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
104            Err(e) => Err(e.to_string()),
105        }
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    // --- DesiredState struct tests ---
114
115    #[test]
116    fn desired_state_default_values() {
117        let state = DesiredState::default();
118        assert!(!state.desired_running);
119        assert_eq!(state.last_start_config, None);
120        assert_eq!(state.last_start_epoch_ms, None);
121        assert_eq!(state.last_heartbeat_epoch_ms, None);
122        assert_eq!(state.last_native_state, None);
123        assert_eq!(state.last_platform_error, None);
124        assert_eq!(state.restart_attempt, 0);
125        assert!(!state.recovery_pending);
126        assert_eq!(state.recovery_reason, None);
127    }
128
129    #[test]
130    fn desired_state_serde_roundtrip() {
131        let state = DesiredState {
132            desired_running: true,
133            last_start_config: Some(serde_json::json!({"serviceLabel":"test"})),
134            last_start_epoch_ms: Some(1700000000000),
135            last_heartbeat_epoch_ms: Some(1700000001000),
136            last_native_state: Some("running".into()),
137            last_platform_error: None,
138            restart_attempt: 2,
139            recovery_pending: true,
140            recovery_reason: Some("boot".into()),
141        };
142        let json = serde_json::to_string(&state).unwrap();
143        let de: DesiredState = serde_json::from_str(&json).unwrap();
144        assert_eq!(de, state);
145    }
146
147    #[test]
148    fn desired_state_json_keys_camel_case() {
149        let state = DesiredState {
150            desired_running: true,
151            last_start_config: Some(serde_json::json!({"serviceLabel":"test"})),
152            last_start_epoch_ms: Some(1700000000000),
153            last_heartbeat_epoch_ms: Some(1700000001000),
154            last_native_state: Some("running".into()),
155            last_platform_error: Some("err".into()),
156            restart_attempt: 1,
157            recovery_pending: true,
158            recovery_reason: Some("boot".into()),
159        };
160        let json = serde_json::to_string(&state).unwrap();
161        assert!(json.contains("\"desiredRunning\":"), "{json}");
162        assert!(json.contains("\"lastStartConfig\":"), "{json}");
163        assert!(json.contains("\"lastStartEpochMs\":"), "{json}");
164        assert!(json.contains("\"lastHeartbeatEpochMs\":"), "{json}");
165        assert!(json.contains("\"lastNativeState\":"), "{json}");
166        assert!(json.contains("\"lastPlatformError\":"), "{json}");
167        assert!(json.contains("\"restartAttempt\":"), "{json}");
168        assert!(json.contains("\"recoveryPending\":"), "{json}");
169        assert!(json.contains("\"recoveryReason\":"), "{json}");
170    }
171
172    #[test]
173    fn desired_state_default_serde_roundtrip() {
174        let state = DesiredState::default();
175        let json = serde_json::to_string(&state).unwrap();
176        let de: DesiredState = serde_json::from_str(&json).unwrap();
177        assert_eq!(de, state);
178    }
179
180    // --- FileDesiredStateBackend tests ---
181
182    fn temp_dir() -> PathBuf {
183        tempfile::tempdir().unwrap().keep()
184    }
185
186    #[test]
187    fn file_backend_roundtrip() {
188        let dir = temp_dir();
189        let backend = FileDesiredStateBackend::new(dir.clone());
190
191        let state = DesiredState {
192            desired_running: true,
193            last_start_config: Some(
194                serde_json::json!({"serviceLabel":"Syncing","foregroundServiceType":"dataSync"}),
195            ),
196            last_start_epoch_ms: Some(1700000000000),
197            last_heartbeat_epoch_ms: Some(1700000005000),
198            last_native_state: Some("running".into()),
199            last_platform_error: None,
200            restart_attempt: 0,
201            recovery_pending: false,
202            recovery_reason: None,
203        };
204
205        backend.save(&state).unwrap();
206        let loaded = backend.load().unwrap();
207        assert_eq!(loaded, state);
208    }
209
210    #[test]
211    fn file_backend_load_missing_file_returns_default() {
212        let dir = temp_dir();
213        let backend = FileDesiredStateBackend::new(dir.clone());
214
215        // No file written — should return default.
216        let loaded = backend.load().unwrap();
217        assert_eq!(loaded, DesiredState::default());
218    }
219
220    #[test]
221    fn file_backend_clear_loads_default() {
222        let dir = temp_dir();
223        let backend = FileDesiredStateBackend::new(dir.clone());
224
225        let state = DesiredState {
226            desired_running: true,
227            ..Default::default()
228        };
229        backend.save(&state).unwrap();
230
231        backend.clear().unwrap();
232        let loaded = backend.load().unwrap();
233        assert_eq!(loaded, DesiredState::default());
234    }
235
236    #[test]
237    fn file_backend_clear_removes_file() {
238        let dir = temp_dir();
239        let backend = FileDesiredStateBackend::new(dir.clone());
240
241        let state = DesiredState {
242            desired_running: true,
243            ..Default::default()
244        };
245        backend.save(&state).unwrap();
246        assert!(dir.join(FILE_NAME).exists());
247
248        backend.clear().unwrap();
249        assert!(!dir.join(FILE_NAME).exists());
250    }
251
252    #[test]
253    fn file_backend_clear_when_missing_is_ok() {
254        let dir = temp_dir();
255        let backend = FileDesiredStateBackend::new(dir.clone());
256
257        // Clear without ever saving — should succeed.
258        backend.clear().unwrap();
259    }
260
261    #[test]
262    fn file_backend_save_creates_parent_dir() {
263        let dir = temp_dir();
264        let nested = dir.join("sub").join("dir");
265        let backend = FileDesiredStateBackend::new(nested);
266
267        let state = DesiredState::default();
268        backend.save(&state).unwrap();
269        let loaded = backend.load().unwrap();
270        assert_eq!(loaded, state);
271    }
272
273    #[test]
274    fn file_backend_overwrite_on_save() {
275        let dir = temp_dir();
276        let backend = FileDesiredStateBackend::new(dir.clone());
277
278        let state1 = DesiredState {
279            desired_running: true,
280            ..Default::default()
281        };
282        backend.save(&state1).unwrap();
283
284        let state2 = DesiredState {
285            desired_running: false,
286            restart_attempt: 5,
287            ..Default::default()
288        };
289        backend.save(&state2).unwrap();
290
291        let loaded = backend.load().unwrap();
292        assert_eq!(loaded, state2);
293        assert_ne!(loaded, state1);
294    }
295
296    // --- Trait object safety test ---
297
298    #[test]
299    fn backend_is_object_safe() {
300        let dir = temp_dir();
301        let backend: Box<dyn DesiredStateBackend> = Box::new(FileDesiredStateBackend::new(dir));
302        let state = DesiredState::default();
303        backend.save(&state).unwrap();
304        let loaded = backend.load().unwrap();
305        assert_eq!(loaded, state);
306    }
307}