tauri_plugin_topcoat/lib.rs
1#![cfg_attr(docsrs, feature(doc_cfg))]
2#![forbid(unsafe_code)]
3
4//! Serve a topcoat application to a Tauri webview over a custom protocol.
5//!
6//! No port is bound and no socket is opened. topcoat's router is already a
7//! function from an HTTP request to an HTTP response - `Router::handle` needs
8//! none of its `serve` feature - so a Tauri custom protocol can call it
9//! directly, and the request never leaves the process.
10//!
11//! ```no_run
12//! use topcoat::router::Router;
13//!
14//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
15//! // With topcoat's `discover` feature this is `Router::builder().discover()`.
16//! let plugin = tauri_plugin_topcoat::Builder::new(Router::builder()).build()?;
17//!
18//! tauri::Builder::default().plugin(plugin);
19//! // ...then `.run(tauri::generate_context!())` as usual.
20//! # Ok(())
21//! # }
22//! ```
23//!
24//! Point the window at `topcoat://localhost/`; Tauri rewrites that to
25//! `http://topcoat.localhost/` on the platforms that need it.
26//!
27//! # What it adds
28//!
29//! Two things the webview gets wrong, both measured on macOS by the `probe`
30//! binary here; nobody has run it on the other two yet:
31//!
32//! * **One origin.** The server always sees `https://<scheme>.localhost`,
33//! whatever URL shape the platform handed the webview.
34//! * **Redirects.** No webview follows a `Location` from a custom protocol, so
35//! Post/Redirect/Get is followed here instead.
36//!
37//! # What you cannot do
38//!
39//! A custom protocol response is one buffered blob, so nothing streams:
40//! topcoat's `sse` feature, `datastar`, and any long-lived body have nowhere to
41//! go. WebSockets need an HTTP upgrade a protocol handler can't perform.
42//! Compression is dropped on the way in, and cookies don't survive in either
43//! direction.
44//!
45//! None of that fails quietly. Use one and you get a `502` naming it, because
46//! delivering half a response would have you debugging your application instead
47//! of this transport.
48//!
49//! Everything else - pages, shards, procedures, forms, assets - goes through
50//! untouched.
51//!
52//! # Sessions
53//!
54//! topcoat puts its session token in a cookie, and WebKit throws away every
55//! cookie a custom protocol sets. The `session` feature fixes that in topcoat's
56//! own `TokenStore` seam. See [`Builder::sessions`].
57//!
58//! # Tracing
59//!
60//! The `tracing` feature reports what this plugin decided, which is the part
61//! nothing else can see: a request served and how it ended, a navigation
62//! blocked, a response refused and which capability did it, a session handed
63//! over or withheld and which rule withheld it. Each request is a `serve` span
64//! naming its webview. Turning it on turns on the transport's events too.
65//!
66//! The token is never reported: no function that reports a session takes one,
67//! so it holds by signature.
68//!
69//! # Tauri commands
70//!
71//! They keep working, with nothing to configure: `invoke` needs the injected
72//! IPC script, and Tauri treats a page on a registered custom protocol as a
73//! local origin. An application that sets a strict `Content-Security-Policy`
74//! must allow `connect-src ipc: http://ipc.localhost` itself.
75
76mod protocol;
77#[cfg(feature = "session")]
78mod session;
79mod trace;
80
81use std::{
82 borrow::Cow,
83 sync::{Arc, OnceLock},
84};
85
86use custom_protocol_http::Origins;
87use protocol::Bridge;
88use tauri::{AppHandle, Runtime, Url, Webview, plugin::TauriPlugin};
89use topcoat::{context::BaseUrl, router::RouterBuilder};
90
91pub use custom_protocol_http::{Origin, OriginError, Platform};
92
93/// The protocol scheme used unless [`Builder::scheme`] says otherwise.
94pub const DEFAULT_SCHEME: &str = "topcoat";
95
96/// Why a plugin could not be built.
97///
98/// Every variant is a configuration mistake, caught before the application
99/// runs rather than as a puzzling failure once it does. More will be found, and
100/// finding one should not break an application that already handles the others.
101#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
102#[non_exhaustive]
103pub enum Error {
104 /// The protocol scheme is not usable. See [`OriginError`].
105 #[error(transparent)]
106 Scheme(OriginError),
107 /// The application's base URL is the origin this protocol serves.
108 ///
109 /// topcoat resolves absolute URLs against its base URL, for links that
110 /// leave the application: mail, feeds, sitemaps. Pointed at our own origin
111 /// it would produce URLs the webview fetches for real, against a host that
112 /// cannot exist. A desktop application either leaves the base URL unset or
113 /// sets it to its public website.
114 #[error(
115 "the router's base URL `{}` is the origin this protocol serves (`{origin}`); leave it \
116 unset, or set it to the application's public website",
117 .base_url.as_str()
118 )]
119 BaseUrlCollision {
120 /// The base URL the router was given.
121 base_url: BaseUrl,
122 /// The origin this protocol serves.
123 origin: Origin,
124 },
125}
126
127/// Builds the plugin.
128///
129/// Takes a [`RouterBuilder`] rather than a finished `Router` so the base URL
130/// can be checked before the router is sealed, which is the only moment that
131/// mistake is still visible.
132pub struct Builder {
133 router: RouterBuilder,
134 scheme: String,
135 /// `None` reads the webview's own `useHttpsScheme` from the application
136 /// configuration, so the two cannot disagree.
137 https_scheme: Option<bool>,
138 allow_external_navigation: bool,
139 /// Shared with the token store the moment [`Builder::sessions`] installs
140 /// one, which is why it is made here rather than with the bridge: the store
141 /// goes onto the router long before the bridge exists.
142 #[cfg(feature = "session")]
143 webviews: Arc<session::Webviews>,
144}
145
146impl core::fmt::Debug for Builder {
147 /// The router is the bulk of the value and has no `Debug` of its own, so
148 /// what is shown is the configuration a reader would be checking.
149 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
150 f.debug_struct("Builder")
151 .field("scheme", &self.scheme)
152 .field("https_scheme", &self.https_scheme)
153 .field("allow_external_navigation", &self.allow_external_navigation)
154 .finish_non_exhaustive()
155 }
156}
157
158impl Builder {
159 /// Starts a plugin for `router`, on the [`DEFAULT_SCHEME`].
160 #[must_use]
161 pub fn new(router: RouterBuilder) -> Builder {
162 Builder {
163 router,
164 scheme: DEFAULT_SCHEME.to_owned(),
165 https_scheme: None,
166 allow_external_navigation: false,
167 #[cfg(feature = "session")]
168 webviews: Arc::new(session::Webviews::new()),
169 }
170 }
171
172 /// Installs topcoat sessions, carrying the token in this process.
173 ///
174 /// ```no_run
175 /// # use topcoat::{router::Router, session::SessionConfig};
176 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
177 /// let plugin = tauri_plugin_topcoat::Builder::new(Router::builder())
178 /// .sessions(SessionConfig::builder())
179 /// .build()?;
180 /// # let _: tauri::plugin::TauriPlugin<tauri::Wry> = plugin;
181 /// # Ok(())
182 /// # }
183 /// ```
184 ///
185 /// # Why you need this
186 ///
187 /// topcoat's session token rides a hardened cookie by default, and WebKit
188 /// throws away every cookie a custom protocol sets. Your login would look
189 /// like it worked and the next request would arrive anonymous. This swaps
190 /// the transport and nothing else - minting, hashing, expiry, `start` and
191 /// `stop` and `rotate`, and your own session storage all stay topcoat's.
192 ///
193 /// The token is held here, keyed by the webview that asked, and never
194 /// crosses into the webview at all. Not `document.cookie`, not a header a
195 /// script can read, not anything WebKit writes to disk. A browser has to
196 /// hand a client its token because the server is somewhere else. Here it
197 /// is the same process, and Tauri tells you which webview asked.
198 ///
199 /// # What it costs
200 ///
201 /// The token is ambient with respect to the webview, so whatever document
202 /// that webview is showing can use it. Confinement is what defends that, so
203 /// leaving [`allow_external_navigation`](Builder::allow_external_navigation)
204 /// off matters more once sessions are on. A webview seen on somebody else's
205 /// origin stops being handed the token either way.
206 ///
207 /// Confinement governs navigation, not sub-resources: a document of yours
208 /// embedding a foreign frame still shows your origin, so the token is still
209 /// handed out, and what stops that frame spending it is topcoat's origin
210 /// check. Serve a `Content-Security-Policy` if you would rather it could
211 /// not load.
212 ///
213 /// Reach for this rather than topcoat's own
214 /// [`sessions`](topcoat::session::RouterBuilderSessionExt::sessions), which
215 /// keeps whatever token store the configuration carries - the cookie one,
216 /// with topcoat's `cookie` feature on, whose every sign-in answers `502`.
217 ///
218 /// # Panics
219 ///
220 /// Never directly. Call this twice and the second store replaces the first,
221 /// taking the first one's tokens with it.
222 #[cfg(feature = "session")]
223 #[must_use]
224 pub fn sessions(mut self, config: topcoat::session::SessionConfigBuilder) -> Builder {
225 use topcoat::session::RouterBuilderSessionExt;
226
227 // Last, so an application's own token store is not quietly kept.
228 let config = config
229 .token_store(session::WebviewTokenStore::new(Arc::clone(&self.webviews)))
230 .build();
231 self.router = self.router.sessions(config);
232 self
233 }
234
235 /// Serves the application under a different scheme name.
236 ///
237 /// The window's URL must match: `<scheme>://localhost/`.
238 #[must_use]
239 pub fn scheme(mut self, scheme: impl Into<String>) -> Builder {
240 self.scheme = scheme.into();
241 self
242 }
243
244 /// Overrides [`WebviewWindowBuilder::use_https_scheme`], which changes the
245 /// URL shape on the platforms that rewrite custom schemes onto http.
246 ///
247 /// Left alone, the plugin reads the same `useHttpsScheme` the webview does
248 /// out of the application configuration, so there is no second place to
249 /// keep in step. Set this only for a webview built in code with a setting
250 /// the configuration does not carry.
251 ///
252 /// [`WebviewWindowBuilder::use_https_scheme`]: tauri::webview::WebviewWindowBuilder::use_https_scheme
253 #[must_use]
254 pub const fn use_https_scheme(mut self, https: bool) -> Builder {
255 self.https_scheme = Some(https);
256 self
257 }
258
259 /// Lets a webview showing this application navigate to another origin.
260 ///
261 /// Off by default. A desktop application usually wants an external link
262 /// opened in the user's browser rather than replacing its own UI, and a
263 /// webview that cannot reach another origin cannot host content that would
264 /// try to forge requests against this one.
265 #[must_use]
266 pub const fn allow_external_navigation(mut self, allow: bool) -> Builder {
267 self.allow_external_navigation = allow;
268 self
269 }
270
271 /// Builds the plugin.
272 ///
273 /// # Errors
274 ///
275 /// [`Error::Scheme`] if the scheme name is unusable, and
276 /// [`Error::BaseUrlCollision`] if the router's base URL is the origin this
277 /// protocol serves.
278 pub fn build<R: Runtime>(self) -> Result<TauriPlugin<R>, Error> {
279 self.validate(Platform::current())?;
280
281 // Built in `setup`, the first moment the application configuration is
282 // readable, so the URL shape is discovered rather than restated.
283 let bridge: Arc<OnceLock<Bridge>> = Arc::new(OnceLock::new());
284 let building = Arc::clone(&bridge);
285 let serving = Arc::clone(&bridge);
286 let navigating = Arc::clone(&bridge);
287
288 let Builder {
289 router,
290 scheme,
291 https_scheme,
292 allow_external_navigation,
293 #[cfg(feature = "session")]
294 webviews,
295 } = self;
296 let confined = !allow_external_navigation;
297 let protocol_scheme = scheme.clone();
298
299 Ok(tauri::plugin::Builder::new("topcoat")
300 .setup(move |app, _api| {
301 let origins = Origins::new(&scheme, platform_for(app, https_scheme))?;
302 let _ = building.set(Bridge::new(
303 origins,
304 router.build(),
305 #[cfg(feature = "session")]
306 Arc::clone(&webviews),
307 ));
308 Ok(())
309 })
310 .register_asynchronous_uri_scheme_protocol(
311 protocol_scheme,
312 move |context, request, responder| {
313 let bridge = Arc::clone(&serving);
314 let label = context.webview_label().to_owned();
315 tauri::async_runtime::spawn(async move {
316 responder.respond(match bridge.get() {
317 Some(bridge) => bridge.serve(&label, request).await,
318 None => protocol::unavailable(),
319 });
320 });
321 },
322 )
323 .on_navigation(move |webview, url| observe(&navigating, webview, url, confined))
324 .build())
325 }
326
327 /// Drives the same application without a window, as `platform` would.
328 ///
329 /// Everything configured here applies, sessions included, so a test
330 /// exercises the transport the application actually runs on. Naming the
331 /// platform lets a test check a request the way Windows delivers it while
332 /// running on macOS.
333 ///
334 /// # Errors
335 ///
336 /// The same as [`Builder::build`].
337 pub fn session(self, platform: Platform) -> Result<Session, Error> {
338 let origins = self.validate(platform)?;
339 let bridge = Bridge::new(
340 origins,
341 self.router.build(),
342 #[cfg(feature = "session")]
343 self.webviews,
344 );
345 // Stands in for a window already showing one of your pages.
346 bridge.observe_navigation(SESSION_LABEL, true);
347 Ok(Session(bridge))
348 }
349
350 /// Checks what can be checked while the mistake is still attached to the
351 /// call that made it, and returns the origins it had to build to do so.
352 ///
353 /// The canonical origin does not depend on the platform, so a collision
354 /// found against one platform holds for all of them.
355 fn validate(&self, platform: Platform) -> Result<Origins, Error> {
356 let origins = Origins::new(&self.scheme, platform).map_err(Error::Scheme)?;
357 if let Some(base_url) = self.router.get_app_context::<BaseUrl>()
358 && origins.collides_with(base_url.as_str())
359 {
360 return Err(Error::BaseUrlCollision {
361 base_url: base_url.clone(),
362 origin: origins.canonical().clone(),
363 });
364 }
365 Ok(origins)
366 }
367}
368
369/// Drives a router exactly as a webview would, without one.
370///
371/// Testing a topcoat router with `Router::handle` skips everything this plugin
372/// adds: the origin rewrite, the redirect following, and the refusal of a
373/// response the transport cannot carry. A [`Session`] runs all three on a plain
374/// `async` call with no window, so a test fails where the application would.
375///
376/// Built by [`Builder::session`], so a test cannot be configured differently
377/// from the application it stands in for.
378///
379/// ```no_run
380/// # use tauri_plugin_topcoat::{Builder, Platform};
381/// # use topcoat::router::Router;
382/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
383/// let session = Builder::new(Router::builder()).session(Platform::Scheme)?;
384///
385/// let response = session
386/// .serve(http::Request::get("topcoat://localhost/").body(Vec::new())?)
387/// .await;
388///
389/// assert_eq!(response.status(), 200);
390/// # Ok(())
391/// # }
392/// ```
393pub struct Session(Bridge);
394
395impl core::fmt::Debug for Session {
396 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
397 f.debug_struct("Session").finish_non_exhaustive()
398 }
399}
400
401impl Session {
402 /// Serves one request, redirects and session included.
403 pub async fn serve(
404 &self,
405 request: http::Request<Vec<u8>>,
406 ) -> http::Response<Cow<'static, [u8]>> {
407 self.0.serve(SESSION_LABEL, request).await
408 }
409}
410
411/// The webview label a [`Session`] pretends to be.
412const SESSION_LABEL: &str = "session";
413
414/// The URL shape this build's webview uses, from the application configuration
415/// unless the caller insisted otherwise.
416fn platform_for<R: Runtime>(app: &AppHandle<R>, explicit: Option<bool>) -> Platform {
417 let https = explicit.unwrap_or_else(|| {
418 let windows = &app.config().app.windows;
419 let any = windows.iter().any(|window| window.use_https_scheme);
420 if any && !windows.iter().all(|window| window.use_https_scheme) {
421 eprintln!(
422 "tauri-plugin-topcoat: windows disagree about `useHttpsScheme`; \
423 serving every window over https. Set `Builder::use_https_scheme` to choose."
424 );
425 }
426 any
427 });
428
429 match (Platform::current(), https) {
430 (Platform::HttpSubdomain | Platform::HttpsSubdomain, true) => Platform::HttpsSubdomain,
431 (Platform::HttpSubdomain | Platform::HttpsSubdomain, false) => Platform::HttpSubdomain,
432 // No http rewrite for the setting to apply to.
433 (platform, _) => platform,
434 }
435}
436
437/// Records where a webview is going, and decides whether it may.
438///
439/// The recording happens either way, and happens even when confinement is off,
440/// because a session token is held per webview and must not be handed to one
441/// that has wandered off to somebody else's document.
442///
443/// Confinement itself is narrow. Only a webview already showing one of our
444/// pages is held to it; every other webview in the application is none of this
445/// plugin's business, and neither is the first navigation into one.
446fn observe<R: Runtime>(
447 bridge: &OnceLock<Bridge>,
448 webview: &Webview<R>,
449 url: &Url,
450 confined: bool,
451) -> bool {
452 let Some(bridge) = bridge.get() else {
453 return true;
454 };
455 let ours = bridge.origins().platform();
456 let target_is_ours = ours.covers(url.as_str());
457 bridge.observe_navigation(webview.label(), target_is_ours);
458
459 let Ok(current) = webview.url() else {
460 return true;
461 };
462 if !confined || !ours.covers(current.as_str()) {
463 if !target_is_ours {
464 trace::left_our_origin(webview.label(), url.as_str());
465 }
466 return true;
467 }
468 if !target_is_ours {
469 trace::blocked_navigation(webview.label(), url.as_str());
470 }
471 target_is_ours
472}
473
474/// What installing the session transport does to a response, driven through the
475/// [`Builder`] an application uses rather than a parallel assembly of one.
476#[cfg(all(test, feature = "session"))]
477mod session_tests {
478 use topcoat::{
479 context::Cx,
480 router::{Body, IntoResponse, Path, RouteFn, RouteFuture, Router},
481 session::{SessionConfig, start, token_hash},
482 };
483
484 use super::*;
485
486 /// Mints a session the way an application's sign-in route does.
487 fn sign_in(cx: &Cx, _body: Body) -> RouteFuture<'_> {
488 Box::pin(async move {
489 start(cx).await?;
490 "signed in".into_response(cx)
491 })
492 }
493
494 /// Reports whether this request arrived carrying a live session.
495 fn whoami(cx: &Cx, _body: Body) -> RouteFuture<'_> {
496 Box::pin(async move {
497 let who = match token_hash(cx).await? {
498 Some(_) => "known",
499 None => "anonymous",
500 };
501 who.into_response(cx)
502 })
503 }
504
505 fn session() -> Session {
506 let router = Router::builder()
507 .route(RouteFn::new(
508 http::Method::POST,
509 Cow::Borrowed(Path::new("/sign-in")),
510 sign_in,
511 ))
512 .route(RouteFn::new(
513 http::Method::GET,
514 Cow::Borrowed(Path::new("/whoami")),
515 whoami,
516 ));
517 Builder::new(router)
518 .sessions(SessionConfig::builder())
519 .session(Platform::Scheme)
520 .expect("the plugin is configured correctly")
521 }
522
523 async fn serve(session: &Session, method: http::Method, path: &str) -> (u16, String) {
524 let request = http::Request::builder()
525 .method(method)
526 .uri(format!("topcoat://localhost{path}"))
527 .body(Vec::new())
528 .expect("a valid request");
529 let response = session.serve(request).await;
530 (
531 response.status().as_u16(),
532 String::from_utf8_lossy(response.body()).into_owned(),
533 )
534 }
535
536 #[tokio::test]
537 async fn signing_in_emits_no_cookie_and_is_not_refused() {
538 let session = session();
539
540 let request = http::Request::post("topcoat://localhost/sign-in")
541 .body(Vec::new())
542 .expect("a valid request");
543 let response = session.serve(request).await;
544
545 assert_eq!(
546 response.status(),
547 http::StatusCode::OK,
548 "the session transport tripped the transport's own cookie refusal: {}",
549 String::from_utf8_lossy(response.body())
550 );
551 assert!(
552 response.headers().get(http::header::SET_COOKIE).is_none(),
553 "the token was handed to a webview that would have discarded it"
554 );
555 }
556
557 #[tokio::test]
558 async fn the_token_survives_into_the_next_request() {
559 let session = session();
560
561 let (status, _) = serve(&session, http::Method::POST, "/sign-in").await;
562 assert_eq!(status, 200);
563
564 let (status, body) = serve(&session, http::Method::GET, "/whoami").await;
565
566 assert_eq!(status, 200);
567 assert_eq!(body, "known", "the session did not outlive the request");
568 }
569
570 #[tokio::test]
571 async fn a_webview_that_never_signed_in_is_anonymous() {
572 let (status, body) = serve(&session(), http::Method::GET, "/whoami").await;
573
574 assert_eq!(status, 200);
575 assert_eq!(body, "anonymous");
576 }
577
578 /// The third case is the blind spot, asserted rather than described: a
579 /// `fetch` here carries no `Origin` and can carry no `Sec-Fetch-Site`, and
580 /// topcoat passes anything sending neither. What keeps that safe is the
581 /// stripping this transport does; what could falsify it is a webview
582 /// omitting `Origin` cross-origin, which the probe measures.
583 #[tokio::test]
584 async fn topcoats_origin_check_sees_what_it_needs_through_the_rewrite() {
585 async fn post(session: &Session, origin: Option<&str>) -> u16 {
586 let mut request = http::Request::post("topcoat://localhost/sign-in");
587 if let Some(origin) = origin {
588 request = request.header(http::header::ORIGIN, origin);
589 }
590 let request = request.body(Vec::new()).expect("a valid request");
591 session.serve(request).await.status().as_u16()
592 }
593
594 let session = session();
595
596 assert_eq!(
597 post(&session, Some("topcoat://localhost")).await,
598 200,
599 "the application's own form post was refused; the rewrite has to \
600 move `Origin` and `Host` together for the check to match them"
601 );
602 assert_eq!(
603 post(&session, Some("https://evil.example")).await,
604 403,
605 "a foreign origin was laundered into a passing one"
606 );
607 assert_eq!(
608 post(&session, None).await,
609 200,
610 "a request carrying neither `Origin` nor `Sec-Fetch-Site` passes, \
611 which over a custom protocol is every `fetch`"
612 );
613 }
614
615 #[test]
616 fn our_own_sessions_builds() {
617 Builder::new(Router::builder())
618 .sessions(SessionConfig::builder())
619 .session(Platform::Scheme)
620 .expect("the plugin installs its own token store");
621 }
622
623 /// Why the plugin does not need to detect the mistake: without topcoat's
624 /// `cookie` feature there is no default store to fall back to, so it cannot
625 /// compile its way to runtime. Enabling `topcoat/cookie` is what to avoid.
626 #[test]
627 #[should_panic(expected = "no token store configured")]
628 fn a_session_config_without_a_token_store_refuses_to_build() {
629 let _ = SessionConfig::builder().build();
630 }
631}