Skip to main content

max / quasi

15.6 KB · 424 lines History Blame Raw
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 std::sync::{Arc, OnceLock};
94
95 use quasi_http::Refusal;
96 use quasi_router::{RouteError, Router};
97 use tauri::{Runtime, UriSchemeContext, UriSchemeResponder};
98
99 pub use quasi_http::{DEFAULT_BODY_LIMIT, Serves, htmx};
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 pub struct Served {
115 /// What the bytes are.
116 pub content_type: String,
117 /// The bytes.
118 pub body: Vec<u8>,
119 }
120
121 impl Served {
122 /// Bytes of a stated type.
123 #[must_use]
124 pub fn new(content_type: impl Into<String>, body: impl Into<Vec<u8>>) -> Self {
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 = Box<dyn Fn(&str) -> Option<Served> + Send + Sync + 'static>;
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 pub struct Late<S>(Arc<OnceLock<Arc<S>>>);
152
153 impl<S> Late<S> {
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 pub fn set(&self, state: Arc<S>) -> bool {
159 self.0.set(state).is_ok()
160 }
161
162 /// Whether the state has arrived.
163 #[must_use]
164 pub fn is_set(&self) -> bool {
165 self.0.get().is_some()
166 }
167 }
168
169 impl<S> Clone for Late<S> {
170 fn clone(&self) -> Self {
171 Self(Arc::clone(&self.0))
172 }
173 }
174
175 /// A scheme name, a router, the app's state and a renderer.
176 pub struct Protocol<S, R> {
177 scheme: String,
178 router: Router<S>,
179 state: Arc<OnceLock<Arc<S>>>,
180 render: Arc<R>,
181 body_limit: usize,
182 passthrough: Option<Passthrough>,
183 }
184
185 /// What a request needs, once, behind one `Arc`.
186 struct Context<S, R> {
187 router: Router<S>,
188 state: Arc<OnceLock<Arc<S>>>,
189 render: Arc<R>,
190 body_limit: usize,
191 passthrough: Option<Passthrough>,
192 }
193
194 impl<S, R> Protocol<S, R>
195 where
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 #[must_use]
212 pub fn new(
213 scheme: impl Into<String>,
214 router: Router<S>,
215 state: Arc<S>,
216 render: Arc<R>,
217 ) -> Self {
218 let scheme = scheme.into();
219 assert!(
220 is_scheme(&scheme),
221 "`{scheme}` is not a legal URL scheme: a letter, then letters, digits, `+`, `-` or `.`"
222 );
223 let cell = OnceLock::new();
224 let _ = cell.set(state);
225 Self {
226 scheme,
227 router,
228 state: Arc::new(cell),
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 #[must_use]
244 pub fn pending(scheme: impl Into<String>, router: Router<S>, render: Arc<R>) -> (Self, Late<S>)
245 where
246 R: Serves,
247 {
248 let scheme = scheme.into();
249 assert!(
250 is_scheme(&scheme),
251 "`{scheme}` is not a legal URL scheme: a letter, then letters, digits, `+`, `-` or `.`"
252 );
253 let state = Arc::new(OnceLock::new());
254 let late = Late(Arc::clone(&state));
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 #[must_use]
270 pub fn body_limit(mut self, bytes: usize) -> Self {
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 #[must_use]
290 pub fn passthrough(
291 mut self,
292 serve: impl Fn(&str) -> Option<Served> + Send + Sync + 'static,
293 ) -> Self {
294 self.passthrough = Some(Box::new(serve));
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 #[must_use]
309 pub fn url(&self) -> tauri::Url {
310 tauri::Url::parse(&format!("{}://{HOST}/", self.scheme))
311 .expect("a checked scheme over a literal host always parses")
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 pub fn into_handler<Rt: Runtime>(
319 self,
320 ) -> impl Fn(UriSchemeContext<'_, Rt>, http::Request<Vec<u8>>, UriSchemeResponder)
321 + Send
322 + Sync
323 + 'static {
324 let context = Arc::new(Context {
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 = Arc::clone(&context);
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 tauri::async_runtime::spawn_blocking(move || {
341 responder.respond(serve(&context, &request));
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 fn serve<S, R>(context: &Context<S, R>, request: &http::Request<Vec<u8>>) -> http::Response<Vec<u8>>
352 where
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(state) = context.state.get() else {
361 return http::Response::builder()
362 .status(503)
363 .header(http::header::CONTENT_TYPE, "text/plain; charset=utf-8")
364 .body(b"the app is still starting".to_vec())
365 .unwrap_or_else(|_| quasi_http::refuse(Refusal::Malformed));
366 };
367
368 if let Some(passthrough) = context.passthrough.as_ref()
369 && let Some(served) = passthrough(path)
370 {
371 return http::Response::builder()
372 .status(200)
373 .header(http::header::CONTENT_TYPE, served.content_type)
374 .body(served.body)
375 .unwrap_or_else(|_| quasi_http::refuse(Refusal::Malformed));
376 }
377
378 let incoming = match quasi_http::decode(
379 request.method(),
380 request.uri(),
381 request.headers(),
382 request.body(),
383 context.body_limit,
384 ) {
385 Ok(incoming) => incoming,
386 Err(refusal) => return quasi_http::refuse(refusal),
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 = quasi_http::Asked::new(&incoming);
393 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
394 context.router.handle(state, incoming.into())
395 }))
396 .unwrap_or_else(|_| Err(RouteError::internal("the request could not be completed")));
397
398 let mut response = quasi_http::respond(&*context.render, outcome, &asked);
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(quasi_http::htmx::PUSH_URL);
407 headers.remove(quasi_http::htmx::REPLACE_URL);
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 fn is_scheme(scheme: &str) -> bool {
417 let mut characters = scheme.chars();
418 characters.next().is_some_and(char::is_alphabetic)
419 && characters.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
420 }
421
422 #[cfg(test)]
423 mod tests;
424