Call handlers from context of a runtime and scope

This commit is contained in:
Jonathan Kelley 2024-01-05 17:35:37 -08:00
parent bc857bf339
commit 8323e45970
No known key found for this signature in database
GPG key ID: 1FBB50F7EB0A08BE
10 changed files with 270 additions and 282 deletions

View file

@ -31,28 +31,33 @@ fn main() {
}
fn app(cx: Scope) -> Element {
use_asset_handler(cx, move |request: &AssetRequest| {
let request = request.clone();
async move {
use_asset_handler(cx, "videos", move |request, responder| {
// Using dioxus::spawn works, but is slower than a dedicated thread
tokio::task::spawn(async move {
let video_file = PathBuf::from(VIDEO_PATH);
let mut file = tokio::fs::File::open(&video_file).await.unwrap();
let response: Option<Response<Cow<'static, [u8]>>> =
match get_stream_response(&mut file, &request).await {
Ok(response) => Some(response.map(Cow::Owned)),
Err(err) => {
eprintln!("Error: {}", err);
None
}
};
response
}
match get_stream_response(&mut file, &request).await {
Ok(response) => responder.respond(response),
Err(err) => eprintln!("Error: {}", err),
}
});
});
render! {
div { video { src: "test_video.mp4", autoplay: true, controls: true, width: 640, height: 480 } }
div {
video {
src: "/videos/test_video.mp4",
autoplay: true,
controls: true,
width: 640,
height: 480
}
}
}
}
/// This was taken from wry's example
async fn get_stream_response(
asset: &mut (impl tokio::io::AsyncSeek + tokio::io::AsyncRead + Unpin + Send + Sync),
request: &AssetRequest,

View file

@ -87,6 +87,16 @@ impl Runtime {
self.scope_stack.borrow().last().copied()
}
/// Call this function with the current scope set to the given scope
///
/// Useful in a limited number of scenarios, not public.
pub(crate) fn with_scope<O>(&self, id: ScopeId, f: impl FnOnce() -> O) -> O {
self.scope_stack.borrow_mut().push(id);
let o = f();
self.scope_stack.borrow_mut().pop();
o
}
/// Get the context for any scope given its ID
///
/// This is useful for inserting or removing contexts from a scope, or rendering out its root node
@ -137,6 +147,17 @@ impl RuntimeGuard {
push_runtime(runtime.clone());
Self(runtime)
}
/// Run a function with a given runtime and scope in context
pub fn with<O>(runtime: Rc<Runtime>, scope: Option<ScopeId>, f: impl FnOnce() -> O) -> O {
let guard = Self::new(runtime.clone());
let o = match scope {
Some(scope) => Runtime::with_scope(&runtime, scope, f),
None => f(),
};
drop(guard);
o
}
}
impl Drop for RuntimeGuard {

View file

@ -318,11 +318,16 @@ pub fn spawn(fut: impl Future<Output = ()> + 'static) {
with_current_scope(|cx| cx.spawn(fut));
}
/// Spawn a future on a component given its [`ScopeId`].
pub fn spawn_at(fut: impl Future<Output = ()> + 'static, scope_id: ScopeId) -> Option<TaskId> {
with_runtime(|rt| rt.get_context(scope_id).unwrap().push_future(fut))
}
/// Spawn a future that Dioxus won't clean up when this component is unmounted
///
/// This is good for tasks that need to be run after the component has been dropped.
pub fn spawn_forever(fut: impl Future<Output = ()> + 'static) -> Option<TaskId> {
with_current_scope(|cx| cx.spawn_forever(fut))
spawn_at(fut, ScopeId(0))
}
/// Informs the scheduler that this task is no longer needed and should be removed.

View file

@ -320,6 +320,7 @@ impl<P: 'static> App<P> {
let Some(view) = self.webviews.get_mut(&id) else {
return;
};
println!("poll_vdom");
view.poll_vdom();
}

View file

@ -1,135 +1,61 @@
use crate::DesktopContext;
use slab::Slab;
use std::{
borrow::Cow,
future::Future,
ops::Deref,
path::{Path, PathBuf},
pin::Pin,
rc::Rc,
sync::Arc,
};
use tokio::{
runtime::Handle,
sync::{OnceCell, RwLock},
};
use wry::http::{Request, Response};
use dioxus_core::prelude::{Runtime, RuntimeGuard, ScopeId};
use rustc_hash::FxHashMap;
use std::{cell::RefCell, rc::Rc};
use wry::{http::Request, RequestAsyncResponder};
/// An arbitrary asset is an HTTP response containing a binary body.
pub type AssetResponse = Response<Cow<'static, [u8]>>;
///
pub type AssetRequest = Request<Vec<u8>>;
/// A future that returns an [`AssetResponse`]. This future may be spawned in a new thread,
/// so it must be [`Send`], [`Sync`], and `'static`.
pub trait AssetFuture: Future<Output = Option<AssetResponse>> + Send + Sync + 'static {}
impl<T: Future<Output = Option<AssetResponse>> + Send + Sync + 'static> AssetFuture for T {}
#[derive(Debug, Clone)]
/// A request for an asset. This is a wrapper around [`Request<Vec<u8>>`] that provides methods specific to asset requests.
pub struct AssetRequest {
pub(crate) path: PathBuf,
pub(crate) request: Arc<Request<Vec<u8>>>,
pub struct AssetHandler {
f: Box<dyn Fn(AssetRequest, RequestAsyncResponder) + 'static>,
scope: ScopeId,
}
impl AssetRequest {
/// Get the path the asset request is for
pub fn path(&self) -> &Path {
&self.path
}
}
impl From<Request<Vec<u8>>> for AssetRequest {
fn from(request: Request<Vec<u8>>) -> Self {
let decoded = urlencoding::decode(request.uri().path().trim_start_matches('/'))
.expect("expected URL to be UTF-8 encoded");
let path = PathBuf::from(&*decoded);
Self {
request: Arc::new(request),
path,
}
}
}
impl Deref for AssetRequest {
type Target = Request<Vec<u8>>;
fn deref(&self) -> &Self::Target {
&self.request
}
}
/// A handler that takes an [`AssetRequest`] and returns a future that either loads the asset, or returns `None`.
/// This handler is stashed indefinitely in a context object, so it must be `'static`.
pub trait AssetHandler<F: AssetFuture>: Send + Sync + 'static {
/// Handle an asset request, returning a future that either loads the asset, or returns `None`
fn handle_request(&self, request: &AssetRequest) -> F;
}
impl<F: AssetFuture, T: Fn(&AssetRequest) -> F + Send + Sync + 'static> AssetHandler<F> for T {
fn handle_request(&self, request: &AssetRequest) -> F {
self(request)
}
}
type UserAssetHandler =
Box<dyn Fn(&AssetRequest) -> Pin<Box<dyn AssetFuture>> + Send + Sync + 'static>;
type AssetHandlerRegistryInner = Slab<UserAssetHandler>;
#[derive(Clone)]
pub struct AssetHandlerRegistry(Arc<RwLock<AssetHandlerRegistryInner>>);
pub struct AssetHandlerRegistry {
dom_rt: Rc<Runtime>,
handlers: Rc<RefCell<FxHashMap<String, AssetHandler>>>,
}
impl AssetHandlerRegistry {
pub fn new() -> Self {
AssetHandlerRegistry(Arc::new(RwLock::new(Slab::new())))
}
pub async fn register_handler<F: AssetFuture>(&self, f: impl AssetHandler<F>) -> usize {
let mut registry = self.0.write().await;
registry.insert(Box::new(move |req| Box::pin(f.handle_request(req))))
}
pub async fn remove_handler(&self, id: usize) -> Option<()> {
let mut registry = self.0.write().await;
registry.try_remove(id).map(|_| ())
}
pub async fn try_handlers(&self, req: &AssetRequest) -> Option<AssetResponse> {
let registry = self.0.read().await;
for (_, handler) in registry.iter() {
if let Some(response) = handler(req).await {
return Some(response);
}
pub fn new(dom_rt: Rc<Runtime>) -> Self {
AssetHandlerRegistry {
dom_rt,
handlers: Default::default(),
}
None
}
}
/// A handle to a registered asset handler.
pub struct AssetHandlerHandle {
pub(crate) desktop: DesktopContext,
pub(crate) handler_id: Rc<OnceCell<usize>>,
}
impl AssetHandlerHandle {
/// Returns the ID for this handle.
///
/// Because registering an ID is asynchronous, this may return `None` if the
/// registration has not completed yet.
pub fn handler_id(&self) -> Option<usize> {
self.handler_id.get().copied()
}
}
impl Drop for AssetHandlerHandle {
fn drop(&mut self) {
let cell = Rc::clone(&self.handler_id);
let desktop = Rc::clone(&self.desktop);
tokio::task::block_in_place(move || {
Handle::current().block_on(async move {
if let Some(id) = cell.get() {
desktop.asset_handlers.remove_handler(*id).await;
}
})
});
}
pub fn has_handler(&self, name: &str) -> bool {
self.handlers.borrow().contains_key(name)
}
pub fn handle_request(
&self,
name: &str,
request: AssetRequest,
responder: RequestAsyncResponder,
) {
if let Some(handler) = self.handlers.borrow().get(name) {
// make sure the runtime is alive for the duration of the handler
// We should do this for all the things - not just asset handlers
RuntimeGuard::with(self.dom_rt.clone(), Some(handler.scope), || {
(handler.f)(request, responder)
});
}
}
pub fn register_handler(
&self,
name: String,
f: Box<dyn Fn(AssetRequest, RequestAsyncResponder) + 'static>,
scope: ScopeId,
) {
self.handlers
.borrow_mut()
.insert(name, AssetHandler { f, scope });
}
pub fn remove_handler(&self, name: &str) -> Option<AssetHandler> {
self.handlers.borrow_mut().remove(name)
}
}

View file

@ -1,14 +1,17 @@
use crate::{
app::SharedContext,
assets::{AssetFuture, AssetHandlerRegistry},
assets::AssetHandlerRegistry,
edits::EditQueue,
ipc::{EventData, UserWindowEvent},
query::QueryEngine,
shortcut::{HotKey, ShortcutId, ShortcutRegistryError},
webview::WebviewInstance,
AssetHandler, Config,
AssetRequest, Config,
};
use dioxus_core::{
prelude::{current_scope_id, ScopeId},
Mutations, VirtualDom,
};
use dioxus_core::{Mutations, VirtualDom};
use dioxus_interpreter_js::binary_protocol::Channel;
use rustc_hash::FxHashMap;
use slab::Slab;
@ -18,7 +21,7 @@ use tao::{
event_loop::EventLoopWindowTarget,
window::{Fullscreen as WryFullscreen, Window, WindowId},
};
use wry::WebView;
use wry::{RequestAsyncResponder, WebView};
#[cfg(target_os = "ios")]
use tao::platform::ios::WindowExtIOS;
@ -244,17 +247,30 @@ impl DesktopService {
}
/// Provide a callback to handle asset loading yourself.
/// If the ScopeId isn't provided, defaults to a global handler.
/// Note that the handler is namespaced by name, not ScopeId.
///
/// When the component is dropped, the handler is removed.
///
/// See [`use_asset_handle`](crate::use_asset_handle) for a convenient hook.
pub async fn register_asset_handler<F: AssetFuture>(&self, f: impl AssetHandler<F>) -> usize {
self.asset_handlers.register_handler(f).await
pub fn register_asset_handler(
&self,
name: String,
f: Box<dyn Fn(AssetRequest, RequestAsyncResponder) + 'static>,
scope: Option<ScopeId>,
) {
self.asset_handlers.register_handler(
name,
f,
scope.unwrap_or(current_scope_id().unwrap_or(ScopeId(0))),
)
}
/// Removes an asset handler by its identifier.
///
/// Returns `None` if the handler did not exist.
pub async fn remove_asset_handler(&self, id: usize) -> Option<()> {
self.asset_handlers.remove_handler(id).await
pub fn remove_asset_handler(&self, name: &str) -> Option<()> {
self.asset_handlers.remove_handler(name).map(|_| ())
}
/// Push an objc view to the window

View file

@ -3,8 +3,8 @@ use crate::{
ShortcutHandle, ShortcutRegistryError, WryEventHandler,
};
use dioxus_core::ScopeState;
use std::rc::Rc;
use tao::{event::Event, event_loop::EventLoopWindowTarget};
use wry::RequestAsyncResponder;
/// Get an imperative handle to the current window
pub fn use_window(cx: &ScopeState) -> &DesktopContext {
@ -34,24 +34,28 @@ pub fn use_wry_event_handler(
///
/// The callback takes a path as requested by the web view, and it should return `Some(response)`
/// if you want to load the asset, and `None` if you want to fallback on the default behavior.
pub fn use_asset_handler<F: AssetFuture>(
pub fn use_asset_handler(
cx: &ScopeState,
handler: impl AssetHandler<F>,
) -> &AssetHandlerHandle {
name: &str,
handler: impl Fn(AssetRequest, RequestAsyncResponder) + 'static,
) {
cx.use_hook(|| {
let desktop = crate::window();
let handler_id = Rc::new(tokio::sync::OnceCell::new());
let handler_id_ref = Rc::clone(&handler_id);
let desktop_ref = Rc::clone(&desktop);
cx.push_future(async move {
let id = desktop.asset_handlers.register_handler(handler).await;
handler_id.set(id).unwrap();
});
AssetHandlerHandle {
desktop: desktop_ref,
handler_id: handler_id_ref,
crate::window().asset_handlers.register_handler(
name.to_string(),
Box::new(handler),
cx.scope_id(),
);
Handler(name.to_string())
});
// todo: can we just put ondrop in core?
struct Handler(String);
impl Drop for Handler {
fn drop(&mut self) {
_ = crate::window().asset_handlers.remove_handler(&self.0);
}
})
}
}
/// Get a closure that executes any JavaScript in the WebView context.

View file

@ -38,7 +38,7 @@ pub use tao::window::WindowBuilder;
pub use wry;
// Public exports
pub use assets::{AssetFuture, AssetHandler, AssetRequest, AssetResponse};
pub use assets::AssetRequest;
pub use cfg::{Config, WindowCloseBehaviour};
pub use desktop_context::{
window, DesktopContext, DesktopService, WryEventHandler, WryEventHandlerId,
@ -46,3 +46,4 @@ pub use desktop_context::{
pub use hooks::{use_asset_handler, use_global_shortcut, use_window, use_wry_event_handler};
pub use menubar::build_default_menu_bar;
pub use shortcut::{ShortcutHandle, ShortcutId, ShortcutRegistryError};
pub use wry::RequestAsyncResponder;

View file

@ -1,8 +1,5 @@
use crate::{assets::*, edits::EditQueue};
use std::{
borrow::Cow,
path::{Path, PathBuf},
};
use std::path::{Path, PathBuf};
use wry::{
http::{status::StatusCode, Request, Response},
RequestAsyncResponder, Result,
@ -11,69 +8,6 @@ use wry::{
static MINIFIED: &str = include_str!("./minified.js");
static DEFAULT_INDEX: &str = include_str!("./index.html");
// todo: clean this up a bit
#[allow(clippy::too_many_arguments)]
pub(super) async fn desktop_handler(
request: Request<Vec<u8>>,
custom_head: Option<String>,
custom_index: Option<String>,
root_name: &str,
asset_handlers: &AssetHandlerRegistry,
edit_queue: &EditQueue,
headless: bool,
responder: RequestAsyncResponder,
) {
let request = AssetRequest::from(request);
// If the request is for the root, we'll serve the index.html file.
if request.uri().path() == "/" {
match build_index_file(custom_index, custom_head, root_name, headless) {
Ok(response) => return responder.respond(response),
Err(err) => return tracing::error!("error building response: {}", err),
}
}
// If the request is asking for edits (ie binary protocol streaming, do that)
if request.uri().path().trim_matches('/') == "edits" {
return edit_queue.handle_request(responder);
}
// If the user provided a custom asset handler, then call it and return the response if the request was handled.
// todo(jon): I dont want this function to be async - we can probably just use a drop handler on the responder
if let Some(response) = asset_handlers.try_handlers(&request).await {
return responder.respond(response);
}
// Else, try to serve a file from the filesystem.
match serve_from_fs(request) {
Ok(res) => responder.respond(res),
Err(e) => tracing::error!("Error serving request from filesystem {}", e),
}
}
fn serve_from_fs(request: AssetRequest) -> Result<AssetResponse> {
// If the path is relative, we'll try to serve it from the assets directory.
let mut asset = get_asset_root_or_default().join(&request.path);
// If we can't find it, make it absolute and try again
if !asset.exists() {
asset = PathBuf::from("/").join(request.path);
}
if asset.exists() {
let content_type = get_mime_from_path(&asset)?;
let asset = std::fs::read(asset)?;
Ok(Response::builder()
.header("Content-Type", content_type)
.body(Cow::from(asset))?)
} else {
Ok(Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Cow::from(String::from("Not Found").into_bytes()))?)
}
}
/// Build the index.html file we use for bootstrapping a new app
///
/// We use wry/webview by building a special index.html that forms a bridge between the webview and your rust code
@ -83,12 +17,18 @@ fn serve_from_fs(request: AssetRequest) -> Result<AssetResponse> {
/// mess with UI elements. We make this decision since other renderers like LiveView are very separate and can
/// never properly bridge the gap. Eventually of course, the idea is to build a custom CSS/HTML renderer where you
/// *do* have native control over elements, but that still won't work with liveview.
fn build_index_file(
custom_index: Option<String>,
pub(super) fn index_request(
request: &Request<Vec<u8>>,
custom_head: Option<String>,
custom_index: Option<String>,
root_name: &str,
headless: bool,
) -> std::result::Result<Response<Vec<u8>>, wry::http::Error> {
) -> Option<Response<Vec<u8>>> {
// If the request is for the root, we'll serve the index.html file.
if request.uri().path() != "/" {
return None;
}
// Load a custom index file if provided
let mut index = custom_index.unwrap_or_else(|| DEFAULT_INDEX.to_string());
@ -110,6 +50,68 @@ fn build_index_file(
.header("Content-Type", "text/html")
.header("Access-Control-Allow-Origin", "*")
.body(index.into())
.ok()
}
/// Handle a request from the webview
///
/// - Tries to stream edits if they're requested.
/// - If that doesn't match, tries a user provided asset handler
/// - If that doesn't match, tries to serve a file from the filesystem
pub(super) fn desktop_handler(
request: Request<Vec<u8>>,
asset_handlers: AssetHandlerRegistry,
edit_queue: &EditQueue,
responder: RequestAsyncResponder,
) {
// If the request is asking for edits (ie binary protocol streaming, do that)
if request.uri().path().trim_matches('/') == "edits" {
return edit_queue.handle_request(responder);
}
// If the user provided a custom asset handler, then call it and return the response if the request was handled.
// The path is the first part of the URI, so we need to trim the leading slash.
let path = PathBuf::from(
urlencoding::decode(request.uri().path().trim_start_matches('/'))
.expect("expected URL to be UTF-8 encoded")
.as_ref(),
);
let Some(name) = path.parent() else {
return tracing::error!("Asset request has no root {path:?}");
};
if let Some(name) = name.to_str() {
if asset_handlers.has_handler(name) {
return asset_handlers.handle_request(name, request, responder);
}
}
// Else, try to serve a file from the filesystem.
match serve_from_fs(path) {
Ok(res) => responder.respond(res),
Err(e) => tracing::error!("Error serving request from filesystem {}", e),
}
}
fn serve_from_fs(path: PathBuf) -> Result<Response<Vec<u8>>> {
// If the path is relative, we'll try to serve it from the assets directory.
let mut asset = get_asset_root_or_default().join(&path);
// If we can't find it, make it absolute and try again
if !asset.exists() {
asset = PathBuf::from("/").join(path);
}
if !asset.exists() {
return Ok(Response::builder()
.status(StatusCode::NOT_FOUND)
.body(String::from("Not Found").into_bytes())?);
}
Ok(Response::builder()
.header("Content-Type", get_mime_from_path(&asset)?)
.body(std::fs::read(asset)?)?)
}
/// Construct the inline script that boots up the page and bridges the webview with rust code.

View file

@ -11,7 +11,7 @@ use crate::{
use dioxus_core::VirtualDom;
use futures_util::{pin_mut, FutureExt};
use std::{rc::Rc, task::Waker};
use wry::{WebContext, WebViewBuilder};
use wry::{RequestAsyncResponder, WebContext, WebViewBuilder};
pub struct WebviewInstance {
pub dom: VirtualDom,
@ -33,12 +33,6 @@ impl WebviewInstance {
build_menu_bar(build_default_menu_bar(), &window);
}
let window_id = window.id();
let file_handler = cfg.file_drop_handler.take();
let custom_head = cfg.custom_head.clone();
let index_file = cfg.custom_index.clone();
let root_name = cfg.root_name.clone();
// We assume that if the icon is None in cfg, then the user just didnt set it
if cfg.window.window.window_icon.is_none() {
window.set_window_icon(Some(
@ -53,52 +47,65 @@ impl WebviewInstance {
let mut web_context = WebContext::new(cfg.data_dir.clone());
let edit_queue = EditQueue::default();
let asset_handlers = AssetHandlerRegistry::new(dom.runtime());
let headless = !cfg.window.window.visible;
let asset_handlers = AssetHandlerRegistry::new();
let asset_handlers_ref = asset_handlers.clone();
// Rust :(
let window_id = window.id();
let file_handler = cfg.file_drop_handler.take();
let custom_head = cfg.custom_head.clone();
let index_file = cfg.custom_index.clone();
let root_name = cfg.root_name.clone();
let asset_handlers_ = asset_handlers.clone();
let edit_queue_ = edit_queue.clone();
let proxy_ = shared.proxy.clone();
let request_handler = move |request, responder: RequestAsyncResponder| {
// Try to serve the index file first
let index_bytes = protocol::index_request(
&request,
custom_head.clone(),
index_file.clone(),
&root_name,
headless,
);
// Otherwise, try to serve an asset, either from the user or the filesystem
match index_bytes {
Some(body) => return responder.respond(body),
None => {
// we need to do this in the context of the dioxus runtime since the user gave us these closures
protocol::desktop_handler(
request,
asset_handlers_.clone(),
&edit_queue_,
responder,
);
}
}
};
let ipc_handler = move |payload: String| {
// defer the event to the main thread
if let Ok(message) = serde_json::from_str(&payload) {
_ = proxy_.send_event(UserWindowEvent(EventData::Ipc(message), window_id));
}
};
let file_drop_handler = move |event| {
file_handler
.as_ref()
.map(|handler| handler(window_id, event))
.unwrap_or_default()
};
let mut webview = WebViewBuilder::new(&window)
.with_transparent(cfg.window.window.transparent)
.with_url("dioxus://index.html/")
.unwrap()
.with_ipc_handler({
let proxy = shared.proxy.clone();
move |payload: String| {
// defer the event to the main thread
if let Ok(message) = serde_json::from_str(&payload) {
_ = proxy.send_event(UserWindowEvent(EventData::Ipc(message), window_id));
}
}
})
.with_asynchronous_custom_protocol(String::from("dioxus"), {
let edit_queue = edit_queue.clone();
move |request, responder| {
let custom_head = custom_head.clone();
let index_file = index_file.clone();
let root_name = root_name.clone();
let asset_handlers_ref = asset_handlers_ref.clone();
let edit_queue = edit_queue.clone();
tokio::spawn(async move {
protocol::desktop_handler(
request,
custom_head.clone(),
index_file.clone(),
&root_name,
&asset_handlers_ref,
&edit_queue,
headless,
responder,
)
.await;
});
}
})
.with_file_drop_handler(move |event| {
file_handler
.as_ref()
.map(|handler| handler(window_id, event))
.unwrap_or_default()
})
.with_ipc_handler(ipc_handler)
.with_asynchronous_custom_protocol(String::from("dioxus"), request_handler)
.with_file_drop_handler(file_drop_handler)
.with_web_context(&mut web_context);
#[cfg(windows)]