1use 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
97pub 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#[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#[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#[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 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}