tauri_plugin_background_service/
desired_state.rs1use serde::{Deserialize, Serialize};
9use std::fs;
10use std::path::PathBuf;
11
12#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
17#[serde(rename_all = "camelCase")]
18#[non_exhaustive]
19pub struct DesiredState {
20 pub desired_running: bool,
22 #[serde(skip_serializing_if = "Option::is_none")]
24 pub last_start_config: Option<serde_json::Value>,
25 #[serde(skip_serializing_if = "Option::is_none")]
27 pub last_start_epoch_ms: Option<u64>,
28 #[serde(skip_serializing_if = "Option::is_none")]
30 pub last_heartbeat_epoch_ms: Option<u64>,
31 #[serde(skip_serializing_if = "Option::is_none")]
33 pub last_native_state: Option<String>,
34 #[serde(skip_serializing_if = "Option::is_none")]
36 pub last_platform_error: Option<String>,
37 #[serde(default)]
39 pub restart_attempt: u32,
40 #[serde(default)]
42 pub recovery_pending: bool,
43 #[serde(skip_serializing_if = "Option::is_none")]
45 pub recovery_reason: Option<String>,
46}
47
48pub trait DesiredStateBackend: Send + Sync {
55 fn load(&self) -> Result<DesiredState, String>;
59 fn save(&self, state: &DesiredState) -> Result<(), String>;
61 fn clear(&self) -> Result<(), String>;
63}
64
65const FILE_NAME: &str = "bg-desired-state.json";
66
67pub struct FileDesiredStateBackend {
71 path: PathBuf,
72}
73
74impl FileDesiredStateBackend {
75 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 #[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 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 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 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 #[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}