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
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:
@@ -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 );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user