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
+2
View File
@@ -6,6 +6,8 @@ All notable changes to `ltk` are documented here. The format is based on [Keep a
### Added
- **The user's files, alike natively and in a browser: `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. Natively they run `std::fs` on a worker thread of ltk's, and each copy on a thread of its own; in a browser they are forge/web's `fs_*` requests over the app's connection, contents travelling as a `Uint8Array` beside the JSON as the second argument of the connection's `request` and of the client's event handler, and what is asked before the app connects is sent once it has, or fails with `Unsupported` on a page without a desktop. Contents travel in chunks: a large file is read with `read_range` and written through a `Writer`, chunk by chunk, `files::CHUNK` (1 MiB) at a time, and its contents appear only once the writer is closed, a new file showing empty until then in a browser; natively it is written beside the file and renamed over it, and a writer dropped unclosed leaves the file as it was. A copy runs where the files are, reports its progress every 100 ms, never replaces an existing file, and, cancelled through its `Operation` or failed, leaves nothing behind. Errors are the same `io::ErrorKind`s on every platform. `Entry` and `Metadata` give a file's kind, size and times, the access time natively only. `mount` asks the browser for a folder of the user's computer, from the user's press, and fails with `Unsupported` natively. `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`. `files::mime_type( name )` gives a file's type by shared-mime-info's `globs2`, the resource directory's `mime/globs2` first, which `scripts/android-assets.sh`, and so `web-assets.sh`, now copies into the tree with shared-mime-info's `subclasses` (`MIME_DIR` overrides where from), then the system's. `rename` never replaces what is at `to`, natively as in a browser. Tests cover writing, aborting, listing, ranges, removing, copying a tree with its progress, a cancelled copy, a copy that would replace, and the globs.
- **`ltk::web::shell_request( json )`** — 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.
- **`Canvas::draw_image_keyed( key, rgba, w, h, dest, opacity )`** — `draw_image_data` with `key` naming the pixels in place of a fingerprint of them, so a caller that draws the same images on every frame, such as a terminal's glyphs, does not hash every one of them each time. Every draw with the same `key` must pass the same pixels, which are read only when the image is not cached; a key never names an image drawn by content.
- **Fullscreen in a browser's desktop** — on a page with forge/web, `App::fullscreen` and `App::start_fullscreen` ask forge/web for the main window to fill the desktop with a `set_fullscreen` request, as Wayland's `xdg_toplevel.set_fullscreen`, where they did nothing; alone on a page the app's canvas still takes the browser's fullscreen.
- **A browser app hears of the desktop's windows** — on a page with forge/web, the web backend asks for the list of toplevels when it connects and turns its `toplevel_opened` and `toplevel_closed` events into `App::on_toplevel_event`, as the Wayland backend does with `ext-foreign-toplevel-list-v1`, so a shell's dock knows which applications have windows open and names each by its `identifier`.
+15
View File
@@ -10,6 +10,9 @@
# EXTRA_FONTS, space-separated paths, adds fonts the app itself reads from
# `ltk::resource_dir()/fonts`, such as a terminal's monospace face.
# KEYBOARDS_DIR defaults to the `keyboards/` next to this script's directory.
# MIME_DIR, shared-mime-info's directory, whose globs2, which
# `ltk::files::mime_type` reads from `mime/`, and subclasses are copied there,
# defaults to /usr/share/mime.
set -eu
keyboards=${KEYBOARDS_DIR:-$( cd "$( dirname "$0" )/.." && pwd )/keyboards}
@@ -57,6 +60,18 @@ do
cp "$font" "$root/fonts/"
done
mime=${MIME_DIR:-/usr/share/mime}
for table in globs2 subclasses
do
if [ -f "$mime/$table" ]
then
mkdir -p "$root/mime"
cp "$mime/$table" "$root/mime/$table"
else
echo "android-assets: $mime/$table not found, skipped" >&2
fi
done
cd "$root"
hash=$( find . -type f ! -name MANIFEST -print0 | LC_ALL=C sort -z | xargs -0 sha256sum | sha256sum | cut -d' ' -f1 )
{
+18
View File
@@ -917,6 +917,24 @@ pub trait App: 'static
None
}
/// A directory the app listed through [`crate::files::list`] changed: in a browser, through another client of the desktop. Natively not reported yet.
fn on_files_changed( &self, _dir: &std::path::Path ) -> Option<Self::Message>
{
None
}
/// The folders of the user's computer mounted in a browser, when the app connects and whenever one comes or goes. Natively there are none.
fn on_mounts_changed( &self, _mounts: &[ crate::files::Mount ] ) -> Option<Self::Message>
{
None
}
/// The files a browser's desktop launched the app to open. Natively they come in the app's arguments instead.
fn on_open_files( &self, _paths: Vec<std::path::PathBuf> ) -> Option<Self::Message>
{
None
}
/// Return `Some(id)` once to focus the widget with that [`WidgetId`](crate::types::WidgetId).
/// The app must return `None` after the first call.
///
+180
View File
@@ -0,0 +1,180 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Copying natively, in chunks, with progress and a way to stop.
use std::fs::{ self, File };
use std::io::{ self, Read };
use std::path::Path;
use std::sync::atomic::{ AtomicBool, Ordering };
use std::time::Duration;
use super::native::Pending;
use super::CHUNK;
use crate::time::Instant;
/// How often progress is reported.
const PROGRESS_EVERY: Duration = Duration::from_millis( 100 );
pub( super ) fn copy( from: &Path, to: &Path, all: bool, cancelled: &AtomicBool, progress: &dyn Fn( u64, u64 ) ) -> io::Result<()>
{
if fs::symlink_metadata( to ).is_ok() { return Err( io::ErrorKind::AlreadyExists.into() ); }
let meta = fs::metadata( from )?;
if meta.is_dir() && !all { return Err( io::ErrorKind::IsADirectory.into() ); }
let total = if meta.is_dir() { tree_size( from )? } else { meta.len() };
let mut meter = Meter { copied: 0, total, last: None, cancelled, progress };
if !meta.is_dir() { return copy_file( from, to, &mut meter ); }
let result = copy_tree( from, to, &mut meter );
if result.is_err() { let _ = fs::remove_dir_all( to ); }
result
}
struct Meter<'a>
{
copied: u64,
total: u64,
last: Option<Instant>,
cancelled: &'a AtomicBool,
progress: &'a dyn Fn( u64, u64 ),
}
impl Meter<'_>
{
fn advance( &mut self, bytes: u64 ) -> io::Result<()>
{
if self.cancelled.load( Ordering::Relaxed ) { return Err( io::Error::new( io::ErrorKind::Interrupted, "cancelled" ) ); }
self.copied += bytes;
let now = Instant::now();
if self.last.is_none_or( |last| now.duration_since( last ) >= PROGRESS_EVERY ) || self.copied == self.total
{
self.last = Some( now );
( self.progress )( self.copied, self.total );
}
Ok( () )
}
}
fn tree_size( dir: &Path ) -> io::Result<u64>
{
let mut size = 0;
for entry in fs::read_dir( dir )?
{
let entry = entry?;
let kind = entry.file_type()?;
if kind.is_dir() { size += tree_size( &entry.path() )?; } else if kind.is_file() { size += entry.metadata()?.len(); }
}
Ok( size )
}
fn copy_tree( from: &Path, to: &Path, meter: &mut Meter<'_> ) -> io::Result<()>
{
fs::create_dir( to )?;
for entry in fs::read_dir( from )?
{
let entry = entry?;
let kind = entry.file_type()?;
let target = to.join( entry.file_name() );
if kind.is_dir()
{
copy_tree( &entry.path(), &target, meter )?;
} else if kind.is_symlink() {
copy_link( &entry.path(), &target )?;
} else {
copy_file( &entry.path(), &target, meter )?;
}
}
let _ = fs::set_permissions( to, fs::metadata( from )?.permissions() );
Ok( () )
}
fn copy_file( from: &Path, to: &Path, meter: &mut Meter<'_> ) -> io::Result<()>
{
let mut source = File::open( from )?;
let mut target = Pending::create( to )?;
let mut buffer = vec![ 0; CHUNK ];
loop
{
let read = match source.read( &mut buffer )
{
Ok( 0 ) => break,
Ok( read ) => read,
Err( e ) if e.kind() == io::ErrorKind::Interrupted => continue,
Err( e ) => return Err( e ),
};
target.write( &buffer[ ..read ] )?;
meter.advance( read as u64 )?;
}
meter.advance( 0 )?;
target.commit()?;
let _ = fs::set_permissions( to, source.metadata()?.permissions() );
Ok( () )
}
#[ cfg( unix ) ]
fn copy_link( from: &Path, to: &Path ) -> io::Result<()>
{
std::os::unix::fs::symlink( fs::read_link( from )?, to )
}
#[ cfg( not( unix ) ) ]
fn copy_link( _from: &Path, _to: &Path ) -> io::Result<()>
{
Ok( () )
}
#[ cfg( test ) ]
mod tests
{
use super::*;
use std::cell::Cell;
use std::path::PathBuf;
fn scratch( name: &str ) -> PathBuf
{
let dir = std::env::temp_dir().join( format!( "ltk-copy-{name}-{}", std::process::id() ) );
let _ = fs::remove_dir_all( &dir );
fs::create_dir_all( &dir ).unwrap();
dir
}
#[ test ]
fn a_tree_is_copied_whole_with_its_progress()
{
let dir = scratch( "tree" );
let src = dir.join( "src" );
fs::create_dir_all( src.join( "sub" ) ).unwrap();
fs::write( src.join( "a" ), vec![ 1; CHUNK + 10 ] ).unwrap();
fs::write( src.join( "sub/b" ), b"bb" ).unwrap();
let last = Cell::new( ( 0, 0 ) );
copy( &src, &dir.join( "dst" ), true, &AtomicBool::new( false ), &|copied, total| last.set( ( copied, total ) ) ).unwrap();
assert_eq!( fs::read( dir.join( "dst/a" ) ).unwrap().len(), CHUNK + 10 );
assert_eq!( fs::read( dir.join( "dst/sub/b" ) ).unwrap(), b"bb" );
assert_eq!( last.get(), ( CHUNK as u64 + 12, CHUNK as u64 + 12 ) );
fs::remove_dir_all( &dir ).unwrap();
}
#[ test ]
fn a_cancelled_copy_leaves_nothing()
{
let dir = scratch( "cancel" );
let src = dir.join( "src" );
fs::create_dir( &src ).unwrap();
fs::write( src.join( "a" ), b"aa" ).unwrap();
let result = copy( &src, &dir.join( "dst" ), true, &AtomicBool::new( true ), &|_, _| {} );
assert_eq!( result.unwrap_err().kind(), io::ErrorKind::Interrupted );
assert!( !dir.join( "dst" ).exists() );
fs::remove_dir_all( &dir ).unwrap();
}
#[ test ]
fn a_copy_never_replaces()
{
let dir = scratch( "exists" );
fs::write( dir.join( "a" ), b"a" ).unwrap();
fs::write( dir.join( "b" ), b"b" ).unwrap();
let result = copy( &dir.join( "a" ), &dir.join( "b" ), false, &AtomicBool::new( false ), &|_, _| {} );
assert_eq!( result.unwrap_err().kind(), io::ErrorKind::AlreadyExists );
assert_eq!( fs::read( dir.join( "b" ) ).unwrap(), b"b" );
fs::remove_dir_all( &dir ).unwrap();
}
}
+179
View File
@@ -0,0 +1,179 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! A file's type from its name, by shared-mime-info's `globs2`: the resource directory's `mime/globs2` where an app bundles it, as a page does, else the system's.
use std::path::PathBuf;
use std::sync::OnceLock;
struct Glob
{
weight: u32,
mime: String,
pattern: String,
case_sensitive: bool,
}
/// The MIME type shared-mime-info gives a file named `name`, from its globs alone.
pub fn mime_type( name: &str ) -> Option<&'static str>
{
static GLOBS: OnceLock<Vec<Glob>> = OnceLock::new();
best( GLOBS.get_or_init( load ), name )
}
fn load() -> Vec<Glob>
{
let bundled = crate::resource_dir().map( |dir| dir.join( "mime" ) );
let home = std::env::var_os( "XDG_DATA_HOME" ).map( PathBuf::from )
.or_else( || std::env::var_os( "HOME" ).map( |home| PathBuf::from( home ).join( ".local/share" ) ) );
let system = std::env::var( "XDG_DATA_DIRS" ).ok().filter( |dirs| !dirs.is_empty() ).unwrap_or_else( || "/usr/local/share:/usr/share".into() );
let dirs = bundled.into_iter()
.chain( home.map( |dir| dir.join( "mime" ) ) )
.chain( system.split( ':' ).map( |dir| PathBuf::from( dir ).join( "mime" ) ) );
let mut globs = Vec::new();
for dir in dirs
{
if let Ok( text ) = crate::vfs::read_to_string( dir.join( "globs2" ) ) { globs.extend( parse( &text ) ); }
}
globs
}
fn parse( text: &str ) -> impl Iterator<Item = Glob> + '_
{
text.lines().filter( |line| !line.starts_with( '#' ) ).filter_map( |line|
{
let mut fields = line.splitn( 4, ':' );
let weight = fields.next()?.parse().ok()?;
let mime = fields.next()?.to_string();
let pattern = fields.next()?.to_string();
let flags = fields.next().unwrap_or_default();
let case_sensitive = flags.split( ',' ).any( |flag| flag == "cs" );
Some( Glob { weight, mime, pattern: if case_sensitive { pattern } else { pattern.to_lowercase() }, case_sensitive } )
} )
}
/// The heaviest glob that matches, then a case-sensitive one, then the longest; the first directory's wins a tie.
fn best<'a>( globs: &'a [ Glob ], name: &str ) -> Option<&'a str>
{
let lower = name.to_lowercase();
let mut found: Option<&Glob> = None;
for glob in globs
{
let subject = if glob.case_sensitive { name } else { &lower };
if !matches( &glob.pattern, subject ) { continue; }
let better = found.is_none_or( |f| ( glob.weight, glob.case_sensitive, glob.pattern.len() ) > ( f.weight, f.case_sensitive, f.pattern.len() ) );
if better { found = Some( glob ); }
}
found.map( |glob| glob.mime.as_str() )
}
/// `fnmatch` without flags: `*`, `?` and bracket classes.
fn matches( pattern: &str, name: &str ) -> bool
{
let pattern: Vec<char> = pattern.chars().collect();
let name: Vec<char> = name.chars().collect();
matches_at( &pattern, &name )
}
fn matches_at( pattern: &[ char ], name: &[ char ] ) -> bool
{
match pattern.first()
{
None => name.is_empty(),
Some( '*' ) => ( 0..=name.len() ).any( |skip| matches_at( &pattern[ 1.. ], &name[ skip.. ] ) ),
Some( '?' ) => !name.is_empty() && matches_at( &pattern[ 1.. ], &name[ 1.. ] ),
Some( '[' ) =>
{
let Some( ( &c, rest ) ) = name.split_first() else { return false };
match class( &pattern[ 1.. ], c )
{
Some( ( true, after ) ) => matches_at( after, rest ),
Some( ( false, _ ) ) => false,
None => c == '[' && matches_at( &pattern[ 1.. ], rest ),
}
}
Some( &p ) => name.first() == Some( &p ) && matches_at( &pattern[ 1.. ], &name[ 1.. ] ),
}
}
/// Whether `c` is in the class that `pattern` opens, and what follows the class; `None` when it is never closed.
fn class( pattern: &[ char ], c: char ) -> Option<( bool, &[ char ] )>
{
let ( negated, mut i ) = match pattern.first() { Some( '!' ) | Some( '^' ) => ( true, 1 ), _ => ( false, 0 ) };
let mut hit = false;
let mut first = true;
while i < pattern.len()
{
let p = pattern[ i ];
if p == ']' && !first { return Some( ( hit != negated, &pattern[ i + 1.. ] ) ); }
first = false;
if pattern.get( i + 1 ) == Some( &'-' ) && pattern.get( i + 2 ).is_some_and( |&end| end != ']' )
{
hit |= ( p..=pattern[ i + 2 ] ).contains( &c );
i += 3;
} else {
hit |= p == c;
i += 1;
}
}
None
}
#[ cfg( test ) ]
mod tests
{
use super::*;
const GLOBS2: &str = "\
# This file was automatically generated
50:text/x-csrc:*.c
50:text/x-c++src:*.C:cs
50:image/jpeg:*.jpg
50:image/jpeg:*.jpeg
50:application/x-compressed-tar:*.tar.gz
50:application/gzip:*.gz
10:text/x-readme:readme*
50:text/x-chdr:*.[ch]h
";
fn globs() -> Vec<Glob>
{
parse( GLOBS2 ).collect()
}
#[ test ]
fn extensions_ignore_case_unless_told()
{
let globs = globs();
assert_eq!( best( &globs, "photo.JPG" ), Some( "image/jpeg" ) );
assert_eq!( best( &globs, "main.c" ), Some( "text/x-csrc" ) );
assert_eq!( best( &globs, "main.C" ), Some( "text/x-c++src" ) );
}
#[ test ]
fn the_longest_pattern_of_a_weight_wins()
{
assert_eq!( best( &globs(), "src.tar.gz" ), Some( "application/x-compressed-tar" ) );
assert_eq!( best( &globs(), "log.gz" ), Some( "application/gzip" ) );
}
#[ test ]
fn wildcards_and_classes_match()
{
let globs = globs();
assert_eq!( best( &globs, "README.md" ), Some( "text/x-readme" ) );
assert_eq!( best( &globs, "util.hh" ), Some( "text/x-chdr" ) );
assert_eq!( best( &globs, "util.ch" ), Some( "text/x-chdr" ) );
assert_eq!( best( &globs, "util.xh" ), None );
assert_eq!( best( &globs, "noextension" ), None );
}
#[ test ]
fn classes_take_ranges_and_negation()
{
assert!( matches( "file[0-9]", "file7" ) );
assert!( !matches( "file[!0-9]", "file7" ) );
assert!( matches( "a[]]b", "a]b" ) );
assert!( matches( "a[b", "a[b" ) );
}
}
+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 );
}
}
+319
View File
@@ -0,0 +1,319 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! `ltk::files` natively: `std::fs` on one worker thread, in the order the app asked, and each copy on a thread of its own so that a long one holds nothing else up.
use std::fs::{ self, File, OpenOptions };
use std::io::{ self, Read, Seek, SeekFrom, Write };
use std::path::{ Path, PathBuf };
use std::sync::atomic::{ AtomicBool, AtomicU64, Ordering };
use std::sync::mpsc;
use std::sync::{ Arc, Mutex, OnceLock, PoisonError };
use std::thread;
use super::{ Entry, Kind, Metadata, Mount, Progress, Reply };
type Job = Box<dyn FnOnce() + Send>;
/// Run `job` on the worker, or here when there is none.
fn run( job: impl FnOnce() + Send + 'static )
{
static WORKER: OnceLock<Option<mpsc::Sender<Job>>> = OnceLock::new();
let worker = WORKER.get_or_init( ||
{
let ( tx, rx ) = mpsc::channel::<Job>();
thread::Builder::new()
.name( "ltk-files".into() )
.spawn( move || { for job in rx { job(); } } )
.ok()
.map( |_| tx )
} );
let job: Job = Box::new( job );
let job = match worker
{
Some( tx ) => match tx.send( job ) { Ok( () ) => return, Err( mpsc::SendError( job ) ) => job },
None => job,
};
job();
}
pub fn list( path: PathBuf, reply: Reply<Vec<Entry>> )
{
run( move || reply( list_dir( &path ) ) );
}
pub fn stat( path: PathBuf, reply: Reply<Metadata> )
{
run( move || reply( fs::metadata( &path ).map( |meta| metadata( &meta ) ) ) );
}
pub fn read( path: PathBuf, range: Option<( u64, u64 )>, reply: Reply<Vec<u8>> )
{
run( move || reply( match range
{
Some( ( offset, length ) ) => read_range( &path, offset, length ),
None => fs::read( &path ),
} ) );
}
pub fn write( path: PathBuf, data: Vec<u8>, reply: Reply<()> )
{
run( move || reply( Pending::create( &path ).and_then( |mut file| { file.write( &data )?; file.commit() } ) ) );
}
pub fn create_dir( path: PathBuf, reply: Reply<()> )
{
run( move || reply( fs::create_dir( &path ) ) );
}
pub fn rename( from: PathBuf, to: PathBuf, reply: Reply<()> )
{
run( move || reply( if fs::symlink_metadata( &to ).is_ok() { Err( io::ErrorKind::AlreadyExists.into() ) } else { fs::rename( &from, &to ) } ) );
}
pub fn remove( path: PathBuf, all: bool, reply: Reply<()> )
{
run( move || reply( remove_path( &path, all ) ) );
}
pub fn mount( reply: Reply<Mount> )
{
reply( Err( io::ErrorKind::Unsupported.into() ) );
}
pub fn unmount( _path: PathBuf, reply: Reply<()> )
{
reply( Err( io::ErrorKind::Unsupported.into() ) );
}
/// A writer's file, shared with the jobs that write it.
pub type WriterHandle = Arc<Mutex<Option<Pending>>>;
pub fn open_write( path: PathBuf, reply: Reply<WriterHandle> )
{
run( move || reply( Pending::create( &path ).map( |file| Arc::new( Mutex::new( Some( file ) ) ) ) ) );
}
pub fn write_chunk( handle: &WriterHandle, chunk: Vec<u8>, reply: Reply<()> )
{
let handle = handle.clone();
run( move || reply( match lock( &handle ).as_mut()
{
Some( file ) => file.write( &chunk ),
None => Err( io::ErrorKind::BrokenPipe.into() ),
} ) );
}
pub fn close( handle: WriterHandle, reply: Reply<()> )
{
run( move ||
{
let file = lock( &handle ).take();
reply( file.map_or_else( || Err( io::ErrorKind::BrokenPipe.into() ), Pending::commit ) );
} );
}
pub fn abort( handle: WriterHandle )
{
run( move || drop( lock( &handle ).take() ) );
}
fn lock( handle: &WriterHandle ) -> std::sync::MutexGuard<'_, Option<Pending>>
{
handle.lock().unwrap_or_else( PoisonError::into_inner )
}
pub type OperationHandle = Arc<AtomicBool>;
pub fn copy( from: PathBuf, to: PathBuf, all: bool, progress: Progress, reply: Reply<()> ) -> OperationHandle
{
let cancelled = Arc::new( AtomicBool::new( false ) );
let flag = cancelled.clone();
let job = move || reply( super::copy::copy( &from, &to, all, &flag, &progress ) );
let job: Job = Box::new( job );
let job = Arc::new( Mutex::new( Some( job ) ) );
let spawned = job.clone();
let started = thread::Builder::new()
.name( "ltk-files-copy".into() )
.spawn( move || { if let Some( job ) = spawned.lock().unwrap_or_else( PoisonError::into_inner ).take() { job(); } } )
.is_ok();
if !started
{
if let Some( job ) = job.lock().unwrap_or_else( PoisonError::into_inner ).take() { run( job ); }
}
cancelled
}
pub fn cancel( handle: &OperationHandle )
{
handle.store( true, Ordering::Relaxed );
}
pub( super ) fn list_dir( path: &Path ) -> io::Result<Vec<Entry>>
{
let mut entries = Vec::new();
for entry in fs::read_dir( path )?
{
let Ok( entry ) = entry else { continue };
// A link is what it points to; a broken one is a file of its own.
let Ok( meta ) = fs::metadata( entry.path() ).or_else( |_| entry.metadata() ) else { continue };
let meta = metadata( &meta );
entries.push( Entry { name: entry.file_name(), kind: meta.kind, size: meta.size, modified: meta.modified, accessed: meta.accessed } );
}
Ok( entries )
}
fn metadata( meta: &fs::Metadata ) -> Metadata
{
let kind = if meta.is_dir() { Kind::Directory } else { Kind::File };
Metadata
{
kind,
size: if kind == Kind::File { meta.len() } else { 0 },
modified: meta.modified().ok(),
accessed: meta.accessed().ok(),
}
}
pub( super ) fn read_range( path: &Path, offset: u64, length: u64 ) -> io::Result<Vec<u8>>
{
let mut file = File::open( path )?;
file.seek( SeekFrom::Start( offset ) )?;
let mut data = Vec::new();
file.take( length ).read_to_end( &mut data )?;
Ok( data )
}
pub( super ) fn remove_path( path: &Path, all: bool ) -> io::Result<()>
{
match ( fs::symlink_metadata( path )?.is_dir(), all )
{
( true, true ) => fs::remove_dir_all( path ),
( true, false ) => fs::remove_dir( path ),
( false, _ ) => fs::remove_file( path ),
}
}
/// A file written beside `target`, which becomes it only on [`Pending::commit`]; dropped before, it goes.
pub struct Pending
{
file: Option<File>,
temp: PathBuf,
target: PathBuf,
}
impl Pending
{
pub( super ) fn create( target: &Path ) -> io::Result<Self>
{
static NEXT: AtomicU64 = AtomicU64::new( 0 );
let name = target.file_name().ok_or( io::ErrorKind::InvalidInput )?.to_string_lossy();
let temp = target.with_file_name( format!( ".{name}.{}-{}.part", std::process::id(), NEXT.fetch_add( 1, Ordering::Relaxed ) ) );
let file = OpenOptions::new().write( true ).create_new( true ).open( &temp )?;
if let Ok( meta ) = fs::metadata( target ) { let _ = file.set_permissions( meta.permissions() ); }
Ok( Self { file: Some( file ), temp, target: target.to_path_buf() } )
}
pub( super ) fn write( &mut self, chunk: &[ u8 ] ) -> io::Result<()>
{
self.file.as_mut().ok_or( io::ErrorKind::BrokenPipe )?.write_all( chunk )
}
pub( super ) fn commit( mut self ) -> io::Result<()>
{
let file = self.file.take().ok_or( io::ErrorKind::BrokenPipe )?;
file.sync_all()?;
drop( file );
fs::rename( &self.temp, &self.target )
}
}
impl Drop for Pending
{
fn drop( &mut self )
{
if self.file.take().is_some() { let _ = fs::remove_file( &self.temp ); }
}
}
#[ cfg( test ) ]
mod tests
{
use super::*;
fn scratch( name: &str ) -> PathBuf
{
let dir = std::env::temp_dir().join( format!( "ltk-files-{name}-{}", std::process::id() ) );
let _ = fs::remove_dir_all( &dir );
fs::create_dir_all( &dir ).unwrap();
dir
}
#[ test ]
fn a_pending_file_appears_only_once_committed()
{
let dir = scratch( "commit" );
let target = dir.join( "a.txt" );
let mut file = Pending::create( &target ).unwrap();
file.write( b"one " ).unwrap();
file.write( b"two" ).unwrap();
assert!( !target.exists() );
file.commit().unwrap();
assert_eq!( fs::read( &target ).unwrap(), b"one two" );
assert_eq!( fs::read_dir( &dir ).unwrap().count(), 1 );
fs::remove_dir_all( &dir ).unwrap();
}
#[ test ]
fn a_dropped_pending_file_leaves_the_old_one()
{
let dir = scratch( "abort" );
let target = dir.join( "a.txt" );
fs::write( &target, b"old" ).unwrap();
let mut file = Pending::create( &target ).unwrap();
file.write( b"new" ).unwrap();
drop( file );
assert_eq!( fs::read( &target ).unwrap(), b"old" );
assert_eq!( fs::read_dir( &dir ).unwrap().count(), 1 );
fs::remove_dir_all( &dir ).unwrap();
}
#[ test ]
fn a_listing_gives_kinds_and_sizes()
{
let dir = scratch( "list" );
fs::write( dir.join( "f" ), b"12345" ).unwrap();
fs::create_dir( dir.join( "d" ) ).unwrap();
let mut entries = list_dir( &dir ).unwrap();
entries.sort_by( |a, b| a.name.cmp( &b.name ) );
assert_eq!( entries.len(), 2 );
assert_eq!( ( entries[ 0 ].kind, entries[ 0 ].size ), ( Kind::Directory, 0 ) );
assert_eq!( ( entries[ 1 ].kind, entries[ 1 ].size ), ( Kind::File, 5 ) );
fs::remove_dir_all( &dir ).unwrap();
}
#[ test ]
fn a_range_stops_at_the_end()
{
let dir = scratch( "range" );
let file = dir.join( "f" );
fs::write( &file, b"0123456789" ).unwrap();
assert_eq!( read_range( &file, 3, 4 ).unwrap(), b"3456" );
assert_eq!( read_range( &file, 8, 4 ).unwrap(), b"89" );
assert!( read_range( &file, 20, 4 ).unwrap().is_empty() );
fs::remove_dir_all( &dir ).unwrap();
}
#[ test ]
fn remove_needs_all_for_a_full_directory()
{
let dir = scratch( "remove" );
let sub = dir.join( "d" );
fs::create_dir( &sub ).unwrap();
fs::write( sub.join( "f" ), b"x" ).unwrap();
assert!( remove_path( &sub, false ).is_err() );
remove_path( &sub, true ).unwrap();
assert!( !sub.exists() );
fs::remove_dir_all( &dir ).unwrap();
}
}
+33
View File
@@ -0,0 +1,33 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! `ltk::files` on wasm32 without the web backend, where there are no files to reach.
use std::io;
use std::path::PathBuf;
use super::{ Entry, Metadata, Mount, Progress, Reply };
pub type WriterHandle = ();
pub type OperationHandle = ();
fn unsupported<T>( reply: Reply<T> )
{
reply( Err( io::ErrorKind::Unsupported.into() ) );
}
pub fn list( _path: PathBuf, reply: Reply<Vec<Entry>> ) { unsupported( reply ) }
pub fn stat( _path: PathBuf, reply: Reply<Metadata> ) { unsupported( reply ) }
pub fn read( _path: PathBuf, _range: Option<( u64, u64 )>, reply: Reply<Vec<u8>> ) { unsupported( reply ) }
pub fn write( _path: PathBuf, _data: Vec<u8>, reply: Reply<()> ) { unsupported( reply ) }
pub fn open_write( _path: PathBuf, reply: Reply<WriterHandle> ) { unsupported( reply ) }
pub fn write_chunk( _handle: &WriterHandle, _chunk: Vec<u8>, reply: Reply<()> ) { unsupported( reply ) }
pub fn close( _handle: WriterHandle, reply: Reply<()> ) { unsupported( reply ) }
pub fn abort( _handle: WriterHandle ) {}
pub fn create_dir( _path: PathBuf, reply: Reply<()> ) { unsupported( reply ) }
pub fn rename( _from: PathBuf, _to: PathBuf, reply: Reply<()> ) { unsupported( reply ) }
pub fn remove( _path: PathBuf, _all: bool, reply: Reply<()> ) { unsupported( reply ) }
pub fn copy( _from: PathBuf, _to: PathBuf, _all: bool, _progress: Progress, reply: Reply<()> ) -> OperationHandle { unsupported( reply ) }
pub fn cancel( _handle: &OperationHandle ) {}
pub fn mount( reply: Reply<Mount> ) { unsupported( reply ) }
pub fn unmount( _path: PathBuf, reply: Reply<()> ) { unsupported( reply ) }
+2 -1
View File
@@ -321,6 +321,7 @@ pub( crate ) mod secure_mem;
pub( crate ) mod resources;
pub mod time;
pub mod vfs;
pub mod files;
pub mod gles_render;
#[ cfg( not( target_arch = "wasm32" ) ) ]
pub mod egl_context;
@@ -432,7 +433,7 @@ pub use platform::android::{ keep_running, set_android_app, AndroidApp };
#[ cfg( feature = "web" ) ]
pub mod web
{
pub use crate::platform::web::{ load_resources, load_tree, set_canvas, set_shell_listener, shell_send };
pub use crate::platform::web::{ load_resources, load_tree, set_canvas, set_shell_listener, shell_request, shell_send };
}
pub use resources::{ resource_dir, set_resource_dir };
pub use xkeysym::Keysym;
+12 -4
View File
@@ -12,7 +12,7 @@ use crate::app::{ App, Layer, ShellMode, ToplevelEvent };
use crate::event_loop::AppData;
use super::run::{ push, PageEvent };
use super::shell;
use super::{ files, shell };
/// The id the main surface goes by.
pub( super ) const MAIN: u32 = 0;
@@ -21,7 +21,7 @@ pub( crate ) struct Connection
{
request: js_sys::Function,
disconnect: js_sys::Function,
_on_event: Closure<dyn FnMut( String )>,
_on_event: Closure<dyn FnMut( String, JsValue )>,
}
impl Connection
@@ -31,11 +31,12 @@ impl Connection
{
let api = js_sys::Reflect::get( &js_sys::global(), &"forgeWeb".into() ).ok().filter( |v| v.is_object() )?;
let connect: js_sys::Function = js_sys::Reflect::get( &api, &"connect".into() ).ok()?.dyn_into().ok()?;
let on_event = Closure::<dyn FnMut( String )>::new( on_event );
let on_event = Closure::<dyn FnMut( String, JsValue )>::new( on_event );
let connection = connect.call2( &api, &JsValue::from_str( app_id ), on_event.as_ref() ).ok()?;
let method = |name: &str| js_sys::Reflect::get( &connection, &name.into() ).ok().and_then( |f| f.dyn_into::<js_sys::Function>().ok() );
let this = Self { request: method( "request" )?, disconnect: method( "disconnect" )?, _on_event: on_event };
shell::connected( Some( this.request.clone() ) );
files::connected( Some( this.request.clone() ) );
if let Err( e ) = this.request( &json!( { "type": "list_toplevels" } ) )
{
web_sys::console::warn_1( &format!( "[ltk] list_toplevels: {e}" ).into() );
@@ -71,11 +72,13 @@ impl Connection
pub( super ) fn close( &self )
{
shell::connected( None );
files::connected( None );
let _ = self.disconnect.call0( &JsValue::NULL );
}
}
fn on_event( json: String )
/// One of forge/web's events, with the file contents `data` carries for an `fs_reply`.
fn on_event( json: String, data: JsValue )
{
let Ok( event ) = serde_json::from_str::<Value>( &json ) else { return };
let surface = event[ "surface" ].as_u64().unwrap_or( 0 ) as u32;
@@ -98,6 +101,11 @@ fn on_event( json: String )
title: event[ "title" ].as_str().unwrap_or_default().to_string(),
} ) ),
Some( "toplevel_closed" ) => push( PageEvent::Toplevel( ToplevelEvent::Closed { id: event[ "id" ].as_u64().unwrap_or( 0 ) as u32 } ) ),
Some( "fs_reply" ) => files::reply( &event, data ),
Some( "fs_progress" ) => files::progress( &event ),
Some( "fs_changed" ) => push( PageEvent::FilesChanged( event[ "path" ].as_str().unwrap_or_default().into() ) ),
Some( "fs_mounts" ) => push( PageEvent::Mounts( files::mounts( &event ) ) ),
Some( "open_files" ) => push( PageEvent::OpenFiles( files::paths( &event ) ) ),
_ => web_sys::console::warn_1( &format!( "[ltk] forge/web: {json}" ).into() ),
}
}
+293
View File
@@ -0,0 +1,293 @@
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! `ltk::files` in a browser: forge/web's file requests over the app's connection (forge/web/DESIGN.md, section 11). Each request carries a number its `fs_reply` answers with; contents travel as a `Uint8Array` beside the JSON, as the request's and the event's second argument. What is asked before the app has connected waits for the connection, and fails with `Unsupported` on a page without a desktop.
use std::cell::{ Cell, RefCell };
use std::collections::HashMap;
use std::io;
use std::path::{ Path, PathBuf };
use std::time::{ Duration, UNIX_EPOCH };
use serde_json::{ json, Value };
use wasm_bindgen::{ JsCast, JsValue };
use crate::files::{ Entry, Kind, Metadata, Mount, Progress, Reply };
type Answer = io::Result<( Value, Option<Vec<u8>> )>;
type OnReply = Box<dyn FnOnce( Answer )>;
enum Link
{
Waiting( Vec<( Value, Option<Vec<u8>> )> ),
Open( js_sys::Function ),
Absent,
}
thread_local!
{
static LINK: RefCell<Link> = const { RefCell::new( Link::Waiting( Vec::new() ) ) };
static NEXT: Cell<u64> = const { Cell::new( 1 ) };
static REPLIES: RefCell<HashMap<u64, OnReply>> = RefCell::new( HashMap::new() );
static PROGRESS: RefCell<HashMap<u64, Progress>> = RefCell::new( HashMap::new() );
}
pub type WriterHandle = u64;
pub type OperationHandle = u64;
pub fn list( path: PathBuf, reply: Reply<Vec<Entry>> )
{
request( json!( { "type": "fs_list", "path": name( &path ) } ), None, Box::new( move |answer| reply( answer.map( |( value, _ )| entries( &value ) ) ) ) );
}
pub fn stat( path: PathBuf, reply: Reply<Metadata> )
{
request( json!( { "type": "fs_stat", "path": name( &path ) } ), None, Box::new( move |answer|
{
reply( answer.and_then( |( value, _ )| metadata( &value ).ok_or_else( || io::ErrorKind::InvalidData.into() ) ) )
} ) );
}
pub fn read( path: PathBuf, range: Option<( u64, u64 )>, reply: Reply<Vec<u8>> )
{
let mut message = json!( { "type": "fs_read", "path": name( &path ) } );
if let Some( ( offset, length ) ) = range
{
message[ "offset" ] = offset.into();
message[ "length" ] = length.into();
}
request( message, None, Box::new( move |answer| reply( answer.map( |( _, data )| data.unwrap_or_default() ) ) ) );
}
pub fn write( path: PathBuf, data: Vec<u8>, reply: Reply<()> )
{
request( json!( { "type": "fs_write", "path": name( &path ) } ), Some( data ), done( reply ) );
}
pub fn open_write( path: PathBuf, reply: Reply<WriterHandle> )
{
request( json!( { "type": "fs_open_write", "path": name( &path ) } ), None, Box::new( move |answer|
{
reply( answer.and_then( |( value, _ )| value[ "handle" ].as_u64().ok_or_else( || io::ErrorKind::InvalidData.into() ) ) )
} ) );
}
pub fn write_chunk( handle: &WriterHandle, chunk: Vec<u8>, reply: Reply<()> )
{
request( json!( { "type": "fs_write_chunk", "handle": handle } ), Some( chunk ), done( reply ) );
}
pub fn close( handle: WriterHandle, reply: Reply<()> )
{
request( json!( { "type": "fs_close", "handle": handle } ), None, done( reply ) );
}
pub fn abort( handle: WriterHandle )
{
request( json!( { "type": "fs_abort", "handle": handle } ), None, Box::new( |_| {} ) );
}
pub fn create_dir( path: PathBuf, reply: Reply<()> )
{
request( json!( { "type": "fs_mkdir", "path": name( &path ) } ), None, done( reply ) );
}
pub fn rename( from: PathBuf, to: PathBuf, reply: Reply<()> )
{
request( json!( { "type": "fs_rename", "from": name( &from ), "to": name( &to ) } ), None, done( reply ) );
}
pub fn remove( path: PathBuf, all: bool, reply: Reply<()> )
{
request( json!( { "type": "fs_remove", "path": name( &path ), "recursive": all } ), None, done( reply ) );
}
pub fn copy( from: PathBuf, to: PathBuf, all: bool, progress: Progress, reply: Reply<()> ) -> OperationHandle
{
let kind = if all { "fs_copy_all" } else { "fs_copy" };
let id = next_id();
PROGRESS.with( |p| p.borrow_mut().insert( id, progress ) );
send( id, json!( { "type": kind, "from": name( &from ), "to": name( &to ) } ), None, done( reply ) );
id
}
pub fn cancel( handle: &OperationHandle )
{
request( json!( { "type": "fs_cancel", "copy": handle } ), None, Box::new( |_| {} ) );
}
pub fn mount( reply: Reply<Mount> )
{
request( json!( { "type": "fs_mount" } ), None, Box::new( move |answer|
{
reply( answer.and_then( |( value, _ )| mount_of( &value ).ok_or_else( || io::ErrorKind::InvalidData.into() ) ) )
} ) );
}
pub fn unmount( path: PathBuf, reply: Reply<()> )
{
request( json!( { "type": "fs_unmount", "path": name( &path ) } ), None, done( reply ) );
}
/// The connection's `request` once connected; `None` when there is no desktop or it has closed, which fails what is waiting.
pub( super ) fn connected( request: Option<js_sys::Function> )
{
let waiting = LINK.with( |l|
{
let link = match &request { Some( f ) => Link::Open( f.clone() ), None => Link::Absent };
match std::mem::replace( &mut *l.borrow_mut(), link )
{
Link::Waiting( queue ) => queue,
_ => Vec::new(),
}
} );
match request
{
Some( request ) =>
{
for ( message, data ) in waiting
{
let id = message[ "request" ].as_u64().unwrap_or_default();
transmit( &request, id, &message, data );
}
}
None =>
{
let pending: Vec<u64> = REPLIES.with( |r| r.borrow().keys().copied().collect() );
for id in pending { answer( id, Err( io::ErrorKind::Unsupported.into() ) ); }
}
}
}
/// forge/web's `fs_reply`, with the contents it carries as `data`.
pub( super ) fn reply( event: &Value, data: JsValue )
{
let Some( id ) = event[ "request" ].as_u64() else { return };
let result = match event[ "error" ].as_str()
{
Some( code ) => Err( error( code ) ),
None => Ok( ( event.clone(), data.dyn_ref::<js_sys::Uint8Array>().map( js_sys::Uint8Array::to_vec ) ) ),
};
answer( id, result );
}
/// forge/web's `fs_progress` for a copy.
pub( super ) fn progress( event: &Value )
{
let Some( id ) = event[ "request" ].as_u64() else { return };
let copied = event[ "done" ].as_u64().unwrap_or_default();
let total = event[ "total" ].as_u64().unwrap_or_default();
PROGRESS.with( |p| { if let Some( progress ) = p.borrow().get( &id ) { progress( copied, total ); } } );
}
pub( super ) fn mounts( event: &Value ) -> Vec<Mount>
{
event[ "mounts" ].as_array().map( |list| list.iter().filter_map( mount_of ).collect() ).unwrap_or_default()
}
pub( super ) fn paths( event: &Value ) -> Vec<PathBuf>
{
event[ "paths" ].as_array().map( |list| list.iter().filter_map( Value::as_str ).map( PathBuf::from ).collect() ).unwrap_or_default()
}
fn next_id() -> u64
{
NEXT.with( |n| { let id = n.get(); n.set( id + 1 ); id } )
}
fn request( message: Value, data: Option<Vec<u8>>, on_reply: OnReply )
{
send( next_id(), message, data, on_reply );
}
fn send( id: u64, mut message: Value, data: Option<Vec<u8>>, on_reply: OnReply )
{
message[ "request" ] = id.into();
REPLIES.with( |r| r.borrow_mut().insert( id, on_reply ) );
let unsent = LINK.with( move |l|
{
match &mut *l.borrow_mut()
{
Link::Waiting( queue ) => { queue.push( ( message, data ) ); None }
Link::Open( request ) => Some( ( Some( request.clone() ), message, data ) ),
Link::Absent => Some( ( None, message, data ) ),
}
} );
match unsent
{
Some( ( Some( request ), message, data ) ) => transmit( &request, id, &message, data ),
Some( ( None, _, _ ) ) => answer( id, Err( io::ErrorKind::Unsupported.into() ) ),
None => {}
}
}
fn transmit( request: &js_sys::Function, id: u64, message: &Value, data: Option<Vec<u8>> )
{
let data = data.map_or( JsValue::UNDEFINED, |data| js_sys::Uint8Array::from( data.as_slice() ).into() );
if let Err( e ) = request.call2( &JsValue::NULL, &JsValue::from_str( &message.to_string() ), &data )
{
let reason = e.as_string().unwrap_or_else( || format!( "{e:?}" ) );
answer( id, Err( io::Error::other( reason ) ) );
}
}
fn answer( id: u64, result: Answer )
{
PROGRESS.with( |p| p.borrow_mut().remove( &id ) );
let Some( on_reply ) = REPLIES.with( |r| r.borrow_mut().remove( &id ) ) else { return };
on_reply( result );
}
fn done( reply: Reply<()> ) -> OnReply
{
Box::new( move |answer| reply( answer.map( |_| () ) ) )
}
fn name( path: &Path ) -> String
{
path.to_string_lossy().into_owned()
}
fn error( code: &str ) -> io::Error
{
let kind = match code
{
"not_found" => io::ErrorKind::NotFound,
"exists" => io::ErrorKind::AlreadyExists,
"not_a_directory" => io::ErrorKind::NotADirectory,
"is_a_directory" => io::ErrorKind::IsADirectory,
"not_empty" => io::ErrorKind::DirectoryNotEmpty,
"denied" => io::ErrorKind::PermissionDenied,
"unsupported" => io::ErrorKind::Unsupported,
"cancelled" => io::ErrorKind::Interrupted,
_ => io::ErrorKind::Other,
};
io::Error::new( kind, code.to_string() )
}
fn metadata( value: &Value ) -> Option<Metadata>
{
let kind = match value[ "kind" ].as_str()? { "directory" => Kind::Directory, _ => Kind::File };
Some( Metadata
{
kind,
size: value[ "size" ].as_u64().unwrap_or_default(),
modified: value[ "modified" ].as_f64().map( |ms| UNIX_EPOCH + Duration::from_millis( ms.max( 0.0 ) as u64 ) ),
accessed: None,
} )
}
fn entries( value: &Value ) -> Vec<Entry>
{
let Some( list ) = value[ "entries" ].as_array() else { return Vec::new() };
list.iter().filter_map( |entry|
{
let meta = metadata( entry )?;
Some( Entry { name: entry[ "name" ].as_str()?.into(), kind: meta.kind, size: meta.size, modified: meta.modified, accessed: None } )
} ).collect()
}
fn mount_of( value: &Value ) -> Option<Mount>
{
Some( Mount { path: value[ "path" ].as_str()?.into(), name: value[ "name" ].as_str()?.to_string() } )
}
+4 -2
View File
@@ -7,7 +7,8 @@
//! | --- | --- |
//! | `assets.rs` | The themes, fonts and keyboard layouts a page serves, fetched into memory as the app's resource directory |
//! | `desktop.rs` | The connection to forge/web and the main surface's role |
//! | `shell.rs` | The shell protocol over that connection, for a shell such as crustace: [`shell_send`] and [`set_shell_listener`] |
//! | `shell.rs` | The shell protocol over that connection, for a shell such as crustace: [`shell_send`], [`shell_request`] and [`set_shell_listener`] |
//! | `files.rs` | [`crate::files`] over that connection: forge/web's file requests, their replies and a copy's progress |
//! | `overlays.rs` | The app's overlays as surfaces of the desktop, kept in step with `App::overlays()` |
//! | `subsurfaces.rs` | The app's subsurfaces as surfaces of the desktop, painted when their content changes and otherwise only moved |
//! | `log.rs` | Panics into the browser's console |
@@ -25,6 +26,7 @@ mod assets;
mod clipboard;
mod cursor;
mod desktop;
pub( crate ) mod files;
mod input;
mod keys;
mod log;
@@ -38,7 +40,7 @@ mod surface;
pub use assets::{ load_resources, load_tree };
pub use run::set_canvas;
pub use shell::{ set_shell_listener, shell_send };
pub use shell::{ set_shell_listener, shell_request, shell_send };
pub( crate ) use state::WebPlatform;
pub( crate ) use surface::WebSurfaceState;
+18
View File
@@ -44,6 +44,11 @@ pub( super ) enum PageEvent
},
/// A window of the desktop's, as `ext-foreign-toplevel-list` reports it.
Toplevel( ToplevelEvent ),
/// A directory another client changed.
FilesChanged( std::path::PathBuf ),
Mounts( Vec<crate::files::Mount> ),
/// The files the app was launched to open.
OpenFiles( Vec<std::path::PathBuf> ),
}
/// An event listener, registered for as long as it lives.
@@ -126,6 +131,7 @@ pub( crate ) fn try_run<A: App>( app: A ) -> Result<(), RunError>
// A canvas the page named wins; else the page's desktop gives the main surface, and without one ltk lays a canvas over the page.
let given = CANVAS.with( |c| c.borrow_mut().take() );
let desktop = if given.is_none() { Connection::open( app.app_id() ) } else { None };
if desktop.is_none() { super::files::connected( None ); }
let ( canvas, target, own ) = match ( desktop.as_ref(), given )
{
( Some( desktop ), _ ) =>
@@ -296,6 +302,18 @@ impl<A: App> AppData<A>
{
if let Some( msg ) = self.app.on_toplevel_event( event ) { self.pending_msgs.push( msg ); }
}
PageEvent::FilesChanged( dir ) =>
{
if let Some( msg ) = self.app.on_files_changed( &dir ) { self.pending_msgs.push( msg ); }
}
PageEvent::Mounts( mounts ) =>
{
if let Some( msg ) = self.app.on_mounts_changed( &mounts ) { self.pending_msgs.push( msg ); }
}
PageEvent::OpenFiles( paths ) =>
{
if let Some( msg ) = self.app.on_open_files( paths ) { self.pending_msgs.push( msg ); }
}
}
}
self.dispatch_sources();
+21 -15
View File
@@ -18,36 +18,42 @@ thread_local!
/// Send `message`, one of the shell protocol's JSON objects, to forge/web.
pub fn shell_send( message: &str )
{
let message: Value = match serde_json::from_str( message )
{
Ok( m ) => m,
Err( e ) =>
{
web_sys::console::warn_1( &format!( "[ltk] shell message is not JSON ({e}): {message}" ).into() );
return;
}
};
let Some( message ) = parse( message ) else { return };
let request = REQUEST.with( |r| r.borrow().clone() );
match request
{
Some( request ) => send( &request, message ),
Some( request ) => { send( &request, message ); }
None => PENDING.with( |p| p.borrow_mut().push( message ) ),
}
}
/// Send `message` as [`shell_send`] does and return forge/web's reply, as JSON, at once. `None` before the app has connected, since a request is not kept for later, and when forge/web replies nothing.
pub fn shell_request( message: &str ) -> Option<String>
{
let message = parse( message )?;
let request = REQUEST.with( |r| r.borrow().clone() )?;
send( &request, message )?.as_string()
}
fn parse( message: &str ) -> Option<Value>
{
serde_json::from_str( message )
.map_err( |e| web_sys::console::warn_1( &format!( "[ltk] shell message is not JSON ({e}): {message}" ).into() ) )
.ok()
}
/// Have `listener` receive each of forge/web's shell messages as JSON.
pub fn set_shell_listener( listener: impl Fn( &str ) + 'static )
{
LISTENER.with( |l| *l.borrow_mut() = Some( Box::new( listener ) ) );
}
fn send( request: &js_sys::Function, message: Value )
fn send( request: &js_sys::Function, message: Value ) -> Option<JsValue>
{
let json = json!( { "type": "shell", "message": message } ).to_string();
if let Err( e ) = request.call1( &JsValue::NULL, &JsValue::from_str( &json ) )
{
web_sys::console::warn_2( &"[ltk] forge/web refused a shell message:".into(), &e );
}
request.call1( &JsValue::NULL, &JsValue::from_str( &json ) )
.map_err( |e| web_sys::console::warn_2( &"[ltk] forge/web refused a shell message:".into(), &e ) )
.ok()
}
/// The connection's `request`, once connected, and `None` once it has closed.