diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bff094..9938cc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. diff --git a/scripts/android-assets.sh b/scripts/android-assets.sh index 17e9155..40947e6 100755 --- a/scripts/android-assets.sh +++ b/scripts/android-assets.sh @@ -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 ) { diff --git a/src/app.rs b/src/app.rs index c962556..10e8ec0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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 + { + 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 + { + 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 ) -> Option + { + None + } + /// Return `Some(id)` once to focus the widget with that [`WidgetId`](crate::types::WidgetId). /// The app must return `None` after the first call. /// diff --git a/src/files/copy.rs b/src/files/copy.rs new file mode 100644 index 0000000..a448a04 --- /dev/null +++ b/src/files/copy.rs @@ -0,0 +1,180 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! 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, + 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 +{ + 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(); + } +} diff --git a/src/files/mime.rs b/src/files/mime.rs new file mode 100644 index 0000000..c735a47 --- /dev/null +++ b/src/files/mime.rs @@ -0,0 +1,179 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! 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> = OnceLock::new(); + best( GLOBS.get_or_init( load ), name ) +} + +fn load() -> Vec +{ + 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 + '_ +{ + 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 = pattern.chars().collect(); + let name: Vec = 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 + { + 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" ) ); + } +} diff --git a/src/files/mod.rs b/src/files/mod.rs new file mode 100644 index 0000000..7ad223c --- /dev/null +++ b/src/files/mod.rs @@ -0,0 +1,249 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! 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, + /// `None` in a browser, which does not keep it. + pub accessed: Option, +} + +#[ derive( Debug, Clone ) ] +pub struct Metadata +{ + pub kind: Kind, + pub size: u64, + pub modified: Option, + pub accessed: Option, +} + +/// 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 = Box ) + Send>; +pub( crate ) type Progress = Box; + +fn reply( sender: &ChannelSender, done: impl FnOnce( io::Result ) -> M + Send + 'static ) -> Reply +{ + 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( path: impl Into, sender: &ChannelSender, done: impl FnOnce( io::Result> ) -> M + Send + 'static ) +{ + backend::list( path.into(), reply( sender, done ) ); +} + +pub fn stat( path: impl Into, sender: &ChannelSender, done: impl FnOnce( io::Result ) -> M + Send + 'static ) +{ + backend::stat( path.into(), reply( sender, done ) ); +} + +/// The whole of a small file. +pub fn read( path: impl Into, sender: &ChannelSender, done: impl FnOnce( io::Result> ) -> 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( path: impl Into, offset: u64, length: u64, sender: &ChannelSender, done: impl FnOnce( io::Result> ) -> 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( path: impl Into, data: Vec, sender: &ChannelSender, 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( path: impl Into, sender: &ChannelSender, done: impl FnOnce( io::Result ) -> 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( path: impl Into, sender: &ChannelSender, 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( from: impl Into, to: impl Into, sender: &ChannelSender, 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( path: impl Into, sender: &ChannelSender, 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( path: impl Into, sender: &ChannelSender, 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( + from: impl Into, + to: impl Into, + sender: &ChannelSender, + 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( + from: impl Into, + to: impl Into, + sender: &ChannelSender, + 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( + from: PathBuf, + to: PathBuf, + all: bool, + sender: &ChannelSender, + 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( sender: &ChannelSender, done: impl FnOnce( io::Result ) -> M + Send + 'static ) +{ + backend::mount( reply( sender, done ) ); +} + +pub fn unmount( path: impl Into, sender: &ChannelSender, 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, +} + +impl Writer +{ + /// Append `chunk`; send the next once `done` has come. + pub fn write( &self, chunk: Vec, sender: &ChannelSender, 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( mut self, sender: &ChannelSender, 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 ); + } +} diff --git a/src/files/native.rs b/src/files/native.rs new file mode 100644 index 0000000..56c7f89 --- /dev/null +++ b/src/files/native.rs @@ -0,0 +1,319 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! `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; + +/// Run `job` on the worker, or here when there is none. +fn run( job: impl FnOnce() + Send + 'static ) +{ + static WORKER: OnceLock>> = OnceLock::new(); + let worker = WORKER.get_or_init( || + { + let ( tx, rx ) = mpsc::channel::(); + 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> ) +{ + run( move || reply( list_dir( &path ) ) ); +} + +pub fn stat( path: PathBuf, reply: Reply ) +{ + run( move || reply( fs::metadata( &path ).map( |meta| metadata( &meta ) ) ) ); +} + +pub fn read( path: PathBuf, range: Option<( u64, u64 )>, reply: Reply> ) +{ + run( move || reply( match range + { + Some( ( offset, length ) ) => read_range( &path, offset, length ), + None => fs::read( &path ), + } ) ); +} + +pub fn write( path: PathBuf, data: Vec, 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 ) +{ + 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>>; + +pub fn open_write( path: PathBuf, reply: Reply ) +{ + run( move || reply( Pending::create( &path ).map( |file| Arc::new( Mutex::new( Some( file ) ) ) ) ) ); +} + +pub fn write_chunk( handle: &WriterHandle, chunk: Vec, 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> +{ + handle.lock().unwrap_or_else( PoisonError::into_inner ) +} + +pub type OperationHandle = Arc; + +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> +{ + 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> +{ + 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, + temp: PathBuf, + target: PathBuf, +} + +impl Pending +{ + pub( super ) fn create( target: &Path ) -> io::Result + { + 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(); + } +} diff --git a/src/files/unsupported.rs b/src/files/unsupported.rs new file mode 100644 index 0000000..d307cd7 --- /dev/null +++ b/src/files/unsupported.rs @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! `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( reply: Reply ) +{ + reply( Err( io::ErrorKind::Unsupported.into() ) ); +} + +pub fn list( _path: PathBuf, reply: Reply> ) { unsupported( reply ) } +pub fn stat( _path: PathBuf, reply: Reply ) { unsupported( reply ) } +pub fn read( _path: PathBuf, _range: Option<( u64, u64 )>, reply: Reply> ) { unsupported( reply ) } +pub fn write( _path: PathBuf, _data: Vec, reply: Reply<()> ) { unsupported( reply ) } +pub fn open_write( _path: PathBuf, reply: Reply ) { unsupported( reply ) } +pub fn write_chunk( _handle: &WriterHandle, _chunk: Vec, 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 ) { unsupported( reply ) } +pub fn unmount( _path: PathBuf, reply: Reply<()> ) { unsupported( reply ) } diff --git a/src/lib.rs b/src/lib.rs index 0a5d575..3558d50 100755 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; diff --git a/src/platform/web/desktop.rs b/src/platform/web/desktop.rs index de47d9e..bf0dc35 100644 --- a/src/platform/web/desktop.rs +++ b/src/platform/web/desktop.rs @@ -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, + _on_event: Closure, } 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::::new( on_event ); + let on_event = Closure::::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::().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::( &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() ), } } diff --git a/src/platform/web/files.rs b/src/platform/web/files.rs new file mode 100644 index 0000000..6c02c47 --- /dev/null +++ b/src/platform/web/files.rs @@ -0,0 +1,293 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! `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> )>; +type OnReply = Box; + +enum Link +{ + Waiting( Vec<( Value, Option> )> ), + Open( js_sys::Function ), + Absent, +} + +thread_local! +{ + static LINK: RefCell = const { RefCell::new( Link::Waiting( Vec::new() ) ) }; + static NEXT: Cell = const { Cell::new( 1 ) }; + static REPLIES: RefCell> = RefCell::new( HashMap::new() ); + static PROGRESS: RefCell> = RefCell::new( HashMap::new() ); +} + +pub type WriterHandle = u64; +pub type OperationHandle = u64; + +pub fn list( path: PathBuf, reply: Reply> ) +{ + 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 ) +{ + 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> ) +{ + 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, reply: Reply<()> ) +{ + request( json!( { "type": "fs_write", "path": name( &path ) } ), Some( data ), done( reply ) ); +} + +pub fn open_write( path: PathBuf, reply: Reply ) +{ + 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, 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 ) +{ + 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 ) +{ + 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 = 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::().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 +{ + event[ "mounts" ].as_array().map( |list| list.iter().filter_map( mount_of ).collect() ).unwrap_or_default() +} + +pub( super ) fn paths( event: &Value ) -> Vec +{ + 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>, on_reply: OnReply ) +{ + send( next_id(), message, data, on_reply ); +} + +fn send( id: u64, mut message: Value, data: Option>, 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> ) +{ + 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 +{ + 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 +{ + 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 +{ + Some( Mount { path: value[ "path" ].as_str()?.into(), name: value[ "name" ].as_str()?.to_string() } ) +} diff --git a/src/platform/web/mod.rs b/src/platform/web/mod.rs index 5fd7405..325033f 100644 --- a/src/platform/web/mod.rs +++ b/src/platform/web/mod.rs @@ -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; diff --git a/src/platform/web/run.rs b/src/platform/web/run.rs index 4d75d61..c37f1cc 100644 --- a/src/platform/web/run.rs +++ b/src/platform/web/run.rs @@ -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 ), + /// The files the app was launched to open. + OpenFiles( Vec ), } /// An event listener, registered for as long as it lives. @@ -126,6 +131,7 @@ pub( crate ) fn try_run( 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 AppData { 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(); diff --git a/src/platform/web/shell.rs b/src/platform/web/shell.rs index 5aca5f4..859903b 100644 --- a/src/platform/web/shell.rs +++ b/src/platform/web/shell.rs @@ -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 +{ + let message = parse( message )?; + let request = REQUEST.with( |r| r.borrow().clone() )?; + send( &request, message )?.as_string() +} + +fn parse( message: &str ) -> Option +{ + 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 { 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.