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

An app read the user's files with `std::fs`, which a browser does not have: there a page reaches a folder of the user's computer only through forge/web, which holds it through the File System Access API and answers asynchronously (forge/web/DESIGN.md, section 11). An app that builds for both, such as Crate, had no way to list, read or write a file in a browser, and a page's memory cannot hold a large file whole.
ltk::files. Asynchronous operations on the user's files whose results reach the app as messages through its `ChannelSender`: `list`, `stat`, `read`, `read_range`, `write`, `open_write`, `create_dir`, `rename`, `remove`, `remove_all`, `copy`, `copy_all`, `mount` and `unmount`, each taking the sender and a `done` that turns the `io::Result` into a message. Results come in no set order, so an app that has moved on checks that one is still the one it waits for. Errors are the same `io::ErrorKind`s on every platform, `Interrupted` for a cancelled copy. `Entry` and `Metadata` give a file's kind, size and times, the access time natively only. `ltk::vfs` stays what reads an app's own resources.
Natively. `std::fs` on one worker thread of ltk's, in the order the app asked (`files/native.rs`), and each copy on a thread of its own, so a long one holds nothing else up (`files/copy.rs`). A file written whole or through a `Writer` goes beside its target as a temporary file, synced and renamed over it on commit and removed if dropped before, so a file being written is never seen half done. `rename` never replaces what is at `to`, as in a browser.
In a browser. Each operation is one of forge/web's `fs_*` requests over the app's connection (`platform/web/files.rs`), carrying a number its `fs_reply` answers with; contents travel as a `Uint8Array` beside the JSON, the second argument of the connection's `request` and of the client's event handler, so `on_event` now takes it. What is asked before the app has connected is sent once it has; on a page without a desktop, or once the connection has closed, it fails with `Unsupported`. forge/web's error codes become their `io::ErrorKind`s, and `fs_progress` reaches a copy's `progress`. Without the web backend, on wasm32 every operation fails with `Unsupported` (`files/unsupported.rs`).
Chunks. Contents never travel whole unless they are small: a large file is read with `read_range` and written through a `Writer`, chunk by chunk, `files::CHUNK` (1 MiB) at a time, the next sent once the last is answered. A `Writer` dropped without `close` is aborted and the file left as it was; in a browser a new file shows empty until closed. A copy runs where the files are, its contents never passing through the app: it reports its progress every 100 ms, never replaces an existing file, and, cancelled through its `Operation` or failed, leaves nothing behind.
Mounts and the desktop's notices. `mount` asks the browser for a folder of the user's computer, from the user's press, and `unmount` lets it go; natively both fail with `Unsupported`. `App::on_files_changed`, `App::on_mounts_changed` and `App::on_open_files` hear of forge/web's `fs_changed`, `fs_mounts` and `open_files`, which the web backend queues as page events; natively they are not called yet.
Types. `files::mime_type( name )` gives a file's type by shared-mime-info's `globs2`, with its weights, its case-sensitive globs and fnmatch's `*`, `?` and bracket classes, from the resource directory's `mime/` first, then the user's and the system's. `scripts/android-assets.sh`, and so `web-assets.sh`, now copies shared-mime-info's `globs2` and `subclasses` into the tree's `mime/` (`MIME_DIR` overrides where from), so a page has them.
ltk::web::shell_request. A shell message that waits for forge/web's reply, such as `get_window_thumbnail`: sent as `shell_send` sends one and answered at once with forge/web's reply as JSON, or `None` before the app has connected, since a request is not kept for later, and when forge/web replies nothing. A shell's requests that wait for an answer natively got none in a browser. `shell.rs` parses a message once for both, and `send` returns forge/web's reply.
Tests cover writing and committing, aborting, listing, ranges, removing, copying a tree with its progress, a cancelled copy, a copy that would replace, and the globs. `CHANGELOG.md` describes the changes.
This commit is contained in:
2026-10-22 00:15:39 +02:00
parent af4009374d
commit cf187a3dcc
14 changed files with 1345 additions and 22 deletions
+12 -4
View File
@@ -12,7 +12,7 @@ use crate::app::{ App, Layer, ShellMode, ToplevelEvent };
use crate::event_loop::AppData;
use super::run::{ push, PageEvent };
use super::shell;
use super::{ files, shell };
/// The id the main surface goes by.
pub( super ) const MAIN: u32 = 0;
@@ -21,7 +21,7 @@ pub( crate ) struct Connection
{
request: js_sys::Function,
disconnect: js_sys::Function,
_on_event: Closure<dyn FnMut( String )>,
_on_event: Closure<dyn FnMut( String, JsValue )>,
}
impl Connection
@@ -31,11 +31,12 @@ impl Connection
{
let api = js_sys::Reflect::get( &js_sys::global(), &"forgeWeb".into() ).ok().filter( |v| v.is_object() )?;
let connect: js_sys::Function = js_sys::Reflect::get( &api, &"connect".into() ).ok()?.dyn_into().ok()?;
let on_event = Closure::<dyn FnMut( String )>::new( on_event );
let on_event = Closure::<dyn FnMut( String, JsValue )>::new( on_event );
let connection = connect.call2( &api, &JsValue::from_str( app_id ), on_event.as_ref() ).ok()?;
let method = |name: &str| js_sys::Reflect::get( &connection, &name.into() ).ok().and_then( |f| f.dyn_into::<js_sys::Function>().ok() );
let this = Self { request: method( "request" )?, disconnect: method( "disconnect" )?, _on_event: on_event };
shell::connected( Some( this.request.clone() ) );
files::connected( Some( this.request.clone() ) );
if let Err( e ) = this.request( &json!( { "type": "list_toplevels" } ) )
{
web_sys::console::warn_1( &format!( "[ltk] list_toplevels: {e}" ).into() );
@@ -71,11 +72,13 @@ impl Connection
pub( super ) fn close( &self )
{
shell::connected( None );
files::connected( None );
let _ = self.disconnect.call0( &JsValue::NULL );
}
}
fn on_event( json: String )
/// One of forge/web's events, with the file contents `data` carries for an `fs_reply`.
fn on_event( json: String, data: JsValue )
{
let Ok( event ) = serde_json::from_str::<Value>( &json ) else { return };
let surface = event[ "surface" ].as_u64().unwrap_or( 0 ) as u32;
@@ -98,6 +101,11 @@ fn on_event( json: String )
title: event[ "title" ].as_str().unwrap_or_default().to_string(),
} ) ),
Some( "toplevel_closed" ) => push( PageEvent::Toplevel( ToplevelEvent::Closed { id: event[ "id" ].as_u64().unwrap_or( 0 ) as u32 } ) ),
Some( "fs_reply" ) => files::reply( &event, data ),
Some( "fs_progress" ) => files::progress( &event ),
Some( "fs_changed" ) => push( PageEvent::FilesChanged( event[ "path" ].as_str().unwrap_or_default().into() ) ),
Some( "fs_mounts" ) => push( PageEvent::Mounts( files::mounts( &event ) ) ),
Some( "open_files" ) => push( PageEvent::OpenFiles( files::paths( &event ) ) ),
_ => web_sys::console::warn_1( &format!( "[ltk] forge/web: {json}" ).into() ),
}
}