//! The Tauri custom-protocol host adapter for [`quasi_router`]. //! //! //! //! The second host, and the one the stack's whole Tauri position rests on. The //! rule is that the view layer never imports `tauri`: router, description and //! renderers are plain Rust, and the protocol handler is a thin adapter that //! hands a request to the router and hands back bytes. This crate is that //! adapter, and it is the only place in quasi where `tauri` appears. //! //! What that buys is repricing. A desktop app on this path has its screens in //! plain Rust rather than in 250 `#[tauri::command]`s, so Tauri becomes a //! dependency you can put a number on later instead of a decision welded into //! thirty thousand lines. //! //! # It serves the document too //! //! This is the decision in the crate, and it comes out of the URL-form spike. //! //! An `hx-post="/task/7/complete"` resolves against the **document's** origin. //! If the document came from tauri's own `tauri://localhost` asset protocol and //! the router answers on a scheme of its own, then every action a screen emits //! is cross-origin: CORS preflights, `Access-Control-Allow-Origin` on every //! response, and the scheme named twice in the CSP because its two platform //! forms are two origins. goingson already pays that for the schemes it does //! not serve documents from, and its `connect-src 'self' ipc: //! http://ipc.localhost` is what the tax looks like written down. //! //! So the window loads *from* this scheme. Give it a //! [`WebviewUrl::CustomProtocol`](tauri::WebviewUrl::CustomProtocol) pointing //! at [`Protocol::url`], the router answers `/`, and every route below it is //! same-origin under plain `'self'` with no CORS anywhere and nothing added to //! the CSP. //! //! Anything that is not a description keeps a way through: see //! [`Protocol::passthrough`], which is this host's version of the axum //! adapter's "real routes merged in front". //! //! # One shape on every platform //! //! A handler is handed `://localhost/` on Linux, macOS, iOS and //! Windows alike. Windows and Android navigate to `http://.localhost/` //! instead, because WebView2 does not serve non-standard schemes, but wry //! reverts that before it builds the request. So there is no `cfg` in this //! crate and no platform branch: [`Uri::path`](http::Uri::path) is the address //! everywhere. //! //! Android is the one platform this adapter does not work on, and the reason is //! not the URL: its webview cannot read a request body, so a POST would arrive //! with its form dropped. Tauri excludes it from its own IPC for the same //! reason. Neither GoingsOn nor Balanced Breakfast targets it. //! //! # Registering it //! //! ```no_run //! use std::sync::Arc; //! use quasi_tauri::{Protocol, Serves}; //! use quasi_router::{Node, Request, Response, RouteError, Router, Screen, Slot, RegionKind}; //! //! struct App; //! struct Html; //! //! impl Serves for Html { //! fn screen(&self, screen: &Screen) -> String { format!("

{}

