ltk/files/
mod.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>

//! The user's files, asynchronously and alike on every platform: natively through `std::fs` on a worker thread of ltk's, in a browser through forge/web's file requests (forge/web/DESIGN.md, section 11). Every operation hands its result to `done`, whose message reaches the app through its [`ChannelSender`], so nothing blocks the app's thread. Results come in no set order, so an app that may have moved on checks that a result is still the one it waits for.
//!
//! Contents travel in chunks: a large file is read in ranges with [`read_range`] and written through a [`Writer`], one chunk after the other is answered, [`CHUNK`] bytes at a time; [`read`] and [`write()`] are for small files. A copy runs where the files are, its contents never passing through the app.
//!
//! Errors are `io::Error`s of the same kinds on every platform: `NotFound`, `AlreadyExists`, `NotADirectory`, `IsADirectory`, `DirectoryNotEmpty`, `PermissionDenied`, `Unsupported`, and `Interrupted` for a cancelled copy.
//!
//! An app's own resources are read through [`crate::vfs`], not here.

pub mod callback;
#[ cfg( not( target_arch = "wasm32" ) ) ]
mod copy;
mod mime;
#[ cfg( not( target_arch = "wasm32" ) ) ]
mod native;
#[ cfg( all( target_arch = "wasm32", not( feature = "web" ) ) ) ]
mod unsupported;

#[ cfg( not( target_arch = "wasm32" ) ) ]
use native as backend;
#[ cfg( all( target_arch = "wasm32", feature = "web" ) ) ]
use crate::platform::web::files as backend;
#[ cfg( all( target_arch = "wasm32", not( feature = "web" ) ) ) ]
use unsupported as backend;

use std::ffi::OsString;
use std::io;
use std::path::PathBuf;
use std::time::SystemTime;

use crate::ChannelSender;

pub use mime::mime_type;

/// The size of the chunks a large file is best read and written in.
pub const CHUNK: usize = 1 << 20;

#[ derive( Debug, Clone, Copy, PartialEq, Eq ) ]
pub enum Kind
{
	File,
	Directory,
}

/// One entry of a directory.
#[ derive( Debug, Clone ) ]
pub struct Entry
{
	pub name:     OsString,
	pub kind:     Kind,
	/// A file's size in bytes; `0` for a directory.
	pub size:     u64,
	pub modified: Option<SystemTime>,
	/// `None` in a browser, which does not keep it.
	pub accessed: Option<SystemTime>,
}

#[ derive( Debug, Clone ) ]
pub struct Metadata
{
	pub kind:     Kind,
	pub size:     u64,
	pub modified: Option<SystemTime>,
	pub accessed: Option<SystemTime>,
}

/// A folder of the user's computer, mounted in a browser.
#[ derive( Debug, Clone, PartialEq, Eq ) ]
pub struct Mount
{
	pub path: PathBuf,
	pub name: String,
}

pub( crate ) type Reply<T> = Box<dyn FnOnce( io::Result<T> ) + Send>;
pub( crate ) type Progress = Box<dyn Fn( u64, u64 ) + Send>;

fn reply<M: Send + 'static, T>( sender: &ChannelSender<M>, done: impl FnOnce( io::Result<T> ) -> M + Send + 'static ) -> Reply<T>
{
	let sender = sender.clone();
	Box::new( move |result| { let _ = sender.send( done( result ) ); } )
}

/// The entries of the directory `path`, in no order.
pub fn list<M: Send + 'static>( path: impl Into<PathBuf>, sender: &ChannelSender<M>, done: impl FnOnce( io::Result<Vec<Entry>> ) -> M + Send + 'static )
{
	backend::list( path.into(), reply( sender, done ) );
}

pub fn stat<M: Send + 'static>( path: impl Into<PathBuf>, sender: &ChannelSender<M>, done: impl FnOnce( io::Result<Metadata> ) -> M + Send + 'static )
{
	backend::stat( path.into(), reply( sender, done ) );
}

/// The whole of a small file.
pub fn read<M: Send + 'static>( path: impl Into<PathBuf>, sender: &ChannelSender<M>, done: impl FnOnce( io::Result<Vec<u8>> ) -> M + Send + 'static )
{
	backend::read( path.into(), None, reply( sender, done ) );
}

/// Up to `length` bytes of the file from `offset`; fewer at its end, none past it.
pub fn read_range<M: Send + 'static>( path: impl Into<PathBuf>, offset: u64, length: u64, sender: &ChannelSender<M>, done: impl FnOnce( io::Result<Vec<u8>> ) -> M + Send + 'static )
{
	backend::read( path.into(), Some( ( offset, length ) ), reply( sender, done ) );
}

/// Create or replace a small file with `data`, whole or not at all.
pub fn write<M: Send + 'static>( path: impl Into<PathBuf>, data: Vec<u8>, sender: &ChannelSender<M>, done: impl FnOnce( io::Result<()> ) -> M + Send + 'static )
{
	backend::write( path.into(), data, reply( sender, done ) );
}

/// A [`Writer`] for the file at `path`.
pub fn open_write<M: Send + 'static>( path: impl Into<PathBuf>, sender: &ChannelSender<M>, done: impl FnOnce( io::Result<Writer> ) -> M + Send + 'static )
{
	let done = reply( sender, done );
	backend::open_write( path.into(), Box::new( move |result| done( result.map( |handle| Writer { handle: Some( handle ) } ) ) ) );
}

pub fn create_dir<M: Send + 'static>( path: impl Into<PathBuf>, sender: &ChannelSender<M>, done: impl FnOnce( io::Result<()> ) -> M + Send + 'static )
{
	backend::create_dir( path.into(), reply( sender, done ) );
}

