The user's files through ltk::files, alike natively and in a browser, and a shell's requests answered in a browser through ltk::web::shell_request
CI / build + test (push) Canceled after 0s
CI / cargo audit (push) Canceled after 0s

An app read the user's files with `std::fs`, which a browser does not have: there a page reaches a folder of the user's computer only through forge/web, which holds it through the File System Access API and answers asynchronously (forge/web/DESIGN.md, section 11). An app that builds for both, such as Crate, had no way to list, read or write a file in a browser, and a page's memory cannot hold a large file whole.
ltk::files. Asynchronous operations on the user's files whose results reach the app as messages through its `ChannelSender`: `list`, `stat`, `read`, `read_range`, `write`, `open_write`, `create_dir`, `rename`, `remove`, `remove_all`, `copy`, `copy_all`, `mount` and `unmount`, each taking the sender and a `done` that turns the `io::Result` into a message. Results come in no set order, so an app that has moved on checks that one is still the one it waits for. Errors are the same `io::ErrorKind`s on every platform, `Interrupted` for a cancelled copy. `Entry` and `Metadata` give a file's kind, size and times, the access time natively only. `ltk::vfs` stays what reads an app's own resources.
Natively. `std::fs` on one worker thread of ltk's, in the order the app asked (`files/native.rs`), and each copy on a thread of its own, so a long one holds nothing else up (`files/copy.rs`). A file written whole or through a `Writer` goes beside its target as a temporary file, synced and renamed over it on commit and removed if dropped before, so a file being written is never seen half done. `rename` never replaces what is at `to`, as in a browser.
In a browser. Each operation is one of forge/web's `fs_*` requests over the app's connection (`platform/web/files.rs`), carrying a number its `fs_reply` answers with; contents travel as a `Uint8Array` beside the JSON, the second argument of the connection's `request` and of the client's event handler, so `on_event` now takes it. What is asked before the app has connected is sent once it has; on a page without a desktop, or once the connection has closed, it fails with `Unsupported`. forge/web's error codes become their `io::ErrorKind`s, and `fs_progress` reaches a copy's `progress`. Without the web backend, on wasm32 every operation fails with `Unsupported` (`files/unsupported.rs`).
Chunks. Contents never travel whole unless they are small: a large file is read with `read_range` and written through a `Writer`, chunk by chunk, `files::CHUNK` (1 MiB) at a time, the next sent once the last is answered. A `Writer` dropped without `close` is aborted and the file left as it was; in a browser a new file shows empty until closed. A copy runs where the files are, its contents never passing through the app: it reports its progress every 100 ms, never replaces an existing file, and, cancelled through its `Operation` or failed, leaves nothing behind.
Mounts and the desktop's notices. `mount` asks the browser for a folder of the user's computer, from the user's press, and `unmount` lets it go; natively both fail with `Unsupported`. `App::on_files_changed`, `App::on_mounts_changed` and `App::on_open_files` hear of forge/web's `fs_changed`, `fs_mounts` and `open_files`, which the web backend queues as page events; natively they are not called yet.
Types. `files::mime_type( name )` gives a file's type by shared-mime-info's `globs2`, with its weights, its case-sensitive globs and fnmatch's `*`, `?` and bracket classes, from the resource directory's `mime/` first, then the user's and the system's. `scripts/android-assets.sh`, and so `web-assets.sh`, now copies shared-mime-info's `globs2` and `subclasses` into the tree's `mime/` (`MIME_DIR` overrides where from), so a page has them.
ltk::web::shell_request. A shell message that waits for forge/web's reply, such as `get_window_thumbnail`: sent as `shell_send` sends one and answered at once with forge/web's reply as JSON, or `None` before the app has connected, since a request is not kept for later, and when forge/web replies nothing. A shell's requests that wait for an answer natively got none in a browser. `shell.rs` parses a message once for both, and `send` returns forge/web's reply.
Tests cover writing and committing, aborting, listing, ranges, removing, copying a tree with its progress, a cancelled copy, a copy that would replace, and the globs. `CHANGELOG.md` describes the changes.
This commit is contained in:
2026-10-22 00:15:39 +02:00
parent af4009374d
commit cf187a3dcc
14 changed files with 1345 additions and 22 deletions
+249
View File
@@ -0,0 +1,249 @@
// 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.
#[ 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 ) );
}
/// 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 );
}
}