Skip to main content

tauri_plugin_jolt/
lib.rs

1//! Tauri plugin exposing the local Jolt daemon to app webviews.
2//!
3//! Jolt desktop applications proxy daemon calls through Rust so the webview
4//! never needs direct network access to the daemon. Before this plugin every
5//! app copied the same proxy commands into its own `src-tauri` (Spoke and
6//! Pastey carried diverged copies); now an app adds one dependency and one
7//! line:
8//!
9//! ```rust,ignore
10//! tauri::Builder::default()
11//!     .plugin(tauri_plugin_jolt::init())
12//!     // ...
13//! ```
14//!
15//! and grants the capability `"jolt:default"` in its capabilities file. The
16//! JS side pairs with `@jolt/sdk/transport-tauri`:
17//!
18//! ```ts
19//! new TauriTransport({ plugin: true })
20//! ```
21//!
22//! The daemon base URL defaults to `http://127.0.0.1:9862` and can be
23//! overridden with the `JOLT_DAEMON_URL` environment variable. Only the
24//! `/app/v1` and `/api/v1` API surfaces are reachable; anything else is
25//! rejected before a request is made.
26
27use reqwest::{
28    header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE},
29    multipart::{Form, Part},
30};
31use serde::Serialize;
32use serde_json::Value;
33use std::time::Duration;
34use tauri::{
35    plugin::{Builder, TauriPlugin},
36    Runtime,
37};
38
39const DEFAULT_DAEMON_URL: &str = "http://127.0.0.1:9862";
40
41#[derive(Debug, Serialize)]
42struct DaemonRequestError {
43    kind: &'static str,
44    message: String,
45    status: Option<u16>,
46    code: Option<String>,
47    body: Option<Value>,
48}
49
50impl DaemonRequestError {
51    fn api(status: reqwest::StatusCode, message: String, body: Option<Value>) -> Self {
52        let code = body
53            .as_ref()
54            .and_then(|value| value.get("code"))
55            .and_then(Value::as_str)
56            .map(ToOwned::to_owned);
57        Self {
58            kind: "api",
59            message,
60            status: Some(status.as_u16()),
61            code,
62            body,
63        }
64    }
65
66    fn transport(message: String) -> Self {
67        Self {
68            kind: "transport",
69            message,
70            status: None,
71            code: None,
72            body: None,
73        }
74    }
75
76    fn configuration(message: String) -> Self {
77        Self {
78            kind: "configuration",
79            message,
80            status: None,
81            code: None,
82            body: None,
83        }
84    }
85
86    fn invalid_response(message: String, status: reqwest::StatusCode) -> Self {
87        Self {
88            kind: "invalid_response",
89            message,
90            status: Some(status.as_u16()),
91            code: None,
92            body: None,
93        }
94    }
95}
96
97/// Initialize the plugin. Register with `.plugin(tauri_plugin_jolt::init())`.
98pub fn init<R: Runtime>() -> TauriPlugin<R> {
99    Builder::new("jolt")
100        .invoke_handler(tauri::generate_handler![
101            daemon_request,
102            daemon_publish_bytes,
103            daemon_append
104        ])
105        .build()
106}
107
108/// Proxy a JSON (or empty-body) request to the daemon.
109#[tauri::command]
110async fn daemon_request(
111    base_path: String,
112    path: String,
113    method: String,
114    body: Option<Value>,
115    session_token: Option<String>,
116) -> Result<Value, DaemonRequestError> {
117    let method = method.parse::<reqwest::Method>().map_err(|error| {
118        DaemonRequestError::configuration(format!(
119            "invalid daemon request method {method}: {error}"
120        ))
121    })?;
122    let url = daemon_url(&base_path, &path).map_err(DaemonRequestError::configuration)?;
123    let client = reqwest::Client::builder()
124        .timeout(request_timeout(&base_path, &path))
125        .build()
126        .map_err(|error| {
127            DaemonRequestError::configuration(format!(
128                "failed to create daemon HTTP client: {error}"
129            ))
130        })?;
131    let mut request = client
132        .request(method, url)
133        .header(ACCEPT, "application/json");
134
135    if let Some(token) = session_token {
136        request = request.header(AUTHORIZATION, format!("Bearer {token}"));
137    }
138    if let Some(body) = body {
139        request = request.header(CONTENT_TYPE, "application/json").json(&body);
140    }
141
142    parse_response(request.send().await).await
143}
144
145/// Proxy a multipart publish of raw bytes to `/app/v1/publish`.
146#[tauri::command]
147async fn daemon_publish_bytes(
148    session_token: String,
149    path: String,
150    bytes: Vec<u8>,
151    file_name: String,
152    mime_type: String,
153) -> Result<Value, DaemonRequestError> {
154    multipart_upload("/publish", session_token, path, bytes, file_name, mime_type).await
155}
156
157/// Proxy a multipart append-record publish to `/app/v1/append`.
158#[tauri::command]
159async fn daemon_append(
160    session_token: String,
161    path: String,
162    bytes: Vec<u8>,
163    file_name: String,
164    mime_type: String,
165) -> Result<Value, DaemonRequestError> {
166    multipart_upload("/append", session_token, path, bytes, file_name, mime_type).await
167}
168
169async fn multipart_upload(
170    endpoint: &str,
171    session_token: String,
172    path: String,
173    bytes: Vec<u8>,
174    file_name: String,
175    mime_type: String,
176) -> Result<Value, DaemonRequestError> {
177    let file = Part::bytes(bytes)
178        .file_name(file_name)
179        .mime_str(&mime_type)
180        .map_err(|error| {
181            DaemonRequestError::configuration(format!("failed to prepare Jolt upload: {error}"))
182        })?;
183    let form = Form::new().part("file", file).text("path", path);
184    let request = reqwest::Client::new()
185        .post(daemon_url("/app/v1", endpoint).map_err(DaemonRequestError::configuration)?)
186        .header(ACCEPT, "application/json")
187        .header(AUTHORIZATION, format!("Bearer {session_token}"))
188        .multipart(form);
189
190    parse_response(request.send().await).await
191}
192
193async fn parse_response(
194    response: Result<reqwest::Response, reqwest::Error>,
195) -> Result<Value, DaemonRequestError> {
196    let response = response.map_err(|error| {
197        DaemonRequestError::transport(format!("daemon request failed: {error}"))
198    })?;
199    let status = response.status();
200    let content_type = response
201        .headers()
202        .get(CONTENT_TYPE)
203        .and_then(|value| value.to_str().ok())
204        .unwrap_or("")
205        .to_string();
206    let body = response.text().await.map_err(|error| {
207        DaemonRequestError::transport(format!("daemon response read failed: {error}"))
208    })?;
209
210    if !status.is_success() {
211        let parsed = if content_type.contains("application/json") {
212            serde_json::from_str::<Value>(&body).ok()
213        } else {
214            None
215        };
216        if let Some(value) = parsed.as_ref() {
217            if let Some(error) = value.get("error").and_then(Value::as_str) {
218                return Err(DaemonRequestError::api(status, error.to_string(), parsed));
219            }
220        }
221
222        let message = if body.trim().is_empty() {
223            format!("daemon returned {status}")
224        } else {
225            body
226        };
227        return Err(DaemonRequestError::api(status, message, parsed));
228    }
229
230    if content_type.contains("application/json") {
231        serde_json::from_str(&body).map_err(|error| {
232            DaemonRequestError::invalid_response(
233                format!("daemon returned invalid JSON: {error}"),
234                status,
235            )
236        })
237    } else {
238        Ok(Value::String(body))
239    }
240}
241
242fn daemon_url(base_path: &str, path: &str) -> Result<String, String> {
243    let prefix = match base_path {
244        "/app/v1" | "/api/v1" => base_path,
245        _ => return Err(format!("unsupported daemon base path: {base_path}")),
246    };
247
248    Ok(format!(
249        "{}{}{}",
250        daemon_base_url().trim_end_matches('/'),
251        prefix,
252        normalize_path(path)
253    ))
254}
255
256fn normalize_path(path: &str) -> String {
257    if path.starts_with('/') {
258        path.to_string()
259    } else {
260        format!("/{path}")
261    }
262}
263
264fn daemon_base_url() -> String {
265    std::env::var("JOLT_DAEMON_URL").unwrap_or_else(|_| DEFAULT_DAEMON_URL.to_string())
266}
267
268fn request_timeout(base_path: &str, path: &str) -> Duration {
269    // The status endpoint backs "is the daemon up" indicators and must fail
270    // fast; everything else gets room for slow network fetches.
271    if base_path == "/api/v1" && normalize_path(path) == "/status" {
272        Duration::from_secs(3)
273    } else {
274        Duration::from_secs(60)
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    #[test]
283    fn daemon_request_error_preserves_http_status_for_tauri_transport() {
284        let error = DaemonRequestError::api(
285            reqwest::StatusCode::NOT_FOUND,
286            "missing feature endpoint".to_string(),
287            Some(serde_json::json!({ "error": "not found" })),
288        );
289
290        assert_eq!(
291            serde_json::to_value(error).unwrap(),
292            serde_json::json!({
293                "kind": "api",
294                "message": "missing feature endpoint",
295                "status": 404,
296                "code": null,
297                "body": { "error": "not found" }
298            })
299        );
300    }
301
302    #[test]
303    fn daemon_url_accepts_daemon_api_paths() {
304        assert_eq!(
305            daemon_url("/app/v1", "/published").unwrap(),
306            "http://127.0.0.1:9862/app/v1/published"
307        );
308        assert_eq!(
309            daemon_url("/api/v1", "status").unwrap(),
310            "http://127.0.0.1:9862/api/v1/status"
311        );
312    }
313
314    #[test]
315    fn daemon_url_rejects_unknown_base_paths() {
316        assert_eq!(
317            daemon_url("/admin/v1", "/status").unwrap_err(),
318            "unsupported daemon base path: /admin/v1"
319        );
320    }
321
322    #[test]
323    fn status_request_uses_short_timeout() {
324        assert_eq!(request_timeout("/api/v1", "status"), Duration::from_secs(3));
325        assert_eq!(
326            request_timeout("/app/v1", "/fetch"),
327            Duration::from_secs(60)
328        );
329    }
330}