", screen.title) } //! fn fragment(&self, _node: &Node) -> String { String::new() } //! } //! //! fn home(_app: &App, _request: Request) -> Result { //! Ok(Screen::sidebar_content("Home") //! .with(Slot::new("content", RegionKind::Pane)) //! .into()) //! } //! //! let quasi = Router::::new().get("/", home); //! let protocol = Protocol::new("quasi", quasi, Arc::new(App), Arc::new(Html)); //! let url = protocol.url(); //! //! // The scheme is named twice on purpose: tauri takes it when the handler is //! // registered, and `Protocol` needs it to build the window's URL. //! let builder: tauri::Builder = tauri::Builder::default() //! .register_asynchronous_uri_scheme_protocol("quasi", protocol.into_handler()) //! .setup(move |app| { //! tauri::WebviewWindowBuilder::new(app, "main", tauri::WebviewUrl::CustomProtocol(url)) //! .build()?; //! Ok(()) //! }); //! //! // Then `builder.run(tauri::generate_context!())`, which needs the app's own //! // `tauri.conf.json` and so is not part of this example. //! ``` use std::sync::Arc; use quasi_http::Refusal; use quasi_router::{RouteError, Router}; use tauri::{Runtime, UriSchemeContext, UriSchemeResponder}; pub use quasi_http::{DEFAULT_BODY_LIMIT, Serves, htmx}; /// The host this scheme answers as. /// /// Not configurable, and it is not a hostname in any real sense: a custom /// scheme has no authority to resolve, and both platform URL forms put /// `localhost` where one would go. Naming it once here is what keeps /// [`Protocol::url`] and the handler agreeing. const HOST: &str = "localhost"; /// A request this adapter hands back rather than routing. /// /// The answer a [`Protocol::passthrough`] gives: a status, a content type and /// some bytes. Deliberately not an `http::Response`, so that the common case, /// which is reading a file off disk, does not require building one. pub struct Served { /// What the bytes are. pub content_type: String, /// The bytes. pub body: Vec, } impl Served { /// Bytes of a stated type. #[must_use] pub fn new(content_type: impl Into, body: impl Into>) -> Self { Self { content_type: content_type.into(), body: body.into(), } } } /// Decides whether a path is this adapter's to route. /// /// Takes the path only. A passthrough is a static thing at an address, so the /// verb and the parameters are not part of the question, and keeping them out /// means a passthrough cannot quietly become a second router. type Passthrough = Box Option + Send + Sync + 'static>; /// A scheme name, a router, the app's state and a renderer. pub struct Protocol { scheme: String, router: Router, state: Arc, render: Arc, body_limit: usize, passthrough: Option, } /// What a request needs, once, behind one `Arc`. struct Context { router: Router, state: Arc, render: Arc, body_limit: usize, passthrough: Option, } impl Protocol where S: Send + Sync + 'static, R: Serves, { /// A router mounted on this scheme, over this state, rendered by this /// renderer. /// /// `scheme` is registered with tauri separately, and the two have to match: /// tauri takes the name when the handler is registered, and this crate /// needs it to build [`Protocol::url`]. /// /// # Panics /// /// If the scheme is not a legal URL scheme. It comes from a startup /// literal, so a malformed one is a bug that should not survive the first /// run. #[must_use] pub fn new( scheme: impl Into, router: Router, state: Arc, render: Arc, ) -> Self { let scheme = scheme.into(); assert!( is_scheme(&scheme), "`{scheme}` is not a legal URL scheme: a letter, then letters, digits, `+`, `-` or `.`" ); Self { scheme, router, state, render, body_limit: DEFAULT_BODY_LIMIT, passthrough: None, } } /// Read at most this many bytes of a form body. #[must_use] pub fn body_limit(mut self, bytes: usize) -> Self { self.body_limit = bytes; self } /// Answer some paths without going through the router. /// /// Because the window loads from this scheme, this handler sees every /// request the document makes, and some of them are not descriptions: a /// stylesheet, the htmx bundle, an icon. The axum adapter's answer is that /// real routes merge in front of the fallback; this is the same answer, and /// the closure is the front. /// /// It runs before the router and wins where both would answer, so a /// passthrough over `/static/` and a route at `/static/:id` is a shadowing /// bug rather than a merge. Keep the two address spaces apart. /// /// It runs on the same worker as a route, so reading a file in it is fine /// and blocking on a network call is not. #[must_use] pub fn passthrough( mut self, serve: impl Fn(&str) -> Option + Send + Sync + 'static, ) -> Self { self.passthrough = Some(Box::new(serve)); self } /// The URL to point a webview at. /// /// `://localhost/`, which is the form the document gets on Linux, /// macOS and iOS. Tauri rewrites it to `http://.localhost/` on /// Windows itself, so this is the right value to hand over on every /// platform. /// /// # Panics /// /// Never, for a scheme [`Protocol::new`] accepted. #[must_use] pub fn url(&self) -> tauri::Url { tauri::Url::parse(&format!("{}://{HOST}/", self.scheme)) .expect("a checked scheme over a literal host always parses") } /// The handler to register with tauri. /// /// Consumes the [`Protocol`], because everything in it is shared with every /// request from here on and nothing should still be able to change it. pub fn into_handler( self, ) -> impl Fn(UriSchemeContext<'_, Rt>, http::Request>, UriSchemeResponder) + Send + Sync + 'static { let context = Arc::new(Context { router: self.router, state: self.state, render: self.render, body_limit: self.body_limit, passthrough: self.passthrough, }); move |_scheme_context, request, responder| { let context = Arc::clone(&context); // The handler is called on the webview's thread, and the router is // sync per decision 6, so calling it here would freeze the UI for // as long as the store takes. The asynchronous responder exists for // exactly this, and tauri's blocking pool is already running, so // there is no runtime to bring along and no thread to spawn per // request. tauri::async_runtime::spawn_blocking(move || { responder.respond(serve(&context, &request)); }); } } } /// Answer one request. /// /// Split out from the handler so it is callable without a `tauri::Builder`, a /// window or an event loop, which is what makes this adapter testable at all. fn serve(context: &Context, request: &http::Request>) -> http::Response> where S: Send + Sync + 'static, R: Serves, { let path = request.uri().path(); if let Some(passthrough) = context.passthrough.as_ref() && let Some(served) = passthrough(path) { return http::Response::builder() .status(200) .header(http::header::CONTENT_TYPE, served.content_type) .body(served.body) .unwrap_or_else(|_| quasi_http::refuse(Refusal::Malformed)); } let incoming = match quasi_http::decode( request.method(), request.uri(), request.headers(), request.body(), context.body_limit, ) { Ok(incoming) => incoming, Err(refusal) => return quasi_http::refuse(refusal), }; // A handler that panics takes down the blocking worker, and with it the // responder, and the webview waits forever on a request nobody will answer. // A hosted server gets this from its executor; here it has to be caught. let asked = quasi_http::Asked::new(&incoming); let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { context.router.handle(&context.state, incoming.into()) })) .unwrap_or_else(|_| Err(RouteError::internal("the request could not be completed"))); let mut response = quasi_http::respond(&*context.render, outcome, &asked); // A Tauri window has no address bar and no back button anyone uses, so a // history entry here is noise at best. The shared path derives one because // it is answering an http request and cannot see which coat it is wearing; // dropping it is cheaper than a flag threaded through for one host, and the // suppression is visible where the difference actually is. let headers = response.headers_mut(); headers.remove(quasi_http::htmx::PUSH_URL); headers.remove(quasi_http::htmx::REPLACE_URL); response } /// Whether this is a legal URL scheme. /// /// RFC 3986: a letter, then letters, digits, `+`, `-` or `.`. Checked because /// the alternative is a window that silently fails to load with nothing in the /// log to say the scheme was the problem. fn is_scheme(scheme: &str) -> bool { let mut characters = scheme.chars(); characters.next().is_some_and(char::is_alphabetic) && characters.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) } #[cfg(test)] mod tests;