Skip to main content

tauri_plugin_background_service/desktop/
ipc.rs

1//! IPC protocol types and framing for desktop OS service mode.
2//!
3//! The desktop OS service uses length-prefixed JSON over Unix domain sockets
4//! for IPC between the GUI process and the headless service process.
5//!
6//! **Unix-only** — the IPC transport currently requires Unix domain sockets.
7//! Windows named pipe support is not yet implemented.
8
9use serde::{Deserialize, Serialize};
10
11use crate::error::ServiceError;
12use crate::models::StopReason;
13
14/// Maximum allowed frame size (16 MB).
15pub const MAX_FRAME_SIZE: usize = 16 * 1024 * 1024;
16
17/// IPC request sent from the GUI process to the headless service.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19#[serde(rename_all = "camelCase", tag = "type")]
20#[non_exhaustive]
21pub enum IpcRequest {
22    /// Start the background service with the given config.
23    Start {
24        /// Startup configuration forwarded from the plugin.
25        config: crate::models::StartConfig,
26    },
27    /// Stop the running background service.
28    Stop,
29    /// Query whether a background service is currently running.
30    IsRunning,
31    /// Query the current service lifecycle state.
32    GetState,
33    /// Enable auto-restart: persist desired_running=true without starting.
34    EnableAutoRestart {
35        /// Optional start config to store for recovery.
36        config: Option<crate::models::StartConfig>,
37    },
38    /// Disable auto-restart: persist desired_running=false without stopping.
39    DisableAutoRestart,
40    /// Get the persisted desired-state.
41    GetDesiredState,
42    /// Validate background service setup prerequisites.
43    ValidateSetup,
44    /// Get the complete lifecycle status snapshot.
45    GetLifecycleStatus,
46}
47
48/// IPC response sent from the headless service to the GUI process.
49#[derive(Debug, Clone, Serialize, Deserialize)]
50#[serde(rename_all = "camelCase")]
51pub struct IpcResponse {
52    /// Whether the request succeeded.
53    pub ok: bool,
54    /// Optional data payload on success.
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub data: Option<serde_json::Value>,
57    /// Optional error message on failure.
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub error: Option<String>,
60}
61
62/// IPC event streamed from the headless service to the GUI process.
63#[derive(Debug, Clone, Serialize, Deserialize)]
64#[serde(rename_all = "camelCase", tag = "type")]
65#[non_exhaustive]
66pub enum IpcEvent {
67    /// Service started successfully.
68    Started,
69    /// Service stopped.
70    Stopped { reason: StopReason },
71    /// Service encountered an error.
72    Error { message: String },
73}
74
75/// Tagged wrapper for all IPC messages on the wire.
76///
77/// Uses `#[serde(tag = "kind")]` for single-point deserialization — no
78/// trial-and-error dispatch needed. The reader deserializes once and matches
79/// on the variant.
80#[derive(Debug, Clone, Serialize, Deserialize)]
81#[serde(tag = "kind", rename_all = "camelCase")]
82pub enum IpcMessage {
83    /// A request from the GUI process to the headless service.
84    Request(IpcRequest),
85    /// A response from the headless service to the GUI process.
86    Response(IpcResponse),
87    /// An event streamed from the headless service to the GUI process.
88    Event(IpcEvent),
89}
90
91/// Encode a message into a length-prefixed JSON frame.
92///
93/// Frame format: `[4-byte big-endian u32 length][JSON payload]`
94pub fn encode_frame<T: Serialize>(msg: &T) -> Result<Vec<u8>, serde_json::Error> {
95    let json = serde_json::to_vec(msg)?;
96    let len = json.len() as u32;
97    let mut buf = Vec::with_capacity(4 + json.len());
98    buf.extend_from_slice(&len.to_be_bytes());
99    buf.extend_from_slice(&json);
100    Ok(buf)
101}
102
103/// Decode an [`IpcMessage`] from raw payload bytes.
104///
105/// Takes payload bytes (without length prefix) and deserializes as
106/// [`IpcMessage`]. The caller matches on the variant to extract the
107/// concrete type. Length-prefix reading and frame-size validation are
108/// handled by `read_frame` / `read_frame_from` at the stream level.
109pub fn decode_frame(payload: &[u8]) -> Result<IpcMessage, serde_json::Error> {
110    serde_json::from_slice(payload)
111}
112
113/// Derive the IPC socket path for a given service label.
114///
115/// - Linux: `$XDG_RUNTIME_DIR/{label}.sock` (fallback: `/run/user/{uid}/{label}.sock`)
116/// - macOS: `/tmp/{label}.sock`
117/// - Windows: `\\.\pipe\{label}`
118///
119/// # Errors
120///
121/// Returns `ServiceError::Init` if the label is empty, contains path
122/// separators, or contains `..` components.
123pub fn socket_path(label: &str) -> Result<std::path::PathBuf, ServiceError> {
124    sanitize_label(label)?;
125    #[cfg(target_os = "linux")]
126    {
127        let dir = std::env::var("XDG_RUNTIME_DIR")
128            .unwrap_or_else(|_| format!("/run/user/{}", unsafe { libc::getuid() }));
129        Ok(std::path::PathBuf::from(format!("{dir}/{label}.sock")))
130    }
131    #[cfg(target_os = "macos")]
132    {
133        Ok(std::path::PathBuf::from(format!("/tmp/{label}.sock")))
134    }
135    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
136    {
137        Ok(std::path::PathBuf::from(format!("/tmp/{label}.sock")))
138    }
139}
140
141/// Validate that a service label does not contain path traversal characters.
142fn sanitize_label(label: &str) -> Result<std::path::PathBuf, ServiceError> {
143    if label.is_empty() {
144        return Err(ServiceError::Init("service label must not be empty".into()));
145    }
146    if label.contains('/') || label.contains('\\') {
147        return Err(ServiceError::Init(format!(
148            "service label must not contain path separators: {label}"
149        )));
150    }
151    if label.contains("..") {
152        return Err(ServiceError::Init(format!(
153            "service label must not contain '..': {label}"
154        )));
155    }
156    Ok(std::path::PathBuf::from(label))
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    // --- IpcRequest serde roundtrip tests ---
164
165    #[test]
166    fn ipc_request_start_serde_roundtrip() {
167        let req = IpcRequest::Start {
168            config: crate::models::StartConfig {
169                service_label: "Syncing".into(),
170                foreground_service_type: "dataSync".into(),
171            },
172        };
173        let json = serde_json::to_string(&req).unwrap();
174        let de: IpcRequest = serde_json::from_str(&json).unwrap();
175        match de {
176            IpcRequest::Start { config } => {
177                assert_eq!(config.service_label, "Syncing");
178                assert_eq!(config.foreground_service_type, "dataSync");
179            }
180            other => panic!("Expected Start, got {other:?}"),
181        }
182    }
183
184    #[test]
185    fn ipc_request_start_json_tag() {
186        let req = IpcRequest::Start {
187            config: crate::models::StartConfig::default(),
188        };
189        let json = serde_json::to_string(&req).unwrap();
190        assert!(json.contains("\"type\":\"start\""), "Tagged JSON: {json}");
191    }
192
193    #[test]
194    fn ipc_request_stop_serde_roundtrip() {
195        let req = IpcRequest::Stop;
196        let json = serde_json::to_string(&req).unwrap();
197        let de: IpcRequest = serde_json::from_str(&json).unwrap();
198        assert!(matches!(de, IpcRequest::Stop));
199    }
200
201    #[test]
202    fn ipc_request_stop_json_tag() {
203        let req = IpcRequest::Stop;
204        let json = serde_json::to_string(&req).unwrap();
205        assert!(json.contains("\"type\":\"stop\""), "Tagged JSON: {json}");
206    }
207
208    #[test]
209    fn ipc_request_is_running_serde_roundtrip() {
210        let req = IpcRequest::IsRunning;
211        let json = serde_json::to_string(&req).unwrap();
212        let de: IpcRequest = serde_json::from_str(&json).unwrap();
213        assert!(matches!(de, IpcRequest::IsRunning));
214    }
215
216    #[test]
217    fn ipc_request_is_running_json_tag() {
218        let req = IpcRequest::IsRunning;
219        let json = serde_json::to_string(&req).unwrap();
220        assert!(
221            json.contains("\"type\":\"isRunning\""),
222            "Tagged JSON: {json}"
223        );
224    }
225
226    // --- IpcRequest::GetState serde tests ---
227
228    #[test]
229    fn ipc_request_get_state_serde_roundtrip() {
230        let req = IpcRequest::GetState;
231        let json = serde_json::to_string(&req).unwrap();
232        let de: IpcRequest = serde_json::from_str(&json).unwrap();
233        assert!(matches!(de, IpcRequest::GetState));
234    }
235
236    #[test]
237    fn ipc_request_get_state_json_tag() {
238        let req = IpcRequest::GetState;
239        let json = serde_json::to_string(&req).unwrap();
240        assert!(
241            json.contains("\"type\":\"getState\""),
242            "Tagged JSON: {json}"
243        );
244    }
245
246    // --- IpcResponse serde roundtrip tests ---
247
248    #[test]
249    fn ipc_response_success_roundtrip() {
250        let resp = IpcResponse {
251            ok: true,
252            data: Some(serde_json::json!({"running": true})),
253            error: None,
254        };
255        let json = serde_json::to_string(&resp).unwrap();
256        let de: IpcResponse = serde_json::from_str(&json).unwrap();
257        assert!(de.ok);
258        assert_eq!(de.data.unwrap()["running"], true);
259        assert!(de.error.is_none());
260    }
261
262    #[test]
263    fn ipc_response_error_roundtrip() {
264        let resp = IpcResponse {
265            ok: false,
266            data: None,
267            error: Some("Service is already running".into()),
268        };
269        let json = serde_json::to_string(&resp).unwrap();
270        let de: IpcResponse = serde_json::from_str(&json).unwrap();
271        assert!(!de.ok);
272        assert!(de.data.is_none());
273        assert_eq!(de.error.unwrap(), "Service is already running");
274    }
275
276    #[test]
277    fn ipc_response_skips_none_fields() {
278        let resp = IpcResponse {
279            ok: true,
280            data: None,
281            error: None,
282        };
283        let json = serde_json::to_string(&resp).unwrap();
284        assert!(!json.contains("\"data\""), "Should skip null data: {json}");
285        assert!(
286            !json.contains("\"error\""),
287            "Should skip null error: {json}"
288        );
289    }
290
291    #[test]
292    fn ipc_response_camel_case_keys() {
293        let resp = IpcResponse {
294            ok: true,
295            data: None,
296            error: None,
297        };
298        let json = serde_json::to_string(&resp).unwrap();
299        assert!(json.contains("\"ok\""), "ok key: {json}");
300    }
301
302    // --- IpcEvent serde roundtrip tests ---
303
304    #[test]
305    fn ipc_event_started_serde_roundtrip() {
306        let event = IpcEvent::Started;
307        let json = serde_json::to_string(&event).unwrap();
308        let de: IpcEvent = serde_json::from_str(&json).unwrap();
309        assert!(matches!(de, IpcEvent::Started));
310    }
311
312    #[test]
313    fn ipc_event_started_json_tag() {
314        let event = IpcEvent::Started;
315        let json = serde_json::to_string(&event).unwrap();
316        assert!(json.contains("\"type\":\"started\""), "Tagged JSON: {json}");
317    }
318
319    #[test]
320    fn ipc_event_stopped_serde_roundtrip() {
321        let event = IpcEvent::Stopped {
322            reason: StopReason::UserStop,
323        };
324        let json = serde_json::to_string(&event).unwrap();
325        let de: IpcEvent = serde_json::from_str(&json).unwrap();
326        match de {
327            IpcEvent::Stopped { reason } => assert_eq!(reason, StopReason::UserStop),
328            other => panic!("Expected Stopped, got {other:?}"),
329        }
330    }
331
332    #[test]
333    fn ipc_event_stopped_json_keys() {
334        let event = IpcEvent::Stopped {
335            reason: StopReason::TaskCompleted,
336        };
337        let json = serde_json::to_string(&event).unwrap();
338        assert!(json.contains("\"type\":\"stopped\""), "Tag: {json}");
339        assert!(
340            json.contains("\"reason\":\"taskCompleted\""),
341            "Reason: {json}"
342        );
343    }
344
345    #[test]
346    fn ipc_event_error_serde_roundtrip() {
347        let event = IpcEvent::Error {
348            message: "init failed".into(),
349        };
350        let json = serde_json::to_string(&event).unwrap();
351        let de: IpcEvent = serde_json::from_str(&json).unwrap();
352        match de {
353            IpcEvent::Error { message } => assert_eq!(message, "init failed"),
354            other => panic!("Expected Error, got {other:?}"),
355        }
356    }
357
358    #[test]
359    fn ipc_event_error_json_keys() {
360        let event = IpcEvent::Error {
361            message: "oops".into(),
362        };
363        let json = serde_json::to_string(&event).unwrap();
364        assert!(json.contains("\"type\":\"error\""), "Tag: {json}");
365        assert!(json.contains("\"message\":\"oops\""), "Message: {json}");
366    }
367
368    // --- Frame encode/decode tests ---
369
370    #[test]
371    fn ipc_frame_encode_decode_request() {
372        let req = IpcRequest::Start {
373            config: crate::models::StartConfig::default(),
374        };
375        let msg = IpcMessage::Request(req);
376        let encoded = encode_frame(&msg).unwrap();
377        let decoded = decode_frame(&encoded[4..]).unwrap();
378        match decoded {
379            IpcMessage::Request(IpcRequest::Start { config }) => {
380                assert_eq!(config.service_label, "Service running");
381            }
382            other => panic!("Expected Request(Start), got {other:?}"),
383        }
384    }
385
386    #[test]
387    fn ipc_frame_encode_decode_response() {
388        let resp = IpcResponse {
389            ok: true,
390            data: Some(serde_json::json!(42)),
391            error: None,
392        };
393        let msg = IpcMessage::Response(resp);
394        let encoded = encode_frame(&msg).unwrap();
395        let decoded = decode_frame(&encoded[4..]).unwrap();
396        match decoded {
397            IpcMessage::Response(r) => {
398                assert!(r.ok);
399                assert_eq!(r.data.unwrap(), 42);
400            }
401            other => panic!("Expected Response, got {other:?}"),
402        }
403    }
404
405    #[test]
406    fn ipc_frame_encode_decode_event() {
407        let event = IpcEvent::Stopped {
408            reason: StopReason::TaskCompleted,
409        };
410        let msg = IpcMessage::Event(event);
411        let encoded = encode_frame(&msg).unwrap();
412        let decoded = decode_frame(&encoded[4..]).unwrap();
413        match decoded {
414            IpcMessage::Event(IpcEvent::Stopped { reason }) => {
415                assert_eq!(reason, StopReason::TaskCompleted)
416            }
417            other => panic!("Expected Event(Stopped), got {other:?}"),
418        }
419    }
420
421    #[test]
422    fn ipc_frame_length_prefix_is_big_endian() {
423        let req = IpcRequest::Stop;
424        let encoded = encode_frame(&req).unwrap();
425        // First 4 bytes are the length of the JSON payload
426        let len = u32::from_be_bytes([encoded[0], encoded[1], encoded[2], encoded[3]]);
427        assert_eq!(len as usize, encoded.len() - 4);
428    }
429
430    #[test]
431    fn ipc_frame_decode_payload_without_length_prefix() {
432        // Verify decode_frame works with payload-only bytes (no length prefix).
433        let resp = IpcResponse {
434            ok: true,
435            data: Some(serde_json::json!({"status": "ok"})),
436            error: None,
437        };
438        let msg = IpcMessage::Response(resp);
439        let payload = serde_json::to_vec(&msg).unwrap();
440        let decoded = decode_frame(&payload).unwrap();
441        match decoded {
442            IpcMessage::Response(r) => {
443                assert!(r.ok);
444                assert_eq!(r.data.unwrap()["status"], "ok");
445            }
446            other => panic!("Expected Response, got {other:?}"),
447        }
448    }
449
450    #[test]
451    fn ipc_frame_malformed_json() {
452        let payload = b"{invalid";
453        let result = decode_frame(payload);
454        assert!(result.is_err(), "Expected JSON error for malformed payload");
455    }
456
457    // --- IpcMessage wrapper enum tests ---
458
459    #[test]
460    fn ipc_message_response_roundtrip() {
461        let msg = IpcMessage::Response(IpcResponse {
462            ok: true,
463            data: Some(serde_json::json!({"running": true})),
464            error: None,
465        });
466        let json = serde_json::to_string(&msg).unwrap();
467        assert!(json.contains("\"kind\":\"response\""), "kind tag: {json}");
468        let de: IpcMessage = serde_json::from_str(&json).unwrap();
469        match de {
470            IpcMessage::Response(resp) => {
471                assert!(resp.ok);
472                assert_eq!(resp.data.unwrap()["running"], true);
473            }
474            other => panic!("Expected Response, got {other:?}"),
475        }
476    }
477
478    #[test]
479    fn ipc_message_event_roundtrip() {
480        let msg = IpcMessage::Event(IpcEvent::Stopped {
481            reason: StopReason::UserStop,
482        });
483        let json = serde_json::to_string(&msg).unwrap();
484        assert!(json.contains("\"kind\":\"event\""), "kind tag: {json}");
485        let de: IpcMessage = serde_json::from_str(&json).unwrap();
486        match de {
487            IpcMessage::Event(IpcEvent::Stopped { reason }) => {
488                assert_eq!(reason, StopReason::UserStop);
489            }
490            other => panic!("Expected Event, got {other:?}"),
491        }
492    }
493
494    #[test]
495    fn ipc_message_ambiguous_frame_deterministic() {
496        // A frame with fields that overlap between Response and Event
497        // must be deserialized deterministically based on the "kind" tag.
498        let json = r#"{"kind":"event","type":"started","ok":true}"#;
499        let de: IpcMessage = serde_json::from_str(json).unwrap();
500        match de {
501            IpcMessage::Event(IpcEvent::Started) => {} // correct
502            other => panic!("Expected Event::Started, got {other:?}"),
503        }
504
505        // Same frame with kind=response must deserialize as Response
506        let json2 = r#"{"kind":"response","ok":true,"data":{"type":"started"}}"#;
507        let de2: IpcMessage = serde_json::from_str(json2).unwrap();
508        match de2 {
509            IpcMessage::Response(resp) => {
510                assert!(resp.ok);
511            }
512            other => panic!("Expected Response, got {other:?}"),
513        }
514    }
515
516    #[test]
517    fn ipc_message_unknown_kind_rejected() {
518        let json = r#"{"kind":"unknown","ok":true}"#;
519        let result: Result<IpcMessage, _> = serde_json::from_str(json);
520        assert!(result.is_err(), "Expected error for unknown kind value");
521    }
522
523    // --- socket_path tests ---
524
525    #[test]
526    fn socket_path_unix_format() {
527        let path = socket_path("com.example.svc").unwrap();
528        #[cfg(target_os = "linux")]
529        {
530            // Should be under XDG_RUNTIME_DIR or /run/user/{uid}
531            let path_str = path.to_str().unwrap();
532            assert!(
533                path_str.ends_with("/com.example.svc.sock"),
534                "Expected path ending with /com.example.svc.sock, got: {path_str}"
535            );
536            if let Ok(xdg) = std::env::var("XDG_RUNTIME_DIR") {
537                assert!(
538                    path_str.starts_with(&xdg),
539                    "Expected path under XDG_RUNTIME_DIR ({xdg}), got: {path_str}"
540                );
541            } else {
542                assert!(
543                    path_str.contains("/run/user/"),
544                    "Expected fallback /run/user/ path, got: {path_str}"
545                );
546            }
547        }
548        #[cfg(target_os = "macos")]
549        {
550            assert_eq!(path.to_str().unwrap(), "/tmp/com.example.svc.sock");
551        }
552    }
553
554    #[test]
555    fn socket_path_nonempty_label() {
556        let path = socket_path("my-app").unwrap();
557        #[cfg(unix)]
558        {
559            assert!(
560                path.to_str().unwrap().ends_with("my-app.sock"),
561                "Expected path ending with my-app.sock, got: {:?}",
562                path
563            );
564        }
565    }
566
567    #[test]
568    fn socket_path_rejects_slash_in_label() {
569        let result = socket_path("../etc/passwd");
570        assert!(result.is_err());
571        let err = result.unwrap_err().to_string();
572        assert!(err.contains("path separators"), "Error: {err}");
573    }
574
575    #[test]
576    fn socket_path_rejects_dotdot_in_label() {
577        let result = socket_path("..");
578        assert!(result.is_err());
579        let err = result.unwrap_err().to_string();
580        assert!(err.contains("'..'"), "Error: {err}");
581    }
582
583    #[test]
584    fn socket_path_rejects_backslash_in_label() {
585        let result = socket_path("foo\\bar");
586        assert!(result.is_err());
587        let err = result.unwrap_err().to_string();
588        assert!(err.contains("path separators"), "Error: {err}");
589    }
590
591    #[test]
592    fn socket_path_rejects_empty_label() {
593        let result = socket_path("");
594        assert!(result.is_err());
595        let err = result.unwrap_err().to_string();
596        assert!(err.contains("empty"), "Error: {err}");
597    }
598}