/// Move `from` to `to`, which must not exist; in a browser, within one mount.
pub fn rename<M: Send + 'static>( from: impl Into<PathBuf>, to: impl Into<PathBuf>, sender: &ChannelSender<M>, done: impl FnOnce( io::Result<()> ) -> M + Send + 'static )
{
	backend::rename( from.into(), to.into(), reply( sender, done ) );
}

/// Remove a file or an empty directory.
pub fn remove<M: Send + 'static>( path: impl Into<PathBuf>, sender: &ChannelSender<M>, done: impl FnOnce( io::Result<()> ) -> M + Send + 'static )
{
	backend::remove( path.into(), false, reply( sender, done ) );
}

/// Remove a file, or a directory and everything in it.
pub fn remove_all<M: Send + 'static>( path: impl Into<PathBuf>, sender: &ChannelSender<M>, done: impl FnOnce( io::Result<()> ) -> M + Send + 'static )
{
	backend::remove( path.into(), true, reply( sender, done ) );
}

/// Copy the file `from` to `to`, which must not exist and appears only once whole. `progress` hears of the bytes copied out of the total every so often.
pub fn copy<M: Send + 'static>(
	from:     impl Into<PathBuf>,
	to:       impl Into<PathBuf>,
	sender:   &ChannelSender<M>,
	progress: impl Fn( u64, u64 ) -> M + Send + 'static,
	done:     impl FnOnce( io::Result<()> ) -> M + Send + 'static,
) -> Operation
{
	start_copy( from.into(), to.into(), false, sender, progress, done )
}

/// Copy the directory `from` and everything in it to `to`, which must not exist; a copy that fails or is cancelled leaves no `to` behind.
pub fn copy_all<M: Send + 'static>(
	from:     impl Into<PathBuf>,
	to:       impl Into<PathBuf>,
	sender:   &ChannelSender<M>,
	progress: impl Fn( u64, u64 ) -> M + Send + 'static,
	done:     impl FnOnce( io::Result<()> ) -> M + Send + 'static,
) -> Operation
{
	start_copy( from.into(), to.into(), true, sender, progress, done )
}

fn start_copy<M: Send + 'static>(
	from:     PathBuf,
	to:       PathBuf,
	all:      bool,
	sender:   &ChannelSender<M>,
	progress: impl Fn( u64, u64 ) -> M + Send + 'static,
	done:     impl FnOnce( io::Result<()> ) -> M + Send + 'static,
) -> Operation
{
	let progress_sender = sender.clone();
	let progress: Progress = Box::new( move |copied, total| { let _ = progress_sender.send( progress( copied, total ) ); } );
	Operation { handle: backend::copy( from, to, all, progress, reply( sender, done ) ) }
}

/// Have the browser ask the user for a folder of their computer and mount it. Call it from the user's press, since the browser opens its picker only then; natively it fails with `Unsupported`.
pub fn mount<M: Send + 'static>( sender: &ChannelSender<M>, done: impl FnOnce( io::Result<Mount> ) -> M + Send + 'static )
{
	backend::mount( reply( sender, done ) );
}

pub fn unmount<M: Send + 'static>( path: impl Into<PathBuf>, sender: &ChannelSender<M>, done: impl FnOnce( io::Result<()> ) -> M + Send + 'static )
{
	backend::unmount( path.into(), reply( sender, done ) );
}

static MOUNTS: std::sync::Mutex<Option<Vec<Mount>>> = std::sync::Mutex::new( None );

/// The folders mounted in a browser, as the desktop last said; `None` natively, where nothing is mounted, and before the desktop has said.
pub fn mounts() -> Option<Vec<Mount>>
{
	MOUNTS.lock().ok()?.clone()
}

#[ cfg_attr( not( feature = "web" ), allow( dead_code ) ) ]
pub( crate ) fn set_mounts( mounts: &[ Mount ] )
{
	if let Ok( mut held ) = MOUNTS.lock() { *held = Some( mounts.to_vec() ); }
}

/// A file being written in chunks, whose contents appear only once closed; in a browser a new file shows empty until then. Dropped without [`Writer::close`], it is aborted and the file left as it was.
pub struct Writer
{
	handle: Option<backend::WriterHandle>,
}

impl Writer
{
	/// Append `chunk`; send the next once `done` has come.
	pub fn write<M: Send + 'static>( &self, chunk: Vec<u8>, sender: &ChannelSender<M>, done: impl FnOnce( io::Result<()> ) -> M + Send + 'static )
	{
		let done = reply( sender, done );
		match &self.handle
		{
			Some( handle ) => backend::write_chunk( handle, chunk, done ),
			None           => done( Err( io::ErrorKind::BrokenPipe.into() ) ),
		}
	}

	pub fn close<M: Send + 'static>( mut self, sender: &ChannelSender<M>, done: impl FnOnce( io::Result<()> ) -> M + Send + 'static )
	{
		let done = reply( sender, done );
		match self.handle.take()
		{
			Some( handle ) => backend::close( handle, done ),
			None           => done( Err( io::ErrorKind::BrokenPipe.into() ) ),
		}
	}

	pub fn abort( mut self )
	{
		if let Some( handle ) = self.handle.take() { backend::abort( handle ); }
	}
}

impl Drop for Writer
{
	fn drop( &mut self )
	{
		if let Some( handle ) = self.handle.take() { backend::abort( handle ); }
	}
}

/// A copy in progress.
pub struct Operation
{
	handle: backend::OperationHandle,
}

impl Operation
{
	/// Stop the copy, whose `done` then comes with `ErrorKind::Interrupted`.
	pub fn cancel( &self )
	{
		backend::cancel( &self.handle );
	}
}