Skip to main content

tauri_plugin_syncular/
lib.rs

1//! # tauri-plugin-syncular — a native syncular instance inside the Tauri process
2//!
3//! A NATIVE syncular client (the Rust `syncular-client` core, consumed
4//! DIRECTLY — no FFI) runs in the Tauri host process and is exposed to the
5//! webview as Tauri commands + events. The JS bridge (`@syncular/tauri`)
6//! implements the same `SyncClientLike` interface the React package
7//! normalizes, so the hooks work unchanged — the fourth host of one interface
8//! after direct / worker-leader / follower.
9//!
10//! The webview does not run JS syncular. Webview OPFS is eviction-prone and inconsistent across
11//! WKWebView/webkitgtk; the Rust core gives a real file DB and native perf.
12//!
13//! ## The surface (mirrors the FFI / conformance shim)
14//!
15//! - `syncular_command(command_json)` — the WHOLE command surface in one
16//!   command (`{"method","params"}`), dispatched through the shared
17//!   `syncular-command` router (the plugin is its THIRD consumer, so the
18//!   surface stays conformance-locked).
19//! - `syncular_query(sql, params)` — the React live-query fast path (arbitrary
20//!   read-only SQL); routed through the same `query` command.
21//! - `syncular_query_snapshot(sql, params, coverage)` — atomic reactive reads
22//!   on an independent read-only SQLite connection for file-backed clients.
23//! - `syncular://event` — exact revisioned `change` batches plus ephemeral
24//!   `presence`; command/realtime sync intents stay inside the event-driven
25//!   owner loop.
26//!
27//! ## Thread-safety, honestly
28//!
29//! [`core::SyncularCore`] owns a rusqlite connection and is NOT `Sync`. One
30//! owning thread holds the mutable client; every command arrives over a mailbox
31//! (mpsc). The background host loop (§8.4 wake-driven `syncUntilIdle` with
32//! deadlines) runs on that thread. File-backed clients add one read owner with
33//! an independent read-only SQLite connection for snapshots, so network work
34//! cannot block local views while the mutable client remains single-owned.
35
36use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender};
37use std::sync::{Condvar, Mutex};
38use std::time::{Duration, Instant};
39
40use serde_json::{json, Value};
41use tauri::plugin::{Builder, TauriPlugin};
42use tauri::{Emitter, Manager, RunEvent, Runtime};
43
44pub mod core;
45pub mod transport;
46
47use core::SyncularCore;
48use syncular_client::{FileQuerySnapshotReader, QueryReadFailure, WindowBase, WindowCoverage};
49
50/// The Tauri event name carrying derived client-observable events.
51pub const EVENT_NAME: &str = "syncular://event";
52
53/// Plugin configuration. Passed to [`init`]; every field is optional except a
54/// caller almost always wants a `base_url` (for real network sync) and a
55/// `db_path` (for persistence — defaults to an in-memory core if absent).
56#[derive(Debug, Clone)]
57pub struct SyncularConfig {
58    /// Server base URL for the native HTTP+WS transport (needs the
59    /// `native-transport` feature). Absent → client-local only.
60    pub base_url: Option<String>,
61    /// Optional realtime WS URL; derived from `base_url` when absent.
62    pub ws_url: Option<String>,
63    /// Extra request headers (auth, actor/project ids) as (name, value).
64    pub headers: Vec<(String, String)>,
65    /// On-disk SQLite path. Absent → in-memory (nothing survives a restart).
66    /// Apps usually set this to a file under the app-data dir; see [`init`].
67    pub db_path: Option<String>,
68    /// Run the background host loop (§8.4). Default true.
69    pub auto_sync: bool,
70}
71
72impl Default for SyncularConfig {
73    fn default() -> Self {
74        Self {
75            base_url: None,
76            ws_url: None,
77            headers: Vec::new(),
78            db_path: None,
79            auto_sync: true,
80        }
81    }
82}
83
84impl SyncularConfig {
85    /// Build the JSON config the core's transport reads.
86    fn to_transport_json(&self) -> Value {
87        let mut map = serde_json::Map::new();
88        if let Some(base) = &self.base_url {
89            map.insert("baseUrl".to_owned(), Value::from(base.clone()));
90        }
91        if let Some(ws) = &self.ws_url {
92            map.insert("wsUrl".to_owned(), Value::from(ws.clone()));
93        }
94        if !self.headers.is_empty() {
95            let headers: serde_json::Map<String, Value> = self
96                .headers
97                .iter()
98                .map(|(k, v)| (k.clone(), Value::from(v.clone())))
99                .collect();
100            map.insert("headers".to_owned(), Value::Object(headers));
101        }
102        Value::Object(map)
103    }
104}
105
106/// A request posted to the owning thread's mailbox. Each carries a one-shot
107/// reply channel; the Tauri command blocks on it (`spawn_blocking`-friendly).
108enum Request {
109    Command {
110        command: Value,
111        reply: Sender<Value>,
112    },
113    Query {
114        sql: String,
115        params: Value,
116        reply: Sender<Value>,
117    },
118    /// Replace the transport's request headers. Header state
119    /// lives on the core-owned transport, so mutation rides the same mailbox
120    /// as every other access — the one-owning-thread invariant holds.
121    SetHeaders {
122        headers: Vec<(String, String)>,
123        reply: Sender<Value>,
124    },
125    /// Native realtime reader wake; contains no data (the transport buffer does).
126    TransportWake,
127    /// §7.6: an owned read-sidecar failure, or the first success after one.
128    QueryRead {
129        id: String,
130        tables: Vec<String>,
131        failure: Option<QueryReadFailure>,
132    },
133    #[cfg(test)]
134    Block {
135        duration: Duration,
136        entered: Sender<()>,
137    },
138    Shutdown,
139}
140
141/// Latency-critical reads use a second, read-only SQLite connection. This
142/// mailbox is deliberately independent from [`Request`]: a network round on
143/// the mutable owner must never head-of-line-block a local UI snapshot.
144enum ReadRequest {
145    QuerySnapshot {
146        sql: String,
147        params: Vec<Value>,
148        coverage: Vec<WindowCoverage>,
149        owner: Option<(String, Vec<String>)>,
150        reply: Sender<Value>,
151    },
152    Shutdown,
153}
154
155/// The plugin's managed state: the mailbox sender the commands post to. Wrapped
156/// in a `Mutex` only to be `Sync` for Tauri state (the `Sender` is `Send`).
157struct SyncularState {
158    sender: Mutex<Sender<Request>>,
159    reader: Option<Mutex<Sender<ReadRequest>>>,
160    security_gate: SecurityGate,
161}
162
163struct SecurityGateState {
164    preflight: bool,
165    active_reads: usize,
166}
167
168struct SecurityGate {
169    state: Mutex<SecurityGateState>,
170    idle: Condvar,
171}
172
173struct SecurityReadGuard<'a> {
174    gate: &'a SecurityGate,
175}
176
177impl SecurityGate {
178    fn new_preflight() -> Self {
179        Self {
180            state: Mutex::new(SecurityGateState {
181                preflight: true,
182                active_reads: 0,
183            }),
184            idle: Condvar::new(),
185        }
186    }
187
188    fn begin_preflight(&self) -> Result<(), String> {
189        let mut state = self
190            .state
191            .lock()
192            .map_err(|_| "syncular security gate poisoned".to_owned())?;
193        state.preflight = true;
194        while state.active_reads > 0 {
195            state = self
196                .idle
197                .wait(state)
198                .map_err(|_| "syncular security gate poisoned".to_owned())?;
199        }
200        Ok(())
201    }
202
203    fn activate(&self) -> Result<(), String> {
204        let mut state = self
205            .state
206            .lock()
207            .map_err(|_| "syncular security gate poisoned".to_owned())?;
208        state.preflight = false;
209        Ok(())
210    }
211
212    fn enter_read(&self) -> Result<SecurityReadGuard<'_>, Value> {
213        let mut state = self
214            .state
215            .lock()
216            .map_err(|_| client_error("syncular security gate poisoned"))?;
217        if state.preflight {
218            return Err(security_preflight_error());
219        }
220        state.active_reads += 1;
221        Ok(SecurityReadGuard { gate: self })
222    }
223}
224
225impl Drop for SecurityReadGuard<'_> {
226    fn drop(&mut self) {
227        let Ok(mut state) = self.gate.state.lock() else {
228            return;
229        };
230        state.active_reads = state.active_reads.saturating_sub(1);
231        if state.active_reads == 0 {
232            self.gate.idle.notify_all();
233        }
234    }
235}
236
237impl SyncularState {
238    fn send(&self, request: Request) -> Result<(), String> {
239        self.sender
240            .lock()
241            .map_err(|_| "syncular mailbox poisoned".to_owned())?
242            .send(request)
243            .map_err(|_| "the syncular core thread has stopped".to_owned())
244    }
245
246    fn send_read(&self, request: ReadRequest) -> Result<(), String> {
247        let Some(reader) = &self.reader else {
248            return Err("this syncular client has no file snapshot reader".to_owned());
249        };
250        reader
251            .lock()
252            .map_err(|_| "syncular read mailbox poisoned".to_owned())?
253            .send(request)
254            .map_err(|_| "the syncular read thread has stopped".to_owned())?;
255        Ok(())
256    }
257}
258
259fn run_reader_thread(path: String, rx: Receiver<ReadRequest>, owner_tx: Sender<Request>) {
260    let mut reader = FileQuerySnapshotReader::new(path);
261    // §7.6: owner ids whose last sidecar read failed. Only a failure or the
262    // first success after one reaches the owning core's mailbox.
263    let mut failing = std::collections::HashSet::<String>::new();
264    while let Ok(request) = rx.recv() {
265        match request {
266            ReadRequest::QuerySnapshot {
267                sql,
268                params,
269                coverage,
270                owner,
271                reply,
272            } => {
273                let result = reader.query_snapshot(&sql, &params, &coverage);
274                let value = match &result {
275                    Ok(snapshot) => json!({ "result": snapshot }),
276                    Err(failure) => json!({
277                        "error": {
278                            "code": failure.code.unwrap_or("client.failed"),
279                            "message": failure.message,
280                        }
281                    }),
282                };
283                if let Some((id, tables)) = owner {
284                    let failure = result.err();
285                    let report = if failure.is_some() {
286                        failing.insert(id.clone());
287                        true
288                    } else {
289                        failing.remove(&id)
290                    };
291                    if report {
292                        let _ = owner_tx.send(Request::QueryRead {
293                            id,
294                            tables,
295                            failure,
296                        });
297                    }
298                }
299                let _ = reply.send(value);
300            }
301            ReadRequest::Shutdown => return,
302        }
303    }
304}
305
306/// The owning thread: builds the core, then loops over the mailbox and the
307/// background host policy. `emit` pushes drained events onto the Tauri channel.
308fn run_owner_thread<F>(config: SyncularConfig, tx: Sender<Request>, rx: Receiver<Request>, emit: F)
309where
310    F: Fn(&Value) + Send + Sync + 'static,
311{
312    let emit = std::sync::Arc::new(emit);
313    let transport_json = config.to_transport_json();
314    let wake_tx = tx.clone();
315    let notify: std::sync::Arc<dyn Fn() + Send + Sync> = std::sync::Arc::new(move || {
316        let _ = wake_tx.send(Request::TransportWake);
317    });
318    let mut core = match SyncularCore::new_with_notify(&transport_json, Some(notify)) {
319        Ok(core) => core,
320        Err(message) => {
321            // A construction failure is terminal for this instance; surface it
322            // once on the channel so the webview can show it, then stop.
323            emit(&json!({ "type": "error", "message": message }));
324            return;
325        }
326    };
327    let progress_emit = emit.clone();
328    core.progress_listener = Some(std::sync::Arc::new(move |progress| {
329        progress_emit(&json!({ "type": "progress", "progress": progress }));
330    }));
331
332    // No idle poll: commands/realtime wake the mailbox, while a retryable
333    // transport failure contributes one real monotonic deadline.
334    let mut background_deadline: Option<Instant> = None;
335    loop {
336        if config.auto_sync {
337            match core.take_sync_intent() {
338                syncular_client::SyncIntent::Interactive => {
339                    background_deadline = None;
340                    core.sync_until_idle();
341                    pump_events(&mut core, &*emit);
342                    continue;
343                }
344                syncular_client::SyncIntent::Background { delay_ms } => {
345                    let candidate = Instant::now()
346                        .checked_add(Duration::from_millis(delay_ms))
347                        .unwrap_or_else(Instant::now);
348                    background_deadline = Some(
349                        background_deadline.map_or(candidate, |current| current.min(candidate)),
350                    );
351                }
352                syncular_client::SyncIntent::None => {}
353            }
354        }
355
356        let request = if let Some(deadline) = background_deadline {
357            let now = Instant::now();
358            if deadline <= now {
359                background_deadline = None;
360                core.sync_until_idle();
361                pump_events(&mut core, &*emit);
362                continue;
363            }
364            match rx.recv_timeout(deadline.saturating_duration_since(now)) {
365                Ok(request) => request,
366                Err(RecvTimeoutError::Timeout) => {
367                    background_deadline = None;
368                    core.sync_until_idle();
369                    pump_events(&mut core, &*emit);
370                    continue;
371                }
372                Err(RecvTimeoutError::Disconnected) => {
373                    core.shutdown();
374                    return;
375                }
376            }
377        } else {
378            match rx.recv() {
379                Ok(request) => request,
380                Err(std::sync::mpsc::RecvError) => {
381                    core.shutdown();
382                    return;
383                }
384            }
385        };
386
387        match request {
388            Request::Command { command, reply } => {
389                let command = inject_db_path(command, &config);
390                let result = core.command(&command);
391                if result.get("error").is_none() {
392                    if let Some(headers) = activation_headers(&command) {
393                        // A successful `activateSecurity` may carry a fresh
394                        // header set; apply it here, before this loop consumes
395                        // the startup sync intent the activation enqueued, so
396                        // a preflight that outlived the boot token starts its
397                        // first round with valid credentials.
398                        core.set_headers(headers);
399                    }
400                }
401                let _ = reply.send(result);
402                pump_events(&mut core, &*emit);
403            }
404            Request::Query { sql, params, reply } => {
405                let result = core.query(&sql, params);
406                let _ = reply.send(result);
407                pump_events(&mut core, &*emit);
408            }
409            Request::SetHeaders { headers, reply } => {
410                core.set_headers(headers);
411                let _ = reply.send(json!({ "result": null }));
412            }
413            Request::TransportWake => {
414                core.poll_transport();
415                pump_events(&mut core, &*emit);
416            }
417            Request::QueryRead {
418                id,
419                tables,
420                failure,
421            } => {
422                core.record_query_read(&id, &tables, failure.as_ref());
423                pump_events(&mut core, &*emit);
424            }
425            #[cfg(test)]
426            Request::Block { duration, entered } => {
427                let _ = entered.send(());
428                std::thread::sleep(duration);
429            }
430            Request::Shutdown => {
431                core.shutdown();
432                return;
433            }
434        }
435    }
436}
437
438/// Inject the configured `db_path` into a `create` command's params if the JS
439/// side did not already supply one — so persistence is a plugin-config concern,
440/// not something every app must thread through the bridge.
441fn inject_db_path(mut command: Value, config: &SyncularConfig) -> Value {
442    if command.get("method").and_then(Value::as_str) != Some("create") {
443        return command;
444    }
445    let Some(db_path) = &config.db_path else {
446        return command;
447    };
448    let params = command.get_mut("params").and_then(Value::as_object_mut);
449    if let Some(params) = params {
450        params
451            .entry("dbPath")
452            .or_insert_with(|| Value::from(db_path.clone()));
453    } else if let Some(obj) = command.as_object_mut() {
454        obj.insert("params".to_owned(), json!({ "dbPath": db_path }));
455    }
456    command
457}
458
459/// The header set an `activateSecurity` command carries (already validated by
460/// the shared command router; a shape failure surfaces as its error reply, so
461/// this extraction only sees well-formed sets on the success path).
462fn activation_headers(command: &Value) -> Option<Vec<(String, String)>> {
463    if command.get("method").and_then(Value::as_str) != Some("activateSecurity") {
464        return None;
465    }
466    let headers = command.pointer("/params/headers")?;
467    syncular_command::parse_headers(headers).ok()
468}
469
470fn client_error(message: impl Into<String>) -> Value {
471    json!({ "error": { "code": "client.failed", "message": message.into() } })
472}
473
474fn security_preflight_error() -> Value {
475    json!({
476        "error": {
477            "code": syncular_client::SECURITY_PREFLIGHT_REQUIRED_CODE,
478            "message": "the local replica is in security preflight; complete quarantine checks and call activateSecurity before accessing protected data"
479        }
480    })
481}
482
483fn parse_window_base(value: Option<&Value>) -> Result<WindowBase, String> {
484    let object = value
485        .and_then(Value::as_object)
486        .ok_or_else(|| "querySnapshot coverage missing base object".to_owned())?;
487    let table = object
488        .get("table")
489        .and_then(Value::as_str)
490        .ok_or_else(|| "window base missing table".to_owned())?
491        .to_owned();
492    let variable = object
493        .get("variable")
494        .and_then(Value::as_str)
495        .ok_or_else(|| "window base missing variable".to_owned())?
496        .to_owned();
497    let fixed_scopes = match object.get("fixedScopes") {
498        Some(value) => syncular_client::values::json_to_scope_map(value)?,
499        None => Vec::new(),
500    };
501    let params = object
502        .get("params")
503        .and_then(Value::as_str)
504        .map(str::to_owned);
505    Ok(WindowBase {
506        table,
507        variable,
508        fixed_scopes,
509        params,
510    })
511}
512
513fn parse_coverage(value: Option<&Value>) -> Result<Vec<WindowCoverage>, String> {
514    let Some(value) = value else {
515        return Ok(Vec::new());
516    };
517    if value.is_null() {
518        return Ok(Vec::new());
519    }
520    let entries = value
521        .as_array()
522        .ok_or_else(|| "querySnapshot coverage must be a list".to_owned())?;
523    entries
524        .iter()
525        .map(|entry| {
526            let units = entry
527                .get("units")
528                .and_then(Value::as_array)
529                .map(|values| {
530                    values
531                        .iter()
532                        .filter_map(|value| value.as_str().map(str::to_owned))
533                        .collect()
534                })
535                .unwrap_or_default();
536            Ok(WindowCoverage {
537                base: parse_window_base(entry.get("base"))?,
538                units,
539            })
540        })
541        .collect()
542}
543
544fn pump_events<F: Fn(&Value)>(core: &mut SyncularCore, emit: &F) {
545    for event in core.drain_events() {
546        emit(&event.json);
547    }
548}
549
550// -- Tauri commands (the thin shell) -----------------------------------------
551
552#[tauri::command]
553async fn syncular_command<R: Runtime>(
554    app: tauri::AppHandle<R>,
555    command: Value,
556) -> Result<Value, String> {
557    let state = app.state::<SyncularState>();
558    let method = command
559        .get("method")
560        .and_then(Value::as_str)
561        .unwrap_or("")
562        .to_owned();
563    if method == "create" || method == "beginSecurityPreflight" || method == "shutdown" {
564        // Gate fast reads and wait for already-started sidecar snapshots before
565        // the owner-thread barrier is enqueued.
566        state.security_gate.begin_preflight()?;
567    }
568    let create_preflight = method == "create"
569        && command
570            .pointer("/params/securityPreflight")
571            .and_then(Value::as_bool)
572            .unwrap_or(false);
573    let (reply_tx, reply_rx) = std::sync::mpsc::channel();
574    state.send(Request::Command {
575        command,
576        reply: reply_tx,
577    })?;
578    let reply = reply_rx
579        .recv()
580        .map_err(|_| "the syncular core dropped the reply".to_owned())?;
581    let succeeded = reply.get("error").is_none();
582    if succeeded && method == "create" {
583        if !create_preflight {
584            state.security_gate.activate()?;
585        }
586    } else if succeeded && method == "activateSecurity" {
587        state.security_gate.activate()?;
588    }
589    Ok(reply)
590}
591
592/// Replace the native transport's request headers at runtime — the auth
593/// Header rotation path: a fresh JWT reaches the transport without
594/// re-registering the plugin. HTTP requests use the new set from the next
595/// call; the realtime socket applies it on its next (re)connect.
596#[tauri::command]
597async fn syncular_set_headers<R: Runtime>(
598    app: tauri::AppHandle<R>,
599    headers: std::collections::BTreeMap<String, String>,
600) -> Result<Value, String> {
601    let state = app.state::<SyncularState>();
602    // Runtime bearer replacement is an active-session operation. Hold the
603    // same gate as fast reads so beginSecurityPreflight both rejects new
604    // replacements and waits for an already-started mailbox update.
605    let _active_guard = match state.security_gate.enter_read() {
606        Ok(guard) => guard,
607        Err(reply) => return Ok(reply),
608    };
609    let (reply_tx, reply_rx) = std::sync::mpsc::channel();
610    state.send(Request::SetHeaders {
611        headers: headers.into_iter().collect(),
612        reply: reply_tx,
613    })?;
614    reply_rx
615        .recv()
616        .map_err(|_| "the syncular core dropped the reply".to_owned())
617}
618
619#[tauri::command]
620async fn syncular_query<R: Runtime>(
621    app: tauri::AppHandle<R>,
622    sql: String,
623    params: Option<Value>,
624) -> Result<Value, String> {
625    let state = app.state::<SyncularState>();
626    let _read_guard = match state.security_gate.enter_read() {
627        Ok(guard) => guard,
628        Err(reply) => return Ok(reply),
629    };
630    let (reply_tx, reply_rx) = std::sync::mpsc::channel();
631    state.send(Request::Query {
632        sql,
633        params: params.unwrap_or(Value::Null),
634        reply: reply_tx,
635    })?;
636    reply_rx
637        .recv()
638        .map_err(|_| "the syncular core dropped the reply".to_owned())
639}
640
641/// Atomic rows + revision + window coverage on the independent read-only
642/// connection. In-memory configurations fall back to the core owner because
643/// SQLite cannot share an anonymous database across connections.
644#[tauri::command]
645async fn syncular_query_snapshot<R: Runtime>(
646    app: tauri::AppHandle<R>,
647    sql: String,
648    params: Option<Value>,
649    coverage: Option<Value>,
650    owner: Option<Value>,
651) -> Result<Value, String> {
652    let state = app.state::<SyncularState>();
653    let _read_guard = match state.security_gate.enter_read() {
654        Ok(guard) => guard,
655        Err(reply) => return Ok(reply),
656    };
657    let params_value = params.unwrap_or_else(|| Value::Array(Vec::new()));
658    let coverage_value = coverage.unwrap_or_else(|| Value::Array(Vec::new()));
659
660    if state.reader.is_some() {
661        let bind = match params_value.as_array() {
662            Some(values) => values.clone(),
663            None => return Ok(client_error("querySnapshot params must be a list")),
664        };
665        let parsed_coverage = match parse_coverage(Some(&coverage_value)) {
666            Ok(value) => value,
667            Err(message) => return Ok(client_error(message)),
668        };
669        // §7.5 owner: the same `{id, tables}` contract the command router parses.
670        let parsed_owner = match &owner {
671            None | Some(Value::Null) => None,
672            Some(value) => {
673                let id = value
674                    .get("id")
675                    .and_then(Value::as_str)
676                    .filter(|id| !id.is_empty());
677                let tables = value
678                    .get("tables")
679                    .and_then(Value::as_array)
680                    .and_then(|list| {
681                        list.iter()
682                            .map(|table| table.as_str().map(str::to_owned))
683                            .collect::<Option<Vec<String>>>()
684                    });
685                match (id, tables) {
686                    (Some(id), Some(tables)) => Some((id.to_owned(), tables)),
687                    _ => {
688                        return Ok(json!({ "error": {
689                            "code": "sync.invalid_request",
690                            "message": "sync.invalid_request: querySnapshot owner must be {id: non-empty string, tables: string[]}",
691                        } }))
692                    }
693                }
694            }
695        };
696        let (reply_tx, reply_rx) = std::sync::mpsc::channel();
697        state.send_read(ReadRequest::QuerySnapshot {
698            sql,
699            params: bind,
700            coverage: parsed_coverage,
701            owner: parsed_owner,
702            reply: reply_tx,
703        })?;
704        return reply_rx
705            .recv()
706            .map_err(|_| "the syncular read thread dropped the reply".to_owned());
707    }
708
709    let (reply_tx, reply_rx) = std::sync::mpsc::channel();
710    state.send(Request::Command {
711        command: json!({
712            "method": "querySnapshot",
713            "params": {
714                "sql": sql,
715                "params": params_value,
716                "coverage": coverage_value,
717                "owner": owner.unwrap_or(Value::Null),
718            }
719        }),
720        reply: reply_tx,
721    })?;
722    reply_rx
723        .recv()
724        .map_err(|_| "the syncular core dropped the reply".to_owned())
725}
726
727/// Initialize the plugin with a config. Register with
728/// `tauri::Builder::default().plugin(tauri_plugin_syncular::init(config))`.
729///
730/// The owning thread is spawned in `setup`; it builds the core (native
731/// transport if `base_url` + the `native-transport` feature), pumps events onto
732/// [`EVENT_NAME`], and runs the §8.4 host loop. The mailbox `Sender` is managed
733/// as plugin state and torn down on `RunEvent::Exit`.
734pub fn init<R: Runtime>(config: SyncularConfig) -> TauriPlugin<R> {
735    Builder::<R>::new("syncular")
736        .invoke_handler(tauri::generate_handler![
737            syncular_command,
738            syncular_query,
739            syncular_query_snapshot,
740            syncular_set_headers
741        ])
742        .setup(move |app, _api| {
743            let (tx, rx) = std::sync::mpsc::channel::<Request>();
744            let reader = match config
745                .db_path
746                .as_ref()
747                .filter(|path| path.as_str() != ":memory:")
748            {
749                Some(path) => {
750                    let (reader_tx, reader_rx) = std::sync::mpsc::channel::<ReadRequest>();
751                    let path = path.clone();
752                    std::thread::Builder::new()
753                        .name("syncular-read".to_owned())
754                        .spawn({
755                            let owner_tx = tx.clone();
756                            move || run_reader_thread(path, reader_rx, owner_tx)
757                        })
758                        .map_err(|e| format!("failed to spawn syncular read thread: {e}"))?;
759                    Some(Mutex::new(reader_tx))
760                }
761                None => None,
762            };
763            app.manage(SyncularState {
764                sender: Mutex::new(tx.clone()),
765                reader,
766                // Fail closed until the first successful `create` declares
767                // whether this process starts active or in preflight.
768                security_gate: SecurityGate::new_preflight(),
769            });
770            let app_handle = app.clone();
771            let emit = move |value: &Value| {
772                // Best-effort: a webview that has gone away must not crash the
773                // owning thread. Emit to all windows on the syncular channel.
774                let _ = app_handle.emit(EVENT_NAME, value.clone());
775            };
776            std::thread::Builder::new()
777                .name("syncular-core".to_owned())
778                .spawn(move || run_owner_thread(config, tx, rx, emit))
779                .map_err(|e| format!("failed to spawn syncular core thread: {e}"))?;
780            Ok(())
781        })
782        .on_event(|app, event| {
783            if let RunEvent::Exit = event {
784                if let Some(state) = app.try_state::<SyncularState>() {
785                    let _ = state.send(Request::Shutdown);
786                    if state.reader.is_some() {
787                        let _ = state.send_read(ReadRequest::Shutdown);
788                    }
789                }
790            }
791        })
792        .build()
793}
794
795#[cfg(test)]
796mod tests {
797    use super::*;
798
799    #[test]
800    fn config_to_transport_json_shapes_fields() {
801        let config = SyncularConfig {
802            base_url: Some("https://api.example.com".to_owned()),
803            headers: vec![("authorization".to_owned(), "Bearer x".to_owned())],
804            ..Default::default()
805        };
806        let json = config.to_transport_json();
807        assert_eq!(json["baseUrl"], "https://api.example.com");
808        assert_eq!(json["headers"]["authorization"], "Bearer x");
809    }
810
811    #[test]
812    fn security_gate_blocks_new_operations_and_waits_for_in_flight_work() {
813        let gate = std::sync::Arc::new(SecurityGate::new_preflight());
814        gate.activate().expect("activate test gate");
815        let read = gate.enter_read().expect("active read");
816        let gate_for_barrier = std::sync::Arc::clone(&gate);
817        let (done_tx, done_rx) = std::sync::mpsc::channel();
818        let barrier = std::thread::spawn(move || {
819            gate_for_barrier.begin_preflight().expect("enter preflight");
820            done_tx.send(()).expect("barrier reply");
821        });
822
823        assert!(done_rx.recv_timeout(Duration::from_millis(20)).is_err());
824        drop(read);
825        done_rx
826            .recv_timeout(Duration::from_secs(1))
827            .expect("barrier drains after the read");
828        barrier.join().expect("barrier thread");
829        assert!(gate.enter_read().is_err());
830    }
831
832    #[test]
833    fn direct_set_headers_command_respects_the_native_preflight_gate() {
834        use std::collections::BTreeMap;
835        use tauri::test::{mock_builder, mock_context, noop_assets};
836
837        let app = mock_builder()
838            .plugin(init(SyncularConfig {
839                auto_sync: false,
840                ..Default::default()
841            }))
842            .build(mock_context(noop_assets()))
843            .expect("build mock app");
844
845        let blocked = tauri::async_runtime::block_on(syncular_set_headers(
846            app.handle().clone(),
847            BTreeMap::from([("authorization".to_owned(), "Bearer blocked".to_owned())]),
848        ))
849        .expect("preflight reply");
850        assert_eq!(
851            blocked["error"]["code"],
852            Value::from("client.security_preflight_required")
853        );
854
855        let created = tauri::async_runtime::block_on(syncular_command(
856            app.handle().clone(),
857            json!({
858                "method": "create",
859                "params": {
860                    "clientId": "native-header-gate",
861                    "schema": { "version": 1, "tables": [] }
862                }
863            }),
864        ))
865        .expect("create reply");
866        assert!(created.get("error").is_none(), "{created}");
867
868        let active = tauri::async_runtime::block_on(syncular_set_headers(
869            app.handle().clone(),
870            BTreeMap::from([("authorization".to_owned(), "Bearer active".to_owned())]),
871        ))
872        .expect("active reply");
873        assert_eq!(active["result"], Value::Null);
874    }
875
876    #[test]
877    fn activation_headers_extracts_only_the_activation_set() {
878        assert_eq!(
879            activation_headers(&json!({
880                "method": "activateSecurity",
881                "params": { "headers": { "authorization": "Bearer fresh" } }
882            })),
883            Some(vec![(
884                "authorization".to_owned(),
885                "Bearer fresh".to_owned()
886            )])
887        );
888        // Absent headers, other methods, and invalid shapes all yield nothing.
889        assert_eq!(
890            activation_headers(&json!({ "method": "activateSecurity", "params": {} })),
891            None
892        );
893        assert_eq!(
894            activation_headers(&json!({
895                "method": "setWindow",
896                "params": { "headers": { "authorization": "Bearer fresh" } }
897            })),
898            None
899        );
900        assert_eq!(
901            activation_headers(&json!({
902                "method": "activateSecurity",
903                "params": { "headers": { "authorization": 7 } }
904            })),
905            None
906        );
907    }
908
909    #[test]
910    fn preflight_refuses_plain_replacement_creates_through_the_plugin() {
911        use tauri::test::{mock_builder, mock_context, noop_assets};
912
913        let app = mock_builder()
914            .plugin(init(SyncularConfig {
915                auto_sync: false,
916                ..Default::default()
917            }))
918            .build(mock_context(noop_assets()))
919            .expect("build mock app");
920        let command = |value: Value| {
921            tauri::async_runtime::block_on(syncular_command(app.handle().clone(), value))
922                .expect("command reply")
923        };
924
925        let created = command(json!({
926            "method": "create",
927            "params": {
928                "clientId": "native-preflight-escape",
929                "schema": { "version": 1, "tables": [] },
930                "securityPreflight": true
931            }
932        }));
933        assert!(created.get("error").is_none(), "{created}");
934
935        // The escape attempt: a plain re-create must be refused by the shared
936        // router, and the plugin's fast-read gate must stay engaged.
937        let escape = command(json!({
938            "method": "create",
939            "params": {
940                "clientId": "native-preflight-escape",
941                "schema": { "version": 1, "tables": [] }
942            }
943        }));
944        assert_eq!(
945            escape["error"]["code"],
946            Value::from("client.security_preflight_required"),
947            "{escape}"
948        );
949        let read = tauri::async_runtime::block_on(syncular_query(
950            app.handle().clone(),
951            "SELECT 1".to_owned(),
952            None,
953        ))
954        .expect("query reply");
955        assert_eq!(
956            read["error"]["code"],
957            Value::from("client.security_preflight_required")
958        );
959
960        // Activation (with a fresh header set) releases both gates.
961        let activated = command(json!({
962            "method": "activateSecurity",
963            "params": { "headers": { "authorization": "Bearer fresh" } }
964        }));
965        assert!(activated.get("error").is_none(), "{activated}");
966        let read = tauri::async_runtime::block_on(syncular_query(
967            app.handle().clone(),
968            "SELECT 1 AS value".to_owned(),
969            None,
970        ))
971        .expect("query reply");
972        assert_eq!(read["result"]["rows"][0]["value"], 1);
973        let recreated = command(json!({
974            "method": "create",
975            "params": {
976                "clientId": "native-preflight-escape",
977                "schema": { "version": 1, "tables": [] }
978            }
979        }));
980        assert!(recreated.get("error").is_none(), "{recreated}");
981    }
982
983    #[test]
984    fn inject_db_path_adds_to_create_only() {
985        let config = SyncularConfig {
986            db_path: Some("/tmp/app.db".to_owned()),
987            ..Default::default()
988        };
989        // create gains the path…
990        let created = inject_db_path(
991            json!({ "method": "create", "params": { "clientId": "c1" } }),
992            &config,
993        );
994        assert_eq!(created["params"]["dbPath"], "/tmp/app.db");
995        // …a create with no params object gets one…
996        let created2 = inject_db_path(json!({ "method": "create" }), &config);
997        assert_eq!(created2["params"]["dbPath"], "/tmp/app.db");
998        // …an explicit dbPath is preserved…
999        let explicit = inject_db_path(
1000            json!({ "method": "create", "params": { "dbPath": "/other.db" } }),
1001            &config,
1002        );
1003        assert_eq!(explicit["params"]["dbPath"], "/other.db");
1004        // …and a non-create command is untouched.
1005        let mutate = inject_db_path(json!({ "method": "mutate", "params": {} }), &config);
1006        assert!(mutate["params"].get("dbPath").is_none());
1007    }
1008
1009    #[test]
1010    fn snapshot_coverage_parser_preserves_the_generated_window_descriptor() {
1011        let parsed = parse_coverage(Some(&json!([{
1012            "base": {
1013                "table": "tasks",
1014                "variable": "project_id",
1015                "fixedScopes": { "tenant_id": ["one", "two"] },
1016                "params": "opaque"
1017            },
1018            "units": ["a", "b"]
1019        }])))
1020        .expect("parse coverage");
1021        assert_eq!(parsed.len(), 1);
1022        let entry = &parsed[0];
1023        assert_eq!(entry.base.table, "tasks");
1024        assert_eq!(entry.base.variable, "project_id");
1025        assert_eq!(
1026            entry.base.fixed_scopes,
1027            vec![(
1028                "tenant_id".to_owned(),
1029                vec!["one".to_owned(), "two".to_owned()]
1030            )]
1031        );
1032        assert_eq!(entry.base.params.as_deref(), Some("opaque"));
1033        assert_eq!(entry.units, vec!["a".to_owned(), "b".to_owned()]);
1034    }
1035
1036    /// The owner-thread mailbox loop end-to-end, without any Tauri window: post
1037    /// commands, collect emitted events. This is the real host path — the Tauri
1038    /// commands are a two-line channel forward over exactly this.
1039    #[test]
1040    fn owner_thread_round_trips_over_mailbox() {
1041        use std::sync::mpsc::channel;
1042        use std::sync::{Arc, Mutex as StdMutex};
1043
1044        let (tx, rx) = channel::<Request>();
1045        let events: Arc<StdMutex<Vec<Value>>> = Arc::new(StdMutex::new(Vec::new()));
1046        let events_for_thread = Arc::clone(&events);
1047        let config = SyncularConfig {
1048            auto_sync: false,
1049            ..Default::default()
1050        };
1051        let owner_tx = tx.clone();
1052        let handle = std::thread::spawn(move || {
1053            run_owner_thread(config, owner_tx, rx, move |v| {
1054                events_for_thread.lock().unwrap().push(v.clone());
1055            });
1056        });
1057
1058        let call = |command: Value| -> Value {
1059            let (rtx, rrx) = channel();
1060            tx.send(Request::Command {
1061                command,
1062                reply: rtx,
1063            })
1064            .unwrap();
1065            rrx.recv().unwrap()
1066        };
1067
1068        let schema = json!({
1069            "version": 1,
1070            "tables": [{
1071                "name": "todo", "primaryKey": "id",
1072                "columns": [
1073                    { "name": "id", "type": "string", "nullable": false },
1074                    { "name": "title", "type": "string", "nullable": false }
1075                ],
1076                "scopes": []
1077            }]
1078        });
1079        assert_eq!(
1080            call(json!({ "method": "create", "params": { "clientId": "c1", "schema": schema } }))
1081                ["result"],
1082            json!({})
1083        );
1084        call(json!({ "method": "mutate", "params": { "mutations": [{
1085            "op": "upsert", "table": "todo", "values": { "id": "t1", "title": "hi" }
1086        }] } }));
1087
1088        // A query over the mailbox.
1089        let (qtx, qrx) = channel();
1090        tx.send(Request::Query {
1091            sql: "SELECT title FROM todo".to_owned(),
1092            params: Value::Null,
1093            reply: qtx,
1094        })
1095        .unwrap();
1096        let rows = qrx.recv().unwrap();
1097        assert_eq!(rows["result"]["rows"][0]["title"], "hi");
1098
1099        // Header rotation rides the same mailbox; a
1100        // client-local (Null-transport) core accepts and ignores the set.
1101        let (htx, hrx) = channel();
1102        tx.send(Request::SetHeaders {
1103            headers: vec![("authorization".to_owned(), "Bearer fresh".to_owned())],
1104            reply: htx,
1105        })
1106        .unwrap();
1107        assert_eq!(hrx.recv().unwrap()["result"], Value::Null);
1108
1109        tx.send(Request::Shutdown).unwrap();
1110        handle.join().unwrap();
1111
1112        let seen = events.lock().unwrap();
1113        let kinds: Vec<String> = seen
1114            .iter()
1115            .filter_map(|e| e.get("type").and_then(Value::as_str).map(str::to_owned))
1116            .collect();
1117        // The local mutate emits the exact revisioned batch onto the channel.
1118        assert!(kinds.iter().any(|k| k == "change"), "kinds: {kinds:?}");
1119    }
1120
1121    #[test]
1122    fn snapshot_reader_is_not_blocked_by_the_network_owner_mailbox() {
1123        use std::sync::mpsc::channel;
1124
1125        let path =
1126            std::env::temp_dir().join(format!("syncular-tauri-sidecar-{}.db", std::process::id()));
1127        let config = SyncularConfig {
1128            db_path: Some(path.to_string_lossy().into_owned()),
1129            auto_sync: false,
1130            ..Default::default()
1131        };
1132        let (tx, rx) = channel::<Request>();
1133        let owner_tx = tx.clone();
1134        let owner = std::thread::spawn(move || run_owner_thread(config, owner_tx, rx, |_| {}));
1135
1136        let (create_tx, create_rx) = channel();
1137        tx.send(Request::Command {
1138            command: json!({
1139                "method": "create",
1140                "params": {
1141                    "clientId": "sidecar-client",
1142                    "schema": { "version": 1, "tables": [] },
1143                    "dbPath": path.to_string_lossy()
1144                }
1145            }),
1146            reply: create_tx,
1147        })
1148        .expect("post create");
1149        assert_eq!(create_rx.recv().expect("create reply")["result"], json!({}));
1150
1151        let (read_tx, read_rx) = channel::<ReadRequest>();
1152        let read_path = path.to_string_lossy().into_owned();
1153        let owner_tx = tx.clone();
1154        let reader = std::thread::spawn(move || run_reader_thread(read_path, read_rx, owner_tx));
1155
1156        // Model a slow HTTP/WS round on the mutable owner. The dedicated read
1157        // mailbox must still return the durable local snapshot immediately.
1158        let (entered_tx, entered_rx) = channel();
1159        tx.send(Request::Block {
1160            duration: Duration::from_millis(200),
1161            entered: entered_tx,
1162        })
1163        .expect("block owner");
1164        entered_rx.recv().expect("owner entered blocking round");
1165        let (snapshot_tx, snapshot_rx) = channel();
1166        read_tx
1167            .send(ReadRequest::QuerySnapshot {
1168                sql: "SELECT 1 AS value".to_owned(),
1169                params: Vec::new(),
1170                coverage: Vec::new(),
1171                owner: None,
1172                reply: snapshot_tx,
1173            })
1174            .expect("post snapshot");
1175        let snapshot = snapshot_rx
1176            .recv_timeout(Duration::from_millis(50))
1177            .expect("local snapshot must not wait for the owner");
1178        assert_eq!(snapshot["result"]["rows"][0]["value"], 1);
1179
1180        let (failed_tx, failed_rx) = channel();
1181        read_tx
1182            .send(ReadRequest::QuerySnapshot {
1183                sql: "SELECT private_value FROM missing_private_table".to_owned(),
1184                params: Vec::new(),
1185                coverage: Vec::new(),
1186                owner: Some(("queries:missing".to_owned(), vec!["tasks".to_owned()])),
1187                reply: failed_tx,
1188            })
1189            .expect("post failed snapshot");
1190        assert_eq!(
1191            failed_rx.recv().expect("failed snapshot reply")["error"]["code"],
1192            "client.failed"
1193        );
1194        let (diagnostics_tx, diagnostics_rx) = channel();
1195        tx.send(Request::Command {
1196            command: json!({ "method": "diagnosticsSnapshot", "params": {} }),
1197            reply: diagnostics_tx,
1198        })
1199        .expect("post diagnostics");
1200        assert_eq!(
1201            diagnostics_rx.recv().expect("diagnostics reply")["result"]["queryFailures"][0]["id"],
1202            "queries:missing"
1203        );
1204
1205        let (recovered_tx, recovered_rx) = channel();
1206        read_tx
1207            .send(ReadRequest::QuerySnapshot {
1208                sql: "SELECT 1 AS value".to_owned(),
1209                params: Vec::new(),
1210                coverage: Vec::new(),
1211                owner: Some(("queries:missing".to_owned(), vec!["tasks".to_owned()])),
1212                reply: recovered_tx,
1213            })
1214            .expect("post recovered snapshot");
1215        assert!(recovered_rx.recv().expect("recovered snapshot reply")["result"].is_object());
1216        let (cleared_tx, cleared_rx) = channel();
1217        tx.send(Request::Command {
1218            command: json!({ "method": "diagnosticsSnapshot", "params": {} }),
1219            reply: cleared_tx,
1220        })
1221        .expect("post cleared diagnostics");
1222        assert_eq!(
1223            cleared_rx.recv().expect("cleared diagnostics reply")["result"]["queryFailures"],
1224            json!([])
1225        );
1226
1227        read_tx.send(ReadRequest::Shutdown).expect("stop reader");
1228        reader.join().expect("join reader");
1229        tx.send(Request::Shutdown).expect("stop owner");
1230        owner.join().expect("join owner");
1231        std::fs::remove_file(path).expect("remove temp database");
1232    }
1233}