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/// Multipart/form-data config for an upload. When present, the shell builds a
48/// `multipart/form-data` body: the text `fields` (in order) then the file part LAST.
49#[derive(Serialize)]
50struct Multipart {
51    /// Form field name for the file part.
52    field: String,
53    /// Override the file part's `filename=`; else the shell infers it from the source.
54    #[serde(skip_serializing_if = "Option::is_none")]
55    filename: Option<String>,
56    /// Override the file part's `Content-Type`; else `application/octet-stream`.
57    #[serde(skip_serializing_if = "Option::is_none")]
58    file_content_type: Option<String>,
59    /// Text fields (reuse HttpHeader's name/value).
60    fields: Vec<HttpHeader>,
61}
62
63/// Wire shape of a transfer request, serialized into the stream call's `input`.
64/// Exactly one of `source` (upload) / `dest` (download) is set.
65#[derive(Serialize)]
66struct TransferReq {
67    url: String,
68    #[serde(skip_serializing_if = "Option::is_none")]
69    source: Option<String>,
70    #[serde(skip_serializing_if = "Option::is_none")]
71    dest: Option<String>,
72    #[serde(skip_serializing_if = "Option::is_none")]
73    method: Option<String>,
74    #[serde(skip_serializing_if = "Option::is_none")]
75    multipart: Option<Multipart>,
76    headers: Vec<HttpHeader>,
77}
78
79/// Builds a streaming transfer. Obtained from [`Cx::upload`] / [`Cx::download`];
80/// finished with [`start`](Self::start), which subscribes and returns the key.
81pub struct TransferBuilder<'a, E> {
82    cx: &'a mut Cx<E>,
83    op: &'static str, // "upload" | "download"
84    req: TransferReq,
85    method_set_by_caller: bool,
86}
87
88impl<'a, E> TransferBuilder<'a, E> {
89    pub(crate) fn upload(cx: &'a mut Cx<E>, url: String, source: String) -> Self {
90        Self {
91            cx,
92            op: "upload",
93            req: TransferReq {
94                url,
95                source: Some(source),
96                dest: None,
97                method: Some("PUT".into()),
98                multipart: None,
99                headers: Vec::new(),
100            },
101            method_set_by_caller: false,
102        }
103    }
104
105    pub(crate) fn download(cx: &'a mut Cx<E>, url: String, dest: String) -> Self {
106        Self {
107            cx,
108            op: "download",
109            req: TransferReq {
110                url,
111                source: None,
112                dest: Some(dest),
113                method: None,
114                multipart: None,
115                headers: Vec::new(),
116            },
117            method_set_by_caller: false,
118        }
119    }
120
121    /// Override the upload method (default `PUT`). No effect on download.
122    #[must_use]
123    pub fn method(mut self, m: impl Into<String>) -> Self {
124        if self.op == "upload" {
125            self.req.method = Some(m.into());
126            self.method_set_by_caller = true;
127        }
128        self
129    }
130
131    /// Send as `multipart/form-data`: the file becomes a part named `field`, emitted after
132    /// any text `field()`s. Flips the default method to POST (an explicit `method()` wins).
133    /// Upload-only.
134    #[must_use]
135    pub fn multipart(mut self, field: impl Into<String>) -> Self {
136        if self.op == "upload" {
137            if !self.method_set_by_caller {
138                self.req.method = Some("POST".into());
139            }
140            match &mut self.req.multipart {
141                Some(m) => m.field = field.into(),
142                None => {
143                    self.req.multipart = Some(Multipart {
144                        field: field.into(),
145                        filename: None,
146                        file_content_type: None,
147                        fields: Vec::new(),
148                    })
149                }
150            }
151        }
152        self
153    }
154
155    /// Add a text field to the multipart body (order preserved). No effect unless
156    /// `multipart()` was called; no effect on download.
157    #[must_use]
158    pub fn field(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
159        if let Some(m) = &mut self.req.multipart {
160            m.fields.push(HttpHeader { name: name.into(), value: value.into() });
161        }
162        self
163    }
164
165    /// Override the multipart file part's `filename=` (default: inferred from the source).
166    #[must_use]
167    pub fn filename(mut self, name: impl Into<String>) -> Self {
168        if let Some(m) = &mut self.req.multipart {
169            m.filename = Some(name.into());
170        }
171        self
172    }
173
174    /// Override the multipart file part's `Content-Type` (default: `application/octet-stream`).
175    ///
176    /// Honored on iOS and Android. On the **web** shell the browser derives the part's
177    /// content type from the `Blob`'s own MIME type, so this override is native-only in v1.
178    #[must_use]
179    pub fn file_content_type(mut self, ct: impl Into<String>) -> Self {
180        if let Some(m) = &mut self.req.multipart {
181            m.file_content_type = Some(ct.into());
182        }
183        self
184    }
185
186    /// Add a request header (order preserved, repeats allowed).
187    #[must_use]
188    pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
189        self.req.headers.push(HttpHeader { name: name.into(), value: value.into() });
190        self
191    }
192
193    /// Sugar for `header("Authorization", format!("Bearer {token}"))`.
194    #[must_use]
195    pub fn bearer(self, token: impl AsRef<str>) -> Self {
196        self.header("Authorization", format!("Bearer {}", token.as_ref()))
197    }
198
199    /// Subscribe under `key`. `on_event` fires per progress tick and once at `Done`.
200    /// Returns `key` so the caller can [`cx.unsubscribe(key)`](crate::Cx::unsubscribe).
201    pub fn start(self, key: impl Into<String>, on_event: impl Fn(TransferEvent) -> E + Send + 'static) -> String {
202        let key = key.into();
203        let input = serde_json::to_string(&self.req).expect("serialize transfer request");
204        self.cx.subscribe(key.clone(), "transfer", self.op, input, move |r: PluginResponse| {
205            on_event(decode_event(&r))
206        });
207        key
208    }
209}
210
211/// Decode a stream payload. A shell that emits something undecodable is a bug, but it
212/// must not panic the app — surface it as a completed transport error.
213fn decode_event(r: &PluginResponse) -> TransferEvent {
214    TransferEvent::decode(&r.output).unwrap_or_else(|e| TransferEvent::Done {
215        outcome: HttpOutcome::TransportError { message: format!("malformed transfer event: {e}") },
216        handle: None,
217    })
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use crate::http::{HttpHeader, HttpOutcome};
224    use serde::Serialize;
225
226    #[derive(Serialize)]
227    enum Ev {
228        Tap,
229    }
230
231    #[test]
232    fn round_trips_progress_with_and_without_total() {
233        for ev in [
234            TransferEvent::Progress { transferred: 0, total: Some(1024) },
235            TransferEvent::Progress { transferred: 999_999_999, total: None },
236        ] {
237            assert_eq!(TransferEvent::decode(&ev.encode()).unwrap(), ev);
238        }
239    }
240
241    #[test]
242    fn round_trips_done_upload_and_download() {
243        let up = TransferEvent::Done {
244            outcome: HttpOutcome::Response { status: 200, headers: vec![], body: vec![] },
245            handle: None,
246        };
247        let down = TransferEvent::Done {
248            outcome: HttpOutcome::Response {
249                status: 200,
250                headers: vec![HttpHeader { name: "Content-Length".into(), value: "5".into() }],
251                body: vec![],
252            },
253            handle: Some("blob:abc".into()),
254        };
255        for ev in [up, down] {
256            assert_eq!(TransferEvent::decode(&ev.encode()).unwrap(), ev);
257        }
258    }
259
260    #[test]
261    fn decode_rejects_garbage_without_panicking() {
262        assert!(TransferEvent::decode(&[0xff, 0xff, 0xff]).is_err());
263    }
264
265    #[test]
266    fn multipart_sets_config_and_flips_method_to_post() {
267        let mut cx = Cx::<Ev>::default();
268        cx.upload("https://h/up", "file:///tmp/a.jpg")
269            .multipart("file")
270            .field("title", "My Photo")
271            .field("album", "vac")
272            .start("m-1", |_| Ev::Tap);
273
274        let (call, _) = &cx.streams[0];
275        let v: serde_json::Value = serde_json::from_str(&call.input).unwrap();
276        assert_eq!(v["method"], "POST", "multipart flips default PUT -> POST");
277        assert_eq!(v["multipart"]["field"], "file");
278        assert_eq!(v["multipart"]["fields"][0]["name"], "title");
279        assert_eq!(v["multipart"]["fields"][0]["value"], "My Photo");
280        assert_eq!(v["multipart"]["fields"][1]["name"], "album");
281        // filename / file_content_type omitted when not overridden
282        assert!(v["multipart"].get("filename").is_none());
283        assert!(v["multipart"].get("file_content_type").is_none());
284    }
285
286    #[test]
287    fn explicit_method_survives_multipart() {
288        let mut cx = Cx::<Ev>::default();
289        cx.upload("https://h/up", "file:///tmp/a.jpg")
290            .method("PUT")
291            .multipart("file")
292            .start("m-2", |_| Ev::Tap);
293        let (call, _) = &cx.streams[0];
294        let v: serde_json::Value = serde_json::from_str(&call.input).unwrap();
295        assert_eq!(v["method"], "PUT", "an explicit .method() is not overridden by .multipart()");
296    }
297
298    #[test]
299    fn multipart_overrides_land_in_config() {
300        let mut cx = Cx::<Ev>::default();
301        cx.upload("https://h/up", "file:///tmp/a.bin")
302            .multipart("f")
303            .filename("photo.jpg")
304            .file_content_type("image/jpeg")
305            .start("m-3", |_| Ev::Tap);
306        let (call, _) = &cx.streams[0];
307        let v: serde_json::Value = serde_json::from_str(&call.input).unwrap();
308        assert_eq!(v["multipart"]["filename"], "photo.jpg");
309        assert_eq!(v["multipart"]["file_content_type"], "image/jpeg");
310    }
311
312    #[test]
313    fn multipart_is_a_noop_on_download() {
314        let mut cx = Cx::<Ev>::default();
315        cx.download("https://h/get", "/d").multipart("f").field("k", "v").start("d-1", |_| Ev::Tap);
316        let (call, _) = &cx.streams[0];
317        let v: serde_json::Value = serde_json::from_str(&call.input).unwrap();
318        assert!(v.get("multipart").is_none(), "download ignores multipart");
319        assert!(v.get("method").is_none(), "download has no method");
320    }
321}