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