1use std::sync::Mutex;
18
19use syncular_client::{BlobDownload, BlobUploadGrant, SegmentRequest, Transport, TransportError};
20
21pub enum Inbound {
23 Text(String),
24 Binary(Vec<u8>),
25}
26
27#[derive(Default)]
30pub struct InboundBuffer {
31 frames: Mutex<Vec<Inbound>>,
32}
33
34impl InboundBuffer {
35 pub fn push(&self, frame: Inbound) {
36 self.frames.lock().expect("inbound lock").push(frame);
37 }
38 fn take(&self) -> Vec<Inbound> {
39 std::mem::take(&mut *self.frames.lock().expect("inbound lock"))
40 }
41}
42
43pub enum HostTransport {
44 Null {
46 signed_urls: bool,
47 inbound: std::sync::Arc<InboundBuffer>,
48 },
49 #[cfg(feature = "native-transport")]
50 Native(native::NativeTransport),
51}
52
53impl HostTransport {
54 pub fn from_config(config: &serde_json::Value) -> Result<Self, String> {
57 #[cfg(feature = "native-transport")]
58 {
59 if let Some(base_url) = config.get("baseUrl").and_then(|v| v.as_str()) {
60 return Ok(HostTransport::Native(native::NativeTransport::new(
61 base_url, config,
62 )?));
63 }
64 }
65 #[cfg(not(feature = "native-transport"))]
66 {
67 if config.get("baseUrl").is_some() {
68 return Err("this build has no native transport (enable the \
69 native-transport feature on tauri-plugin-syncular)"
70 .to_owned());
71 }
72 }
73 Ok(HostTransport::Null {
74 signed_urls: false,
75 inbound: std::sync::Arc::new(InboundBuffer::default()),
76 })
77 }
78
79 pub fn set_signed_urls(&mut self, value: bool) {
80 match self {
81 HostTransport::Null { signed_urls, .. } => *signed_urls = value,
82 #[cfg(feature = "native-transport")]
83 HostTransport::Native(t) => t.signed_urls = value,
84 }
85 }
86
87 pub fn set_headers(&mut self, headers: Vec<(String, String)>) {
93 match self {
94 HostTransport::Null { .. } => drop(headers),
95 #[cfg(feature = "native-transport")]
96 HostTransport::Native(t) => t.set_headers(headers),
97 }
98 }
99
100 pub fn take_inbound(&mut self) -> Vec<Inbound> {
102 match self {
103 HostTransport::Null { inbound, .. } => inbound.take(),
104 #[cfg(feature = "native-transport")]
105 HostTransport::Native(t) => t.inbound.take(),
106 }
107 }
108
109 pub fn shutdown(&mut self) {
111 match self {
112 HostTransport::Null { .. } => {}
113 #[cfg(feature = "native-transport")]
114 HostTransport::Native(t) => t.shutdown(),
115 }
116 }
117}
118
119fn unavailable(op: &str) -> TransportError {
120 TransportError::new(
121 "transport.unavailable",
122 format!("{op} needs the native transport (enable the native-transport feature)"),
123 )
124}
125
126#[cfg_attr(not(feature = "native-transport"), allow(unused_variables))]
127impl Transport for HostTransport {
128 fn sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
129 match self {
130 HostTransport::Null { .. } => Err(unavailable("sync")),
131 #[cfg(feature = "native-transport")]
132 HostTransport::Native(t) => t.sync(request),
133 }
134 }
135
136 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
137 match self {
138 HostTransport::Null { .. } => Err(unavailable("realtimeSync")),
139 #[cfg(feature = "native-transport")]
140 HostTransport::Native(t) => t.realtime_sync(request),
141 }
142 }
143
144 fn download_segment(&mut self, request: &SegmentRequest) -> Result<Vec<u8>, TransportError> {
145 match self {
146 HostTransport::Null { .. } => Err(unavailable("downloadSegment")),
147 #[cfg(feature = "native-transport")]
148 HostTransport::Native(t) => t.download_segment(request),
149 }
150 }
151
152 fn supports_url_fetch(&self) -> bool {
153 match self {
154 HostTransport::Null { signed_urls, .. } => *signed_urls,
155 #[cfg(feature = "native-transport")]
156 HostTransport::Native(t) => t.signed_urls,
157 }
158 }
159
160 fn fetch_url(&mut self, url: &str) -> Result<Vec<u8>, TransportError> {
161 match self {
162 HostTransport::Null { .. } => Err(unavailable("fetchUrl")),
163 #[cfg(feature = "native-transport")]
164 HostTransport::Native(t) => t.fetch_url(url),
165 }
166 }
167
168 fn blob_upload(
169 &mut self,
170 blob_id: &str,
171 bytes: &[u8],
172 media_type: Option<&str>,
173 ) -> Result<(), TransportError> {
174 match self {
175 HostTransport::Null { .. } => Err(unavailable("blobUpload")),
176 #[cfg(feature = "native-transport")]
177 HostTransport::Native(t) => t.blob_upload(blob_id, bytes, media_type),
178 }
179 }
180
181 fn blob_download(&mut self, blob_id: &str) -> Result<BlobDownload, TransportError> {
182 match self {
183 HostTransport::Null { .. } => Err(unavailable("blobDownload")),
184 #[cfg(feature = "native-transport")]
185 HostTransport::Native(t) => t.blob_download(blob_id),
186 }
187 }
188
189 fn fetch_blob_url(&mut self, url: &str) -> Result<Vec<u8>, TransportError> {
190 match self {
191 HostTransport::Null { .. } => Err(unavailable("fetchBlobUrl")),
192 #[cfg(feature = "native-transport")]
193 HostTransport::Native(t) => t.fetch_blob_url(url),
194 }
195 }
196
197 fn blob_upload_grant(
198 &mut self,
199 blob_id: &str,
200 byte_length: u64,
201 media_type: Option<&str>,
202 ) -> Result<BlobUploadGrant, TransportError> {
203 match self {
204 HostTransport::Null { .. } => Ok(BlobUploadGrant::None),
205 #[cfg(feature = "native-transport")]
206 HostTransport::Native(t) => t.blob_upload_grant(blob_id, byte_length, media_type),
207 }
208 }
209
210 fn realtime_connect(&mut self) -> Result<(), TransportError> {
211 match self {
212 HostTransport::Null { .. } => Err(unavailable("realtimeConnect")),
213 #[cfg(feature = "native-transport")]
214 HostTransport::Native(t) => t.realtime_connect(),
215 }
216 }
217
218 fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
219 match self {
220 HostTransport::Null { .. } => Err(unavailable("realtimeSend")),
221 #[cfg(feature = "native-transport")]
222 HostTransport::Native(t) => t.realtime_send(text),
223 }
224 }
225
226 fn realtime_close(&mut self) -> Result<(), TransportError> {
227 match self {
228 HostTransport::Null { .. } => Ok(()),
229 #[cfg(feature = "native-transport")]
230 HostTransport::Native(t) => t.realtime_close(),
231 }
232 }
233}
234
235#[cfg(feature = "native-transport")]
236mod native {
237 use std::io::Read;
242 use std::net::TcpStream;
243 use std::sync::atomic::{AtomicBool, Ordering};
244 use std::sync::{Arc, Condvar, Mutex};
245 use std::thread::JoinHandle;
246 use std::time::Duration;
247
248 use tungstenite::stream::MaybeTlsStream;
249 use tungstenite::{Message, WebSocket};
250
251 use super::{Inbound, InboundBuffer};
252 use syncular_client::{
253 BlobDownload, RealtimeRound, RoundInbound, SegmentRequest, Transport, TransportError,
254 };
255
256 type Ws = WebSocket<MaybeTlsStream<TcpStream>>;
257
258 const ROUND_TIMEOUT: Duration = Duration::from_secs(30);
265 const READ_TIMEOUT: Duration = Duration::from_millis(50);
266
267 #[derive(Default)]
270 pub(super) struct RoundChannel {
271 state: Mutex<RoundState>,
272 ready: Condvar,
273 }
274
275 #[derive(Default)]
276 struct RoundState {
277 round: RealtimeRound,
278 outcome: Option<Result<Vec<u8>, TransportError>>,
279 }
280
281 impl RoundChannel {
282 fn begin(&self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
283 let mut state = self.state.lock().expect("round lock");
284 state.outcome = None;
285 state.round.begin(request)
286 }
287
288 fn route_binary(&self, frame: &[u8]) -> Option<Vec<u8>> {
289 let mut state = self.state.lock().expect("round lock");
290 match state.round.route_binary(frame) {
291 Ok(RoundInbound::Delta(body)) => Some(body),
292 Ok(RoundInbound::RoundProgress) | Ok(RoundInbound::Ignored) => None,
293 Ok(RoundInbound::RoundComplete(bytes)) => {
294 state.outcome = Some(Ok(bytes));
295 self.ready.notify_all();
296 None
297 }
298 Err(error) => {
299 state.outcome = Some(Err(error));
300 self.ready.notify_all();
301 None
302 }
303 }
304 }
305
306 fn fail_in_flight(&self, error: TransportError) {
307 let mut state = self.state.lock().expect("round lock");
308 if state.round.in_flight() && state.outcome.is_none() {
309 state.round.abort();
310 state.outcome = Some(Err(error));
311 self.ready.notify_all();
312 }
313 }
314
315 fn wait(&self) -> Result<Vec<u8>, TransportError> {
316 let mut state = self.state.lock().expect("round lock");
317 let deadline = std::time::Instant::now() + ROUND_TIMEOUT;
318 while state.outcome.is_none() {
319 let now = std::time::Instant::now();
320 if now >= deadline {
321 state.round.abort();
322 return Err(TransportError::new(
323 "sync.transport_failed",
324 "realtime sync round timed out (§8.7)",
325 ));
326 }
327 let (guard, _timeout) = self
328 .ready
329 .wait_timeout(state, deadline - now)
330 .expect("round wait");
331 state = guard;
332 }
333 state.outcome.take().expect("outcome present")
334 }
335 }
336
337 fn is_would_block(e: &tungstenite::Error) -> bool {
338 matches!(
339 e,
340 tungstenite::Error::Io(io) if matches!(
341 io.kind(),
342 std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
343 )
344 )
345 }
346
347 fn set_read_timeout(ws: &mut Ws, timeout: Option<Duration>) {
348 match ws.get_mut() {
349 MaybeTlsStream::Plain(s) => {
350 let _ = s.set_read_timeout(timeout);
351 }
352 MaybeTlsStream::Rustls(s) => {
353 let _ = s.get_ref().set_read_timeout(timeout);
354 }
355 _ => {}
356 }
357 }
358
359 pub struct NativeTransport {
360 base_url: String,
361 ws_url: String,
362 headers: Vec<(String, String)>,
363 agent: ureq::Agent,
364 pub signed_urls: bool,
365 pub inbound: Arc<InboundBuffer>,
366 socket: Option<Arc<Mutex<Ws>>>,
367 reader: Option<JoinHandle<()>>,
368 reader_stop: Arc<AtomicBool>,
369 round: Arc<RoundChannel>,
370 }
371
372 fn http_err(op: &str, e: impl std::fmt::Display) -> TransportError {
373 TransportError::new("transport.failed", format!("{op}: {e}"))
374 }
375
376 fn derive_ws_url(base_url: &str) -> String {
377 let ws = if let Some(rest) = base_url.strip_prefix("https://") {
378 format!("wss://{rest}")
379 } else if let Some(rest) = base_url.strip_prefix("http://") {
380 format!("ws://{rest}")
381 } else {
382 base_url.to_owned()
383 };
384 let trimmed = ws.trim_end_matches('/');
385 format!("{trimmed}/realtime")
386 }
387
388 impl NativeTransport {
389 pub fn new(base_url: &str, config: &serde_json::Value) -> Result<Self, String> {
390 let mut headers = Vec::new();
391 if let Some(map) = config.get("headers").and_then(|v| v.as_object()) {
392 for (k, v) in map {
393 if let Some(s) = v.as_str() {
394 headers.push((k.clone(), s.to_owned()));
395 }
396 }
397 }
398 let ws_url = config
399 .get("wsUrl")
400 .and_then(|v| v.as_str())
401 .map(str::to_owned)
402 .unwrap_or_else(|| derive_ws_url(base_url));
403 Ok(NativeTransport {
404 base_url: base_url.trim_end_matches('/').to_owned(),
405 ws_url,
406 headers,
407 agent: ureq::AgentBuilder::new().build(),
408 signed_urls: false,
409 inbound: Arc::new(InboundBuffer::default()),
410 socket: None,
411 reader: None,
412 reader_stop: Arc::new(AtomicBool::new(false)),
413 round: Arc::new(RoundChannel::default()),
414 })
415 }
416
417 fn post_sync(&self, path: &str, body: &[u8]) -> Result<Vec<u8>, TransportError> {
418 let url = format!("{}{}", self.base_url, path);
419 let mut req = self
422 .agent
423 .post(&url)
424 .set("content-type", "application/vnd.syncular.sync.v2");
425 for (k, v) in &self.headers {
426 req = req.set(k, v);
427 }
428 let resp = req.send_bytes(body).map_err(|e| http_err("POST", e))?;
429 read_body(resp)
430 }
431
432 fn get_bytes(&self, url: &str, with_headers: bool) -> Result<Vec<u8>, TransportError> {
433 let mut req = self.agent.get(url);
434 if with_headers {
435 for (k, v) in &self.headers {
436 req = req.set(k, v);
437 }
438 }
439 let resp = req.call().map_err(|e| http_err("GET", e))?;
440 read_body(resp)
441 }
442
443 pub fn set_headers(&mut self, headers: Vec<(String, String)>) {
445 self.headers = headers;
446 }
447
448 pub fn shutdown(&mut self) {
449 self.reader_stop.store(true, Ordering::SeqCst);
450 self.round.fail_in_flight(TransportError::new(
451 "sync.transport_failed",
452 "realtime disconnected mid-round (§8.7)",
453 ));
454 if let Some(socket) = &self.socket {
455 if let Ok(mut ws) = socket.lock() {
456 let _ = ws.close(None);
457 let _ = ws.flush();
458 }
459 }
460 if let Some(handle) = self.reader.take() {
461 let _ = handle.join();
462 }
463 self.socket = None;
464 }
465 }
466
467 fn read_body(resp: ureq::Response) -> Result<Vec<u8>, TransportError> {
468 let mut buf = Vec::new();
469 resp.into_reader()
470 .read_to_end(&mut buf)
471 .map_err(|e| http_err("read", e))?;
472 Ok(buf)
473 }
474
475 impl Transport for NativeTransport {
476 fn sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
477 self.post_sync("/sync", request)
478 }
479
480 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
481 let Some(socket) = self.socket.clone() else {
486 return self.post_sync("/sync", request);
487 };
488 let framed = self.round.begin(request)?;
489 let send = {
490 let mut ws = socket
491 .lock()
492 .map_err(|_| TransportError::new("transport.failed", "ws lock poisoned"))?;
493 ws.send(Message::Binary(framed))
494 .map_err(|e| http_err("ws round send", &e))
495 .and_then(|()| ws.flush().map_err(|e| http_err("ws round flush", &e)))
496 };
497 if let Err(e) = send {
498 self.round.fail_in_flight(e);
499 }
500 self.round.wait()
501 }
502
503 fn download_segment(
504 &mut self,
505 request: &SegmentRequest,
506 ) -> Result<Vec<u8>, TransportError> {
507 let id = request
508 .segment_id
509 .strip_prefix("sha256:")
510 .unwrap_or(&request.segment_id);
511 let url = format!("{}/segments/{}", self.base_url, id);
512 self.get_bytes(&url, true)
513 }
514
515 fn supports_url_fetch(&self) -> bool {
516 self.signed_urls
517 }
518
519 fn fetch_url(&mut self, url: &str) -> Result<Vec<u8>, TransportError> {
520 self.get_bytes(url, false)
521 }
522
523 fn blob_upload(
524 &mut self,
525 blob_id: &str,
526 bytes: &[u8],
527 media_type: Option<&str>,
528 ) -> Result<(), TransportError> {
529 let id = blob_id.strip_prefix("sha256:").unwrap_or(blob_id);
530 let url = format!("{}/blobs/{}", self.base_url, id);
531 let mut req = self.agent.put(&url).set(
532 "content-type",
533 media_type.unwrap_or("application/octet-stream"),
534 );
535 for (k, v) in &self.headers {
536 req = req.set(k, v);
537 }
538 req.send_bytes(bytes).map_err(|e| http_err("PUT blob", e))?;
539 Ok(())
540 }
541
542 fn blob_download(&mut self, blob_id: &str) -> Result<BlobDownload, TransportError> {
543 let id = blob_id.strip_prefix("sha256:").unwrap_or(blob_id);
544 let url = format!("{}/blobs/{}", self.base_url, id);
545 let mut req = self.agent.get(&url);
546 for (k, v) in &self.headers {
547 req = req.set(k, v);
548 }
549 let resp = req.call().map_err(|e| {
550 let e = http_err("GET blob", e);
551 if e.code == "transport.failed" {
552 TransportError::new("blob.not_found", e.message)
553 } else {
554 e
555 }
556 })?;
557 let is_json = resp
560 .header("content-type")
561 .is_some_and(|ct| ct.contains("application/json"));
562 let body = read_body(resp)?;
563 if is_json {
564 if let Ok(parsed) = serde_json::from_slice::<serde_json::Value>(&body) {
565 if let Some(u) = parsed.get("url").and_then(|v| v.as_str()) {
566 return Ok(BlobDownload::Url {
567 url: u.to_owned(),
568 url_expires_at_ms: parsed
569 .get("urlExpiresAtMs")
570 .and_then(|v| v.as_i64()),
571 });
572 }
573 }
574 }
575 Ok(BlobDownload::Bytes(body))
576 }
577
578 fn fetch_blob_url(&mut self, url: &str) -> Result<Vec<u8>, TransportError> {
579 self.get_bytes(url, false)
581 }
582
583 fn realtime_connect(&mut self) -> Result<(), TransportError> {
584 if self.socket.is_some() {
585 return Ok(());
586 }
587 use tungstenite::client::IntoClientRequest;
591 let mut request = self
592 .ws_url
593 .as_str()
594 .into_client_request()
595 .map_err(|e| TransportError::new("transport.failed", format!("ws url: {e}")))?;
596 {
597 let out = request.headers_mut();
598 for (k, v) in &self.headers {
599 if let (Ok(name), Ok(value)) = (
600 tungstenite::http::HeaderName::try_from(k.as_str()),
601 tungstenite::http::HeaderValue::try_from(v.as_str()),
602 ) {
603 out.insert(name, value);
604 }
605 }
606 }
607 let (mut ws, _resp) = tungstenite::connect(request)
608 .map_err(|e| TransportError::new("transport.failed", format!("ws connect: {e}")))?;
609 set_read_timeout(&mut ws, Some(READ_TIMEOUT));
610 let socket = Arc::new(Mutex::new(ws));
611 self.socket = Some(Arc::clone(&socket));
612 self.reader_stop.store(false, Ordering::SeqCst);
613 let inbound = Arc::clone(&self.inbound);
614 let stop = Arc::clone(&self.reader_stop);
615 let reader_socket = Arc::clone(&socket);
616 let round = Arc::clone(&self.round);
617 self.reader = Some(std::thread::spawn(move || loop {
618 if stop.load(Ordering::SeqCst) {
619 break;
620 }
621 let msg = {
622 let mut ws = match reader_socket.lock() {
623 Ok(ws) => ws,
624 Err(_) => break,
625 };
626 ws.read()
627 };
628 match msg {
629 Ok(Message::Text(text)) => inbound.push(Inbound::Text(text)),
630 Ok(Message::Binary(bytes)) => {
631 if let Some(delta) = round.route_binary(&bytes) {
635 inbound.push(Inbound::Binary(delta));
636 }
637 }
638 Err(e) if is_would_block(&e) => continue,
639 Ok(Message::Close(_)) | Err(_) => {
640 round.fail_in_flight(TransportError::new(
641 "sync.transport_failed",
642 "realtime disconnected mid-round (§8.7)",
643 ));
644 break;
645 }
646 Ok(_) => {}
647 }
648 }));
649 Ok(())
650 }
651
652 fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
653 let Some(socket) = &self.socket else {
654 return Err(TransportError::new(
655 "transport.failed",
656 "realtime not connected",
657 ));
658 };
659 let mut ws = socket
660 .lock()
661 .map_err(|_| TransportError::new("transport.failed", "ws lock poisoned"))?;
662 ws.send(Message::Text(text.to_owned()))
663 .map_err(|e| http_err("ws send", e))?;
664 ws.flush().map_err(|e| http_err("ws flush", e))?;
665 Ok(())
666 }
667
668 fn realtime_close(&mut self) -> Result<(), TransportError> {
669 self.shutdown();
670 Ok(())
671 }
672 }
673}