max / quasi
| 1 | //! The Tauri custom-protocol host adapter for [`quasi_router`]. |
| 2 | //! |
| 3 | //! <!-- wiki: quasi-overview --> |
| 4 | //! |
| 5 | //! The second host, and the one the stack's whole Tauri position rests on. The |
| 6 | //! rule is that the view layer never imports `tauri`: router, description and |
| 7 | //! renderers are plain Rust, and the protocol handler is a thin adapter that |
| 8 | //! hands a request to the router and hands back bytes. This crate is that |
| 9 | //! adapter, and it is the only place in quasi where `tauri` appears. |
| 10 | //! |
| 11 | //! What that buys is repricing. A desktop app on this path has its screens in |
| 12 | //! plain Rust rather than in 250 `#[tauri::command]`s, so Tauri becomes a |
| 13 | //! dependency you can put a number on later instead of a decision welded into |
| 14 | //! thirty thousand lines. |
| 15 | //! |
| 16 | //! # It serves the document too |
| 17 | //! |
| 18 | //! This is the decision in the crate, and it comes out of the URL-form spike. |
| 19 | //! |
| 20 | //! An `hx-post="/task/7/complete"` resolves against the **document's** origin. |
| 21 | //! If the document came from tauri's own `tauri://localhost` asset protocol and |
| 22 | //! the router answers on a scheme of its own, then every action a screen emits |
| 23 | //! is cross-origin: CORS preflights, `Access-Control-Allow-Origin` on every |
| 24 | //! response, and the scheme named twice in the CSP because its two platform |
| 25 | //! forms are two origins. goingson already pays that for the schemes it does |
| 26 | //! not serve documents from, and its `connect-src 'self' ipc: |
| 27 | //! http://ipc.localhost` is what the tax looks like written down. |
| 28 | //! |
| 29 | //! So the window loads *from* this scheme. Give it a |
| 30 | //! [`WebviewUrl::CustomProtocol`](tauri::WebviewUrl::CustomProtocol) pointing |
| 31 | //! at [`Protocol::url`], the router answers `/`, and every route below it is |
| 32 | //! same-origin under plain `'self'` with no CORS anywhere and nothing added to |
| 33 | //! the CSP. |
| 34 | //! |
| 35 | //! Anything that is not a description keeps a way through: see |
| 36 | //! [`Protocol::passthrough`], which is this host's version of the axum |
| 37 | //! adapter's "real routes merged in front". |
| 38 | //! |
| 39 | //! # One shape on every platform |
| 40 | //! |
| 41 | //! A handler is handed `<scheme>://localhost/<path>` on Linux, macOS, iOS and |
| 42 | //! Windows alike. Windows and Android navigate to `http://<scheme>.localhost/` |
| 43 | //! instead, because WebView2 does not serve non-standard schemes, but wry |
| 44 | //! reverts that before it builds the request. So there is no `cfg` in this |
| 45 | //! crate and no platform branch: [`Uri::path`](http::Uri::path) is the address |
| 46 | //! everywhere. |
| 47 | //! |
| 48 | //! Android is the one platform this adapter does not work on, and the reason is |
| 49 | //! not the URL: its webview cannot read a request body, so a POST would arrive |
| 50 | //! with its form dropped. Tauri excludes it from its own IPC for the same |
| 51 | //! reason. Neither GoingsOn nor Balanced Breakfast targets it. |
| 52 | //! |
| 53 | //! # Registering it |
| 54 | //! |
| 55 | //! ```no_run |
| 56 | //! use std::sync::Arc; |
| 57 | //! use quasi_tauri::{Protocol, Serves}; |
| 58 | //! use quasi_router::{Node, Request, Response, RouteError, Router, Screen, Slot, RegionKind}; |
| 59 | //! |
| 60 | //! struct App; |
| 61 | //! struct Html; |
| 62 | //! |
| 63 | //! impl Serves for Html { |
| 64 | //! fn screen(&self, screen: &Screen) -> String { format!("<h1>{}</h1>", screen.title) } |
| 65 | //! fn fragment(&self, _node: &Node) -> String { String::new() } |
| 66 | //! } |
| 67 | //! |
| 68 | //! fn home(_app: &App, _request: Request) -> Result<Response, RouteError> { |
| 69 | //! Ok(Screen::sidebar_content("Home") |
| 70 | //! .with(Slot::new("content", RegionKind::Pane)) |
| 71 | //! .into()) |
| 72 | //! } |
| 73 | //! |
| 74 | //! let quasi = Router::<App>::new().get("/", home); |
| 75 | //! let protocol = Protocol::new("quasi", quasi, Arc::new(App), Arc::new(Html)); |
| 76 | //! let url = protocol.url(); |
| 77 | //! |
| 78 | //! // The scheme is named twice on purpose: tauri takes it when the handler is |
| 79 | //! // registered, and `Protocol` needs it to build the window's URL. |
| 80 | //! let builder: tauri::Builder<tauri::Wry> = tauri::Builder::default() |
| 81 | //! .register_asynchronous_uri_scheme_protocol("quasi", protocol.into_handler()) |
| 82 | //! .setup(move |app| { |
| 83 | //! tauri::WebviewWindowBuilder::new(app, "main", tauri::WebviewUrl::CustomProtocol(url)) |
| 84 | //! .build()?; |
| 85 | //! Ok(()) |
| 86 | //! }); |
| 87 | //! |
| 88 | //! // Then `builder.run(tauri::generate_context!())`, which needs the app's own |
| 89 | //! // `tauri.conf.json` and so is not part of this example. |
| 90 | //! ``` |
| 91 | |
| 92 | use Arc; |
| 93 | |
| 94 | use Refusal; |
| 95 | use ; |
| 96 | use ; |
| 97 | |
| 98 | pub use ; |
| 99 | |
| 100 | /// The host this scheme answers as. |
| 101 | /// |
| 102 | /// Not configurable, and it is not a hostname in any real sense: a custom |
| 103 | /// scheme has no authority to resolve, and both platform URL forms put |
| 104 | /// `localhost` where one would go. Naming it once here is what keeps |
| 105 | /// [`Protocol::url`] and the handler agreeing. |
| 106 | const HOST: &str = "localhost"; |
| 107 | |
| 108 | /// A request this adapter hands back rather than routing. |
| 109 | /// |
| 110 | /// The answer a [`Protocol::passthrough`] gives: a status, a content type and |
| 111 | /// some bytes. Deliberately not an `http::Response`, so that the common case, |
| 112 | /// which is reading a file off disk, does not require building one. |
| 113 | |
| 114 | /// What the bytes are. |
| 115 | pub content_type: String, |
| 116 | /// The bytes. |
| 117 | pub body: , |
| 118 | |
| 119 | |
| 120 | |
| 121 | /// Bytes of a stated type. |
| 122 | |
| 123 | |
| 124 | Self |
| 125 | content_type: content_type.into, |
| 126 | body: body.into, |
| 127 | |
| 128 | |
| 129 | |
| 130 | |
| 131 | /// Decides whether a path is this adapter's to route. |
| 132 | /// |
| 133 | /// Takes the path only. A passthrough is a static thing at an address, so the |
| 134 | /// verb and the parameters are not part of the question, and keeping them out |
| 135 | /// means a passthrough cannot quietly become a second router. |
| 136 | type Passthrough = ; |
| 137 | |
| 138 | /// A scheme name, a router, the app's state and a renderer. |
| 139 | |
| 140 | scheme: String, |
| 141 | router: , |
| 142 | state: , |
| 143 | render: , |
| 144 | body_limit: usize, |
| 145 | passthrough: , |
| 146 | |
| 147 | |
| 148 | /// What a request needs, once, behind one `Arc`. |
| 149 | |
| 150 | router: , |
| 151 | state: , |
| 152 | render: , |
| 153 | body_limit: usize, |
| 154 | passthrough: , |
| 155 | |
| 156 | |
| 157 | |
| 158 | |
| 159 | S: Send + Sync + 'static, |
| 160 | R: Serves, |
| 161 | |
| 162 | /// A router mounted on this scheme, over this state, rendered by this |
| 163 | /// renderer. |
| 164 | /// |
| 165 | /// `scheme` is registered with tauri separately, and the two have to match: |
| 166 | /// tauri takes the name when the handler is registered, and this crate |
| 167 | /// needs it to build [`Protocol::url`]. |
| 168 | /// |
| 169 | /// # Panics |
| 170 | /// |
| 171 | /// If the scheme is not a legal URL scheme. It comes from a startup |
| 172 | /// literal, so a malformed one is a bug that should not survive the first |
| 173 | /// run. |
| 174 | |
| 175 | |
| 176 | scheme: impl , |
| 177 | router: , |
| 178 | state: , |
| 179 | render: , |
| 180 | |
| 181 | let scheme = scheme.into; |
| 182 | assert! |
| 183 | is_scheme, |
| 184 | "`{scheme}` is not a legal URL scheme: a letter, then letters, digits, `+`, `-` or `.`" |
| 185 | ; |
| 186 | Self |
| 187 | scheme, |
| 188 | router, |
| 189 | state, |
| 190 | render, |
| 191 | body_limit: DEFAULT_BODY_LIMIT, |
| 192 | passthrough: None, |
| 193 | |
| 194 | |
| 195 | |
| 196 | /// Read at most this many bytes of a form body. |
| 197 | |
| 198 | |
| 199 | self.body_limit = bytes; |
| 200 | self |
| 201 | |
| 202 | |
| 203 | /// Answer some paths without going through the router. |
| 204 | /// |
| 205 | /// Because the window loads from this scheme, this handler sees every |
| 206 | /// request the document makes, and some of them are not descriptions: a |
| 207 | /// stylesheet, the htmx bundle, an icon. The axum adapter's answer is that |
| 208 | /// real routes merge in front of the fallback; this is the same answer, and |
| 209 | /// the closure is the front. |
| 210 | /// |
| 211 | /// It runs before the router and wins where both would answer, so a |
| 212 | /// passthrough over `/static/` and a route at `/static/:id` is a shadowing |
| 213 | /// bug rather than a merge. Keep the two address spaces apart. |
| 214 | /// |
| 215 | /// It runs on the same worker as a route, so reading a file in it is fine |
| 216 | /// and blocking on a network call is not. |
| 217 | |
| 218 | |
| 219 | mut self, |
| 220 | serve: impl Fn -> + Send + Sync + 'static, |
| 221 | |
| 222 | self.passthrough = Some; |
| 223 | self |
| 224 | |
| 225 | |
| 226 | /// The URL to point a webview at. |
| 227 | /// |
| 228 | /// `<scheme>://localhost/`, which is the form the document gets on Linux, |
| 229 | /// macOS and iOS. Tauri rewrites it to `http://<scheme>.localhost/` on |
| 230 | /// Windows itself, so this is the right value to hand over on every |
| 231 | /// platform. |
| 232 | /// |
| 233 | /// # Panics |
| 234 | /// |
| 235 | /// Never, for a scheme [`Protocol::new`] accepted. |
| 236 | |
| 237 | |
| 238 | parse |
| 239 | .expect |
| 240 | |
| 241 | |
| 242 | /// The handler to register with tauri. |
| 243 | /// |
| 244 | /// Consumes the [`Protocol`], because everything in it is shared with every |
| 245 | /// request from here on and nothing should still be able to change it. |
| 246 | |
| 247 | self, |
| 248 | |
| 249 | + Send |
| 250 | + Sync |
| 251 | + 'static |
| 252 | let context = new |
| 253 | router: self.router, |
| 254 | state: self.state, |
| 255 | render: self.render, |
| 256 | body_limit: self.body_limit, |
| 257 | passthrough: self.passthrough, |
| 258 | ; |
| 259 | |
| 260 | move |_scheme_context, request, responder| |
| 261 | let context = clone; |
| 262 | // The handler is called on the webview's thread, and the router is |
| 263 | // sync per decision 6, so calling it here would freeze the UI for |
| 264 | // as long as the store takes. The asynchronous responder exists for |
| 265 | // exactly this, and tauri's blocking pool is already running, so |
| 266 | // there is no runtime to bring along and no thread to spawn per |
| 267 | // request. |
| 268 | spawn_blocking |
| 269 | responder.respond; |
| 270 | ; |
| 271 | |
| 272 | |
| 273 | |
| 274 | |
| 275 | /// Answer one request. |
| 276 | /// |
| 277 | /// Split out from the handler so it is callable without a `tauri::Builder`, a |
| 278 | /// window or an event loop, which is what makes this adapter testable at all. |
| 279 | |
| 280 | |
| 281 | S: Send + Sync + 'static, |
| 282 | R: Serves, |
| 283 | |
| 284 | let path = request.uri.path; |
| 285 | |
| 286 | if let Some = context.passthrough.as_ref |
| 287 | && let Some = passthrough |
| 288 | |
| 289 | return builder |
| 290 | .status |
| 291 | .header |
| 292 | .body |
| 293 | .unwrap_or_else; |
| 294 | |
| 295 | |
| 296 | let incoming = match decode |
| 297 | request.method, |
| 298 | request.uri, |
| 299 | request.headers, |
| 300 | request.body, |
| 301 | context.body_limit, |
| 302 | |
| 303 | Ok => incoming, |
| 304 | Err => return refuse, |
| 305 | ; |
| 306 | |
| 307 | // A handler that panics takes down the blocking worker, and with it the |
| 308 | // responder, and the webview waits forever on a request nobody will answer. |
| 309 | // A hosted server gets this from its executor; here it has to be caught. |
| 310 | let asked = new; |
| 311 | let outcome = catch_unwind |
| 312 | context.router.handle |
| 313 | |
| 314 | .unwrap_or_else; |
| 315 | |
| 316 | let mut response = respond; |
| 317 | |
| 318 | // A Tauri window has no address bar and no back button anyone uses, so a |
| 319 | // history entry here is noise at best. The shared path derives one because |
| 320 | // it is answering an http request and cannot see which coat it is wearing; |
| 321 | // dropping it is cheaper than a flag threaded through for one host, and the |
| 322 | // suppression is visible where the difference actually is. |
| 323 | let headers = response.headers_mut; |
| 324 | headers.remove; |
| 325 | headers.remove; |
| 326 | response |
| 327 | |
| 328 | |
| 329 | /// Whether this is a legal URL scheme. |
| 330 | /// |
| 331 | /// RFC 3986: a letter, then letters, digits, `+`, `-` or `.`. Checked because |
| 332 | /// the alternative is a window that silently fails to load with nothing in the |
| 333 | /// log to say the scheme was the problem. |
| 334 | |
| 335 | let mut characters = scheme.chars; |
| 336 | characters.next.is_some_and |
| 337 | && characters.all |
| 338 | |
| 339 | |
| 340 | |
| 341 | |
| 342 |