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 | //! fn suggestions(&self, _: &str, _: &[quasi_router::Candidate]) -> String { String::new() } |
| 67 | //! } |
| 68 | //! |
| 69 | //! fn home(_app: &App, _request: Request) -> Result<Response, RouteError> { |
| 70 | //! Ok(Screen::sidebar_content("Home") |
| 71 | //! .with(Slot::new("content", RegionKind::Pane)) |
| 72 | //! .into()) |
| 73 | //! } |
| 74 | //! |
| 75 | //! let quasi = Router::<App>::new().get("/", home); |
| 76 | //! let protocol = Protocol::new("quasi", quasi, Arc::new(App), Arc::new(Html)); |
| 77 | //! let url = protocol.url(); |
| 78 | //! |
| 79 | //! // The scheme is named twice on purpose: tauri takes it when the handler is |
| 80 | //! // registered, and `Protocol` needs it to build the window's URL. |
| 81 | //! let builder: tauri::Builder<tauri::Wry> = tauri::Builder::default() |
| 82 | //! .register_asynchronous_uri_scheme_protocol("quasi", protocol.into_handler()) |
| 83 | //! .setup(move |app| { |
| 84 | //! tauri::WebviewWindowBuilder::new(app, "main", tauri::WebviewUrl::CustomProtocol(url)) |
| 85 | //! .build()?; |
| 86 | //! Ok(()) |
| 87 | //! }); |
| 88 | //! |
| 89 | //! // Then `builder.run(tauri::generate_context!())`, which needs the app's own |
| 90 | //! // `tauri.conf.json` and so is not part of this example. |
| 91 | //! ``` |
| 92 | |
| 93 | use ; |
| 94 | |
| 95 | use Refusal; |
| 96 | use ; |
| 97 | use ; |
| 98 | |
| 99 | pub use ; |
| 100 | |
| 101 | /// The host this scheme answers as. |
| 102 | /// |
| 103 | /// Not configurable, and it is not a hostname in any real sense: a custom |
| 104 | /// scheme has no authority to resolve, and both platform URL forms put |
| 105 | /// `localhost` where one would go. Naming it once here is what keeps |
| 106 | /// [`Protocol::url`] and the handler agreeing. |
| 107 | const HOST: &str = "localhost"; |
| 108 | |
| 109 | /// A request this adapter hands back rather than routing. |
| 110 | /// |
| 111 | /// The answer a [`Protocol::passthrough`] gives: a status, a content type and |
| 112 | /// some bytes. Deliberately not an `http::Response`, so that the common case, |
| 113 | /// which is reading a file off disk, does not require building one. |
| 114 | |
| 115 | /// What the bytes are. |
| 116 | pub content_type: String, |
| 117 | /// The bytes. |
| 118 | pub body: , |
| 119 | |
| 120 | |
| 121 | |
| 122 | /// Bytes of a stated type. |
| 123 | |
| 124 | |
| 125 | Self |
| 126 | content_type: content_type.into, |
| 127 | body: body.into, |
| 128 | |
| 129 | |
| 130 | |
| 131 | |
| 132 | /// Decides whether a path is this adapter's to route. |
| 133 | /// |
| 134 | /// Takes the path only. A passthrough is a static thing at an address, so the |
| 135 | /// verb and the parameters are not part of the question, and keeping them out |
| 136 | /// means a passthrough cannot quietly become a second router. |
| 137 | type Passthrough = ; |
| 138 | |
| 139 | /// State a host cannot build until it has already started. |
| 140 | /// |
| 141 | /// Tauri is the case this exists for, and it is not an edge one. An app whose |
| 142 | /// state needs a data directory needs an `AppHandle` to resolve it, and the |
| 143 | /// handle does not exist until `Builder::build` runs — which is after every |
| 144 | /// scheme is registered. So the protocol has to be handed to the builder |
| 145 | /// before the thing it routes over can be made. |
| 146 | /// |
| 147 | /// [`Protocol::pending`] gives the builder its protocol now and the setup |
| 148 | /// closure this, to fill in once. Requests that arrive before it is filled are |
| 149 | /// answered 503 rather than blocked on: the window is not up yet, so there is |
| 150 | /// nobody to keep waiting. |
| 151 | ; |
| 152 | |
| 153 | |
| 154 | /// Hand over the state. The first call wins. |
| 155 | /// |
| 156 | /// Returns whether this call was the one that set it, so a second caller |
| 157 | /// can say so rather than silently doing nothing. |
| 158 | |
| 159 | self.0.set.is_ok |
| 160 | |
| 161 | |
| 162 | /// Whether the state has arrived. |
| 163 | |
| 164 | |
| 165 | self.0.get.is_some |
| 166 | |
| 167 | |
| 168 | |
| 169 | |
| 170 | |
| 171 | Self |
| 172 | |
| 173 | |
| 174 | |
| 175 | /// A scheme name, a router, the app's state and a renderer. |
| 176 | |
| 177 | scheme: String, |
| 178 | router: , |
| 179 | state: , |
| 180 | render: , |
| 181 | body_limit: usize, |
| 182 | passthrough: , |
| 183 | |
| 184 | |
| 185 | /// What a request needs, once, behind one `Arc`. |
| 186 | |
| 187 | router: , |
| 188 | state: , |
| 189 | render: , |
| 190 | body_limit: usize, |
| 191 | passthrough: , |
| 192 | |
| 193 | |
| 194 | |
| 195 | |
| 196 | S: Send + Sync + 'static, |
| 197 | R: Serves, |
| 198 | |
| 199 | /// A router mounted on this scheme, over this state, rendered by this |
| 200 | /// renderer. |
| 201 | /// |
| 202 | /// `scheme` is registered with tauri separately, and the two have to match: |
| 203 | /// tauri takes the name when the handler is registered, and this crate |
| 204 | /// needs it to build [`Protocol::url`]. |
| 205 | /// |
| 206 | /// # Panics |
| 207 | /// |
| 208 | /// If the scheme is not a legal URL scheme. It comes from a startup |
| 209 | /// literal, so a malformed one is a bug that should not survive the first |
| 210 | /// run. |
| 211 | |
| 212 | |
| 213 | scheme: impl , |
| 214 | router: , |
| 215 | state: , |
| 216 | render: , |
| 217 | |
| 218 | let scheme = scheme.into; |
| 219 | assert! |
| 220 | is_scheme, |
| 221 | "`{scheme}` is not a legal URL scheme: a letter, then letters, digits, `+`, `-` or `.`" |
| 222 | ; |
| 223 | let cell = new; |
| 224 | let _ = cell.set; |
| 225 | Self |
| 226 | scheme, |
| 227 | router, |
| 228 | state: new, |
| 229 | render, |
| 230 | body_limit: DEFAULT_BODY_LIMIT, |
| 231 | passthrough: None, |
| 232 | |
| 233 | |
| 234 | |
| 235 | /// The same, for a host whose state does not exist yet. |
| 236 | /// |
| 237 | /// Returns the protocol to register and the [`Late`] to fill in once the |
| 238 | /// state can be built. See [`Late`] for why Tauri needs this at all. |
| 239 | /// |
| 240 | /// # Panics |
| 241 | /// |
| 242 | /// On the same illegal scheme [`Protocol::new`] rejects. |
| 243 | |
| 244 | |
| 245 | |
| 246 | R: Serves, |
| 247 | |
| 248 | let scheme = scheme.into; |
| 249 | assert! |
| 250 | is_scheme, |
| 251 | "`{scheme}` is not a legal URL scheme: a letter, then letters, digits, `+`, `-` or `.`" |
| 252 | ; |
| 253 | let state = new; |
| 254 | let late = Late; |
| 255 | |
| 256 | Self |
| 257 | scheme, |
| 258 | router, |
| 259 | state, |
| 260 | render, |
| 261 | body_limit: DEFAULT_BODY_LIMIT, |
| 262 | passthrough: None, |
| 263 | , |
| 264 | late, |
| 265 | |
| 266 | |
| 267 | |
| 268 | /// Read at most this many bytes of a form body. |
| 269 | |
| 270 | |
| 271 | self.body_limit = bytes; |
| 272 | self |
| 273 | |
| 274 | |
| 275 | /// Answer some paths without going through the router. |
| 276 | /// |
| 277 | /// Because the window loads from this scheme, this handler sees every |
| 278 | /// request the document makes, and some of them are not descriptions: a |
| 279 | /// stylesheet, the htmx bundle, an icon. The axum adapter's answer is that |
| 280 | /// real routes merge in front of the fallback; this is the same answer, and |
| 281 | /// the closure is the front. |
| 282 | /// |
| 283 | /// It runs before the router and wins where both would answer, so a |
| 284 | /// passthrough over `/static/` and a route at `/static/:id` is a shadowing |
| 285 | /// bug rather than a merge. Keep the two address spaces apart. |
| 286 | /// |
| 287 | /// It runs on the same worker as a route, so reading a file in it is fine |
| 288 | /// and blocking on a network call is not. |
| 289 | |
| 290 | |
| 291 | mut self, |
| 292 | serve: impl Fn -> + Send + Sync + 'static, |
| 293 | |
| 294 | self.passthrough = Some; |
| 295 | self |
| 296 | |
| 297 | |
| 298 | /// The URL to point a webview at. |
| 299 | /// |
| 300 | /// `<scheme>://localhost/`, which is the form the document gets on Linux, |
| 301 | /// macOS and iOS. Tauri rewrites it to `http://<scheme>.localhost/` on |
| 302 | /// Windows itself, so this is the right value to hand over on every |
| 303 | /// platform. |
| 304 | /// |
| 305 | /// # Panics |
| 306 | /// |
| 307 | /// Never, for a scheme [`Protocol::new`] accepted. |
| 308 | |
| 309 | |
| 310 | parse |
| 311 | .expect |
| 312 | |
| 313 | |
| 314 | /// The handler to register with tauri. |
| 315 | /// |
| 316 | /// Consumes the [`Protocol`], because everything in it is shared with every |
| 317 | /// request from here on and nothing should still be able to change it. |
| 318 | |
| 319 | self, |
| 320 | |
| 321 | + Send |
| 322 | + Sync |
| 323 | + 'static |
| 324 | let context = new |
| 325 | router: self.router, |
| 326 | state: self.state, |
| 327 | render: self.render, |
| 328 | body_limit: self.body_limit, |
| 329 | passthrough: self.passthrough, |
| 330 | ; |
| 331 | |
| 332 | move |_scheme_context, request, responder| |
| 333 | let context = clone; |
| 334 | // The handler is called on the webview's thread, and the router is |
| 335 | // sync per decision 6, so calling it here would freeze the UI for |
| 336 | // as long as the store takes. The asynchronous responder exists for |
| 337 | // exactly this, and tauri's blocking pool is already running, so |
| 338 | // there is no runtime to bring along and no thread to spawn per |
| 339 | // request. |
| 340 | spawn_blocking |
| 341 | responder.respond; |
| 342 | ; |
| 343 | |
| 344 | |
| 345 | |
| 346 | |
| 347 | /// Answer one request. |
| 348 | /// |
| 349 | /// Split out from the handler so it is callable without a `tauri::Builder`, a |
| 350 | /// window or an event loop, which is what makes this adapter testable at all. |
| 351 | |
| 352 | |
| 353 | S: Send + Sync + 'static, |
| 354 | R: Serves, |
| 355 | |
| 356 | let path = request.uri.path; |
| 357 | |
| 358 | // Nothing to route over yet. Only reachable on the deferred path, and only |
| 359 | // in the window between registering the scheme and setup filling it in. |
| 360 | let Some = context.state.get else |
| 361 | return builder |
| 362 | .status |
| 363 | .header |
| 364 | .body |
| 365 | .unwrap_or_else; |
| 366 | ; |
| 367 | |
| 368 | if let Some = context.passthrough.as_ref |
| 369 | && let Some = passthrough |
| 370 | |
| 371 | return builder |
| 372 | .status |
| 373 | .header |
| 374 | .body |
| 375 | .unwrap_or_else; |
| 376 | |
| 377 | |
| 378 | let incoming = match decode |
| 379 | request.method, |
| 380 | request.uri, |
| 381 | request.headers, |
| 382 | request.body, |
| 383 | context.body_limit, |
| 384 | |
| 385 | Ok => incoming, |
| 386 | Err => return refuse, |
| 387 | ; |
| 388 | |
| 389 | // A handler that panics takes down the blocking worker, and with it the |
| 390 | // responder, and the webview waits forever on a request nobody will answer. |
| 391 | // A hosted server gets this from its executor; here it has to be caught. |
| 392 | let asked = new; |
| 393 | let outcome = catch_unwind |
| 394 | context.router.handle |
| 395 | |
| 396 | .unwrap_or_else; |
| 397 | |
| 398 | let mut response = respond; |
| 399 | |
| 400 | // A Tauri window has no address bar and no back button anyone uses, so a |
| 401 | // history entry here is noise at best. The shared path derives one because |
| 402 | // it is answering an http request and cannot see which coat it is wearing; |
| 403 | // dropping it is cheaper than a flag threaded through for one host, and the |
| 404 | // suppression is visible where the difference actually is. |
| 405 | let headers = response.headers_mut; |
| 406 | headers.remove; |
| 407 | headers.remove; |
| 408 | response |
| 409 | |
| 410 | |
| 411 | /// Whether this is a legal URL scheme. |
| 412 | /// |
| 413 | /// RFC 3986: a letter, then letters, digits, `+`, `-` or `.`. Checked because |
| 414 | /// the alternative is a window that silently fails to load with nothing in the |
| 415 | /// log to say the scheme was the problem. |
| 416 | |
| 417 | let mut characters = scheme.chars; |
| 418 | characters.next.is_some_and |
| 419 | && characters.all |
| 420 | |
| 421 | |
| 422 | |
| 423 | |
| 424 |