@react-native-gpui/fs 0.0.1-alpha.0 → 0.0.1-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -0
- package/README.md +2 -2
- package/desktop/Cargo.toml +4 -0
- package/desktop/src/download.rs +329 -0
- package/desktop/src/lib.rs +28 -0
- package/desktop/src/module.rs +5 -0
- package/desktop/src/upload.rs +237 -0
- package/package.json +5 -4
- package/src/NativeFs.ts +73 -0
- package/src/download.ts +110 -0
- package/src/index.ts +20 -1
- package/src/upload.ts +97 -0
- package/desktop/Cargo.lock +0 -1989
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@react-native-gpui/fs` are listed here. Each version's section becomes the notes of its GitHub release.
|
|
4
4
|
|
|
5
|
+
## 0.0.1-alpha.1 - 2026-11-12
|
|
6
|
+
|
|
7
|
+
- Add `upload(url, path, options)`, which streams a file, or a range of one, as an HTTP request body in small blocks, reports progress, honours an `AbortSignal` and resolves with the response status, headers and the start of its body.
|
|
8
|
+
- Add `download(url, path, options)`, which streams an HTTP response to a file through `<path>.part`, reports progress, honours an `AbortSignal` and rejects on a non-2xx status.
|
|
9
|
+
|
|
5
10
|
## 0.0.1-alpha.0 - 2026-11-01
|
|
6
11
|
|
|
7
12
|
- First release.
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @react-native-gpui/fs
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Our own module: a file system API for desktop apps, shaped like Node's `node:fs`, with file dialogs and the operating system's well-known folders.
|
|
4
4
|
|
|
5
5
|
Part of [react-native-gpui](https://github.com/streetwriters/react-native-gpui), React Native for macOS, Windows and Linux.
|
|
6
6
|
|
|
@@ -30,7 +30,7 @@ The default export has the synchronous, callback and promise forms of the common
|
|
|
30
30
|
|
|
31
31
|
## Documentation
|
|
32
32
|
|
|
33
|
-
[File system](
|
|
33
|
+
[File system](docs/file-system.md)
|
|
34
34
|
|
|
35
35
|
## License
|
|
36
36
|
|
package/desktop/Cargo.toml
CHANGED
|
@@ -20,6 +20,10 @@ tempfile = "3"
|
|
|
20
20
|
libc = "0.2"
|
|
21
21
|
# `realpath` without the `\\?\` prefix on Windows.
|
|
22
22
|
dunce = "1"
|
|
23
|
+
# Streams a download; rustls with the system certificate store, as the upload module does.
|
|
24
|
+
ureq = { version = "3", default-features = false, features = ["rustls", "platform-verifier"] }
|
|
25
|
+
# Hands a download's result back to the async method.
|
|
26
|
+
futures = "0.3"
|
|
23
27
|
|
|
24
28
|
[build-dependencies]
|
|
25
29
|
rn-gpui-module-build = { version = "0.1" }
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
//! `download`: an HTTP body streamed to a file.
|
|
2
|
+
//!
|
|
3
|
+
//! A transfer is two threads. The network thread makes the request and reads the
|
|
4
|
+
//! body in chunks; the coordinator receives them, writes `<path>.part` and renames
|
|
5
|
+
//! it onto `path` when the body is complete. Splitting them is what makes a cancel
|
|
6
|
+
//! prompt: the coordinator polls the cancel flag between messages, so it does not
|
|
7
|
+
//! wait on a socket that has gone quiet, and the network thread simply stops the
|
|
8
|
+
//! next time it tries to hand over a chunk.
|
|
9
|
+
//!
|
|
10
|
+
//! The module's own thread is only waited on through a channel, so a slow download
|
|
11
|
+
//! never blocks other file calls.
|
|
12
|
+
|
|
13
|
+
use std::collections::HashMap;
|
|
14
|
+
use std::fs::File;
|
|
15
|
+
use std::io::{Read, Write};
|
|
16
|
+
use std::path::PathBuf;
|
|
17
|
+
use std::sync::atomic::{AtomicBool, Ordering};
|
|
18
|
+
use std::sync::mpsc::{self, RecvTimeoutError, SyncSender};
|
|
19
|
+
use std::sync::{Arc, Mutex, OnceLock};
|
|
20
|
+
use std::time::{Duration, Instant};
|
|
21
|
+
|
|
22
|
+
use rn_gpui_module::ModuleError;
|
|
23
|
+
use ureq::config::Config;
|
|
24
|
+
use ureq::tls::{RootCerts, TlsConfig};
|
|
25
|
+
use ureq::Agent;
|
|
26
|
+
|
|
27
|
+
use crate::{
|
|
28
|
+
errors, DownloadHeader, DownloadOptions, DownloadProgress, DownloadResult, RNFsModule,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const CHUNK: usize = 64 * 1024;
|
|
32
|
+
const PROGRESS_INTERVAL: Duration = Duration::from_millis(100);
|
|
33
|
+
const POLL: Duration = Duration::from_millis(25);
|
|
34
|
+
/// How much of a failed response's body is kept for the error.
|
|
35
|
+
const ERROR_BODY_LIMIT: u64 = 2048;
|
|
36
|
+
|
|
37
|
+
pub type Downloads = Mutex<HashMap<u32, Arc<AtomicBool>>>;
|
|
38
|
+
|
|
39
|
+
enum Message {
|
|
40
|
+
Head {
|
|
41
|
+
status: u16,
|
|
42
|
+
headers: Vec<DownloadHeader>,
|
|
43
|
+
total: Option<u64>,
|
|
44
|
+
},
|
|
45
|
+
Rejected {
|
|
46
|
+
status: u16,
|
|
47
|
+
headers: Vec<DownloadHeader>,
|
|
48
|
+
body: String,
|
|
49
|
+
},
|
|
50
|
+
Chunk(Vec<u8>),
|
|
51
|
+
End,
|
|
52
|
+
Failed(String),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
fn agent() -> &'static Agent {
|
|
56
|
+
static AGENT: OnceLock<Agent> = OnceLock::new();
|
|
57
|
+
AGENT.get_or_init(|| {
|
|
58
|
+
let config = Config::builder()
|
|
59
|
+
.http_status_as_error(false)
|
|
60
|
+
.timeout_connect(Some(Duration::from_secs(30)))
|
|
61
|
+
.tls_config(
|
|
62
|
+
TlsConfig::builder()
|
|
63
|
+
.root_certs(RootCerts::PlatformVerifier)
|
|
64
|
+
.build(),
|
|
65
|
+
)
|
|
66
|
+
.build();
|
|
67
|
+
Agent::new_with_config(config)
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
fn aborted() -> ModuleError {
|
|
72
|
+
ModuleError::with_code("ABORT_ERR", "The download was aborted")
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
fn failed(message: impl Into<String>) -> ModuleError {
|
|
76
|
+
ModuleError::with_code("ERR_DOWNLOAD", message.into())
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
fn id_of(value: f64) -> u32 {
|
|
80
|
+
value as u32
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
pub fn cancel(module: &RNFsModule, id: f64) -> bool {
|
|
84
|
+
match module.downloads.lock().expect("downloads").get(&id_of(id)) {
|
|
85
|
+
Some(stop) => {
|
|
86
|
+
stop.store(true, Ordering::SeqCst);
|
|
87
|
+
true
|
|
88
|
+
}
|
|
89
|
+
None => false,
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
fn fetch(
|
|
94
|
+
url: &str,
|
|
95
|
+
method: &str,
|
|
96
|
+
headers: &[DownloadHeader],
|
|
97
|
+
sender: &SyncSender<Message>,
|
|
98
|
+
stop: &AtomicBool,
|
|
99
|
+
) {
|
|
100
|
+
let result = (|| -> Result<(), String> {
|
|
101
|
+
let mut builder = ureq::http::Request::builder().method(method).uri(url);
|
|
102
|
+
for header in headers {
|
|
103
|
+
builder = builder.header(header.name.as_str(), header.value.as_str());
|
|
104
|
+
}
|
|
105
|
+
let request = builder.body(()).map_err(|e| e.to_string())?;
|
|
106
|
+
let mut response = agent().run(request).map_err(|e| e.to_string())?;
|
|
107
|
+
let status = response.status().as_u16();
|
|
108
|
+
let mut listed: Vec<DownloadHeader> = Vec::new();
|
|
109
|
+
for (name, value) in response.headers() {
|
|
110
|
+
let value = String::from_utf8_lossy(value.as_bytes()).into_owned();
|
|
111
|
+
match listed.iter_mut().find(|h| h.name == name.as_str()) {
|
|
112
|
+
Some(existing) => {
|
|
113
|
+
existing.value.push_str(", ");
|
|
114
|
+
existing.value.push_str(&value);
|
|
115
|
+
}
|
|
116
|
+
None => listed.push(DownloadHeader {
|
|
117
|
+
name: name.as_str().to_string(),
|
|
118
|
+
value,
|
|
119
|
+
}),
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
let total = response
|
|
123
|
+
.headers()
|
|
124
|
+
.get("content-length")
|
|
125
|
+
.and_then(|v| v.to_str().ok())
|
|
126
|
+
.and_then(|v| v.parse::<u64>().ok());
|
|
127
|
+
let mut reader = response.body_mut().as_reader();
|
|
128
|
+
if !(200..300).contains(&status) {
|
|
129
|
+
let mut body = Vec::new();
|
|
130
|
+
let _ = reader
|
|
131
|
+
.by_ref()
|
|
132
|
+
.take(ERROR_BODY_LIMIT)
|
|
133
|
+
.read_to_end(&mut body);
|
|
134
|
+
let _ = sender.send(Message::Rejected {
|
|
135
|
+
status,
|
|
136
|
+
headers: listed,
|
|
137
|
+
body: String::from_utf8_lossy(&body).into_owned(),
|
|
138
|
+
});
|
|
139
|
+
return Ok(());
|
|
140
|
+
}
|
|
141
|
+
if sender
|
|
142
|
+
.send(Message::Head {
|
|
143
|
+
status,
|
|
144
|
+
headers: listed,
|
|
145
|
+
total,
|
|
146
|
+
})
|
|
147
|
+
.is_err()
|
|
148
|
+
{
|
|
149
|
+
return Ok(());
|
|
150
|
+
}
|
|
151
|
+
loop {
|
|
152
|
+
if stop.load(Ordering::SeqCst) {
|
|
153
|
+
return Ok(());
|
|
154
|
+
}
|
|
155
|
+
let mut chunk = vec![0u8; CHUNK];
|
|
156
|
+
let read = reader.read(&mut chunk).map_err(|e| e.to_string())?;
|
|
157
|
+
if read == 0 {
|
|
158
|
+
let _ = sender.send(Message::End);
|
|
159
|
+
return Ok(());
|
|
160
|
+
}
|
|
161
|
+
chunk.truncate(read);
|
|
162
|
+
if sender.send(Message::Chunk(chunk)).is_err() {
|
|
163
|
+
return Ok(());
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
})();
|
|
167
|
+
if let Err(message) = result {
|
|
168
|
+
let _ = sender.send(Message::Failed(message));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
fn transfer(
|
|
173
|
+
module_events: &crate::NativeFsEvents,
|
|
174
|
+
id: u32,
|
|
175
|
+
url: String,
|
|
176
|
+
dest: PathBuf,
|
|
177
|
+
method: String,
|
|
178
|
+
headers: Vec<DownloadHeader>,
|
|
179
|
+
stop: Arc<AtomicBool>,
|
|
180
|
+
) -> Result<DownloadResult, ModuleError> {
|
|
181
|
+
let mut partial = dest.clone().into_os_string();
|
|
182
|
+
partial.push(".part");
|
|
183
|
+
let partial = PathBuf::from(partial);
|
|
184
|
+
let shown = dest.display().to_string();
|
|
185
|
+
|
|
186
|
+
let (sender, receiver) = mpsc::sync_channel::<Message>(8);
|
|
187
|
+
{
|
|
188
|
+
let stop = stop.clone();
|
|
189
|
+
std::thread::spawn(move || fetch(&url, &method, &headers, &sender, &stop));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
let mut file: Option<File> = None;
|
|
193
|
+
let mut head: Option<(u16, Vec<DownloadHeader>, Option<u64>)> = None;
|
|
194
|
+
let mut received: u64 = 0;
|
|
195
|
+
let mut last_progress = Instant::now() - PROGRESS_INTERVAL;
|
|
196
|
+
let emit = |received: u64, total: Option<u64>| {
|
|
197
|
+
module_events.on_download_progress(DownloadProgress {
|
|
198
|
+
id: f64::from(id),
|
|
199
|
+
received: received as f64,
|
|
200
|
+
total: total.map_or(-1.0, |t| t as f64),
|
|
201
|
+
});
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
let outcome: Result<DownloadResult, ModuleError> = loop {
|
|
205
|
+
if stop.load(Ordering::SeqCst) {
|
|
206
|
+
break Err(aborted());
|
|
207
|
+
}
|
|
208
|
+
let message = match receiver.recv_timeout(POLL) {
|
|
209
|
+
Ok(message) => message,
|
|
210
|
+
Err(RecvTimeoutError::Timeout) => continue,
|
|
211
|
+
Err(RecvTimeoutError::Disconnected) => break Err(failed("the connection closed")),
|
|
212
|
+
};
|
|
213
|
+
match message {
|
|
214
|
+
Message::Rejected {
|
|
215
|
+
status,
|
|
216
|
+
headers,
|
|
217
|
+
body,
|
|
218
|
+
} => {
|
|
219
|
+
break Ok(DownloadResult {
|
|
220
|
+
status: f64::from(status),
|
|
221
|
+
headers,
|
|
222
|
+
bytes: 0.0,
|
|
223
|
+
body: Some(body),
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
Message::Head {
|
|
227
|
+
status,
|
|
228
|
+
headers,
|
|
229
|
+
total,
|
|
230
|
+
} => {
|
|
231
|
+
match File::create(&partial) {
|
|
232
|
+
Ok(created) => file = Some(created),
|
|
233
|
+
Err(e) => break Err(errors::from_io(e, &shown)),
|
|
234
|
+
}
|
|
235
|
+
head = Some((status, headers, total));
|
|
236
|
+
emit(0, total);
|
|
237
|
+
}
|
|
238
|
+
Message::Chunk(bytes) => {
|
|
239
|
+
let Some(target) = file.as_mut() else {
|
|
240
|
+
break Err(failed("data arrived before the response"));
|
|
241
|
+
};
|
|
242
|
+
if let Err(e) = target.write_all(&bytes) {
|
|
243
|
+
break Err(errors::from_io(e, &shown));
|
|
244
|
+
}
|
|
245
|
+
received += bytes.len() as u64;
|
|
246
|
+
if last_progress.elapsed() >= PROGRESS_INTERVAL {
|
|
247
|
+
last_progress = Instant::now();
|
|
248
|
+
emit(received, head.as_ref().and_then(|h| h.2));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
Message::End => {
|
|
252
|
+
let Some((status, headers, total)) = head.take() else {
|
|
253
|
+
break Err(failed("the response had no head"));
|
|
254
|
+
};
|
|
255
|
+
if let Some(expected) = total {
|
|
256
|
+
if received != expected {
|
|
257
|
+
break Err(failed(format!(
|
|
258
|
+
"the connection closed after {received} of {expected} bytes"
|
|
259
|
+
)));
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if let Some(mut target) = file.take() {
|
|
263
|
+
if let Err(e) = target.flush() {
|
|
264
|
+
break Err(errors::from_io(e, &shown));
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if let Err(e) = std::fs::rename(&partial, &dest) {
|
|
268
|
+
break Err(errors::from_io(e, &shown));
|
|
269
|
+
}
|
|
270
|
+
emit(received, total);
|
|
271
|
+
break Ok(DownloadResult {
|
|
272
|
+
status: f64::from(status),
|
|
273
|
+
headers,
|
|
274
|
+
bytes: received as f64,
|
|
275
|
+
body: None,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
Message::Failed(message) => break Err(failed(message)),
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
drop(receiver);
|
|
283
|
+
drop(file);
|
|
284
|
+
let kept = matches!(&outcome, Ok(result) if result.body.is_none());
|
|
285
|
+
if !kept {
|
|
286
|
+
let _ = std::fs::remove_file(&partial);
|
|
287
|
+
}
|
|
288
|
+
outcome
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
pub async fn download(
|
|
292
|
+
module: &RNFsModule,
|
|
293
|
+
url: String,
|
|
294
|
+
path: String,
|
|
295
|
+
options: DownloadOptions,
|
|
296
|
+
) -> Result<DownloadResult, ModuleError> {
|
|
297
|
+
let id = id_of(options.id);
|
|
298
|
+
let stop = Arc::new(AtomicBool::new(false));
|
|
299
|
+
{
|
|
300
|
+
let mut running = module.downloads.lock().expect("downloads");
|
|
301
|
+
if running.contains_key(&id) {
|
|
302
|
+
return Err(failed(format!("download {id} is already running")));
|
|
303
|
+
}
|
|
304
|
+
running.insert(id, stop.clone());
|
|
305
|
+
}
|
|
306
|
+
let (sender, receiver) = futures::channel::oneshot::channel();
|
|
307
|
+
let events = module.events.clone();
|
|
308
|
+
let method = if options.method.is_empty() {
|
|
309
|
+
"GET".to_string()
|
|
310
|
+
} else {
|
|
311
|
+
options.method
|
|
312
|
+
};
|
|
313
|
+
let flag = stop.clone();
|
|
314
|
+
std::thread::spawn(move || {
|
|
315
|
+
let result = transfer(
|
|
316
|
+
&events,
|
|
317
|
+
id,
|
|
318
|
+
url,
|
|
319
|
+
PathBuf::from(path),
|
|
320
|
+
method,
|
|
321
|
+
options.headers,
|
|
322
|
+
flag,
|
|
323
|
+
);
|
|
324
|
+
let _ = sender.send(result);
|
|
325
|
+
});
|
|
326
|
+
let result = receiver.await;
|
|
327
|
+
module.downloads.lock().expect("downloads").remove(&id);
|
|
328
|
+
result.unwrap_or_else(|_| Err(failed("the download stopped unexpectedly")))
|
|
329
|
+
}
|
package/desktop/src/lib.rs
CHANGED
|
@@ -23,12 +23,14 @@ rn_gpui_module::include_specs!();
|
|
|
23
23
|
|
|
24
24
|
mod dialogs;
|
|
25
25
|
mod dirs;
|
|
26
|
+
mod download;
|
|
26
27
|
mod errors;
|
|
27
28
|
mod files;
|
|
28
29
|
mod handles;
|
|
29
30
|
mod links;
|
|
30
31
|
mod module;
|
|
31
32
|
mod paths;
|
|
33
|
+
mod upload;
|
|
32
34
|
mod watching;
|
|
33
35
|
|
|
34
36
|
pub use module::RNFsModule;
|
|
@@ -183,6 +185,32 @@ impl NativeFsSpec for RNFsModule {
|
|
|
183
185
|
watching::watch(self, path, recursive)
|
|
184
186
|
}
|
|
185
187
|
|
|
188
|
+
async fn download(
|
|
189
|
+
&self,
|
|
190
|
+
url: String,
|
|
191
|
+
path: String,
|
|
192
|
+
options: DownloadOptions,
|
|
193
|
+
) -> Result<DownloadResult, ModuleError> {
|
|
194
|
+
download::download(self, url, path, options).await
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async fn upload(
|
|
198
|
+
&self,
|
|
199
|
+
url: String,
|
|
200
|
+
path: String,
|
|
201
|
+
options: UploadOptions,
|
|
202
|
+
) -> Result<UploadResult, ModuleError> {
|
|
203
|
+
upload::upload(self, url, path, options).await
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
fn cancel_upload(&self, id: f64) -> Result<bool, ModuleError> {
|
|
207
|
+
Ok(upload::cancel(self, id))
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
fn cancel_download(&self, id: f64) -> Result<bool, ModuleError> {
|
|
211
|
+
Ok(download::cancel(self, id))
|
|
212
|
+
}
|
|
213
|
+
|
|
186
214
|
fn unwatch(&self, id: f64) {
|
|
187
215
|
watching::unwatch(self, id);
|
|
188
216
|
}
|
package/desktop/src/module.rs
CHANGED
|
@@ -62,6 +62,9 @@ pub struct RNFsModule {
|
|
|
62
62
|
/// Live watchers, keyed the same way.
|
|
63
63
|
pub watchers: Mutex<std::collections::HashMap<u32, crate::watching::Watcher>>,
|
|
64
64
|
pub next_watcher: AtomicU32,
|
|
65
|
+
/// Running downloads, keyed by the id JS chose, with the flag that stops each.
|
|
66
|
+
pub downloads: crate::download::Downloads,
|
|
67
|
+
pub uploads: crate::upload::Uploads,
|
|
65
68
|
}
|
|
66
69
|
|
|
67
70
|
impl RNFsModule {
|
|
@@ -72,6 +75,8 @@ impl RNFsModule {
|
|
|
72
75
|
next_fd: AtomicU32::new(1),
|
|
73
76
|
watchers: Mutex::new(std::collections::HashMap::new()),
|
|
74
77
|
next_watcher: AtomicU32::new(1),
|
|
78
|
+
downloads: Default::default(),
|
|
79
|
+
uploads: Default::default(),
|
|
75
80
|
}
|
|
76
81
|
}
|
|
77
82
|
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
//! `upload`: a file, or a range of one, streamed as an HTTP request body.
|
|
2
|
+
//!
|
|
3
|
+
//! The file is read in small blocks as the connection takes them, so the body is
|
|
4
|
+
//! never held in memory. The request runs on its own thread; the cancel flag is
|
|
5
|
+
//! checked on every block, which is what makes a cancel prompt while the
|
|
6
|
+
//! connection is busy.
|
|
7
|
+
|
|
8
|
+
use std::collections::HashMap;
|
|
9
|
+
use std::fs::File;
|
|
10
|
+
use std::io::{self, Read, Seek, SeekFrom};
|
|
11
|
+
use std::path::PathBuf;
|
|
12
|
+
use std::sync::atomic::{AtomicBool, Ordering};
|
|
13
|
+
use std::sync::{Arc, Mutex, OnceLock};
|
|
14
|
+
use std::time::{Duration, Instant};
|
|
15
|
+
|
|
16
|
+
use rn_gpui_module::ModuleError;
|
|
17
|
+
use ureq::config::Config;
|
|
18
|
+
use ureq::tls::{RootCerts, TlsConfig};
|
|
19
|
+
use ureq::{Agent, SendBody};
|
|
20
|
+
|
|
21
|
+
use crate::{
|
|
22
|
+
errors, DownloadHeader, NativeFsEvents, RNFsModule, UploadOptions, UploadProgress,
|
|
23
|
+
UploadResult,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const BLOCK: usize = 64 * 1024;
|
|
27
|
+
const PROGRESS_INTERVAL: Duration = Duration::from_millis(100);
|
|
28
|
+
const RESPONSE_BODY_LIMIT: u64 = 2048;
|
|
29
|
+
|
|
30
|
+
pub type Uploads = Mutex<HashMap<u32, Arc<AtomicBool>>>;
|
|
31
|
+
|
|
32
|
+
fn agent() -> &'static Agent {
|
|
33
|
+
static AGENT: OnceLock<Agent> = OnceLock::new();
|
|
34
|
+
AGENT.get_or_init(|| {
|
|
35
|
+
let config = Config::builder()
|
|
36
|
+
.http_status_as_error(false)
|
|
37
|
+
.timeout_connect(Some(Duration::from_secs(30)))
|
|
38
|
+
.tls_config(
|
|
39
|
+
TlsConfig::builder()
|
|
40
|
+
.root_certs(RootCerts::PlatformVerifier)
|
|
41
|
+
.build(),
|
|
42
|
+
)
|
|
43
|
+
.build();
|
|
44
|
+
Agent::new_with_config(config)
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
fn aborted() -> ModuleError {
|
|
49
|
+
ModuleError::with_code("ABORT_ERR", "The upload was aborted")
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
fn failed(message: impl Into<String>) -> ModuleError {
|
|
53
|
+
ModuleError::with_code("ERR_UPLOAD", message.into())
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
fn id_of(value: f64) -> u32 {
|
|
57
|
+
value as u32
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
pub fn cancel(module: &RNFsModule, id: f64) -> bool {
|
|
61
|
+
match module.uploads.lock().expect("uploads").get(&id_of(id)) {
|
|
62
|
+
Some(stop) => {
|
|
63
|
+
stop.store(true, Ordering::SeqCst);
|
|
64
|
+
true
|
|
65
|
+
}
|
|
66
|
+
None => false,
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
struct Body {
|
|
71
|
+
file: File,
|
|
72
|
+
remaining: u64,
|
|
73
|
+
sent: u64,
|
|
74
|
+
total: u64,
|
|
75
|
+
id: u32,
|
|
76
|
+
events: NativeFsEvents,
|
|
77
|
+
stop: Arc<AtomicBool>,
|
|
78
|
+
last_progress: Instant,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
impl Body {
|
|
82
|
+
fn emit(&self) {
|
|
83
|
+
self.events.on_upload_progress(UploadProgress {
|
|
84
|
+
id: f64::from(self.id),
|
|
85
|
+
sent: self.sent as f64,
|
|
86
|
+
total: self.total as f64,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
impl Read for Body {
|
|
92
|
+
fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
|
|
93
|
+
if self.stop.load(Ordering::SeqCst) {
|
|
94
|
+
return Err(io::Error::new(io::ErrorKind::Other, "aborted"));
|
|
95
|
+
}
|
|
96
|
+
if self.remaining == 0 {
|
|
97
|
+
return Ok(0);
|
|
98
|
+
}
|
|
99
|
+
let wanted = buffer.len().min(BLOCK).min(self.remaining as usize);
|
|
100
|
+
let read = self.file.read(&mut buffer[..wanted])?;
|
|
101
|
+
if read == 0 {
|
|
102
|
+
return Err(io::Error::new(
|
|
103
|
+
io::ErrorKind::UnexpectedEof,
|
|
104
|
+
"the file is shorter than the requested range",
|
|
105
|
+
));
|
|
106
|
+
}
|
|
107
|
+
self.remaining -= read as u64;
|
|
108
|
+
self.sent += read as u64;
|
|
109
|
+
if self.last_progress.elapsed() >= PROGRESS_INTERVAL || self.remaining == 0 {
|
|
110
|
+
self.last_progress = Instant::now();
|
|
111
|
+
self.emit();
|
|
112
|
+
}
|
|
113
|
+
Ok(read)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
fn run(
|
|
118
|
+
events: NativeFsEvents,
|
|
119
|
+
id: u32,
|
|
120
|
+
url: String,
|
|
121
|
+
path: PathBuf,
|
|
122
|
+
options: UploadOptions,
|
|
123
|
+
stop: Arc<AtomicBool>,
|
|
124
|
+
) -> Result<UploadResult, ModuleError> {
|
|
125
|
+
let shown = path.display().to_string();
|
|
126
|
+
let mut file = File::open(&path).map_err(|e| errors::from_io(e, &shown))?;
|
|
127
|
+
let size = file
|
|
128
|
+
.metadata()
|
|
129
|
+
.map_err(|e| errors::from_io(e, &shown))?
|
|
130
|
+
.len();
|
|
131
|
+
let offset = if options.offset > 0.0 {
|
|
132
|
+
options.offset as u64
|
|
133
|
+
} else {
|
|
134
|
+
0
|
|
135
|
+
};
|
|
136
|
+
if offset > size {
|
|
137
|
+
return Err(failed("the offset is past the end of the file"));
|
|
138
|
+
}
|
|
139
|
+
let length = if options.length < 0.0 {
|
|
140
|
+
size - offset
|
|
141
|
+
} else {
|
|
142
|
+
options.length as u64
|
|
143
|
+
};
|
|
144
|
+
if offset + length > size {
|
|
145
|
+
return Err(failed("the range is past the end of the file"));
|
|
146
|
+
}
|
|
147
|
+
file.seek(SeekFrom::Start(offset))
|
|
148
|
+
.map_err(|e| errors::from_io(e, &shown))?;
|
|
149
|
+
|
|
150
|
+
let method = if options.method.is_empty() {
|
|
151
|
+
"PUT".to_string()
|
|
152
|
+
} else {
|
|
153
|
+
options.method.clone()
|
|
154
|
+
};
|
|
155
|
+
let mut builder = ureq::http::Request::builder().method(method.as_str()).uri(&url);
|
|
156
|
+
let mut has_length = false;
|
|
157
|
+
for header in &options.headers {
|
|
158
|
+
if header.name.eq_ignore_ascii_case("content-length") {
|
|
159
|
+
has_length = true;
|
|
160
|
+
}
|
|
161
|
+
builder = builder.header(header.name.as_str(), header.value.as_str());
|
|
162
|
+
}
|
|
163
|
+
if !has_length {
|
|
164
|
+
builder = builder.header("content-length", length.to_string());
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
let mut body = Body {
|
|
168
|
+
file,
|
|
169
|
+
remaining: length,
|
|
170
|
+
sent: 0,
|
|
171
|
+
total: length,
|
|
172
|
+
id,
|
|
173
|
+
events,
|
|
174
|
+
stop: stop.clone(),
|
|
175
|
+
last_progress: Instant::now() - PROGRESS_INTERVAL,
|
|
176
|
+
};
|
|
177
|
+
body.emit();
|
|
178
|
+
let request = builder
|
|
179
|
+
.body(SendBody::from_reader(&mut body))
|
|
180
|
+
.map_err(|e| failed(e.to_string()))?;
|
|
181
|
+
let outcome = agent().run(request);
|
|
182
|
+
if stop.load(Ordering::SeqCst) {
|
|
183
|
+
return Err(aborted());
|
|
184
|
+
}
|
|
185
|
+
let mut response = outcome.map_err(|e| failed(e.to_string()))?;
|
|
186
|
+
let status = response.status().as_u16();
|
|
187
|
+
let mut headers: Vec<DownloadHeader> = Vec::new();
|
|
188
|
+
for (name, value) in response.headers() {
|
|
189
|
+
let value = String::from_utf8_lossy(value.as_bytes()).into_owned();
|
|
190
|
+
match headers.iter_mut().find(|h| h.name == name.as_str()) {
|
|
191
|
+
Some(existing) => {
|
|
192
|
+
existing.value.push_str(", ");
|
|
193
|
+
existing.value.push_str(&value);
|
|
194
|
+
}
|
|
195
|
+
None => headers.push(DownloadHeader {
|
|
196
|
+
name: name.as_str().to_string(),
|
|
197
|
+
value,
|
|
198
|
+
}),
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
let mut text = Vec::new();
|
|
202
|
+
let _ = response
|
|
203
|
+
.body_mut()
|
|
204
|
+
.as_reader()
|
|
205
|
+
.take(RESPONSE_BODY_LIMIT)
|
|
206
|
+
.read_to_end(&mut text);
|
|
207
|
+
Ok(UploadResult {
|
|
208
|
+
status: f64::from(status),
|
|
209
|
+
headers,
|
|
210
|
+
body: String::from_utf8_lossy(&text).into_owned(),
|
|
211
|
+
})
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
pub async fn upload(
|
|
215
|
+
module: &RNFsModule,
|
|
216
|
+
url: String,
|
|
217
|
+
path: String,
|
|
218
|
+
options: UploadOptions,
|
|
219
|
+
) -> Result<UploadResult, ModuleError> {
|
|
220
|
+
let id = id_of(options.id);
|
|
221
|
+
let stop = Arc::new(AtomicBool::new(false));
|
|
222
|
+
{
|
|
223
|
+
let mut running = module.uploads.lock().expect("uploads");
|
|
224
|
+
if running.contains_key(&id) {
|
|
225
|
+
return Err(failed(format!("upload {id} is already running")));
|
|
226
|
+
}
|
|
227
|
+
running.insert(id, stop.clone());
|
|
228
|
+
}
|
|
229
|
+
let (sender, receiver) = futures::channel::oneshot::channel();
|
|
230
|
+
let events = module.events.clone();
|
|
231
|
+
std::thread::spawn(move || {
|
|
232
|
+
let _ = sender.send(run(events, id, url, PathBuf::from(path), options, stop));
|
|
233
|
+
});
|
|
234
|
+
let result = receiver.await;
|
|
235
|
+
module.uploads.lock().expect("uploads").remove(&id);
|
|
236
|
+
result.unwrap_or_else(|_| Err(failed("the upload stopped unexpectedly")))
|
|
237
|
+
}
|
package/package.json
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@react-native-gpui/fs",
|
|
3
|
-
"version": "0.0.1-alpha.
|
|
3
|
+
"version": "0.0.1-alpha.1",
|
|
4
4
|
"description": "File system access for React Native GPUI apps",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
|
-
"url": "git+https://github.com/streetwriters/react-native-gpui.git",
|
|
8
|
+
"url": "git+https://github.com/streetwriters/react-native-gpui-modules.git",
|
|
9
9
|
"directory": "modules/fs"
|
|
10
10
|
},
|
|
11
|
-
"homepage": "https://
|
|
11
|
+
"homepage": "https://github.com/streetwriters/react-native-gpui-modules/tree/main/modules/fs#readme",
|
|
12
12
|
"bugs": {
|
|
13
|
-
"url": "https://github.com/streetwriters/react-native-gpui/issues"
|
|
13
|
+
"url": "https://github.com/streetwriters/react-native-gpui-modules/issues"
|
|
14
14
|
},
|
|
15
15
|
"main": "src/index.ts",
|
|
16
16
|
"files": [
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"desktop",
|
|
19
19
|
"react-native.config.js",
|
|
20
20
|
"README.md",
|
|
21
|
+
"LICENSE",
|
|
21
22
|
"CHANGELOG.md",
|
|
22
23
|
"!**/*.test.js",
|
|
23
24
|
"!**/*.test.mjs",
|