The user's files through ltk::files, alike natively and in a browser, and a shell's requests answered in a browser through ltk::web::shell_request
An app read the user's files with `std::fs`, which a browser does not have: there a page reaches a folder of the user's computer only through forge/web, which holds it through the File System Access API and answers asynchronously (forge/web/DESIGN.md, section 11). An app that builds for both, such as Crate, had no way to list, read or write a file in a browser, and a page's memory cannot hold a large file whole. ltk::files. Asynchronous operations on the user's files whose results reach the app as messages through its `ChannelSender`: `list`, `stat`, `read`, `read_range`, `write`, `open_write`, `create_dir`, `rename`, `remove`, `remove_all`, `copy`, `copy_all`, `mount` and `unmount`, each taking the sender and a `done` that turns the `io::Result` into a message. Results come in no set order, so an app that has moved on checks that one is still the one it waits for. Errors are the same `io::ErrorKind`s on every platform, `Interrupted` for a cancelled copy. `Entry` and `Metadata` give a file's kind, size and times, the access time natively only. `ltk::vfs` stays what reads an app's own resources. Natively. `std::fs` on one worker thread of ltk's, in the order the app asked (`files/native.rs`), and each copy on a thread of its own, so a long one holds nothing else up (`files/copy.rs`). A file written whole or through a `Writer` goes beside its target as a temporary file, synced and renamed over it on commit and removed if dropped before, so a file being written is never seen half done. `rename` never replaces what is at `to`, as in a browser. In a browser. Each operation is one of forge/web's `fs_*` requests over the app's connection (`platform/web/files.rs`), carrying a number its `fs_reply` answers with; contents travel as a `Uint8Array` beside the JSON, the second argument of the connection's `request` and of the client's event handler, so `on_event` now takes it. What is asked before the app has connected is sent once it has; on a page without a desktop, or once the connection has closed, it fails with `Unsupported`. forge/web's error codes become their `io::ErrorKind`s, and `fs_progress` reaches a copy's `progress`. Without the web backend, on wasm32 every operation fails with `Unsupported` (`files/unsupported.rs`). Chunks. Contents never travel whole unless they are small: a large file is read with `read_range` and written through a `Writer`, chunk by chunk, `files::CHUNK` (1 MiB) at a time, the next sent once the last is answered. A `Writer` dropped without `close` is aborted and the file left as it was; in a browser a new file shows empty until closed. A copy runs where the files are, its contents never passing through the app: it reports its progress every 100 ms, never replaces an existing file, and, cancelled through its `Operation` or failed, leaves nothing behind. Mounts and the desktop's notices. `mount` asks the browser for a folder of the user's computer, from the user's press, and `unmount` lets it go; natively both fail with `Unsupported`. `App::on_files_changed`, `App::on_mounts_changed` and `App::on_open_files` hear of forge/web's `fs_changed`, `fs_mounts` and `open_files`, which the web backend queues as page events; natively they are not called yet. Types. `files::mime_type( name )` gives a file's type by shared-mime-info's `globs2`, with its weights, its case-sensitive globs and fnmatch's `*`, `?` and bracket classes, from the resource directory's `mime/` first, then the user's and the system's. `scripts/android-assets.sh`, and so `web-assets.sh`, now copies shared-mime-info's `globs2` and `subclasses` into the tree's `mime/` (`MIME_DIR` overrides where from), so a page has them. ltk::web::shell_request. A shell message that waits for forge/web's reply, such as `get_window_thumbnail`: sent as `shell_send` sends one and answered at once with forge/web's reply as JSON, or `None` before the app has connected, since a request is not kept for later, and when forge/web replies nothing. A shell's requests that wait for an answer natively got none in a browser. `shell.rs` parses a message once for both, and `send` returns forge/web's reply. Tests cover writing and committing, aborting, listing, ranges, removing, copying a tree with its progress, a cancelled copy, a copy that would replace, and the globs. `CHANGELOG.md` describes the changes.
This commit is contained in:
@@ -6,6 +6,8 @@ All notable changes to `ltk` are documented here. The format is based on [Keep a
|
|||||||
|
|
||||||
### Added
|
### 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.
|
- **`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.
|
- **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`.
|
- **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`.
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
# EXTRA_FONTS, space-separated paths, adds fonts the app itself reads from
|
# EXTRA_FONTS, space-separated paths, adds fonts the app itself reads from
|
||||||
# `ltk::resource_dir()/fonts`, such as a terminal's monospace face.
|
# `ltk::resource_dir()/fonts`, such as a terminal's monospace face.
|
||||||
# KEYBOARDS_DIR defaults to the `keyboards/` next to this script's directory.
|
# 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
|
set -eu
|
||||||
|
|
||||||
keyboards=${KEYBOARDS_DIR:-$( cd "$( dirname "$0" )/.." && pwd )/keyboards}
|
keyboards=${KEYBOARDS_DIR:-$( cd "$( dirname "$0" )/.." && pwd )/keyboards}
|
||||||
@@ -57,6 +60,18 @@ do
|
|||||||
cp "$font" "$root/fonts/"
|
cp "$font" "$root/fonts/"
|
||||||
done
|
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"
|
cd "$root"
|
||||||
hash=$( find . -type f ! -name MANIFEST -print0 | LC_ALL=C sort -z | xargs -0 sha256sum | sha256sum | cut -d' ' -f1 )
|
hash=$( find . -type f ! -name MANIFEST -print0 | LC_ALL=C sort -z | xargs -0 sha256sum | sha256sum | cut -d' ' -f1 )
|
||||||
{
|
{
|
||||||
|
|||||||
+18
@@ -917,6 +917,24 @@ pub trait App: 'static
|
|||||||
None
|
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).
|
/// Return `Some(id)` once to focus the widget with that [`WidgetId`](crate::types::WidgetId).
|
||||||
/// The app must return `None` after the first call.
|
/// The app must return `None` after the first call.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -321,6 +321,7 @@ pub( crate ) mod secure_mem;
|
|||||||
pub( crate ) mod resources;
|
pub( crate ) mod resources;
|
||||||
pub mod time;
|
pub mod time;
|
||||||
pub mod vfs;
|
pub mod vfs;
|
||||||
|
pub mod files;
|
||||||
pub mod gles_render;
|
pub mod gles_render;
|
||||||
#[ cfg( not( target_arch = "wasm32" ) ) ]
|
#[ cfg( not( target_arch = "wasm32" ) ) ]
|
||||||
pub mod egl_context;
|
pub mod egl_context;
|
||||||
@@ -432,7 +433,7 @@ pub use platform::android::{ keep_running, set_android_app, AndroidApp };
|
|||||||
#[ cfg( feature = "web" ) ]
|
#[ cfg( feature = "web" ) ]
|
||||||
pub mod 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 resources::{ resource_dir, set_resource_dir };
|
||||||
pub use xkeysym::Keysym;
|
pub use xkeysym::Keysym;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use crate::app::{ App, Layer, ShellMode, ToplevelEvent };
|
|||||||
use crate::event_loop::AppData;
|
use crate::event_loop::AppData;
|
||||||
|
|
||||||
use super::run::{ push, PageEvent };
|
use super::run::{ push, PageEvent };
|
||||||
use super::shell;
|
use super::{ files, shell };
|
||||||
|
|
||||||
/// The id the main surface goes by.
|
/// The id the main surface goes by.
|
||||||
pub( super ) const MAIN: u32 = 0;
|
pub( super ) const MAIN: u32 = 0;
|
||||||
@@ -21,7 +21,7 @@ pub( crate ) struct Connection
|
|||||||
{
|
{
|
||||||
request: js_sys::Function,
|
request: js_sys::Function,
|
||||||
disconnect: js_sys::Function,
|
disconnect: js_sys::Function,
|
||||||
_on_event: Closure<dyn FnMut( String )>,
|
_on_event: Closure<dyn FnMut( String, JsValue )>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Connection
|
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 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 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 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 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 };
|
let this = Self { request: method( "request" )?, disconnect: method( "disconnect" )?, _on_event: on_event };
|
||||||
shell::connected( Some( this.request.clone() ) );
|
shell::connected( Some( this.request.clone() ) );
|
||||||
|
files::connected( Some( this.request.clone() ) );
|
||||||
if let Err( e ) = this.request( &json!( { "type": "list_toplevels" } ) )
|
if let Err( e ) = this.request( &json!( { "type": "list_toplevels" } ) )
|
||||||
{
|
{
|
||||||
web_sys::console::warn_1( &format!( "[ltk] list_toplevels: {e}" ).into() );
|
web_sys::console::warn_1( &format!( "[ltk] list_toplevels: {e}" ).into() );
|
||||||
@@ -71,11 +72,13 @@ impl Connection
|
|||||||
pub( super ) fn close( &self )
|
pub( super ) fn close( &self )
|
||||||
{
|
{
|
||||||
shell::connected( None );
|
shell::connected( None );
|
||||||
|
files::connected( None );
|
||||||
let _ = self.disconnect.call0( &JsValue::NULL );
|
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 Ok( event ) = serde_json::from_str::<Value>( &json ) else { return };
|
||||||
let surface = event[ "surface" ].as_u64().unwrap_or( 0 ) as u32;
|
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(),
|
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( "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() ),
|
_ => web_sys::console::warn_1( &format!( "[ltk] forge/web: {json}" ).into() ),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() } )
|
||||||
|
}
|
||||||
@@ -7,7 +7,8 @@
|
|||||||
//! | --- | --- |
|
//! | --- | --- |
|
||||||
//! | `assets.rs` | The themes, fonts and keyboard layouts a page serves, fetched into memory as the app's resource directory |
|
//! | `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 |
|
//! | `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()` |
|
//! | `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 |
|
//! | `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 |
|
//! | `log.rs` | Panics into the browser's console |
|
||||||
@@ -25,6 +26,7 @@ mod assets;
|
|||||||
mod clipboard;
|
mod clipboard;
|
||||||
mod cursor;
|
mod cursor;
|
||||||
mod desktop;
|
mod desktop;
|
||||||
|
pub( crate ) mod files;
|
||||||
mod input;
|
mod input;
|
||||||
mod keys;
|
mod keys;
|
||||||
mod log;
|
mod log;
|
||||||
@@ -38,7 +40,7 @@ mod surface;
|
|||||||
|
|
||||||
pub use assets::{ load_resources, load_tree };
|
pub use assets::{ load_resources, load_tree };
|
||||||
pub use run::set_canvas;
|
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 state::WebPlatform;
|
||||||
pub( crate ) use surface::WebSurfaceState;
|
pub( crate ) use surface::WebSurfaceState;
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,11 @@ pub( super ) enum PageEvent
|
|||||||
},
|
},
|
||||||
/// A window of the desktop's, as `ext-foreign-toplevel-list` reports it.
|
/// A window of the desktop's, as `ext-foreign-toplevel-list` reports it.
|
||||||
Toplevel( ToplevelEvent ),
|
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.
|
/// 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.
|
// 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 given = CANVAS.with( |c| c.borrow_mut().take() );
|
||||||
let desktop = if given.is_none() { Connection::open( app.app_id() ) } else { None };
|
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 )
|
let ( canvas, target, own ) = match ( desktop.as_ref(), given )
|
||||||
{
|
{
|
||||||
( Some( desktop ), _ ) =>
|
( 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 ); }
|
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();
|
self.dispatch_sources();
|
||||||
|
|||||||
+21
-15
@@ -18,36 +18,42 @@ thread_local!
|
|||||||
/// Send `message`, one of the shell protocol's JSON objects, to forge/web.
|
/// Send `message`, one of the shell protocol's JSON objects, to forge/web.
|
||||||
pub fn shell_send( message: &str )
|
pub fn shell_send( message: &str )
|
||||||
{
|
{
|
||||||
let message: Value = match serde_json::from_str( message )
|
let Some( message ) = parse( message ) else { return };
|
||||||
{
|
|
||||||
Ok( m ) => m,
|
|
||||||
Err( e ) =>
|
|
||||||
{
|
|
||||||
web_sys::console::warn_1( &format!( "[ltk] shell message is not JSON ({e}): {message}" ).into() );
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let request = REQUEST.with( |r| r.borrow().clone() );
|
let request = REQUEST.with( |r| r.borrow().clone() );
|
||||||
match request
|
match request
|
||||||
{
|
{
|
||||||
Some( request ) => send( &request, message ),
|
Some( request ) => { send( &request, message ); }
|
||||||
None => PENDING.with( |p| p.borrow_mut().push( 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.
|
/// Have `listener` receive each of forge/web's shell messages as JSON.
|
||||||
pub fn set_shell_listener( listener: impl Fn( &str ) + 'static )
|
pub fn set_shell_listener( listener: impl Fn( &str ) + 'static )
|
||||||
{
|
{
|
||||||
LISTENER.with( |l| *l.borrow_mut() = Some( Box::new( listener ) ) );
|
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();
|
let json = json!( { "type": "shell", "message": message } ).to_string();
|
||||||
if let Err( e ) = request.call1( &JsValue::NULL, &JsValue::from_str( &json ) )
|
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 ) )
|
||||||
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.
|
/// The connection's `request`, once connected, and `None` once it has closed.
|
||||||
|
|||||||
Reference in New Issue
Block a user