1use std::sync::mpsc::{Receiver, Sender};
35use std::sync::Mutex;
36use std::time::{Duration, Instant};
37
38use serde_json::{json, Value};
39use tauri::plugin::{Builder, TauriPlugin};
40use tauri::{Emitter, Manager, RunEvent, Runtime};
41
42pub mod core;
43pub mod transport;
44
45use core::SyncularCore;
46
47pub const EVENT_NAME: &str = "syncular://event";
49
50#[derive(Debug, Clone, Default)]
54pub struct SyncularConfig {
55 pub base_url: Option<String>,
58 pub ws_url: Option<String>,
60 pub headers: Vec<(String, String)>,
62 pub db_path: Option<String>,
65 pub wake_jitter_ms: u64,
68 pub auto_sync: bool,
70}
71
72impl SyncularConfig {
73 fn to_transport_json(&self) -> Value {
75 let mut map = serde_json::Map::new();
76 if let Some(base) = &self.base_url {
77 map.insert("baseUrl".to_owned(), Value::from(base.clone()));
78 }
79 if let Some(ws) = &self.ws_url {
80 map.insert("wsUrl".to_owned(), Value::from(ws.clone()));
81 }
82 if !self.headers.is_empty() {
83 let headers: serde_json::Map<String, Value> = self
84 .headers
85 .iter()
86 .map(|(k, v)| (k.clone(), Value::from(v.clone())))
87 .collect();
88 map.insert("headers".to_owned(), Value::Object(headers));
89 }
90 Value::Object(map)
91 }
92}
93
94enum Request {
97 Command {
98 command: Value,
99 reply: Sender<Value>,
100 },
101 Query {
102 sql: String,
103 params: Value,
104 reply: Sender<Value>,
105 },
106 SetHeaders {
110 headers: Vec<(String, String)>,
111 reply: Sender<Value>,
112 },
113 Shutdown,
114}
115
116struct SyncularState {
119 sender: Mutex<Sender<Request>>,
120}
121
122impl SyncularState {
123 fn send(&self, request: Request) -> Result<(), String> {
124 self.sender
125 .lock()
126 .map_err(|_| "syncular mailbox poisoned".to_owned())?
127 .send(request)
128 .map_err(|_| "the syncular core thread has stopped".to_owned())
129 }
130}
131
132fn run_owner_thread<F>(config: SyncularConfig, rx: Receiver<Request>, emit: F)
135where
136 F: Fn(&Value) + Send + 'static,
137{
138 let transport_json = config.to_transport_json();
139 let mut core = match SyncularCore::new(&transport_json) {
140 Ok(core) => core,
141 Err(message) => {
142 emit(&json!({ "type": "error", "message": message }));
145 return;
146 }
147 };
148
149 let idle_poll = Duration::from_millis(200);
154 let jitter_cap = config.wake_jitter_ms;
155 let mut next_seed: u64 = std::process::id() as u64 ^ 0x9E37_79B9_7F4A_7C15;
156
157 let mut jitter = || {
159 if jitter_cap == 0 {
160 return Duration::ZERO;
161 }
162 next_seed ^= next_seed << 13;
163 next_seed ^= next_seed >> 7;
164 next_seed ^= next_seed << 17;
165 Duration::from_millis(next_seed % (jitter_cap + 1))
166 };
167
168 let mut last_sync = Instant::now();
169 loop {
170 let request = rx.recv_timeout(idle_poll);
171 match request {
172 Ok(Request::Command { command, reply }) => {
173 let command = inject_db_path(command, &config);
174 let result = core.command(&command);
175 let _ = reply.send(result);
176 pump_events(&mut core, &emit);
177 }
178 Ok(Request::Query { sql, params, reply }) => {
179 let result = core.query(&sql, params);
180 let _ = reply.send(result);
181 pump_events(&mut core, &emit);
182 }
183 Ok(Request::SetHeaders { headers, reply }) => {
184 core.set_headers(headers);
185 let _ = reply.send(json!({ "result": null }));
186 }
187 Ok(Request::Shutdown) => {
188 core.shutdown();
189 return;
190 }
191 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
192 if config.auto_sync && core.sync_needed() {
197 let wait = jitter();
198 if !wait.is_zero() {
199 std::thread::sleep(wait);
200 }
201 core.sync_until_idle();
202 last_sync = Instant::now();
203 pump_events(&mut core, &emit);
204 }
205 let _ = last_sync;
206 }
207 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
208 core.shutdown();
209 return;
210 }
211 }
212 }
213}
214
215fn inject_db_path(mut command: Value, config: &SyncularConfig) -> Value {
219 if command.get("method").and_then(Value::as_str) != Some("create") {
220 return command;
221 }
222 let Some(db_path) = &config.db_path else {
223 return command;
224 };
225 let params = command.get_mut("params").and_then(Value::as_object_mut);
226 if let Some(params) = params {
227 params
228 .entry("dbPath")
229 .or_insert_with(|| Value::from(db_path.clone()));
230 } else if let Some(obj) = command.as_object_mut() {
231 obj.insert("params".to_owned(), json!({ "dbPath": db_path }));
232 }
233 command
234}
235
236fn pump_events<F: Fn(&Value)>(core: &mut SyncularCore, emit: &F) {
237 for event in core.drain_events() {
238 emit(&event.json);
239 }
240}
241
242#[tauri::command]
245async fn syncular_command<R: Runtime>(
246 app: tauri::AppHandle<R>,
247 command: Value,
248) -> Result<Value, String> {
249 let state = app.state::<SyncularState>();
250 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
251 state.send(Request::Command {
252 command,
253 reply: reply_tx,
254 })?;
255 reply_rx
256 .recv()
257 .map_err(|_| "the syncular core dropped the reply".to_owned())
258}
259
260#[tauri::command]
265async fn syncular_set_headers<R: Runtime>(
266 app: tauri::AppHandle<R>,
267 headers: std::collections::BTreeMap<String, String>,
268) -> Result<Value, String> {
269 let state = app.state::<SyncularState>();
270 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
271 state.send(Request::SetHeaders {
272 headers: headers.into_iter().collect(),
273 reply: reply_tx,
274 })?;
275 reply_rx
276 .recv()
277 .map_err(|_| "the syncular core dropped the reply".to_owned())
278}
279
280#[tauri::command]
281async fn syncular_query<R: Runtime>(
282 app: tauri::AppHandle<R>,
283 sql: String,
284 params: Option<Value>,
285) -> Result<Value, String> {
286 let state = app.state::<SyncularState>();
287 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
288 state.send(Request::Query {
289 sql,
290 params: params.unwrap_or(Value::Null),
291 reply: reply_tx,
292 })?;
293 reply_rx
294 .recv()
295 .map_err(|_| "the syncular core dropped the reply".to_owned())
296}
297
298pub fn init<R: Runtime>(config: SyncularConfig) -> TauriPlugin<R> {
306 let config = SyncularConfig {
308 auto_sync: true,
309 wake_jitter_ms: if config.wake_jitter_ms == 0 && config.base_url.is_none() {
310 0
311 } else if config.wake_jitter_ms == 0 {
312 250
313 } else {
314 config.wake_jitter_ms
315 },
316 ..config
317 };
318
319 Builder::<R>::new("syncular")
320 .invoke_handler(tauri::generate_handler![
321 syncular_command,
322 syncular_query,
323 syncular_set_headers
324 ])
325 .setup(move |app, _api| {
326 let (tx, rx) = std::sync::mpsc::channel::<Request>();
327 app.manage(SyncularState {
328 sender: Mutex::new(tx),
329 });
330 let app_handle = app.clone();
331 let emit = move |value: &Value| {
332 let _ = app_handle.emit(EVENT_NAME, value.clone());
335 };
336 std::thread::Builder::new()
337 .name("syncular-core".to_owned())
338 .spawn(move || run_owner_thread(config, rx, emit))
339 .map_err(|e| format!("failed to spawn syncular core thread: {e}"))?;
340 Ok(())
341 })
342 .on_event(|app, event| {
343 if let RunEvent::Exit = event {
344 if let Some(state) = app.try_state::<SyncularState>() {
345 let _ = state.send(Request::Shutdown);
346 }
347 }
348 })
349 .build()
350}
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355
356 #[test]
357 fn config_to_transport_json_shapes_fields() {
358 let config = SyncularConfig {
359 base_url: Some("https://api.example.com".to_owned()),
360 headers: vec![("authorization".to_owned(), "Bearer x".to_owned())],
361 ..Default::default()
362 };
363 let json = config.to_transport_json();
364 assert_eq!(json["baseUrl"], "https://api.example.com");
365 assert_eq!(json["headers"]["authorization"], "Bearer x");
366 }
367
368 #[test]
369 fn inject_db_path_adds_to_create_only() {
370 let config = SyncularConfig {
371 db_path: Some("/tmp/app.db".to_owned()),
372 ..Default::default()
373 };
374 let created = inject_db_path(
376 json!({ "method": "create", "params": { "clientId": "c1" } }),
377 &config,
378 );
379 assert_eq!(created["params"]["dbPath"], "/tmp/app.db");
380 let created2 = inject_db_path(json!({ "method": "create" }), &config);
382 assert_eq!(created2["params"]["dbPath"], "/tmp/app.db");
383 let explicit = inject_db_path(
385 json!({ "method": "create", "params": { "dbPath": "/other.db" } }),
386 &config,
387 );
388 assert_eq!(explicit["params"]["dbPath"], "/other.db");
389 let mutate = inject_db_path(json!({ "method": "mutate", "params": {} }), &config);
391 assert!(mutate["params"].get("dbPath").is_none());
392 }
393
394 #[test]
398 fn owner_thread_round_trips_over_mailbox() {
399 use std::sync::mpsc::channel;
400 use std::sync::{Arc, Mutex as StdMutex};
401
402 let (tx, rx) = channel::<Request>();
403 let events: Arc<StdMutex<Vec<Value>>> = Arc::new(StdMutex::new(Vec::new()));
404 let events_for_thread = Arc::clone(&events);
405 let config = SyncularConfig {
406 auto_sync: false,
407 ..Default::default()
408 };
409 let handle = std::thread::spawn(move || {
410 run_owner_thread(config, rx, move |v| {
411 events_for_thread.lock().unwrap().push(v.clone());
412 });
413 });
414
415 let call = |command: Value| -> Value {
416 let (rtx, rrx) = channel();
417 tx.send(Request::Command {
418 command,
419 reply: rtx,
420 })
421 .unwrap();
422 rrx.recv().unwrap()
423 };
424
425 let schema = json!({
426 "version": 1,
427 "tables": [{
428 "name": "todo", "primaryKey": "id",
429 "columns": [
430 { "name": "id", "type": "string", "nullable": false },
431 { "name": "title", "type": "string", "nullable": false }
432 ],
433 "scopes": []
434 }]
435 });
436 assert_eq!(
437 call(json!({ "method": "create", "params": { "clientId": "c1", "schema": schema } }))
438 ["result"],
439 json!({})
440 );
441 call(json!({ "method": "mutate", "params": { "mutations": [{
442 "op": "upsert", "table": "todo", "values": { "id": "t1", "title": "hi" }
443 }] } }));
444
445 let (qtx, qrx) = channel();
447 tx.send(Request::Query {
448 sql: "SELECT title FROM todo".to_owned(),
449 params: Value::Null,
450 reply: qtx,
451 })
452 .unwrap();
453 let rows = qrx.recv().unwrap();
454 assert_eq!(rows["result"]["rows"][0]["title"], "hi");
455
456 let (htx, hrx) = channel();
459 tx.send(Request::SetHeaders {
460 headers: vec![("authorization".to_owned(), "Bearer fresh".to_owned())],
461 reply: htx,
462 })
463 .unwrap();
464 assert_eq!(hrx.recv().unwrap()["result"], Value::Null);
465
466 tx.send(Request::Shutdown).unwrap();
467 handle.join().unwrap();
468
469 let seen = events.lock().unwrap();
470 let kinds: Vec<String> = seen
471 .iter()
472 .filter_map(|e| e.get("type").and_then(Value::as_str).map(str::to_owned))
473 .collect();
474 assert!(kinds.iter().any(|k| k == "invalidate"), "kinds: {kinds:?}");
477 }
478}