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_json::Value;
32use std::time::Duration;
33use tauri::{
34    plugin::{Builder, TauriPlugin},
35    Runtime,
36};
37
38const DEFAULT_DAEMON_URL: &str = "http://127.0.0.1:9862";
39
40/// Initialize the plugin. Register with `.plugin(tauri_plugin_jolt::init())`.
41pub fn init<R: Runtime>() -> TauriPlugin<R> {
42    Builder::new("jolt")
43        .invoke_handler(tauri::generate_handler![
44            daemon_request,
45            daemon_publish_bytes,
46            daemon_append
47        ])
48        .build()
49}
50
51/// Proxy a JSON (or empty-body) request to the daemon.
52#[tauri::command]
53async fn daemon_request(
54    base_path: String,
55    path: String,
56    method: String,
57    body: Option<Value>,
58    session_token: Option<String>,
59) -> Result<Value, String> {
60    let method = method
61        .parse::<reqwest::Method>()
62        .map_err(|error| format!("invalid daemon request method {method}: {error}"))?;
63    let url = daemon_url(&base_path, &path)?;
64    let client = reqwest::Client::builder()
65        .timeout(request_timeout(&base_path, &path))
66        .build()
67        .map_err(|error| format!("failed to create daemon HTTP client: {error}"))?;
68    let mut request = client.request(method, url).header(ACCEPT, "application/json");
69
70    if let Some(token) = session_token {
71        request = request.header(AUTHORIZATION, format!("Bearer {token}"));
72    }
73    if let Some(body) = body {
74        request = request.header(CONTENT_TYPE, "application/json").json(&body);
75    }
76
77    parse_response(request.send().await).await
78}
79
80/// Proxy a multipart publish of raw bytes to `/app/v1/publish`.
81#[tauri::command]
82async fn daemon_publish_bytes(
83    session_token: String,
84    path: String,
85    bytes: Vec<u8>,
86    file_name: String,
87    mime_type: String,
88) -> Result<Value, String> {
89    multipart_upload("/publish", session_token, path, bytes, file_name, mime_type).await
90}
91
92/// Proxy a multipart append-record publish to `/app/v1/append`.
93#[tauri::command]
94async fn daemon_append(
95    session_token: String,
96    path: String,
97    bytes: Vec<u8>,
98    file_name: String,
99    mime_type: String,
100) -> Result<Value, String> {
101    multipart_upload("/append", session_token, path, bytes, file_name, mime_type).await
102}
103
104async fn multipart_upload(
105    endpoint: &str,
106    session_token: String,
107    path: String,
108    bytes: Vec<u8>,
109    file_name: String,
110    mime_type: String,
111) -> Result<Value, String> {
112    let file = Part::bytes(bytes)
113        .file_name(file_name)
114        .mime_str(&mime_type)
115        .map_err(|error| format!("failed to prepare Jolt upload: {error}"))?;
116    let form = Form::new().part("file", file).text("path", path);
117    let request = reqwest::Client::new()
118        .post(daemon_url("/app/v1", endpoint)?)
119        .header(ACCEPT, "application/json")
120        .header(AUTHORIZATION, format!("Bearer {session_token}"))
121        .multipart(form);
122
123    parse_response(request.send().await).await
124}
125
126async fn parse_response(
127    response: Result<reqwest::Response, reqwest::Error>,
128) -> Result<Value, String> {
129    let response = response.map_err(|error| format!("daemon request failed: {error}"))?;
130    let status = response.status();
131    let content_type = response
132        .headers()
133        .get(CONTENT_TYPE)
134        .and_then(|value| value.to_str().ok())
135        .unwrap_or("")
136        .to_string();
137    let body = response
138        .text()
139        .await
140        .map_err(|error| format!("daemon response read failed: {error}"))?;
141
142    if !status.is_success() {
143        if content_type.contains("application/json") {
144            if let Ok(value) = serde_json::from_str::<Value>(&body) {
145                if let Some(error) = value.get("error").and_then(Value::as_str) {
146                    return Err(error.to_string());
147                }
148            }
149        }
150
151        return Err(if body.trim().is_empty() {
152            format!("daemon returned {status}")
153        } else {
154            body
155        });
156    }
157
158    if content_type.contains("application/json") {
159        serde_json::from_str(&body)
160            .map_err(|error| format!("daemon returned invalid JSON: {error}"))
161    } else {
162        Ok(Value::String(body))
163    }
164}
165
166fn daemon_url(base_path: &str, path: &str) -> Result<String, String> {
167    let prefix = match base_path {
168        "/app/v1" | "/api/v1" => base_path,
169        _ => return Err(format!("unsupported daemon base path: {base_path}")),
170    };
171
172    Ok(format!(
173        "{}{}{}",
174        daemon_base_url().trim_end_matches('/'),
175        prefix,
176        normalize_path(path)
177    ))
178}
179
180fn normalize_path(path: &str) -> String {
181    if path.starts_with('/') {
182        path.to_string()
183    } else {
184        format!("/{path}")
185    }
186}
187
188fn daemon_base_url() -> String {
189    std::env::var("JOLT_DAEMON_URL").unwrap_or_else(|_| DEFAULT_DAEMON_URL.to_string())
190}
191
192fn request_timeout(base_path: &str, path: &str) -> Duration {
193    // The status endpoint backs "is the daemon up" indicators and must fail
194    // fast; everything else gets room for slow network fetches.
195    if base_path == "/api/v1" && normalize_path(path) == "/status" {
196        Duration::from_secs(3)
197    } else {
198        Duration::from_secs(60)
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn daemon_url_accepts_daemon_api_paths() {
208        assert_eq!(
209            daemon_url("/app/v1", "/published").unwrap(),
210            "http://127.0.0.1:9862/app/v1/published"
211        );
212        assert_eq!(
213            daemon_url("/api/v1", "status").unwrap(),
214            "http://127.0.0.1:9862/api/v1/status"
215        );
216    }
217
218    #[test]
219    fn daemon_url_rejects_unknown_base_paths() {
220        assert_eq!(
221            daemon_url("/admin/v1", "/status").unwrap_err(),
222            "unsupported daemon base path: /admin/v1"
223        );
224    }
225
226    #[test]
227    fn status_request_uses_short_timeout() {
228        assert_eq!(request_timeout("/api/v1", "status"), Duration::from_secs(3));
229        assert_eq!(request_timeout("/app/v1", "/fetch"), Duration::from_secs(60));
230    }
231}