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