Skip to main content

mobiler_core/
transfer.rs

1//! Streaming file transfers (`cx.upload` / `cx.download`).
2//!
3//! Large transfers move by string **handle** (a filesystem path, a `content://` /
4//! `file://` URI, or a web `blob:` URL) — the bytes never cross the FFI. Each transfer
5//! rides the streaming primitive ([`Cx::subscribe`](crate::Cx::subscribe)) and delivers
6//! a [`TransferEvent`] per progress tick and once at completion.
7
8use facet::Facet;
9use serde::{Deserialize, Serialize};
10
11use crate::http::HttpOutcome;
12
13/// One event from an in-flight transfer, bincoded into the stream's
14/// `PluginResponse.output` (the same pattern Release A uses for [`HttpOutcome`]).
15#[derive(Facet, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
16#[repr(C)]
17pub enum TransferEvent {
18    /// A progress tick. `total` is `None` when the size is unknown (chunked response).
19    Progress { transferred: u64, total: Option<u64> },
20    /// The transfer finished. `outcome` is Release A's request result; for a download
21    /// its `body` is empty (bytes went to disk) and `handle` is the destination the
22    /// shell wrote (sandbox path on native, `blob:` URL on web). `handle` is `None` for
23    /// an upload.
24    Done { outcome: HttpOutcome, handle: Option<String> },
25}
26
27impl TransferEvent {
28    /// Serialize for the stream payload. Uses crux's FFI format, not bincode directly —
29    /// see [`HttpOutcome::encode`](crate::http::HttpOutcome::encode) for why the version
30    /// and config matter.
31    pub fn encode(&self) -> Vec<u8> {
32        use crux_core::bridge::{BincodeFfiFormat, FfiFormat};
33        let mut buffer = Vec::new();
34        BincodeFfiFormat::serialize(&mut buffer, self).expect("encode TransferEvent");
35        buffer
36    }
37
38    /// Decode a stream payload produced by a shell's `transfer` plugin.
39    pub fn decode(bytes: &[u8]) -> Result<Self, String> {
40        use crux_core::bridge::{BincodeFfiFormat, FfiFormat};
41        BincodeFfiFormat::deserialize(bytes).map_err(|e| e.to_string())
42    }
43}
44
45use crate::{Cx, HttpHeader, PluginResponse};
46
47/// Wire shape of a transfer request, serialized into the stream call's `input`.
48/// Exactly one of `source` (upload) / `dest` (download) is set.
49#[derive(Serialize)]
50struct TransferReq {
51    url: String,
52    #[serde(skip_serializing_if = "Option::is_none")]
53    source: Option<String>,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    dest: Option<String>,
56    #[serde(skip_serializing_if = "Option::is_none")]
57    method: Option<String>,
58    headers: Vec<HttpHeader>,
59}
60
61/// Builds a streaming transfer. Obtained from [`Cx::upload`] / [`Cx::download`];
62/// finished with [`start`](Self::start), which subscribes and returns the key.
63pub struct TransferBuilder<'a, E> {
64    cx: &'a mut Cx<E>,
65    op: &'static str, // "upload" | "download"
66    req: TransferReq,
67}
68
69impl<'a, E> TransferBuilder<'a, E> {
70    pub(crate) fn upload(cx: &'a mut Cx<E>, url: String, source: String) -> Self {
71        Self {
72            cx,
73            op: "upload",
74            req: TransferReq { url, source: Some(source), dest: None, method: Some("PUT".into()), headers: Vec::new() },
75        }
76    }
77
78    pub(crate) fn download(cx: &'a mut Cx<E>, url: String, dest: String) -> Self {
79        Self {
80            cx,
81            op: "download",
82            req: TransferReq { url, source: None, dest: Some(dest), method: None, headers: Vec::new() },
83        }
84    }
85
86    /// Override the upload method (default `PUT`). No effect on download.
87    #[must_use]
88    pub fn method(mut self, m: impl Into<String>) -> Self {
89        if self.op == "upload" {
90            self.req.method = Some(m.into());
91        }
92        self
93    }
94
95    /// Add a request header (order preserved, repeats allowed).
96    #[must_use]
97    pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
98        self.req.headers.push(HttpHeader { name: name.into(), value: value.into() });
99        self
100    }
101
102    /// Sugar for `header("Authorization", format!("Bearer {token}"))`.
103    #[must_use]
104    pub fn bearer(self, token: impl AsRef<str>) -> Self {
105        self.header("Authorization", format!("Bearer {}", token.as_ref()))
106    }
107
108    /// Subscribe under `key`. `on_event` fires per progress tick and once at `Done`.
109    /// Returns `key` so the caller can [`cx.unsubscribe(key)`](crate::Cx::unsubscribe).
110    pub fn start(self, key: impl Into<String>, on_event: impl Fn(TransferEvent) -> E + Send + 'static) -> String {
111        let key = key.into();
112        let input = serde_json::to_string(&self.req).expect("serialize transfer request");
113        self.cx.subscribe(key.clone(), "transfer", self.op, input, move |r: PluginResponse| {
114            on_event(decode_event(&r))
115        });
116        key
117    }
118}
119
120/// Decode a stream payload. A shell that emits something undecodable is a bug, but it
121/// must not panic the app — surface it as a completed transport error.
122fn decode_event(r: &PluginResponse) -> TransferEvent {
123    TransferEvent::decode(&r.output).unwrap_or_else(|e| TransferEvent::Done {
124        outcome: HttpOutcome::TransportError { message: format!("malformed transfer event: {e}") },
125        handle: None,
126    })
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use crate::http::{HttpHeader, HttpOutcome};
133
134    #[test]
135    fn round_trips_progress_with_and_without_total() {
136        for ev in [
137            TransferEvent::Progress { transferred: 0, total: Some(1024) },
138            TransferEvent::Progress { transferred: 999_999_999, total: None },
139        ] {
140            assert_eq!(TransferEvent::decode(&ev.encode()).unwrap(), ev);
141        }
142    }
143
144    #[test]
145    fn round_trips_done_upload_and_download() {
146        let up = TransferEvent::Done {
147            outcome: HttpOutcome::Response { status: 200, headers: vec![], body: vec![] },
148            handle: None,
149        };
150        let down = TransferEvent::Done {
151            outcome: HttpOutcome::Response {
152                status: 200,
153                headers: vec![HttpHeader { name: "Content-Length".into(), value: "5".into() }],
154                body: vec![],
155            },
156            handle: Some("blob:abc".into()),
157        };
158        for ev in [up, down] {
159            assert_eq!(TransferEvent::decode(&ev.encode()).unwrap(), ev);
160        }
161    }
162
163    #[test]
164    fn decode_rejects_garbage_without_panicking() {
165        assert!(TransferEvent::decode(&[0xff, 0xff, 0xff]).is_err());
166    }
167}