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