1use std::sync::mpsc::{Receiver, RecvTimeoutError, 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)]
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 auto_sync: bool,
67}
68
69impl Default for SyncularConfig {
70 fn default() -> Self {
71 Self {
72 base_url: None,
73 ws_url: None,
74 headers: Vec::new(),
75 db_path: None,
76 auto_sync: true,
77 }
78 }
79}
80
81impl SyncularConfig {
82 fn to_transport_json(&self) -> Value {
84 let mut map = serde_json::Map::new();
85 if let Some(base) = &self.base_url {
86 map.insert("baseUrl".to_owned(), Value::from(base.clone()));
87 }
88 if let Some(ws) = &self.ws_url {
89 map.insert("wsUrl".to_owned(), Value::from(ws.clone()));
90 }
91 if !self.headers.is_empty() {
92 let headers: serde_json::Map<String, Value> = self
93 .headers
94 .iter()
95 .map(|(k, v)| (k.clone(), Value::from(v.clone())))
96 .collect();
97 map.insert("headers".to_owned(), Value::Object(headers));
98 }
99 Value::Object(map)
100 }
101}
102
103enum Request {
106 Command {
107 command: Value,
108 reply: Sender<Value>,
109 },
110 Query {
111 sql: String,
112 params: Value,
113 reply: Sender<Value>,
114 },
115 SetHeaders {
119 headers: Vec<(String, String)>,
120 reply: Sender<Value>,
121 },
122 TransportWake,
124 Shutdown,
125}
126
127struct SyncularState {
130 sender: Mutex<Sender<Request>>,
131}
132
133impl SyncularState {
134 fn send(&self, request: Request) -> Result<(), String> {
135 self.sender
136 .lock()
137 .map_err(|_| "syncular mailbox poisoned".to_owned())?
138 .send(request)
139 .map_err(|_| "the syncular core thread has stopped".to_owned())
140 }
141}
142
143fn run_owner_thread<F>(config: SyncularConfig, tx: Sender<Request>, rx: Receiver<Request>, emit: F)
146where
147 F: Fn(&Value) + Send + 'static,
148{
149 let transport_json = config.to_transport_json();
150 let wake_tx = tx.clone();
151 let notify: std::sync::Arc<dyn Fn() + Send + Sync> = std::sync::Arc::new(move || {
152 let _ = wake_tx.send(Request::TransportWake);
153 });
154 let mut core = match SyncularCore::new_with_notify(&transport_json, Some(notify)) {
155 Ok(core) => core,
156 Err(message) => {
157 emit(&json!({ "type": "error", "message": message }));
160 return;
161 }
162 };
163
164 let mut background_deadline: Option<Instant> = None;
167 loop {
168 if config.auto_sync {
169 match core.take_sync_intent() {
170 syncular_client::SyncIntent::Interactive => {
171 background_deadline = None;
172 core.sync_until_idle();
173 pump_events(&mut core, &emit);
174 continue;
175 }
176 syncular_client::SyncIntent::Background { delay_ms } => {
177 let candidate = Instant::now()
178 .checked_add(Duration::from_millis(delay_ms))
179 .unwrap_or_else(Instant::now);
180 background_deadline = Some(
181 background_deadline.map_or(candidate, |current| current.min(candidate)),
182 );
183 }
184 syncular_client::SyncIntent::None => {}
185 }
186 }
187
188 let request = if let Some(deadline) = background_deadline {
189 let now = Instant::now();
190 if deadline <= now {
191 background_deadline = None;
192 core.sync_until_idle();
193 pump_events(&mut core, &emit);
194 continue;
195 }
196 match rx.recv_timeout(deadline.saturating_duration_since(now)) {
197 Ok(request) => request,
198 Err(RecvTimeoutError::Timeout) => {
199 background_deadline = None;
200 core.sync_until_idle();
201 pump_events(&mut core, &emit);
202 continue;
203 }
204 Err(RecvTimeoutError::Disconnected) => {
205 core.shutdown();
206 return;
207 }
208 }
209 } else {
210 match rx.recv() {
211 Ok(request) => request,
212 Err(std::sync::mpsc::RecvError) => {
213 core.shutdown();
214 return;
215 }
216 }
217 };
218
219 match request {
220 Request::Command { command, reply } => {
221 let command = inject_db_path(command, &config);
222 let result = core.command(&command);
223 let _ = reply.send(result);
224 pump_events(&mut core, &emit);
225 }
226 Request::Query { sql, params, reply } => {
227 let result = core.query(&sql, params);
228 let _ = reply.send(result);
229 pump_events(&mut core, &emit);
230 }
231 Request::SetHeaders { headers, reply } => {
232 core.set_headers(headers);
233 let _ = reply.send(json!({ "result": null }));
234 }
235 Request::TransportWake => {
236 core.poll_transport();
237 pump_events(&mut core, &emit);
238 }
239 Request::Shutdown => {
240 core.shutdown();
241 return;
242 }
243 }
244 }
245}
246
247fn inject_db_path(mut command: Value, config: &SyncularConfig) -> Value {
251 if command.get("method").and_then(Value::as_str) != Some("create") {
252 return command;
253 }
254 let Some(db_path) = &config.db_path else {
255 return command;
256 };
257 let params = command.get_mut("params").and_then(Value::as_object_mut);
258 if let Some(params) = params {
259 params
260 .entry("dbPath")
261 .or_insert_with(|| Value::from(db_path.clone()));
262 } else if let Some(obj) = command.as_object_mut() {
263 obj.insert("params".to_owned(), json!({ "dbPath": db_path }));
264 }
265 command
266}
267
268fn pump_events<F: Fn(&Value)>(core: &mut SyncularCore, emit: &F) {
269 for event in core.drain_events() {
270 emit(&event.json);
271 }
272}
273
274#[tauri::command]
277async fn syncular_command<R: Runtime>(
278 app: tauri::AppHandle<R>,
279 command: Value,
280) -> Result<Value, String> {
281 let state = app.state::<SyncularState>();
282 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
283 state.send(Request::Command {
284 command,
285 reply: reply_tx,
286 })?;
287 reply_rx
288 .recv()
289 .map_err(|_| "the syncular core dropped the reply".to_owned())
290}
291
292#[tauri::command]
297async fn syncular_set_headers<R: Runtime>(
298 app: tauri::AppHandle<R>,
299 headers: std::collections::BTreeMap<String, String>,
300) -> Result<Value, String> {
301 let state = app.state::<SyncularState>();
302 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
303 state.send(Request::SetHeaders {
304 headers: headers.into_iter().collect(),
305 reply: reply_tx,
306 })?;
307 reply_rx
308 .recv()
309 .map_err(|_| "the syncular core dropped the reply".to_owned())
310}
311
312#[tauri::command]
313async fn syncular_query<R: Runtime>(
314 app: tauri::AppHandle<R>,
315 sql: String,
316 params: Option<Value>,
317) -> Result<Value, String> {
318 let state = app.state::<SyncularState>();
319 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
320 state.send(Request::Query {
321 sql,
322 params: params.unwrap_or(Value::Null),
323 reply: reply_tx,
324 })?;
325 reply_rx
326 .recv()
327 .map_err(|_| "the syncular core dropped the reply".to_owned())
328}
329
330pub fn init<R: Runtime>(config: SyncularConfig) -> TauriPlugin<R> {
338 Builder::<R>::new("syncular")
339 .invoke_handler(tauri::generate_handler![
340 syncular_command,
341 syncular_query,
342 syncular_set_headers
343 ])
344 .setup(move |app, _api| {
345 let (tx, rx) = std::sync::mpsc::channel::<Request>();
346 app.manage(SyncularState {
347 sender: Mutex::new(tx.clone()),
348 });
349 let app_handle = app.clone();
350 let emit = move |value: &Value| {
351 let _ = app_handle.emit(EVENT_NAME, value.clone());
354 };
355 std::thread::Builder::new()
356 .name("syncular-core".to_owned())
357 .spawn(move || run_owner_thread(config, tx, rx, emit))
358 .map_err(|e| format!("failed to spawn syncular core thread: {e}"))?;
359 Ok(())
360 })
361 .on_event(|app, event| {
362 if let RunEvent::Exit = event {
363 if let Some(state) = app.try_state::<SyncularState>() {
364 let _ = state.send(Request::Shutdown);
365 }
366 }
367 })
368 .build()
369}
370
371#[cfg(test)]
372mod tests {
373 use super::*;
374
375 #[test]
376 fn config_to_transport_json_shapes_fields() {
377 let config = SyncularConfig {
378 base_url: Some("https://api.example.com".to_owned()),
379 headers: vec![("authorization".to_owned(), "Bearer x".to_owned())],
380 ..Default::default()
381 };
382 let json = config.to_transport_json();
383 assert_eq!(json["baseUrl"], "https://api.example.com");
384 assert_eq!(json["headers"]["authorization"], "Bearer x");
385 }
386
387 #[test]
388 fn inject_db_path_adds_to_create_only() {
389 let config = SyncularConfig {
390 db_path: Some("/tmp/app.db".to_owned()),
391 ..Default::default()
392 };
393 let created = inject_db_path(
395 json!({ "method": "create", "params": { "clientId": "c1" } }),
396 &config,
397 );
398 assert_eq!(created["params"]["dbPath"], "/tmp/app.db");
399 let created2 = inject_db_path(json!({ "method": "create" }), &config);
401 assert_eq!(created2["params"]["dbPath"], "/tmp/app.db");
402 let explicit = inject_db_path(
404 json!({ "method": "create", "params": { "dbPath": "/other.db" } }),
405 &config,
406 );
407 assert_eq!(explicit["params"]["dbPath"], "/other.db");
408 let mutate = inject_db_path(json!({ "method": "mutate", "params": {} }), &config);
410 assert!(mutate["params"].get("dbPath").is_none());
411 }
412
413 #[test]
417 fn owner_thread_round_trips_over_mailbox() {
418 use std::sync::mpsc::channel;
419 use std::sync::{Arc, Mutex as StdMutex};
420
421 let (tx, rx) = channel::<Request>();
422 let events: Arc<StdMutex<Vec<Value>>> = Arc::new(StdMutex::new(Vec::new()));
423 let events_for_thread = Arc::clone(&events);
424 let config = SyncularConfig {
425 auto_sync: false,
426 ..Default::default()
427 };
428 let owner_tx = tx.clone();
429 let handle = std::thread::spawn(move || {
430 run_owner_thread(config, owner_tx, rx, move |v| {
431 events_for_thread.lock().unwrap().push(v.clone());
432 });
433 });
434
435 let call = |command: Value| -> Value {
436 let (rtx, rrx) = channel();
437 tx.send(Request::Command {
438 command,
439 reply: rtx,
440 })
441 .unwrap();
442 rrx.recv().unwrap()
443 };
444
445 let schema = json!({
446 "version": 1,
447 "tables": [{
448 "name": "todo", "primaryKey": "id",
449 "columns": [
450 { "name": "id", "type": "string", "nullable": false },
451 { "name": "title", "type": "string", "nullable": false }
452 ],
453 "scopes": []
454 }]
455 });
456 assert_eq!(
457 call(json!({ "method": "create", "params": { "clientId": "c1", "schema": schema } }))
458 ["result"],
459 json!({})
460 );
461 call(json!({ "method": "mutate", "params": { "mutations": [{
462 "op": "upsert", "table": "todo", "values": { "id": "t1", "title": "hi" }
463 }] } }));
464
465 let (qtx, qrx) = channel();
467 tx.send(Request::Query {
468 sql: "SELECT title FROM todo".to_owned(),
469 params: Value::Null,
470 reply: qtx,
471 })
472 .unwrap();
473 let rows = qrx.recv().unwrap();
474 assert_eq!(rows["result"]["rows"][0]["title"], "hi");
475
476 let (htx, hrx) = channel();
479 tx.send(Request::SetHeaders {
480 headers: vec![("authorization".to_owned(), "Bearer fresh".to_owned())],
481 reply: htx,
482 })
483 .unwrap();
484 assert_eq!(hrx.recv().unwrap()["result"], Value::Null);
485
486 tx.send(Request::Shutdown).unwrap();
487 handle.join().unwrap();
488
489 let seen = events.lock().unwrap();
490 let kinds: Vec<String> = seen
491 .iter()
492 .filter_map(|e| e.get("type").and_then(Value::as_str).map(str::to_owned))
493 .collect();
494 assert!(kinds.iter().any(|k| k == "change"), "kinds: {kinds:?}");
496 }
497}