Skip to main content

iris_chat_core/
direct_file_destination.rs

1//! User-selected output streams. The transfer worker, never the UI thread,
2//! calls these methods with bounded chunks and commits only after all hashes pass.
3use crate::DirectFileSnapshot;
4use std::{
5    fmt,
6    fs::{self, File, OpenOptions},
7    io::Write,
8    path::PathBuf,
9    sync::{Arc, Mutex},
10};
11
12#[derive(Debug, Clone, uniffi::Error)]
13pub enum DirectFileDestinationError {
14    Failed { reason: String },
15}
16impl fmt::Display for DirectFileDestinationError {
17    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
18        match self {
19            Self::Failed { reason } => f.write_str(reason),
20        }
21    }
22}
23impl std::error::Error for DirectFileDestinationError {}
24impl From<uniffi::UnexpectedUniFFICallbackError> for DirectFileDestinationError {
25    fn from(_: uniffi::UnexpectedUniFFICallbackError) -> Self {
26        failure("Could not save received files.")
27    }
28}
29
30impl From<std::io::Error> for DirectFileDestinationError {
31    fn from(_: std::io::Error) -> Self {
32        failure("Could not save received files.")
33    }
34}
35fn failure(message: &str) -> DirectFileDestinationError {
36    DirectFileDestinationError::Failed {
37        reason: message.into(),
38    }
39}
40type Result<T> = std::result::Result<T, DirectFileDestinationError>;
41
42/// Each selected destination belongs to one acceptance. `abort` must remove only
43/// newly created partial outputs; it must never delete or truncate existing files.
44/// `prepare` must clean its own partial preparation on error, without disturbing an
45/// already-open destination if the same object is accidentally reused.
46#[uniffi::export(with_foreign)]
47pub trait DirectFileDestination: Send + Sync {
48    fn prepare(&self, transfer_id: String, files: Vec<DirectFileSnapshot>) -> Result<()>;
49    fn write(&self, file_index: u32, bytes: Vec<u8>) -> Result<()>;
50    fn finish_file(&self, file_index: u32) -> Result<()>;
51    fn commit(&self) -> Result<Vec<String>>;
52    fn abort(&self);
53}
54impl fmt::Debug for dyn DirectFileDestination {
55    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56        f.write_str("Selected file destination")
57    }
58}
59
60/// Save directly into a new batch folder within the folder selected by the user.
61/// The exclusive batch folder keeps existing destination files untouched.
62#[uniffi::export]
63pub fn direct_file_directory_destination(directory: String) -> Arc<dyn DirectFileDestination> {
64    Arc::new(DirectoryDestination {
65        directory: PathBuf::from(directory),
66        state: Mutex::new(None),
67    })
68}
69struct DirectoryDestination {
70    directory: PathBuf,
71    state: Mutex<Option<DirectoryState>>,
72}
73struct DirectoryState {
74    directory: PathBuf,
75    partial: Vec<PathBuf>,
76    paths: Vec<PathBuf>,
77    handles: Vec<Option<File>>,
78    committed: bool,
79}
80impl Drop for DirectoryState {
81    fn drop(&mut self) {
82        if self.committed {
83            return;
84        }
85        self.handles.clear();
86        for path in self.partial.iter().chain(&self.paths) {
87            let _ = fs::remove_file(path);
88        }
89        let _ = fs::remove_dir(&self.directory);
90    }
91}
92impl DirectFileDestination for DirectoryDestination {
93    fn prepare(&self, id: String, files: Vec<DirectFileSnapshot>) -> Result<()> {
94        if !crate::direct_files::valid_transfer_id(&id)
95            || files.is_empty()
96            || files.len() > 32
97            || files
98                .iter()
99                .any(|f| !crate::direct_files::valid_filename(&f.filename))
100        {
101            return Err(failure("Invalid file destination."));
102        }
103        let mut slot = self
104            .state
105            .lock()
106            .map_err(|_| failure("File destination is unavailable."))?;
107        if slot.is_some() {
108            return Err(failure("File destination is already in use."));
109        }
110        if !self.directory.is_dir() {
111            return Err(failure("Choose a folder to save these files."));
112        }
113        let directory = self.directory.join(format!("Iris files {id}"));
114        fs::create_dir(&directory)?;
115        let mut state = DirectoryState {
116            directory,
117            partial: vec![],
118            paths: vec![],
119            handles: vec![],
120            committed: false,
121        };
122        for (i, file) in files.iter().enumerate() {
123            let path = state.directory.join(format!("{}.part", i));
124            let handle = OpenOptions::new()
125                .write(true)
126                .create_new(true)
127                .open(&path)?;
128            state.partial.push(path);
129            state
130                .paths
131                .push(state.directory.join(format!("{}-{}", i + 1, file.filename)));
132            state.handles.push(Some(handle));
133        }
134        *slot = Some(state);
135        Ok(())
136    }
137    fn write(&self, index: u32, bytes: Vec<u8>) -> Result<()> {
138        if bytes.len() > 32 * 1024 {
139            return Err(failure("File chunk is too large."));
140        }
141        let mut state = self
142            .state
143            .lock()
144            .map_err(|_| failure("File destination is unavailable."))?;
145        state
146            .as_mut()
147            .and_then(|s| s.handles.get_mut(index as usize))
148            .and_then(Option::as_mut)
149            .ok_or_else(|| failure("File destination is closed."))?
150            .write_all(&bytes)?;
151        Ok(())
152    }
153    fn finish_file(&self, index: u32) -> Result<()> {
154        let mut state = self
155            .state
156            .lock()
157            .map_err(|_| failure("File destination is unavailable."))?;
158        state
159            .as_mut()
160            .and_then(|s| s.handles.get_mut(index as usize))
161            .and_then(Option::take)
162            .ok_or_else(|| failure("File destination is closed."))?
163            .sync_all()?;
164        Ok(())
165    }
166    fn commit(&self) -> Result<Vec<String>> {
167        let mut slot = self
168            .state
169            .lock()
170            .map_err(|_| failure("File destination is unavailable."))?;
171        let state = slot
172            .as_mut()
173            .ok_or_else(|| failure("File destination is closed."))?;
174        if state.committed || state.handles.iter().any(Option::is_some) {
175            return Err(failure("Files are not ready to save."));
176        }
177        for (partial, path) in state.partial.iter().zip(&state.paths) {
178            fs::rename(partial, path)?;
179        }
180        #[cfg(unix)]
181        {
182            File::open(&state.directory)?.sync_all()?;
183            File::open(&self.directory)?.sync_all()?;
184        }
185        state.committed = true;
186        Ok(state
187            .paths
188            .iter()
189            .map(|p| p.to_string_lossy().into_owned())
190            .collect())
191    }
192    fn abort(&self) {
193        if let Ok(mut state) = self.state.lock() {
194            state.take();
195        }
196    }
197}