Skip to main content

max / quasi

12.7 KB · 342 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 //! }
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 std::sync::Arc;
93
94 use quasi_http::Refusal;
95 use quasi_router::{RouteError, Router};
96 use tauri::{Runtime, UriSchemeContext, UriSchemeResponder};
97
98 pub use quasi_http::{DEFAULT_BODY_LIMIT, Serves, htmx};
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 pub struct Served {
114 /// What the bytes are.
115 pub content_type: String,
116 /// The bytes.
117 pub body: Vec<u8>,
118 }
119
120 impl Served {
121 /// Bytes of a stated type.
122 #[must_use]
123 pub fn new(content_type: impl Into<String>, body: impl Into<Vec<u8>>) -> Self {
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 = Box<dyn Fn(&str) -> Option<Served> + Send + Sync + 'static>;
137
138 /// A scheme name, a router, the app's state and a renderer.
139 pub struct Protocol<S, R> {
140 scheme: String,
141 router: Router<S>,
142 state: Arc<S>,
143 render: Arc<R>,
144 body_limit: usize,
145 passthrough: Option<Passthrough>,
146 }
147
148 /// What a request needs, once, behind one `Arc`.
149 struct Context<S, R> {
150 router: Router<S>,
151 state: Arc<S>,
152 render: Arc<R>,
153 body_limit: usize,
154 passthrough: Option<Passthrough>,
155 }
156
157 impl<S, R> Protocol<S, R>
158 where
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 #[must_use]
175 pub fn new(
176 scheme: impl Into<String>,
177 router: Router<S>,
178 state: Arc<S>,
179 render: Arc<R>,
180 ) -> Self {
181 let scheme = scheme.into();
182 assert!(
183 is_scheme(&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 #[must_use]
198 pub fn body_limit(mut self, bytes: usize) -> Self {
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 #[must_use]
218 pub fn passthrough(
219 mut self,
220 serve: impl Fn(&str) -> Option<Served> + Send + Sync + 'static,
221 ) -> Self {
222 self.passthrough = Some(Box::new(serve));
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 #[must_use]
237 pub fn url(&self) -> tauri::Url {
238 tauri::Url::parse(&format!("{}://{HOST}/", self.scheme))
239 .expect("a checked scheme over a literal host always parses")
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 pub fn into_handler<Rt: Runtime>(
247 self,
248 ) -> impl Fn(UriSchemeContext<'_, Rt>, http::Request<Vec<u8>>, UriSchemeResponder)
249 + Send
250 + Sync
251 + 'static {
252 let context = Arc::new(Context {
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 = Arc::clone(&context);
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 tauri::async_runtime::spawn_blocking(move || {
269 responder.respond(serve(&context, &request));
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 fn serve<S, R>(context: &Context<S, R>, request: &http::Request<Vec<u8>>) -> http::Response<Vec<u8>>
280 where
281 S: Send + Sync + 'static,
282 R: Serves,
283 {
284 let path = request.uri().path();
285
286 if let Some(passthrough) = context.passthrough.as_ref()
287 && let Some(served) = passthrough(path)
288 {
289 return http::Response::builder()
290 .status(200)
291 .header(http::header::CONTENT_TYPE, served.content_type)
292 .body(served.body)
293 .unwrap_or_else(|_| quasi_http::refuse(Refusal::Malformed));
294 }
295
296 let incoming = match quasi_http::decode(
297 request.method(),
298 request.uri(),
299 request.headers(),
300 request.body(),
301 context.body_limit,
302 ) {
303 Ok(incoming) => incoming,
304 Err(refusal) => return quasi_http::refuse(refusal),
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 = quasi_http::Asked::new(&incoming);
311 let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
312 context.router.handle(&context.state, incoming.into())
313 }))
314 .unwrap_or_else(|_| Err(RouteError::internal("the request could not be completed")));
315
316 let mut response = quasi_http::respond(&*context.render, outcome, &asked);
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(quasi_http::htmx::PUSH_URL);
325 headers.remove(quasi_http::htmx::REPLACE_URL);
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 fn is_scheme(scheme: &str) -> bool {
335 let mut characters = scheme.chars();
336 characters.next().is_some_and(char::is_alphabetic)
337 && characters.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
338 }
339
340 #[cfg(test)]
341 mod tests;
342